mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-15 17:08:21 -04:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cac531148a |
@@ -83,7 +83,6 @@
|
||||
"@types/luxon": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"tw-animate-css": "1.4.0",
|
||||
"typescript": "catalog:",
|
||||
"vite": "catalog:",
|
||||
"vite-plugin-icons-spritesheet": "3.0.1",
|
||||
@@ -991,7 +990,6 @@
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"solid-js": "catalog:",
|
||||
"tailwindcss": "catalog:",
|
||||
"tw-animate-css": "1.4.0",
|
||||
"typescript": "catalog:",
|
||||
"vite": "catalog:",
|
||||
"vite-plugin-icons-spritesheet": "3.0.1",
|
||||
@@ -5317,8 +5315,6 @@
|
||||
|
||||
"turndown": ["turndown@7.2.0", "", { "dependencies": { "@mixmark-io/domino": "^2.2.0" } }, "sha512-eCZGBN4nNNqM9Owkv9HAtWRYfLA4h909E/WGAWWBpmB275ehNhZyk87/Tpvjbp0jjNl9XwCsbe6bm6CqFsgD+A=="],
|
||||
|
||||
"tw-animate-css": ["tw-animate-css@1.4.0", "", {}, "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ=="],
|
||||
|
||||
"tw-to-css": ["tw-to-css@0.0.12", "", { "dependencies": { "postcss": "8.4.31", "postcss-css-variables": "0.18.0", "tailwindcss": "3.3.2" } }, "sha512-rQAsQvOtV1lBkyCw+iypMygNHrShYAItES5r8fMsrhhaj5qrV2LkZyXc8ccEH+u5bFjHjQ9iuxe90I7Kykf6pw=="],
|
||||
|
||||
"type-fest": ["type-fest@4.41.0", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="],
|
||||
|
||||
+4
-4
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-rB/CcrBUQVZ08nBFSYA8u2w88rQmTpKxKPkIreDEKgI=",
|
||||
"aarch64-linux": "sha256-ZRTphtic8Ip96MnILteFgZAUxjK9O4YfJu2O6u/0H8k=",
|
||||
"aarch64-darwin": "sha256-VK5XIzraP0HtqnPwPCejiDKer4ewtNtX1vxP5uuyjSk=",
|
||||
"x86_64-darwin": "sha256-ZLPHqcCZB1EmxQk95cmUpiODTTKOyi7PSF0yr/rDk6Y="
|
||||
"x86_64-linux": "sha256-OiWvZ57vuyHwiIKNtW1n1KX+MLmOXVG3x4fLKvUoGQw=",
|
||||
"aarch64-linux": "sha256-RnPLxVEg/UsL5IeIFWmXMSLUOG6rVrajYxhyDYj1vTA=",
|
||||
"aarch64-darwin": "sha256-KPIgcBA0pTFBPrCTSZgIbvEorbtWcMgXvyX9bFAypVs=",
|
||||
"x86_64-darwin": "sha256-6jVU7/uVId0VD24MVQ8s8Ill5b6PsKdlBgHg+oceKRg="
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,7 +38,6 @@
|
||||
"@types/luxon": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"tw-animate-css": "1.4.0",
|
||||
"typescript": "catalog:",
|
||||
"vite": "catalog:",
|
||||
"vite-plugin-icons-spritesheet": "3.0.1",
|
||||
|
||||
+12
-22
@@ -38,7 +38,7 @@ import { HighlightsProvider } from "@/context/highlights"
|
||||
import { LanguageProvider, type Locale, useLanguage } from "@/context/language"
|
||||
import { LayoutProvider } from "@/context/layout"
|
||||
import { ModelsProvider } from "@/context/models"
|
||||
import { NotificationProvider, useNotification } from "@/context/notification"
|
||||
import { NotificationProvider } from "@/context/notification"
|
||||
import { PermissionProvider } from "@/context/permission"
|
||||
import { PromptProvider } from "@/context/prompt"
|
||||
import { ServerConnection, ServerProvider, serverName, useServer } from "@/context/server"
|
||||
@@ -316,7 +316,9 @@ function ServerScopedProviders(props: ServerScopedShellProps) {
|
||||
return (
|
||||
<PermissionProvider directory={props.directory}>
|
||||
<LayoutProvider>
|
||||
<ModelsProvider directory={props.directory}>{props.children}</ModelsProvider>
|
||||
<NotificationProvider directory={props.directory} sessionID={props.sessionID}>
|
||||
<ModelsProvider directory={props.directory}>{props.children}</ModelsProvider>
|
||||
</NotificationProvider>
|
||||
</LayoutProvider>
|
||||
</PermissionProvider>
|
||||
)
|
||||
@@ -343,23 +345,13 @@ function NewAppLayout(props: ParentProps) {
|
||||
function TargetServerScopedProviders(props: ServerScopedShellProps) {
|
||||
return (
|
||||
<PermissionProvider directory={props.directory}>
|
||||
<MarkSessionNotificationsViewed sessionID={props.sessionID} />
|
||||
<ModelsProvider directory={props.directory}>{props.children}</ModelsProvider>
|
||||
<NotificationProvider directory={props.directory} sessionID={props.sessionID}>
|
||||
<ModelsProvider directory={props.directory}>{props.children}</ModelsProvider>
|
||||
</NotificationProvider>
|
||||
</PermissionProvider>
|
||||
)
|
||||
}
|
||||
|
||||
function MarkSessionNotificationsViewed(props: { sessionID?: () => string | undefined }) {
|
||||
const notification = useNotification()
|
||||
createEffect(() => {
|
||||
const sessionID = props.sessionID?.()
|
||||
if (!notification.ready() || !sessionID) return
|
||||
if (notification.session.unseenCount(sessionID) === 0) return
|
||||
notification.session.markViewed(sessionID)
|
||||
})
|
||||
return null
|
||||
}
|
||||
|
||||
function SessionProviders(props: ParentProps) {
|
||||
return (
|
||||
<TerminalProvider>
|
||||
@@ -568,13 +560,11 @@ export function AppInterface(props: {
|
||||
component={props.router ?? Router}
|
||||
root={(routerProps) => (
|
||||
<TabsProvider>
|
||||
<NotificationProvider>
|
||||
<ServerShell>
|
||||
<Show when={useSettings().general.newLayoutDesigns()} fallback={routerProps.children}>
|
||||
<NewAppLayout>{routerProps.children}</NewAppLayout>
|
||||
</Show>
|
||||
</ServerShell>
|
||||
</NotificationProvider>
|
||||
<ServerShell>
|
||||
<Show when={useSettings().general.newLayoutDesigns()} fallback={routerProps.children}>
|
||||
<NewAppLayout>{routerProps.children}</NewAppLayout>
|
||||
</Show>
|
||||
</ServerShell>
|
||||
</TabsProvider>
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import "@pierre/trees/web-components"
|
||||
import { FileTree } from "@pierre/trees"
|
||||
import { Dialog, DialogBody, DialogFooter, DialogHeader, DialogTitle } from "@opencode-ai/ui/v2/dialog-v2"
|
||||
import { Dialog, DialogFooter } from "@opencode-ai/ui/v2/dialog-v2"
|
||||
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
|
||||
import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
@@ -27,7 +27,6 @@ import {
|
||||
pickerRoot,
|
||||
} from "./directory-picker-domain"
|
||||
import "./dialog-select-directory-v2.css"
|
||||
import { DividerV2 } from "@opencode-ai/ui/v2/divider-v2"
|
||||
|
||||
interface DialogSelectDirectoryV2Props {
|
||||
title?: string
|
||||
@@ -267,12 +266,8 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) {
|
||||
onCleanup(() => tree?.cleanUp())
|
||||
|
||||
return (
|
||||
<Dialog size="large" class="directory-picker-v2">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{props.title ?? language.t("command.project.open")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<DividerV2 />
|
||||
<DialogBody class="directory-picker-v2-body pt-4!">
|
||||
<Dialog title={props.title ?? language.t("command.project.open")} size="large" class="directory-picker-v2">
|
||||
<div class="directory-picker-v2-body">
|
||||
<div class="directory-picker-v2-path" ref={pathArea}>
|
||||
<TextInputV2
|
||||
value={input()}
|
||||
@@ -354,7 +349,7 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) {
|
||||
</Show>
|
||||
</div>
|
||||
<div class="directory-picker-v2-selection">{policy.result(root(), selected(), rootValid())}</div>
|
||||
</DialogBody>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<ButtonV2 variant="neutral" onClick={() => dialog.close()}>
|
||||
{language.t("common.cancel")}
|
||||
|
||||
@@ -59,7 +59,6 @@ const ModelList: Component<{
|
||||
class="w-full"
|
||||
placement="right-start"
|
||||
gutter={12}
|
||||
openDelay={0}
|
||||
value={<ModelTooltip model={item} latest={item.latest} free={isFree(item.provider.id, item.cost)} />}
|
||||
>
|
||||
{node}
|
||||
|
||||
@@ -40,7 +40,6 @@ beforeAll(async () => {
|
||||
describe("file tree fetch discipline", () => {
|
||||
test("root lists on mount unless already loaded or loading", () => {
|
||||
expect(shouldListRoot({ level: 0 })).toBe(true)
|
||||
expect(shouldListRoot({ level: 0, filtered: true })).toBe(false)
|
||||
expect(shouldListRoot({ level: 0, dir: { loaded: true } })).toBe(false)
|
||||
expect(shouldListRoot({ level: 0, dir: { loading: true } })).toBe(false)
|
||||
expect(shouldListRoot({ level: 1 })).toBe(false)
|
||||
|
||||
@@ -32,12 +32,7 @@ type Filter = {
|
||||
dirs: Set<string>
|
||||
}
|
||||
|
||||
export function shouldListRoot(input: {
|
||||
level: number
|
||||
filtered?: boolean
|
||||
dir?: { loaded?: boolean; loading?: boolean }
|
||||
}) {
|
||||
if (input.filtered) return false
|
||||
export function shouldListRoot(input: { level: number; dir?: { loaded?: boolean; loading?: boolean } }) {
|
||||
if (input.level !== 0) return false
|
||||
if (input.dir?.loaded) return false
|
||||
if (input.dir?.loading) return false
|
||||
@@ -314,7 +309,7 @@ export default function FileTree(props: {
|
||||
filter: current,
|
||||
expanded: (dir) => untrack(() => file.tree.state(dir)?.expanded) ?? false,
|
||||
})
|
||||
for (const dir of dirs) file.tree.expand(dir, { load: false })
|
||||
for (const dir of dirs) file.tree.expand(dir)
|
||||
})
|
||||
|
||||
createEffect(
|
||||
@@ -322,7 +317,7 @@ export default function FileTree(props: {
|
||||
() => props.path,
|
||||
(path) => {
|
||||
const dir = untrack(() => file.tree.state(path))
|
||||
if (!shouldListRoot({ level, filtered: !!filter(), dir })) return
|
||||
if (!shouldListRoot({ level, dir })) return
|
||||
void file.tree.list(path)
|
||||
},
|
||||
{ defer: false },
|
||||
@@ -406,9 +401,7 @@ export default function FileTree(props: {
|
||||
data-scope="filetree"
|
||||
forceMount={false}
|
||||
open={expanded()}
|
||||
onOpenChange={(open) =>
|
||||
open ? file.tree.expand(node.path, { load: !filter() }) : file.tree.collapse(node.path)
|
||||
}
|
||||
onOpenChange={(open) => (open ? file.tree.expand(node.path) : file.tree.collapse(node.path))}
|
||||
>
|
||||
<Collapsible.Trigger>
|
||||
<FileTreeNode
|
||||
|
||||
@@ -66,6 +66,7 @@ import { promptPlaceholder } from "./prompt-input/placeholder"
|
||||
import { createPromptInputTransientState } from "./prompt-input/transient-state"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { ImagePreview } from "@opencode-ai/ui/image-preview"
|
||||
import { SessionProgressIndicatorV2 } from "@opencode-ai/session-ui/v2/session-progress-indicator-v2"
|
||||
|
||||
export type PromptInputState = ReturnType<typeof usePrompt>
|
||||
|
||||
@@ -339,6 +340,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
() => prompt.capture(),
|
||||
Math.floor(Math.random() * EXAMPLES.length),
|
||||
)
|
||||
const [submissionState, setSubmissionState] = createStore({ interrupting: false })
|
||||
const buttonsSpring = useSpring(() => (store.mode === "normal" ? 1 : 0), { visualDuration: 0.2, bounce: 0 })
|
||||
const motion = (value: number) => ({
|
||||
opacity: value,
|
||||
@@ -1180,6 +1182,22 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
onSubmit: props.onSubmit,
|
||||
})
|
||||
|
||||
const interrupt = () => {
|
||||
if (submissionState.interrupting) return
|
||||
if (platform.platform !== "desktop" || !props.controls.newLayoutDesigns) return abort()
|
||||
|
||||
setSubmissionState("interrupting", true)
|
||||
return Promise.resolve()
|
||||
.then(() => abort())
|
||||
.finally(() => setSubmissionState("interrupting", false))
|
||||
}
|
||||
|
||||
const handleV2Submit = (event: Event) => {
|
||||
if (!stopping() || platform.platform !== "desktop") return handleSubmit(event)
|
||||
event.preventDefault()
|
||||
return interrupt()
|
||||
}
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if ((event.metaKey || event.ctrlKey) && !event.altKey && !event.shiftKey && event.key.toLowerCase() === "u") {
|
||||
event.preventDefault()
|
||||
@@ -1232,7 +1250,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
}
|
||||
|
||||
if (working()) {
|
||||
void abort()
|
||||
void interrupt()
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
return
|
||||
@@ -1298,7 +1316,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
return
|
||||
}
|
||||
if (working()) {
|
||||
void abort()
|
||||
void interrupt()
|
||||
event.preventDefault()
|
||||
}
|
||||
return
|
||||
@@ -1343,9 +1361,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
}
|
||||
|
||||
const agentsLoading = () => props.controls.agents.loading
|
||||
const agentsShouldFadeIn = createMemo<boolean>((prev) => prev ?? agentsLoading())
|
||||
const agentsShouldFadeIn = createMemo((prev) => prev ?? agentsLoading())
|
||||
const providersLoading = () => props.controls.model.loading
|
||||
const providersShouldFadeIn = createMemo<boolean>((prev) => prev ?? providersLoading())
|
||||
const providersShouldFadeIn = createMemo((prev) => prev ?? providersLoading())
|
||||
|
||||
const [promptReady] = createResource(
|
||||
() => prompt.ready.promise,
|
||||
@@ -1359,7 +1377,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
|
||||
const modelControlState = createMemo<ComposerModelControlState>(() => ({
|
||||
loading: providersLoading(),
|
||||
shouldAnimate: providersShouldFadeIn(),
|
||||
paid: props.controls.model.paid,
|
||||
title: language.t("command.model.choose"),
|
||||
keybind: command.keybind("model.choose"),
|
||||
@@ -1411,7 +1428,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
<div class="flex flex-col gap-3">
|
||||
<DockShellForm
|
||||
data-component={newSession() ? "session-new-composer" : "session-composer"}
|
||||
onSubmit={handleSubmit}
|
||||
onSubmit={handleV2Submit}
|
||||
classList={{
|
||||
"group/prompt-input min-h-[96px] w-full rounded-xl bg-v2-background-bg-base shadow-[var(--v2-elevation-raised)]": true,
|
||||
"border-icon-info-active border-dashed": store.draggingType !== null,
|
||||
@@ -1520,11 +1537,10 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
</Show>
|
||||
{props.toolbar}
|
||||
<ComposerModelControl state={modelControlState()} />
|
||||
<Show when={!providersLoading() && store.mode !== "shell" && showVariantControl()}>
|
||||
<Show when={store.mode !== "shell" && showVariantControl()}>
|
||||
<div
|
||||
data-component="prompt-variant-control"
|
||||
classList={{
|
||||
"animate-in fade-in": providersShouldFadeIn(),
|
||||
"hidden group-hover/prompt-input:block group-focus-within/prompt-input:block":
|
||||
!props.controls.model.selection.variant.current() && !store.variantOpen,
|
||||
}}
|
||||
@@ -1556,20 +1572,27 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
</Show>
|
||||
</div>
|
||||
<Tooltip placement="top" inactive={!working() && blank()} value={tip()}>
|
||||
<IconButton
|
||||
data-action="prompt-submit"
|
||||
type="submit"
|
||||
disabled={!working() && blank()}
|
||||
tabIndex={store.mode === "normal" ? undefined : -1}
|
||||
icon={stopping() ? "stop" : store.mode === "shell" ? "arrow-undo-down" : "arrow-up"}
|
||||
variant="primary"
|
||||
class="size-7 rounded-md p-[6px] text-v2-icon-icon-muted shadow-[var(--v2-elevation-button-contrast)] disabled:opacity-50"
|
||||
style={{
|
||||
"background-image":
|
||||
"linear-gradient(180deg,var(--v2-alpha-light-20) 0%,var(--v2-alpha-light-0) 100%),linear-gradient(90deg,var(--v2-background-bg-contrast) 0%,var(--v2-background-bg-contrast) 100%)",
|
||||
}}
|
||||
aria-label={stopping() ? language.t("prompt.action.stop") : language.t("prompt.action.send")}
|
||||
/>
|
||||
<span class="relative flex size-7">
|
||||
<IconButton
|
||||
data-action="prompt-submit"
|
||||
type="submit"
|
||||
disabled={submissionState.interrupting || (!working() && blank())}
|
||||
tabIndex={store.mode === "normal" ? undefined : -1}
|
||||
icon={stopping() ? "stop" : store.mode === "shell" ? "arrow-undo-down" : "arrow-up"}
|
||||
variant="primary"
|
||||
class="size-7 rounded-md p-[6px] text-v2-icon-icon-muted shadow-[var(--v2-elevation-button-contrast)] disabled:opacity-50"
|
||||
classList={{ "[&_[data-slot=icon-svg]]:invisible": submissionState.interrupting }}
|
||||
style={{
|
||||
"background-image":
|
||||
"linear-gradient(180deg,var(--v2-alpha-light-20) 0%,var(--v2-alpha-light-0) 100%),linear-gradient(90deg,var(--v2-background-bg-contrast) 0%,var(--v2-background-bg-contrast) 100%)",
|
||||
}}
|
||||
aria-label={stopping() ? language.t("prompt.action.stop") : language.t("prompt.action.send")}
|
||||
aria-busy={submissionState.interrupting}
|
||||
/>
|
||||
<Show when={submissionState.interrupting}>
|
||||
<SessionProgressIndicatorV2 class="pointer-events-none absolute inset-[6px] size-4 opacity-50" />
|
||||
</Show>
|
||||
</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</DockShellForm>
|
||||
@@ -1767,7 +1790,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
<Show when={!agentsLoading()}>
|
||||
<div
|
||||
data-component="prompt-agent-control"
|
||||
classList={{ "animate-in fade-in duration-300": agentsShouldFadeIn() }}
|
||||
style={agentsShouldFadeIn() ? { animation: "fade-in 0.3s" } : undefined}
|
||||
>
|
||||
<TooltipKeybind
|
||||
placement="top"
|
||||
@@ -1796,7 +1819,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
<Show when={store.mode !== "shell"}>
|
||||
<div
|
||||
data-component="prompt-model-control"
|
||||
classList={{ "animate-in fade-in duration-300": providersShouldFadeIn() }}
|
||||
style={providersShouldFadeIn() ? { animation: "fade-in 0.3s" } : undefined}
|
||||
>
|
||||
<Show
|
||||
when={props.controls.model.paid}
|
||||
@@ -1875,7 +1898,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
<Show when={showVariantControl()}>
|
||||
<div
|
||||
data-component="prompt-variant-control"
|
||||
classList={{ "animate-in fade-in duration-300": providersShouldFadeIn() }}
|
||||
style={providersShouldFadeIn() ? { animation: "fade-in 0.3s" } : undefined}
|
||||
>
|
||||
<TooltipKeybind
|
||||
placement="top"
|
||||
@@ -1925,7 +1948,6 @@ type ComposerAgentControlState = {
|
||||
|
||||
type ComposerModelControlState = {
|
||||
loading: boolean
|
||||
shouldAnimate: boolean
|
||||
paid: boolean
|
||||
title: string
|
||||
keybind: string
|
||||
@@ -1973,7 +1995,6 @@ function ComposerModelControl(props: { state: ComposerModelControlState }) {
|
||||
variant="ghost"
|
||||
size="normal"
|
||||
class="min-w-0 max-w-[220px] justify-start text-[13px] font-[440] leading-5 text-v2-text-text-faint group"
|
||||
classList={{ "animate-in fade-in": props.state.shouldAnimate }}
|
||||
style={props.state.style}
|
||||
onClick={props.state.onUnpaidClick}
|
||||
>
|
||||
@@ -2004,7 +2025,6 @@ function ComposerModelControl(props: { state: ComposerModelControlState }) {
|
||||
style: props.state.style,
|
||||
class:
|
||||
"min-w-0 max-w-[220px] justify-start text-[13px] font-[440] leading-5 text-v2-text-text-faint group",
|
||||
classList: { "animate-in fade-in": props.state.shouldAnimate },
|
||||
"data-action": "prompt-model",
|
||||
}}
|
||||
onClose={props.state.onClose}
|
||||
|
||||
@@ -69,14 +69,12 @@ export function createPromptProjectController(input: {
|
||||
if (servers().length <= 1) {
|
||||
return [...projects().map(projectKey), actionKey(servers()[0]?.key)]
|
||||
}
|
||||
return [
|
||||
...servers().flatMap((server) =>
|
||||
projects()
|
||||
.filter((project) => project.server?.key === server!.key)
|
||||
.map(projectKey),
|
||||
),
|
||||
actionKey(),
|
||||
]
|
||||
return servers().flatMap((server) => [
|
||||
...projects()
|
||||
.filter((project) => project.server?.key === server!.key)
|
||||
.map(projectKey),
|
||||
actionKey(server!.key),
|
||||
])
|
||||
}
|
||||
const initialActive = () => {
|
||||
const selectedKey = selected() ? projectKey(selected()!) : undefined
|
||||
@@ -132,10 +130,7 @@ export function createPromptProjectController(input: {
|
||||
const first = input
|
||||
.controls()
|
||||
.available.find((project) => !search || displayName(project).toLowerCase().includes(search))
|
||||
setStore({
|
||||
search: value,
|
||||
active: first ? projectKey(first) : actionKey(servers().length > 1 ? undefined : servers()[0]?.key),
|
||||
})
|
||||
setStore({ search: value, active: first ? projectKey(first) : actionKey(servers()[0]?.key) })
|
||||
},
|
||||
clearSearch() {
|
||||
setStore({ search: "", active: initialActive() })
|
||||
@@ -161,9 +156,6 @@ export function createPromptProjectController(input: {
|
||||
? decodeURIComponent(store.active.slice(actionPrefix.length)) || undefined
|
||||
: undefined
|
||||
},
|
||||
activeAction() {
|
||||
return store.active.startsWith(actionPrefix)
|
||||
},
|
||||
setSearchRef(el: HTMLInputElement) {
|
||||
searchRef = el
|
||||
},
|
||||
@@ -175,10 +167,7 @@ export function createPromptProjectController(input: {
|
||||
|
||||
export type PromptProjectController = ReturnType<typeof createPromptProjectController>
|
||||
|
||||
export function PromptProjectSelector(props: {
|
||||
controller: PromptProjectController
|
||||
placement?: "bottom" | "bottom-start"
|
||||
}) {
|
||||
export function PromptProjectSelector(props: { controller: PromptProjectController }) {
|
||||
let contentRef: HTMLDivElement | undefined
|
||||
let restoreTrigger = true
|
||||
|
||||
@@ -212,12 +201,6 @@ export function PromptProjectSelector(props: {
|
||||
selectProject(project)
|
||||
return
|
||||
}
|
||||
if (props.controller.activeAction() && props.controller.servers().length > 1) {
|
||||
const item = activeItem()
|
||||
item?.focus()
|
||||
item?.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowRight", bubbles: true }))
|
||||
return
|
||||
}
|
||||
selectAction(props.controller.activeServer())
|
||||
}
|
||||
const moveActive = (delta: number) => {
|
||||
@@ -246,8 +229,9 @@ export function PromptProjectSelector(props: {
|
||||
return (
|
||||
<DropdownMenu
|
||||
open={props.controller.open()}
|
||||
placement={props.placement ?? "bottom"}
|
||||
placement="bottom-start"
|
||||
gutter={4}
|
||||
shift={-6}
|
||||
modal={false}
|
||||
onOpenChange={(open) => props.controller.setOpen(open)}
|
||||
>
|
||||
@@ -334,13 +318,7 @@ export function PromptProjectSelector(props: {
|
||||
</DropdownMenu.RadioGroup>
|
||||
}
|
||||
>
|
||||
<For
|
||||
each={props.controller
|
||||
.servers()
|
||||
.filter((server) =>
|
||||
props.controller.projects().some((project) => project.server?.key === server!.key),
|
||||
)}
|
||||
>
|
||||
<For each={props.controller.servers()}>
|
||||
{(server) => (
|
||||
<div>
|
||||
<div class="flex h-7 select-none items-center pl-1.5 pr-3 text-[11px] font-[530] leading-none tracking-[0.05px] text-v2-text-text-faint">
|
||||
@@ -353,49 +331,22 @@ export function PromptProjectSelector(props: {
|
||||
)}
|
||||
</For>
|
||||
</DropdownMenu.RadioGroup>
|
||||
<ProjectAction server={server!.key} controller={props.controller} onSelect={selectAction} />
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</div>
|
||||
<div class="h-px bg-v2-border-border-muted" />
|
||||
<div class="flex flex-col p-0.5">
|
||||
<Show
|
||||
when={props.controller.servers().length > 1}
|
||||
fallback={
|
||||
<ProjectAction
|
||||
server={props.controller.servers()[0]?.key}
|
||||
controller={props.controller}
|
||||
onSelect={selectAction}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<DropdownMenu.Sub>
|
||||
<DropdownMenu.SubTrigger
|
||||
id={props.controller.actionKey()}
|
||||
data-option-key={props.controller.actionKey()}
|
||||
class={projectActionClass}
|
||||
classList={{
|
||||
"!bg-v2-overlay-simple-overlay-hover": props.controller.active() === props.controller.actionKey(),
|
||||
}}
|
||||
onMouseEnter={() => props.controller.setActive(props.controller.actionKey())}
|
||||
>
|
||||
<Icon name="plus" size="small" />
|
||||
<span data-slot="dropdown-menu-item-label" class="min-w-0 flex-1 truncate leading-5">
|
||||
{props.controller.labels.add()}
|
||||
</span>
|
||||
<Icon name="chevron-right" size="small" class="shrink-0 text-v2-icon-icon-muted" />
|
||||
</DropdownMenu.SubTrigger>
|
||||
<DropdownMenu.Portal>
|
||||
<DropdownMenu.SubContent class="min-w-[180px] overflow-hidden rounded-md border-0 bg-v2-background-bg-layer-01 p-0.5 shadow-[var(--v2-elevation-floating)] focus:outline-none">
|
||||
<For each={props.controller.servers()}>
|
||||
{(server) => <ServerAction server={server!} onSelect={selectAction} />}
|
||||
</For>
|
||||
</DropdownMenu.SubContent>
|
||||
</DropdownMenu.Portal>
|
||||
</DropdownMenu.Sub>
|
||||
</Show>
|
||||
</div>
|
||||
<Show when={props.controller.servers().length <= 1}>
|
||||
<div class="h-px bg-v2-border-border-muted" />
|
||||
<div class="flex flex-col p-0.5">
|
||||
<ProjectAction
|
||||
server={props.controller.servers()[0]?.key}
|
||||
controller={props.controller}
|
||||
onSelect={selectAction}
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Portal>
|
||||
</DropdownMenu>
|
||||
@@ -425,12 +376,11 @@ function ProjectTrigger(props: ComponentProps<"button"> & { controller: PromptPr
|
||||
{...rest}
|
||||
data-action="prompt-project"
|
||||
type="button"
|
||||
class="flex h-7 min-w-0 max-w-[203px] items-center gap-1.5 rounded-sm px-1.5 transition-colors focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none"
|
||||
class="flex h-7 min-w-0 max-w-[203px] items-center gap-1.5 rounded-sm px-2 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-faint transition-colors focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none"
|
||||
classList={{
|
||||
...local.classList,
|
||||
"hover:bg-v2-overlay-simple-overlay-hover": !local.controller.open(),
|
||||
"bg-v2-overlay-simple-overlay-pressed": local.controller.open(),
|
||||
"text-v2-text-text-muted": local.controller.open(),
|
||||
}}
|
||||
onClick={local.onClick ?? (() => local.controller.setOpen(true))}
|
||||
onKeyDown={(event) => {
|
||||
@@ -504,9 +454,6 @@ function ProjectItem(props: {
|
||||
)
|
||||
}
|
||||
|
||||
const projectActionClass =
|
||||
"h-7 gap-2 rounded-sm px-3 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base [font-family:var(--v2-font-family-sans)] data-[highlighted]:!bg-v2-overlay-simple-overlay-hover"
|
||||
|
||||
function ProjectAction(props: {
|
||||
server?: string
|
||||
controller: PromptProjectController
|
||||
@@ -541,11 +488,3 @@ function ProjectAction(props: {
|
||||
</DropdownMenu.Item>
|
||||
)
|
||||
}
|
||||
|
||||
function ServerAction(props: { server: { key: string; name: string }; onSelect: (server: string) => void }) {
|
||||
return (
|
||||
<DropdownMenu.Item class={projectActionClass} onSelect={() => props.onSelect(props.server.key)}>
|
||||
<DropdownMenu.ItemLabel class="min-w-0 flex-1 truncate leading-5">{props.server.name}</DropdownMenu.ItemLabel>
|
||||
</DropdownMenu.Item>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,102 +0,0 @@
|
||||
import { For, Show } from "solid-js"
|
||||
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
||||
import { getFilename } from "@opencode-ai/core/util/path"
|
||||
import { useLanguage } from "@/context/language"
|
||||
|
||||
export function PromptWorkspaceSelector(props: {
|
||||
value: string
|
||||
projectRoot: string
|
||||
workspaces: string[]
|
||||
branch?: string
|
||||
onChange: (value: string) => void
|
||||
onDone: () => void
|
||||
}) {
|
||||
const language = useLanguage()
|
||||
let pending: string | undefined
|
||||
const selected = () => (props.value === props.projectRoot ? "main" : props.value)
|
||||
const icon = () => {
|
||||
if (selected() === "main") return "monitor"
|
||||
if (selected() === "create") return "workspace-new"
|
||||
return "workspace"
|
||||
}
|
||||
const select = (value: string) => {
|
||||
pending = value
|
||||
}
|
||||
const onOpenChange = (open: boolean) => {
|
||||
if (open) return
|
||||
const value = pending
|
||||
pending = undefined
|
||||
if (value) props.onChange(value)
|
||||
props.onDone()
|
||||
}
|
||||
const label = () => {
|
||||
if (selected() === "main") return language.t("session.new.workspace.triggerLocal")
|
||||
if (props.value === "create") return language.t("workspace.new")
|
||||
return getFilename(props.value)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<span class="hidden select-none opacity-50 sm:inline mx-1">/</span>
|
||||
<MenuV2 placement="bottom" gutter={4} onOpenChange={onOpenChange}>
|
||||
<MenuV2.Trigger class="flex h-7 min-w-0 max-w-[203px] items-center gap-1.5 rounded-sm px-1.5 hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none data-[expanded]:bg-v2-overlay-simple-overlay-pressed data-[expanded]:text-v2-text-text-muted">
|
||||
<IconV2 name={icon()} class="shrink-0 text-v2-icon-icon-muted" />
|
||||
<span class="min-w-0 truncate">{label()}</span>
|
||||
<Icon name="chevron-down" size="small" class="shrink-0 text-v2-icon-icon-muted" />
|
||||
</MenuV2.Trigger>
|
||||
<MenuV2.Portal>
|
||||
<MenuV2.Content class="w-[180px]">
|
||||
<MenuV2.Group>
|
||||
<MenuV2.GroupLabel>{language.t("session.new.workspace.runIn")}</MenuV2.GroupLabel>
|
||||
<MenuV2.Item onSelect={() => select("main")}>
|
||||
<IconV2 name="monitor" />
|
||||
<span class="min-w-0 flex-1 truncate">{language.t("session.new.workspace.local")}</span>
|
||||
<Show when={selected() === "main"}>
|
||||
<Icon name="check" size="small" class="shrink-0" />
|
||||
</Show>
|
||||
</MenuV2.Item>
|
||||
<MenuV2.Item onSelect={() => select("create")}>
|
||||
<IconV2 name="workspace-new" />
|
||||
<span class="min-w-0 flex-1 truncate">{language.t("workspace.new")}</span>
|
||||
<Show when={selected() === "create"}>
|
||||
<Icon name="check" size="small" class="shrink-0" />
|
||||
</Show>
|
||||
</MenuV2.Item>
|
||||
</MenuV2.Group>
|
||||
<Show when={props.workspaces.length > 0}>
|
||||
<MenuV2.Separator />
|
||||
<MenuV2.Sub gutter={0} overlap overflowPadding={8}>
|
||||
<MenuV2.SubTrigger>
|
||||
<IconV2 name="workspace" />
|
||||
{language.t("session.new.workspace.existing")}
|
||||
</MenuV2.SubTrigger>
|
||||
<MenuV2.Portal>
|
||||
<MenuV2.SubContent class="max-w-[200px]">
|
||||
<For each={props.workspaces}>
|
||||
{(workspace) => (
|
||||
<MenuV2.Item onSelect={() => select(workspace)}>
|
||||
<IconV2 name="workspace-isolated" />
|
||||
<span class="min-w-0 flex-1 truncate">{getFilename(workspace)}</span>
|
||||
<Show when={selected() === workspace}>
|
||||
<Icon name="check" size="small" class="shrink-0" />
|
||||
</Show>
|
||||
</MenuV2.Item>
|
||||
)}
|
||||
</For>
|
||||
</MenuV2.SubContent>
|
||||
</MenuV2.Portal>
|
||||
</MenuV2.Sub>
|
||||
</Show>
|
||||
</MenuV2.Content>
|
||||
</MenuV2.Portal>
|
||||
</MenuV2>
|
||||
<span class="hidden select-none opacity-50 sm:inline mx-1">/</span>
|
||||
<div class="flex h-7 min-w-0 max-w-[220px] items-center gap-1.5 px-2 text-[13px] font-[440] leading-5 tracking-[-0.04px]">
|
||||
<Icon name="branch" size="small" class="shrink-0 text-v2-icon-icon-muted" />
|
||||
<span class="min-w-0 truncate">{props.branch || "main"}</span>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
|
||||
import { Dialog, DialogBody, DialogFooter, DialogHeader, DialogTitle } from "@opencode-ai/ui/v2/dialog-v2"
|
||||
import { DividerV2 } from "@opencode-ai/ui/v2/divider-v2"
|
||||
import { Dialog, DialogFooter } from "@opencode-ai/ui/v2/dialog-v2"
|
||||
import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { type Component, Show, createEffect, createSignal, onCleanup, onMount } from "solid-js"
|
||||
@@ -53,12 +52,8 @@ export const DialogServerV2: Component<{
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog fit class="settings-v2-server-dialog">
|
||||
<DialogHeader hideClose={true}>
|
||||
<DialogTitle>{title()}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<DividerV2 />
|
||||
<DialogBody class="flex w-full min-w-0 flex-1 flex-col px-4 pt-4 pb-2">
|
||||
<Dialog title={title()} fit class="settings-v2-server-dialog">
|
||||
<div class="flex w-full min-w-0 flex-1 flex-col px-4">
|
||||
<div class="flex w-full min-w-0 flex-col gap-6">
|
||||
<div class="flex w-full min-w-0 flex-col gap-2">
|
||||
<label class="settings-v2-server-dialog-label">{language.t("dialog.server.add.url")}</label>
|
||||
@@ -120,7 +115,7 @@ export const DialogServerV2: Component<{
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DialogBody>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<ButtonV2 variant="neutral" disabled={controller.formBusy()} onClick={() => dialog.close()}>
|
||||
{language.t("common.cancel")}
|
||||
|
||||
@@ -633,7 +633,7 @@
|
||||
|
||||
[data-component="dialog-v2"].settings-v2-server-dialog [data-slot="dialog-header"] {
|
||||
align-items: center;
|
||||
padding: 24px 24px 16px;
|
||||
padding: 24px 24px 0;
|
||||
}
|
||||
|
||||
[data-component="dialog-v2"].settings-v2-server-dialog [data-slot="dialog-body"] {
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { createRoot } from "solid-js"
|
||||
import { createFileTreeStore } from "./tree-store"
|
||||
|
||||
describe("file tree store", () => {
|
||||
test("expands synthetic directories without listing them", () => {
|
||||
const listed: string[] = []
|
||||
const value = createRoot((dispose) => ({
|
||||
dispose,
|
||||
tree: createFileTreeStore({
|
||||
scope: () => "/project",
|
||||
normalizeDir: (input) => input,
|
||||
list: (input) => {
|
||||
listed.push(input)
|
||||
return Promise.resolve([])
|
||||
},
|
||||
onError: () => undefined,
|
||||
}),
|
||||
}))
|
||||
|
||||
value.tree.expandDir("deleted/parent", { load: false })
|
||||
|
||||
expect(value.tree.dirState("deleted/parent")?.expanded).toBe(true)
|
||||
expect(listed).toEqual([])
|
||||
|
||||
value.dispose()
|
||||
})
|
||||
})
|
||||
@@ -127,11 +127,10 @@ export function createFileTreeStore(options: TreeStoreOptions) {
|
||||
return promise
|
||||
}
|
||||
|
||||
const expandDir = (input: string, opts?: { load?: boolean }) => {
|
||||
const expandDir = (input: string) => {
|
||||
const dir = options.normalizeDir(input)
|
||||
ensureDir(dir)
|
||||
setTree("dir", dir, "expanded", true)
|
||||
if (opts?.load === false) return
|
||||
void listDir(dir)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { createStore, reconcile } from "solid-js/store"
|
||||
import { type Accessor, batch, createEffect, createMemo, createRoot, getOwner, onCleanup } from "solid-js"
|
||||
import { useParams, useSearchParams } from "@solidjs/router"
|
||||
import { type Accessor, batch, createEffect, createMemo, onCleanup } from "solid-js"
|
||||
import { useParams } from "@solidjs/router"
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import type { ServerSDK } from "./server-sdk"
|
||||
import type { ServerSync } from "./server-sync"
|
||||
import { useServerSDK } from "./server-sdk"
|
||||
import { useServerSync } from "./server-sync"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useSettings } from "@/context/settings"
|
||||
@@ -12,11 +12,6 @@ import { decode64 } from "@/utils/base64"
|
||||
import { EventSessionError } from "@opencode-ai/sdk/v2"
|
||||
import { Persist, persisted } from "@/utils/persist"
|
||||
import { playSoundById } from "@/utils/sound"
|
||||
import { useGlobal } from "./global"
|
||||
import { ServerConnection, useServer } from "./server"
|
||||
import { type DraftTab, useTabs } from "./tabs"
|
||||
import { requireServerKey } from "@/utils/session-route"
|
||||
import type { ServerScope } from "@/utils/server-scope"
|
||||
|
||||
type NotificationBase = {
|
||||
directory?: string
|
||||
@@ -112,360 +107,267 @@ function buildNotificationIndex(list: Notification[]) {
|
||||
export const { use: useNotification, provider: NotificationProvider } = createSimpleContext({
|
||||
name: "Notification",
|
||||
gate: false,
|
||||
init: () => {
|
||||
const params = useParams<{ serverKey?: string; dir?: string; id?: string }>()
|
||||
const [search] = useSearchParams<{ draftId?: string }>()
|
||||
const global = useGlobal()
|
||||
const server = useServer()
|
||||
const tabs = useTabs()
|
||||
init: (props: { directory?: Accessor<string | undefined>; sessionID?: Accessor<string | undefined> }) => {
|
||||
const params = useParams()
|
||||
const serverSDK = useServerSDK()
|
||||
const serverSync = useServerSync()
|
||||
const platform = usePlatform()
|
||||
const settings = useSettings()
|
||||
const language = useLanguage()
|
||||
const owner = getOwner()
|
||||
const states = new Map<ServerScope, { dispose: () => void; state: NotificationState }>()
|
||||
|
||||
const activeServer = createMemo(() => {
|
||||
if (params.serverKey) return requireServerKey(params.serverKey)
|
||||
if (search.draftId) {
|
||||
const draft = tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === search.draftId)
|
||||
if (draft) return draft.server
|
||||
}
|
||||
return server.key
|
||||
const empty: Notification[] = []
|
||||
|
||||
const currentDirectory = createMemo(() => {
|
||||
return props.directory?.() ?? decode64(params.dir)
|
||||
})
|
||||
const activeDirectory = createMemo(() => decode64(params.dir))
|
||||
const activeSession = createMemo(() => params.id)
|
||||
|
||||
const ensure = (key: ServerConnection.Key) => {
|
||||
const conn = global.servers.list().find((item) => ServerConnection.key(item) === key)
|
||||
if (!conn) throw new Error(`Notification server not found: ${key}`)
|
||||
const ctx = global.ensureServerCtx(conn)
|
||||
const existing = states.get(ctx.sdk.scope)
|
||||
if (existing) return existing.state
|
||||
const root = createRoot(
|
||||
(dispose) => ({
|
||||
dispose,
|
||||
state: createServerNotificationState({
|
||||
sdk: ctx.sdk,
|
||||
sync: ctx.sync,
|
||||
active: () => server.scope(activeServer()) === ctx.sdk.scope,
|
||||
directory: activeDirectory,
|
||||
sessionID: activeSession,
|
||||
platform,
|
||||
settings,
|
||||
language,
|
||||
}),
|
||||
}),
|
||||
owner ?? undefined,
|
||||
const currentSession = createMemo(() => props.sessionID?.() ?? params.id)
|
||||
|
||||
const [store, setStore, _, ready] = persisted(
|
||||
Persist.serverGlobal(serverSDK().scope, "notification", ["notification.v1"]),
|
||||
createStore({
|
||||
list: [] as Notification[],
|
||||
}),
|
||||
)
|
||||
const [index, setIndex] = createStore<NotificationIndex>(buildNotificationIndex(store.list))
|
||||
|
||||
const meta = { pruned: false, disposed: false }
|
||||
|
||||
const updateUnseen = (scope: "session" | "project", key: string, unseen: Notification[]) => {
|
||||
setIndex(scope, "unseen", key, unseen)
|
||||
setIndex(scope, "unseenCount", key, unseen.length)
|
||||
setIndex(
|
||||
scope,
|
||||
"unseenHasError",
|
||||
key,
|
||||
unseen.some((notification) => notification.type === "error"),
|
||||
)
|
||||
states.set(ctx.sdk.scope, root)
|
||||
return root.state
|
||||
}
|
||||
|
||||
const appendToIndex = (notification: Notification) => {
|
||||
if (notification.session) {
|
||||
setIndex("session", "all", notification.session, (all = []) => [...all, notification])
|
||||
if (!notification.viewed) {
|
||||
setIndex("session", "unseen", notification.session, (unseen = []) => [...unseen, notification])
|
||||
setIndex("session", "unseenCount", notification.session, (count = 0) => count + 1)
|
||||
if (notification.type === "error") setIndex("session", "unseenHasError", notification.session, true)
|
||||
}
|
||||
}
|
||||
|
||||
if (notification.directory) {
|
||||
setIndex("project", "all", notification.directory, (all = []) => [...all, notification])
|
||||
if (!notification.viewed) {
|
||||
setIndex("project", "unseen", notification.directory, (unseen = []) => [...unseen, notification])
|
||||
setIndex("project", "unseenCount", notification.directory, (count = 0) => count + 1)
|
||||
if (notification.type === "error") setIndex("project", "unseenHasError", notification.directory, true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const removeFromIndex = (notification: Notification) => {
|
||||
if (notification.session) {
|
||||
setIndex("session", "all", notification.session, (all = []) => all.filter((n) => n !== notification))
|
||||
if (!notification.viewed) {
|
||||
const unseen = (index.session.unseen[notification.session] ?? empty).filter((n) => n !== notification)
|
||||
updateUnseen("session", notification.session, unseen)
|
||||
}
|
||||
}
|
||||
|
||||
if (notification.directory) {
|
||||
setIndex("project", "all", notification.directory, (all = []) => all.filter((n) => n !== notification))
|
||||
if (!notification.viewed) {
|
||||
const unseen = (index.project.unseen[notification.directory] ?? empty).filter((n) => n !== notification)
|
||||
updateUnseen("project", notification.directory, unseen)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
global.servers.list().forEach((conn) => ensure(ServerConnection.key(conn)))
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const scopes = new Set(global.servers.list().map((conn) => server.scope(ServerConnection.key(conn))))
|
||||
states.forEach((value, scope) => {
|
||||
if (scopes.has(scope)) return
|
||||
value.dispose()
|
||||
states.delete(scope)
|
||||
if (!ready()) return
|
||||
if (meta.pruned) return
|
||||
meta.pruned = true
|
||||
const list = pruneNotifications(store.list)
|
||||
batch(() => {
|
||||
setStore("list", list)
|
||||
setIndex(reconcile(buildNotificationIndex(list), { merge: false }))
|
||||
})
|
||||
})
|
||||
|
||||
onCleanup(() => states.forEach((value) => value.dispose()))
|
||||
const append = (notification: Notification) => {
|
||||
const list = pruneNotifications([...store.list, notification])
|
||||
const keep = new Set(list)
|
||||
const removed = store.list.filter((n) => !keep.has(n))
|
||||
|
||||
const selected = () => ensure(activeServer())
|
||||
batch(() => {
|
||||
if (keep.has(notification)) appendToIndex(notification)
|
||||
removed.forEach((n) => removeFromIndex(n))
|
||||
setStore("list", list)
|
||||
})
|
||||
}
|
||||
|
||||
const lookup = async (directory: string, sessionID?: string) => {
|
||||
if (!sessionID) return undefined
|
||||
const sync = serverSync().ensureDirSyncContext(directory)
|
||||
const session = sync.session.get(sessionID)
|
||||
if (session) return session
|
||||
return sync.session
|
||||
.sync(sessionID)
|
||||
.then(() => sync.session.get(sessionID))
|
||||
.catch(() => undefined)
|
||||
}
|
||||
|
||||
const viewedInCurrentSession = (directory: string, sessionID?: string) => {
|
||||
const activeDirectory = currentDirectory()
|
||||
const activeSession = currentSession()
|
||||
if (!activeDirectory) return false
|
||||
if (!activeSession) return false
|
||||
if (!sessionID) return false
|
||||
if (directory !== activeDirectory) return false
|
||||
return sessionID === activeSession
|
||||
}
|
||||
|
||||
const handleSessionIdle = (directory: string, event: { properties: { sessionID?: string } }, time: number) => {
|
||||
const sessionID = event.properties.sessionID
|
||||
void lookup(directory, sessionID).then((session) => {
|
||||
if (meta.disposed) return
|
||||
if (!session) return
|
||||
if (session.parentID) return
|
||||
|
||||
if (settings.sounds.agentEnabled()) {
|
||||
void playSoundById(settings.sounds.agent())
|
||||
}
|
||||
|
||||
append({
|
||||
directory,
|
||||
time,
|
||||
viewed: viewedInCurrentSession(directory, sessionID),
|
||||
type: "turn-complete",
|
||||
session: sessionID,
|
||||
})
|
||||
|
||||
const href = `/${base64Encode(directory)}/session/${sessionID}`
|
||||
if (settings.notifications.agent()) {
|
||||
void platform.notify(language.t("notification.session.responseReady.title"), session.title ?? sessionID, href)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const handleSessionError = (
|
||||
directory: string,
|
||||
event: { properties: { sessionID?: string; error?: EventSessionError["properties"]["error"] } },
|
||||
time: number,
|
||||
) => {
|
||||
const sessionID = event.properties.sessionID
|
||||
void lookup(directory, sessionID).then((session) => {
|
||||
if (meta.disposed) return
|
||||
if (session?.parentID) return
|
||||
|
||||
if (settings.sounds.errorsEnabled()) {
|
||||
void playSoundById(settings.sounds.errors())
|
||||
}
|
||||
|
||||
const error = "error" in event.properties ? event.properties.error : undefined
|
||||
append({
|
||||
directory,
|
||||
time,
|
||||
viewed: viewedInCurrentSession(directory, sessionID),
|
||||
type: "error",
|
||||
session: sessionID ?? "global",
|
||||
error,
|
||||
})
|
||||
const description =
|
||||
session?.title ??
|
||||
(typeof error === "string" ? error : language.t("notification.session.error.fallbackDescription"))
|
||||
const href = sessionID ? `/${base64Encode(directory)}/session/${sessionID}` : `/${base64Encode(directory)}`
|
||||
if (settings.notifications.errors()) {
|
||||
void platform.notify(language.t("notification.session.error.title"), description, href)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const unsub = serverSDK().event.listen((e) => {
|
||||
const event = e.details
|
||||
if (event.type !== "session.idle" && event.type !== "session.error") return
|
||||
|
||||
const directory = e.name
|
||||
const time = Date.now()
|
||||
if (event.type === "session.idle") {
|
||||
handleSessionIdle(directory, event, time)
|
||||
return
|
||||
}
|
||||
handleSessionError(directory, event, time)
|
||||
})
|
||||
onCleanup(() => {
|
||||
meta.disposed = true
|
||||
unsub()
|
||||
})
|
||||
|
||||
return {
|
||||
ready: () => selected().ready(),
|
||||
ensureServerState: ensure,
|
||||
ready,
|
||||
session: {
|
||||
all: (session: string) => selected().session.all(session),
|
||||
unseen: (session: string) => selected().session.unseen(session),
|
||||
unseenCount: (session: string) => selected().session.unseenCount(session),
|
||||
unseenHasError: (session: string) => selected().session.unseenHasError(session),
|
||||
markViewed: (session: string) => selected().session.markViewed(session),
|
||||
all(session: string) {
|
||||
return index.session.all[session] ?? empty
|
||||
},
|
||||
unseen(session: string) {
|
||||
return index.session.unseen[session] ?? empty
|
||||
},
|
||||
unseenCount(session: string) {
|
||||
return index.session.unseenCount[session] ?? 0
|
||||
},
|
||||
unseenHasError(session: string) {
|
||||
return index.session.unseenHasError[session] ?? false
|
||||
},
|
||||
markViewed(session: string) {
|
||||
const unseen = index.session.unseen[session] ?? empty
|
||||
if (!unseen.length) return
|
||||
|
||||
const projects = [
|
||||
...new Set(unseen.flatMap((notification) => (notification.directory ? [notification.directory] : []))),
|
||||
]
|
||||
batch(() => {
|
||||
setStore("list", (n) => n.session === session && !n.viewed, "viewed", true)
|
||||
updateUnseen("session", session, [])
|
||||
projects.forEach((directory) => {
|
||||
const next = (index.project.unseen[directory] ?? empty).filter(
|
||||
(notification) => notification.session !== session,
|
||||
)
|
||||
updateUnseen("project", directory, next)
|
||||
})
|
||||
})
|
||||
},
|
||||
},
|
||||
project: {
|
||||
all: (directory: string) => selected().project.all(directory),
|
||||
unseen: (directory: string) => selected().project.unseen(directory),
|
||||
unseenCount: (directory: string) => selected().project.unseenCount(directory),
|
||||
unseenHasError: (directory: string) => selected().project.unseenHasError(directory),
|
||||
markViewed: (directory: string) => selected().project.markViewed(directory),
|
||||
all(directory: string) {
|
||||
return index.project.all[directory] ?? empty
|
||||
},
|
||||
unseen(directory: string) {
|
||||
return index.project.unseen[directory] ?? empty
|
||||
},
|
||||
unseenCount(directory: string) {
|
||||
return index.project.unseenCount[directory] ?? 0
|
||||
},
|
||||
unseenHasError(directory: string) {
|
||||
return index.project.unseenHasError[directory] ?? false
|
||||
},
|
||||
markViewed(directory: string) {
|
||||
const unseen = index.project.unseen[directory] ?? empty
|
||||
if (!unseen.length) return
|
||||
|
||||
const sessions = [
|
||||
...new Set(unseen.flatMap((notification) => (notification.session ? [notification.session] : []))),
|
||||
]
|
||||
batch(() => {
|
||||
setStore("list", (n) => n.directory === directory && !n.viewed, "viewed", true)
|
||||
updateUnseen("project", directory, [])
|
||||
sessions.forEach((session) => {
|
||||
const next = (index.session.unseen[session] ?? empty).filter(
|
||||
(notification) => notification.directory !== directory,
|
||||
)
|
||||
updateUnseen("session", session, next)
|
||||
})
|
||||
})
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
type NotificationState = ReturnType<typeof createServerNotificationState>
|
||||
|
||||
function createServerNotificationState(input: {
|
||||
sdk: ServerSDK
|
||||
sync: ServerSync
|
||||
active: Accessor<boolean>
|
||||
directory: Accessor<string | undefined>
|
||||
sessionID: Accessor<string | undefined>
|
||||
platform: ReturnType<typeof usePlatform>
|
||||
settings: ReturnType<typeof useSettings>
|
||||
language: ReturnType<typeof useLanguage>
|
||||
}) {
|
||||
const serverSDK = () => input.sdk
|
||||
const serverSync = () => input.sync
|
||||
const platform = input.platform
|
||||
const settings = input.settings
|
||||
const language = input.language
|
||||
|
||||
const empty: Notification[] = []
|
||||
|
||||
const currentDirectory = input.directory
|
||||
const currentSession = input.sessionID
|
||||
|
||||
const [store, setStore, _, ready] = persisted(
|
||||
Persist.serverGlobal(serverSDK().scope, "notification", ["notification.v1"]),
|
||||
createStore({
|
||||
list: [] as Notification[],
|
||||
}),
|
||||
)
|
||||
const [index, setIndex] = createStore<NotificationIndex>(buildNotificationIndex(store.list))
|
||||
|
||||
const meta = { pruned: false, disposed: false }
|
||||
|
||||
const updateUnseen = (scope: "session" | "project", key: string, unseen: Notification[]) => {
|
||||
setIndex(scope, "unseen", key, unseen)
|
||||
setIndex(scope, "unseenCount", key, unseen.length)
|
||||
setIndex(
|
||||
scope,
|
||||
"unseenHasError",
|
||||
key,
|
||||
unseen.some((notification) => notification.type === "error"),
|
||||
)
|
||||
}
|
||||
|
||||
const appendToIndex = (notification: Notification) => {
|
||||
if (notification.session) {
|
||||
setIndex("session", "all", notification.session, (all = []) => [...all, notification])
|
||||
if (!notification.viewed) {
|
||||
setIndex("session", "unseen", notification.session, (unseen = []) => [...unseen, notification])
|
||||
setIndex("session", "unseenCount", notification.session, (count = 0) => count + 1)
|
||||
if (notification.type === "error") setIndex("session", "unseenHasError", notification.session, true)
|
||||
}
|
||||
}
|
||||
|
||||
if (notification.directory) {
|
||||
setIndex("project", "all", notification.directory, (all = []) => [...all, notification])
|
||||
if (!notification.viewed) {
|
||||
setIndex("project", "unseen", notification.directory, (unseen = []) => [...unseen, notification])
|
||||
setIndex("project", "unseenCount", notification.directory, (count = 0) => count + 1)
|
||||
if (notification.type === "error") setIndex("project", "unseenHasError", notification.directory, true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const removeFromIndex = (notification: Notification) => {
|
||||
if (notification.session) {
|
||||
setIndex("session", "all", notification.session, (all = []) => all.filter((n) => n !== notification))
|
||||
if (!notification.viewed) {
|
||||
const unseen = (index.session.unseen[notification.session] ?? empty).filter((n) => n !== notification)
|
||||
updateUnseen("session", notification.session, unseen)
|
||||
}
|
||||
}
|
||||
|
||||
if (notification.directory) {
|
||||
setIndex("project", "all", notification.directory, (all = []) => all.filter((n) => n !== notification))
|
||||
if (!notification.viewed) {
|
||||
const unseen = (index.project.unseen[notification.directory] ?? empty).filter((n) => n !== notification)
|
||||
updateUnseen("project", notification.directory, unseen)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
if (!ready()) return
|
||||
if (meta.pruned) return
|
||||
meta.pruned = true
|
||||
const list = pruneNotifications(store.list)
|
||||
batch(() => {
|
||||
setStore("list", list)
|
||||
setIndex(reconcile(buildNotificationIndex(list), { merge: false }))
|
||||
})
|
||||
})
|
||||
|
||||
const append = (notification: Notification) => {
|
||||
const list = pruneNotifications([...store.list, notification])
|
||||
const keep = new Set(list)
|
||||
const removed = store.list.filter((n) => !keep.has(n))
|
||||
|
||||
batch(() => {
|
||||
if (keep.has(notification)) appendToIndex(notification)
|
||||
removed.forEach((n) => removeFromIndex(n))
|
||||
setStore("list", list)
|
||||
})
|
||||
}
|
||||
|
||||
const lookup = async (directory: string, sessionID?: string) => {
|
||||
if (!sessionID) return undefined
|
||||
const sync = serverSync().ensureDirSyncContext(directory)
|
||||
const session = sync.session.get(sessionID)
|
||||
if (session) return session
|
||||
return sync.session
|
||||
.sync(sessionID)
|
||||
.then(() => sync.session.get(sessionID))
|
||||
.catch(() => undefined)
|
||||
}
|
||||
|
||||
const viewedInCurrentSession = (directory: string, sessionID?: string) => {
|
||||
if (!input.active()) return false
|
||||
const activeDirectory = currentDirectory()
|
||||
const activeSession = currentSession()
|
||||
if (!activeSession) return false
|
||||
if (!sessionID) return false
|
||||
if (activeDirectory && directory !== activeDirectory) return false
|
||||
return sessionID === activeSession
|
||||
}
|
||||
|
||||
const handleSessionIdle = (directory: string, event: { properties: { sessionID?: string } }, time: number) => {
|
||||
const sessionID = event.properties.sessionID
|
||||
void lookup(directory, sessionID).then((session) => {
|
||||
if (meta.disposed) return
|
||||
if (!session) return
|
||||
if (session.parentID) return
|
||||
|
||||
if (settings.sounds.agentEnabled()) {
|
||||
void playSoundById(settings.sounds.agent())
|
||||
}
|
||||
|
||||
append({
|
||||
directory,
|
||||
time,
|
||||
viewed: viewedInCurrentSession(directory, sessionID),
|
||||
type: "turn-complete",
|
||||
session: sessionID,
|
||||
})
|
||||
|
||||
const href = `/${base64Encode(directory)}/session/${sessionID}`
|
||||
if (settings.notifications.agent()) {
|
||||
void platform.notify(language.t("notification.session.responseReady.title"), session.title ?? sessionID, href)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const handleSessionError = (
|
||||
directory: string,
|
||||
event: { properties: { sessionID?: string; error?: EventSessionError["properties"]["error"] } },
|
||||
time: number,
|
||||
) => {
|
||||
const sessionID = event.properties.sessionID
|
||||
void lookup(directory, sessionID).then((session) => {
|
||||
if (meta.disposed) return
|
||||
if (session?.parentID) return
|
||||
|
||||
if (settings.sounds.errorsEnabled()) {
|
||||
void playSoundById(settings.sounds.errors())
|
||||
}
|
||||
|
||||
const error = "error" in event.properties ? event.properties.error : undefined
|
||||
append({
|
||||
directory,
|
||||
time,
|
||||
viewed: viewedInCurrentSession(directory, sessionID),
|
||||
type: "error",
|
||||
session: sessionID ?? "global",
|
||||
error,
|
||||
})
|
||||
const description =
|
||||
session?.title ??
|
||||
(typeof error === "string" ? error : language.t("notification.session.error.fallbackDescription"))
|
||||
const href = sessionID ? `/${base64Encode(directory)}/session/${sessionID}` : `/${base64Encode(directory)}`
|
||||
if (settings.notifications.errors()) {
|
||||
void platform.notify(language.t("notification.session.error.title"), description, href)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const unsub = serverSDK().event.listen((e) => {
|
||||
const event = e.details
|
||||
if (event.type !== "session.idle" && event.type !== "session.error") return
|
||||
|
||||
const directory = e.name
|
||||
const time = Date.now()
|
||||
if (event.type === "session.idle") {
|
||||
handleSessionIdle(directory, event, time)
|
||||
return
|
||||
}
|
||||
handleSessionError(directory, event, time)
|
||||
})
|
||||
onCleanup(() => {
|
||||
meta.disposed = true
|
||||
unsub()
|
||||
})
|
||||
|
||||
return {
|
||||
ready,
|
||||
session: {
|
||||
all(session: string) {
|
||||
return index.session.all[session] ?? empty
|
||||
},
|
||||
unseen(session: string) {
|
||||
return index.session.unseen[session] ?? empty
|
||||
},
|
||||
unseenCount(session: string) {
|
||||
return index.session.unseenCount[session] ?? 0
|
||||
},
|
||||
unseenHasError(session: string) {
|
||||
return index.session.unseenHasError[session] ?? false
|
||||
},
|
||||
markViewed(session: string) {
|
||||
const unseen = index.session.unseen[session] ?? empty
|
||||
if (!unseen.length) return
|
||||
|
||||
const projects = [
|
||||
...new Set(unseen.flatMap((notification) => (notification.directory ? [notification.directory] : []))),
|
||||
]
|
||||
batch(() => {
|
||||
setStore("list", (n) => n.session === session && !n.viewed, "viewed", true)
|
||||
updateUnseen("session", session, [])
|
||||
projects.forEach((directory) => {
|
||||
const next = (index.project.unseen[directory] ?? empty).filter(
|
||||
(notification) => notification.session !== session,
|
||||
)
|
||||
updateUnseen("project", directory, next)
|
||||
})
|
||||
})
|
||||
},
|
||||
},
|
||||
project: {
|
||||
all(directory: string) {
|
||||
return index.project.all[directory] ?? empty
|
||||
},
|
||||
unseen(directory: string) {
|
||||
return index.project.unseen[directory] ?? empty
|
||||
},
|
||||
unseenCount(directory: string) {
|
||||
return index.project.unseenCount[directory] ?? 0
|
||||
},
|
||||
unseenHasError(directory: string) {
|
||||
return index.project.unseenHasError[directory] ?? false
|
||||
},
|
||||
markViewed(directory: string) {
|
||||
const unseen = index.project.unseen[directory] ?? empty
|
||||
if (!unseen.length) return
|
||||
|
||||
const sessions = [
|
||||
...new Set(unseen.flatMap((notification) => (notification.session ? [notification.session] : []))),
|
||||
]
|
||||
batch(() => {
|
||||
setStore("list", (n) => n.directory === directory && !n.viewed, "viewed", true)
|
||||
updateUnseen("project", directory, [])
|
||||
sessions.forEach((session) => {
|
||||
const next = (index.session.unseen[session] ?? empty).filter(
|
||||
(notification) => notification.directory !== directory,
|
||||
)
|
||||
updateUnseen("session", session, next)
|
||||
})
|
||||
})
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -656,10 +656,6 @@ export const dict = {
|
||||
"session.new.worktree.main": "Main branch",
|
||||
"session.new.worktree.mainWithBranch": "Main branch ({{branch}})",
|
||||
"session.new.worktree.create": "Create new worktree",
|
||||
"session.new.workspace.runIn": "Run session in",
|
||||
"session.new.workspace.triggerLocal": "Local",
|
||||
"session.new.workspace.local": "Local repository",
|
||||
"session.new.workspace.existing": "Workspace…",
|
||||
"session.new.lastModified": "Last modified",
|
||||
|
||||
"session.header.search.placeholder": "Search {{project}}",
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
@import "@opencode-ai/ui/styles/tailwind";
|
||||
@import "@opencode-ai/session-ui/styles";
|
||||
@import "@opencode-ai/ui/v2/styles/tailwind.css";
|
||||
@import "tw-animate-css";
|
||||
|
||||
@font-face {
|
||||
font-family: "JetBrainsMono Nerd Font Mono";
|
||||
@@ -132,4 +131,13 @@
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ import type { Session } from "@opencode-ai/sdk/v2/client"
|
||||
import {
|
||||
createEffect,
|
||||
createMemo,
|
||||
createResource,
|
||||
createRoot,
|
||||
For,
|
||||
Match,
|
||||
@@ -68,7 +67,6 @@ import { archiveHomeSession } from "./home-session-archive"
|
||||
import { showToast } from "@/utils/toast"
|
||||
|
||||
const HOME_SESSION_LIMIT = 64
|
||||
const SHOW_HOME_SESSION_ARCHIVE = false
|
||||
const HOME_ROW_LAYOUT =
|
||||
"flex min-w-0 w-full shrink-0 cursor-default items-center rounded-[6px] bg-transparent text-left transition-[background-color,color,box-shadow] duration-[120ms] ease-in-out focus-visible:outline-none"
|
||||
const HOME_ROW_BASE = `${HOME_ROW_LAYOUT} border-0`
|
||||
@@ -342,15 +340,15 @@ export function NewHome() {
|
||||
}
|
||||
|
||||
function unseenCount(conn: ServerConnection.Any, project: LocalProject) {
|
||||
const state = notification.ensureServerState(ServerConnection.key(conn))
|
||||
return directories(project).reduce((total, directory) => total + state.project.unseenCount(directory), 0)
|
||||
if (ServerConnection.key(conn) !== server.key) return 0
|
||||
return directories(project).reduce((total, directory) => total + notification.project.unseenCount(directory), 0)
|
||||
}
|
||||
|
||||
function clearNotifications(conn: ServerConnection.Any, project: LocalProject) {
|
||||
const state = notification.ensureServerState(ServerConnection.key(conn))
|
||||
if (ServerConnection.key(conn) !== server.key) return
|
||||
directories(project)
|
||||
.filter((directory) => state.project.unseenCount(directory) > 0)
|
||||
.forEach((directory) => state.project.markViewed(directory))
|
||||
.filter((directory) => notification.project.unseenCount(directory) > 0)
|
||||
.forEach((directory) => notification.project.markViewed(directory))
|
||||
}
|
||||
|
||||
function openSession(session: Session) {
|
||||
@@ -527,16 +525,10 @@ function HomeProjectColumn(props: {
|
||||
const global = useGlobal()
|
||||
const dialog = useDialog()
|
||||
const controller = useServerManagementController({ navigateOnAdd: false })
|
||||
const [_state, setState, _, ready] = persisted(
|
||||
const [state, setState] = persisted(
|
||||
Persist.global("home.servers", ["home.servers.v1"]),
|
||||
createStore({ collapsed: {} as Record<string, boolean> }),
|
||||
)
|
||||
const [state] = createResource(
|
||||
() => ready.promise ?? Promise.resolve(),
|
||||
(p) => p.then(() => _state),
|
||||
{ initialValue: _state },
|
||||
)
|
||||
|
||||
return (
|
||||
<aside
|
||||
class="mt-6 flex min-w-0 flex-col gap-4 lg:mt-14 lg:pt-[52px]"
|
||||
@@ -568,7 +560,7 @@ function HomeProjectColumn(props: {
|
||||
const key = ServerConnection.key(item)
|
||||
const healthy = () => !!global.servers.health[key]?.healthy
|
||||
const serverCtx = global.ensureServerCtx(item)
|
||||
const collapsed = () => !!state().collapsed[key]
|
||||
const collapsed = () => !!state.collapsed[key]
|
||||
return (
|
||||
<div class="flex max-h-[min(572px,calc(100vh_-_300px))] min-w-0 flex-col gap-1 overflow-y-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
|
||||
<HomeServerRow
|
||||
@@ -581,7 +573,7 @@ function HomeProjectColumn(props: {
|
||||
focusServer={props.focusServer}
|
||||
chooseProject={props.chooseProject}
|
||||
openEdit={(server) => dialog.show(() => <DialogServerV2 mode="edit" server={server} />)}
|
||||
toggleCollapsed={() => setState("collapsed", key, !state().collapsed[key])}
|
||||
toggleCollapsed={() => setState("collapsed", key, !state.collapsed[key])}
|
||||
language={props.language}
|
||||
/>
|
||||
<Show when={healthy() && !collapsed()}>
|
||||
@@ -1190,24 +1182,22 @@ function HomeSessionRow(props: {
|
||||
</span>
|
||||
</Show>
|
||||
</button>
|
||||
<Show when={SHOW_HOME_SESSION_ARCHIVE}>
|
||||
<div class="hover-reveal absolute right-1.5 top-1/2 flex -translate-y-1/2 items-center gap-1 group-hover/session:opacity-100 focus-within:opacity-100">
|
||||
<TooltipV2 class="flex shrink-0 items-center" placement="bottom" value={language.t("common.archive")}>
|
||||
<IconButtonV2
|
||||
data-action="home-session-archive"
|
||||
variant="ghost-muted"
|
||||
size="large"
|
||||
icon={<IconV2 name="archive" />}
|
||||
aria-label={language.t("common.archive")}
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
void props.archiveSession(props.record.session)
|
||||
}}
|
||||
/>
|
||||
</TooltipV2>
|
||||
</div>
|
||||
</Show>
|
||||
<div class="hover-reveal absolute right-1.5 top-1/2 flex -translate-y-1/2 items-center gap-1 group-hover/session:opacity-100 focus-within:opacity-100">
|
||||
<TooltipV2 class="flex shrink-0 items-center" placement="bottom" value={language.t("common.archive")}>
|
||||
<IconButtonV2
|
||||
data-action="home-session-archive"
|
||||
variant="ghost-muted"
|
||||
size="large"
|
||||
icon={<IconV2 name="archive" />}
|
||||
aria-label={language.t("common.archive")}
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
void props.archiveSession(props.record.session)
|
||||
}}
|
||||
/>
|
||||
</TooltipV2>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,18 +1,26 @@
|
||||
import { createEffect, Suspense, type ParentProps } from "solid-js"
|
||||
import { useNavigate } from "@solidjs/router"
|
||||
import { useNavigate, useParams } from "@solidjs/router"
|
||||
import { DebugBar } from "@/components/debug-bar"
|
||||
import { HelpButton } from "@/components/help-button"
|
||||
import { Titlebar, type TitlebarUpdate } from "@/components/titlebar"
|
||||
import { useNotification } from "@/context/notification"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { setNavigate } from "@/utils/notification-click"
|
||||
import { setV2Toast, ToastRegion } from "@/utils/toast"
|
||||
|
||||
export default function NewLayout(props: ParentProps) {
|
||||
const platform = usePlatform()
|
||||
const notification = useNotification()
|
||||
const navigate = useNavigate()
|
||||
const params = useParams<{ id?: string }>()
|
||||
setNavigate(navigate)
|
||||
|
||||
createEffect(() => setV2Toast(true))
|
||||
createEffect(() => {
|
||||
if (!notification.ready() || !params.id) return
|
||||
if (notification.session.unseenCount(params.id) === 0) return
|
||||
notification.session.markViewed(params.id)
|
||||
})
|
||||
|
||||
const update: TitlebarUpdate = {
|
||||
version: () => {
|
||||
|
||||
@@ -19,9 +19,6 @@ import { createPromptInputController, createPromptProjectControls } from "@/page
|
||||
import { useSessionKey } from "@/pages/session/session-layout"
|
||||
import { useComposerCommands } from "@/pages/session/use-composer-commands"
|
||||
import { NEW_SESSION_CONTENT_WIDTH } from "@/pages/session/new-session-layout"
|
||||
import { PromptWorkspaceSelector } from "@/components/prompt-workspace-selector"
|
||||
|
||||
const showWorkspaceBar = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"
|
||||
|
||||
/**
|
||||
* The `/new-session` draft page. Unlike `session.tsx`, this only renders the prompt
|
||||
@@ -54,21 +51,16 @@ export default function NewSessionPage() {
|
||||
onDone: () => inputRef?.focus(),
|
||||
})
|
||||
|
||||
const [store, setStore] = createStore<{ worktree?: string }>({})
|
||||
const [store, setStore] = createStore({
|
||||
worktree: "main",
|
||||
})
|
||||
|
||||
const newSessionWorktree = createMemo(() => {
|
||||
if (store.worktree) return store.worktree
|
||||
if (store.worktree === "create") return "create"
|
||||
const project = sync().project
|
||||
if (project && sdk().directory !== project.worktree) return sdk().directory
|
||||
return "main"
|
||||
})
|
||||
const projectRoot = createMemo(() => sync().project?.worktree ?? sdk().directory)
|
||||
const localBranch = createMemo(() => serverSync().child(projectRoot())[0].vcs?.branch)
|
||||
const selectedBranch = createMemo(() => {
|
||||
const worktree = newSessionWorktree()
|
||||
if (worktree === "main" || worktree === "create") return localBranch()
|
||||
return serverSync().child(worktree)[0].vcs?.branch ?? localBranch()
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (!prompt.ready()) return
|
||||
@@ -105,7 +97,7 @@ export default function NewSessionPage() {
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div class="flex flex-col" classList={{ "gap-8": showWorkspaceBar, "gap-3": !showWorkspaceBar }}>
|
||||
<div class="flex flex-col gap-3">
|
||||
<PromptInput
|
||||
controls={inputController()}
|
||||
variant="new-session"
|
||||
@@ -113,7 +105,7 @@ export default function NewSessionPage() {
|
||||
inputRef = el
|
||||
}}
|
||||
newSessionWorktree={newSessionWorktree()}
|
||||
onNewSessionWorktreeReset={() => setStore("worktree", undefined)}
|
||||
onNewSessionWorktreeReset={() => setStore("worktree", "main")}
|
||||
onSubmit={() => comments.clear()}
|
||||
toolbar={
|
||||
<Show when={!projectController.selected()}>
|
||||
@@ -122,34 +114,8 @@ export default function NewSessionPage() {
|
||||
}
|
||||
/>
|
||||
<Show when={projectController.selected()}>
|
||||
<div
|
||||
class="flex min-h-7 min-w-0 items-center gap-0 text-v2-text-text-faint"
|
||||
classList={{
|
||||
"flex-col justify-center sm:flex-row": showWorkspaceBar,
|
||||
"justify-start": !showWorkspaceBar,
|
||||
}}
|
||||
>
|
||||
<PromptProjectSelector
|
||||
controller={projectController}
|
||||
placement={showWorkspaceBar ? "bottom" : "bottom-start"}
|
||||
/>
|
||||
<Show when={showWorkspaceBar}>
|
||||
<PromptWorkspaceSelector
|
||||
value={newSessionWorktree()}
|
||||
projectRoot={projectRoot()}
|
||||
workspaces={sync().project?.sandboxes ?? []}
|
||||
branch={selectedBranch()}
|
||||
onChange={(value) =>
|
||||
setStore(
|
||||
"worktree",
|
||||
value === "main" && sync().project?.worktree !== sdk().directory
|
||||
? sync().project?.worktree
|
||||
: value,
|
||||
)
|
||||
}
|
||||
onDone={() => inputRef?.focus()}
|
||||
/>
|
||||
</Show>
|
||||
<div class="flex h-7 min-w-0 items-center gap-0 px-2">
|
||||
<PromptProjectSelector controller={projectController} />
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { Tag } from "@opencode-ai/ui/v2/badge-v2"
|
||||
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
|
||||
import { Dialog, DialogBody, DialogHeader, DialogTitle } from "@opencode-ai/ui/v2/dialog-v2"
|
||||
import { DividerV2 } from "@opencode-ai/ui/v2/divider-v2"
|
||||
import { Dialog } from "@opencode-ai/ui/v2/dialog-v2"
|
||||
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
||||
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
|
||||
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
|
||||
@@ -31,14 +30,8 @@ export function AddServerMenu(props: { onAddServer: () => void }) {
|
||||
const language = useLanguage()
|
||||
const openAddWsl = () => {
|
||||
dialog.push(() => (
|
||||
<Dialog size="large" fit class="settings-v2-wsl-dialog">
|
||||
<DialogHeader hideClose={true}>
|
||||
<DialogTitle>{language.t("wsl.server.add")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<DividerV2 />
|
||||
<DialogBody>
|
||||
<DialogAddWslServer />
|
||||
</DialogBody>
|
||||
<Dialog title={language.t("wsl.server.add")} size="large" fit class="settings-v2-wsl-dialog">
|
||||
<DialogAddWslServer />
|
||||
</Dialog>
|
||||
))
|
||||
}
|
||||
|
||||
@@ -1,29 +1,20 @@
|
||||
import { NodeFileSystem } from "@effect/platform-node"
|
||||
import { compile, emitEffectImported, emitPromise, write } from "@opencode-ai/httpapi-codegen"
|
||||
import { ClientApi } from "../src/contract"
|
||||
import { Api } from "@opencode-ai/server/api"
|
||||
import { Effect } from "effect"
|
||||
import { HttpApi } from "effect/unstable/httpapi"
|
||||
import { fileURLToPath } from "url"
|
||||
|
||||
const contract = compile(ClientApi, {
|
||||
groupNames: { "server.session": "sessions", "server.event": "events" },
|
||||
const contract = compile(HttpApi.make("opencode-client").add(Api.groups["server.session"]), {
|
||||
groupNames: { "server.session": "sessions" },
|
||||
})
|
||||
|
||||
await Effect.runPromise(
|
||||
Effect.all(
|
||||
[
|
||||
write(emitPromise(contract), fileURLToPath(new URL("../src/generated", import.meta.url))),
|
||||
write(
|
||||
emitPromise(contract, {
|
||||
outputTypes: {
|
||||
"events.subscribe": {
|
||||
name: "OpenCodeEventEncoded",
|
||||
import: 'import type { OpenCodeEventEncoded } from "@opencode-ai/protocol/groups/event"',
|
||||
},
|
||||
},
|
||||
}),
|
||||
fileURLToPath(new URL("../src/generated", import.meta.url)),
|
||||
),
|
||||
write(
|
||||
emitEffectImported(contract, { module: "../contract", api: "ClientApi" }),
|
||||
emitEffectImported(contract, { module: "../contract", group: "SessionGroup" }),
|
||||
fileURLToPath(new URL("../src/generated-effect", import.meta.url)),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { makeDefaultApi } from "@opencode-ai/protocol/api"
|
||||
import { InvalidRequestError, SessionNotFoundError } from "@opencode-ai/protocol/errors"
|
||||
import { HttpApi, HttpApiMiddleware } from "effect/unstable/httpapi"
|
||||
import { HttpApiMiddleware } from "effect/unstable/httpapi"
|
||||
|
||||
class LocationMiddleware extends HttpApiMiddleware.Service<LocationMiddleware>()(
|
||||
"@opencode-ai/client/LocationMiddleware",
|
||||
@@ -17,5 +17,3 @@ const Api = makeDefaultApi({
|
||||
})
|
||||
|
||||
export const SessionGroup = Api.groups["server.session"]
|
||||
export const EventGroup = Api.groups["server.event"]
|
||||
export const ClientApi = HttpApi.make("opencode-client").add(SessionGroup).add(EventGroup)
|
||||
|
||||
@@ -10,4 +10,3 @@ export { Session } from "@opencode-ai/schema/session"
|
||||
export { SessionInput } from "@opencode-ai/schema/session-input"
|
||||
export { SessionMessage } from "@opencode-ai/schema/session-message"
|
||||
export { Prompt } from "@opencode-ai/schema/prompt"
|
||||
export type { OpenCodeEvent } from "@opencode-ai/protocol/groups/event"
|
||||
|
||||
@@ -2,11 +2,13 @@
|
||||
import { Effect, Stream, Schema } from "effect"
|
||||
import { Sse } from "effect/unstable/encoding"
|
||||
import { HttpClientError } from "effect/unstable/http"
|
||||
import { HttpApiClient } from "effect/unstable/httpapi"
|
||||
import { ClientApi } from "../contract"
|
||||
import { HttpApi, HttpApiClient } from "effect/unstable/httpapi"
|
||||
import { SessionGroup } from "../contract"
|
||||
import { ClientError } from "./client-error"
|
||||
|
||||
type RawClient = HttpApiClient.ForApi<typeof ClientApi>
|
||||
const Api = HttpApi.make("generated").add(SessionGroup)
|
||||
|
||||
type RawClient = HttpApiClient.ForApi<typeof Api>
|
||||
|
||||
const mapClientError = <E>(error: E) =>
|
||||
HttpClientError.isHttpClientError(error) || Schema.isSchemaError(error) || Sse.Retry.is(error)
|
||||
@@ -147,24 +149,12 @@ const Endpoint0_12 = (raw: RawClient["server.session"]) => (input: Endpoint0_12I
|
||||
Effect.map((value) => value.data),
|
||||
)
|
||||
|
||||
type Endpoint0_13Request = Parameters<RawClient["server.session"]["session.history"]>[0]
|
||||
type Endpoint0_13Request = Parameters<RawClient["server.session"]["session.events"]>[0]
|
||||
type Endpoint0_13Input = {
|
||||
readonly sessionID: Endpoint0_13Request["params"]["sessionID"]
|
||||
readonly limit?: Endpoint0_13Request["query"]["limit"]
|
||||
readonly after?: Endpoint0_13Request["query"]["after"]
|
||||
}
|
||||
const Endpoint0_13 = (raw: RawClient["server.session"]) => (input: Endpoint0_13Input) =>
|
||||
raw["session.history"]({
|
||||
params: { sessionID: input.sessionID },
|
||||
query: { limit: input.limit, after: input.after },
|
||||
}).pipe(Effect.mapError(mapClientError))
|
||||
|
||||
type Endpoint0_14Request = Parameters<RawClient["server.session"]["session.events"]>[0]
|
||||
type Endpoint0_14Input = {
|
||||
readonly sessionID: Endpoint0_14Request["params"]["sessionID"]
|
||||
readonly after?: Endpoint0_14Request["query"]["after"]
|
||||
}
|
||||
const Endpoint0_14 = (raw: RawClient["server.session"]) => (input: Endpoint0_14Input) =>
|
||||
Stream.unwrap(
|
||||
raw["session.events"]({ params: { sessionID: input.sessionID }, query: { after: input.after } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
@@ -172,17 +162,17 @@ const Endpoint0_14 = (raw: RawClient["server.session"]) => (input: Endpoint0_14I
|
||||
),
|
||||
)
|
||||
|
||||
type Endpoint0_15Request = Parameters<RawClient["server.session"]["session.interrupt"]>[0]
|
||||
type Endpoint0_15Input = { readonly sessionID: Endpoint0_15Request["params"]["sessionID"] }
|
||||
const Endpoint0_15 = (raw: RawClient["server.session"]) => (input: Endpoint0_15Input) =>
|
||||
type Endpoint0_14Request = Parameters<RawClient["server.session"]["session.interrupt"]>[0]
|
||||
type Endpoint0_14Input = { readonly sessionID: Endpoint0_14Request["params"]["sessionID"] }
|
||||
const Endpoint0_14 = (raw: RawClient["server.session"]) => (input: Endpoint0_14Input) =>
|
||||
raw["session.interrupt"]({ params: { sessionID: input.sessionID } }).pipe(Effect.mapError(mapClientError))
|
||||
|
||||
type Endpoint0_16Request = Parameters<RawClient["server.session"]["session.message"]>[0]
|
||||
type Endpoint0_16Input = {
|
||||
readonly sessionID: Endpoint0_16Request["params"]["sessionID"]
|
||||
readonly messageID: Endpoint0_16Request["params"]["messageID"]
|
||||
type Endpoint0_15Request = Parameters<RawClient["server.session"]["session.message"]>[0]
|
||||
type Endpoint0_15Input = {
|
||||
readonly sessionID: Endpoint0_15Request["params"]["sessionID"]
|
||||
readonly messageID: Endpoint0_15Request["params"]["messageID"]
|
||||
}
|
||||
const Endpoint0_16 = (raw: RawClient["server.session"]) => (input: Endpoint0_16Input) =>
|
||||
const Endpoint0_15 = (raw: RawClient["server.session"]) => (input: Endpoint0_15Input) =>
|
||||
raw["session.message"]({ params: { sessionID: input.sessionID, messageID: input.messageID } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
Effect.map((value) => value.data),
|
||||
@@ -202,26 +192,12 @@ const adaptGroup0 = (raw: RawClient["server.session"]) => ({
|
||||
clear: Endpoint0_10(raw),
|
||||
commit: Endpoint0_11(raw),
|
||||
context: Endpoint0_12(raw),
|
||||
history: Endpoint0_13(raw),
|
||||
events: Endpoint0_14(raw),
|
||||
interrupt: Endpoint0_15(raw),
|
||||
message: Endpoint0_16(raw),
|
||||
events: Endpoint0_13(raw),
|
||||
interrupt: Endpoint0_14(raw),
|
||||
message: Endpoint0_15(raw),
|
||||
})
|
||||
|
||||
const Endpoint1_0 = (raw: RawClient["server.event"]) => () =>
|
||||
Stream.unwrap(
|
||||
raw["event.subscribe"]({}).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
Effect.map((stream) => stream.pipe(Stream.mapError(mapClientError))),
|
||||
),
|
||||
)
|
||||
|
||||
const adaptGroup1 = (raw: RawClient["server.event"]) => ({ subscribe: Endpoint1_0(raw) })
|
||||
|
||||
const adaptClient = (raw: RawClient) => ({
|
||||
sessions: adaptGroup0(raw["server.session"]),
|
||||
events: adaptGroup1(raw["server.event"]),
|
||||
})
|
||||
const adaptClient = (raw: RawClient) => ({ sessions: adaptGroup0(raw["server.session"]) })
|
||||
|
||||
export const make = (options?: { readonly baseUrl?: URL | string }) =>
|
||||
HttpApiClient.make(ClientApi, options).pipe(Effect.map(adaptClient))
|
||||
HttpApiClient.make(Api, options).pipe(Effect.map(adaptClient))
|
||||
|
||||
@@ -24,15 +24,12 @@ import type {
|
||||
SessionsCommitOutput,
|
||||
SessionsContextInput,
|
||||
SessionsContextOutput,
|
||||
SessionsHistoryInput,
|
||||
SessionsHistoryOutput,
|
||||
SessionsEventsInput,
|
||||
SessionsEventsOutput,
|
||||
SessionsInterruptInput,
|
||||
SessionsInterruptOutput,
|
||||
SessionsMessageInput,
|
||||
SessionsMessageOutput,
|
||||
EventsSubscribeOutput,
|
||||
} from "./types"
|
||||
import { ClientError } from "./client-error"
|
||||
|
||||
@@ -327,18 +324,6 @@ export function make(options: ClientOptions) {
|
||||
},
|
||||
requestOptions,
|
||||
).then((value) => value.data),
|
||||
history: (input: SessionsHistoryInput, requestOptions?: RequestOptions) =>
|
||||
request<SessionsHistoryOutput>(
|
||||
{
|
||||
method: "GET",
|
||||
path: `/api/session/${encodeURIComponent(input.sessionID)}/history`,
|
||||
query: { limit: input.limit, after: input.after },
|
||||
successStatus: 200,
|
||||
declaredStatuses: [404, 400, 401],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
events: (input: SessionsEventsInput, requestOptions?: RequestOptions): AsyncIterable<SessionsEventsOutput> =>
|
||||
sse<SessionsEventsOutput>(
|
||||
{
|
||||
@@ -374,13 +359,6 @@ export function make(options: ClientOptions) {
|
||||
requestOptions,
|
||||
).then((value) => value.data),
|
||||
},
|
||||
events: {
|
||||
subscribe: (requestOptions?: RequestOptions): AsyncIterable<EventsSubscribeOutput> =>
|
||||
sse<EventsSubscribeOutput>(
|
||||
{ method: "GET", path: `/api/event`, successStatus: 200, declaredStatuses: [401, 400], empty: false },
|
||||
requestOptions,
|
||||
),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import type { OpenCodeEventEncoded } from "@opencode-ai/protocol/groups/event"
|
||||
|
||||
export type JsonValue =
|
||||
| null
|
||||
| boolean
|
||||
@@ -69,7 +67,7 @@ export const isUnknownError = (value: unknown): value is UnknownError =>
|
||||
export type SessionsListInput = {
|
||||
readonly workspace?: {
|
||||
readonly workspace?: string | undefined
|
||||
readonly limit?: number | undefined
|
||||
readonly limit?: string | undefined
|
||||
readonly order?: "asc" | "desc" | undefined
|
||||
readonly search?: string | undefined
|
||||
readonly directory?: string | undefined
|
||||
@@ -79,7 +77,7 @@ export type SessionsListInput = {
|
||||
}["workspace"]
|
||||
readonly limit?: {
|
||||
readonly workspace?: string | undefined
|
||||
readonly limit?: number | undefined
|
||||
readonly limit?: string | undefined
|
||||
readonly order?: "asc" | "desc" | undefined
|
||||
readonly search?: string | undefined
|
||||
readonly directory?: string | undefined
|
||||
@@ -89,7 +87,7 @@ export type SessionsListInput = {
|
||||
}["limit"]
|
||||
readonly order?: {
|
||||
readonly workspace?: string | undefined
|
||||
readonly limit?: number | undefined
|
||||
readonly limit?: string | undefined
|
||||
readonly order?: "asc" | "desc" | undefined
|
||||
readonly search?: string | undefined
|
||||
readonly directory?: string | undefined
|
||||
@@ -99,7 +97,7 @@ export type SessionsListInput = {
|
||||
}["order"]
|
||||
readonly search?: {
|
||||
readonly workspace?: string | undefined
|
||||
readonly limit?: number | undefined
|
||||
readonly limit?: string | undefined
|
||||
readonly order?: "asc" | "desc" | undefined
|
||||
readonly search?: string | undefined
|
||||
readonly directory?: string | undefined
|
||||
@@ -109,7 +107,7 @@ export type SessionsListInput = {
|
||||
}["search"]
|
||||
readonly directory?: {
|
||||
readonly workspace?: string | undefined
|
||||
readonly limit?: number | undefined
|
||||
readonly limit?: string | undefined
|
||||
readonly order?: "asc" | "desc" | undefined
|
||||
readonly search?: string | undefined
|
||||
readonly directory?: string | undefined
|
||||
@@ -119,7 +117,7 @@ export type SessionsListInput = {
|
||||
}["directory"]
|
||||
readonly project?: {
|
||||
readonly workspace?: string | undefined
|
||||
readonly limit?: number | undefined
|
||||
readonly limit?: string | undefined
|
||||
readonly order?: "asc" | "desc" | undefined
|
||||
readonly search?: string | undefined
|
||||
readonly directory?: string | undefined
|
||||
@@ -129,7 +127,7 @@ export type SessionsListInput = {
|
||||
}["project"]
|
||||
readonly subpath?: {
|
||||
readonly workspace?: string | undefined
|
||||
readonly limit?: number | undefined
|
||||
readonly limit?: string | undefined
|
||||
readonly order?: "asc" | "desc" | undefined
|
||||
readonly search?: string | undefined
|
||||
readonly directory?: string | undefined
|
||||
@@ -139,7 +137,7 @@ export type SessionsListInput = {
|
||||
}["subpath"]
|
||||
readonly cursor?: {
|
||||
readonly workspace?: string | undefined
|
||||
readonly limit?: number | undefined
|
||||
readonly limit?: string | undefined
|
||||
readonly order?: "asc" | "desc" | undefined
|
||||
readonly search?: string | undefined
|
||||
readonly directory?: string | undefined
|
||||
@@ -307,6 +305,7 @@ export type SessionsPromptInput = {
|
||||
readonly text: string
|
||||
readonly files?: ReadonlyArray<{
|
||||
readonly uri: string
|
||||
readonly mime: string
|
||||
readonly name?: string
|
||||
readonly description?: string
|
||||
readonly source?: { readonly start: number; readonly end: number; readonly text: string }
|
||||
@@ -325,6 +324,7 @@ export type SessionsPromptInput = {
|
||||
readonly text: string
|
||||
readonly files?: ReadonlyArray<{
|
||||
readonly uri: string
|
||||
readonly mime: string
|
||||
readonly name?: string
|
||||
readonly description?: string
|
||||
readonly source?: { readonly start: number; readonly end: number; readonly text: string }
|
||||
@@ -343,6 +343,7 @@ export type SessionsPromptInput = {
|
||||
readonly text: string
|
||||
readonly files?: ReadonlyArray<{
|
||||
readonly uri: string
|
||||
readonly mime: string
|
||||
readonly name?: string
|
||||
readonly description?: string
|
||||
readonly source?: { readonly start: number; readonly end: number; readonly text: string }
|
||||
@@ -361,6 +362,7 @@ export type SessionsPromptInput = {
|
||||
readonly text: string
|
||||
readonly files?: ReadonlyArray<{
|
||||
readonly uri: string
|
||||
readonly mime: string
|
||||
readonly name?: string
|
||||
readonly description?: string
|
||||
readonly source?: { readonly start: number; readonly end: number; readonly text: string }
|
||||
@@ -593,469 +595,9 @@ export type SessionsContextOutput = {
|
||||
>
|
||||
}["data"]
|
||||
|
||||
export type SessionsHistoryInput = {
|
||||
readonly sessionID: { readonly sessionID: string }["sessionID"]
|
||||
readonly limit?: { readonly limit?: number | undefined; readonly after?: number | undefined }["limit"]
|
||||
readonly after?: { readonly limit?: number | undefined; readonly after?: number | undefined }["after"]
|
||||
}
|
||||
|
||||
export type SessionsHistoryOutput = {
|
||||
readonly data: ReadonlyArray<
|
||||
| {
|
||||
readonly id: string
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
readonly type: "session.next.agent.switched"
|
||||
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||
readonly data: {
|
||||
readonly timestamp: number
|
||||
readonly sessionID: string
|
||||
readonly messageID: string
|
||||
readonly agent: string
|
||||
}
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
readonly type: "session.next.model.switched"
|
||||
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||
readonly data: {
|
||||
readonly timestamp: number
|
||||
readonly sessionID: string
|
||||
readonly messageID: string
|
||||
readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string }
|
||||
}
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
readonly type: "session.next.moved"
|
||||
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||
readonly data: {
|
||||
readonly timestamp: number
|
||||
readonly sessionID: string
|
||||
readonly location: { readonly directory: string; readonly workspaceID?: string }
|
||||
readonly subdirectory?: string
|
||||
}
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
readonly type: "session.next.prompted"
|
||||
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||
readonly data: {
|
||||
readonly timestamp: number
|
||||
readonly sessionID: string
|
||||
readonly messageID: string
|
||||
readonly prompt: {
|
||||
readonly text: string
|
||||
readonly files?: ReadonlyArray<{
|
||||
readonly uri: string
|
||||
readonly mime: string
|
||||
readonly name?: string
|
||||
readonly description?: string
|
||||
readonly source?: { readonly start: number; readonly end: number; readonly text: string }
|
||||
}>
|
||||
readonly agents?: ReadonlyArray<{
|
||||
readonly name: string
|
||||
readonly source?: { readonly start: number; readonly end: number; readonly text: string }
|
||||
}>
|
||||
}
|
||||
readonly delivery: "steer" | "queue"
|
||||
}
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
readonly type: "session.next.prompt.admitted"
|
||||
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||
readonly data: {
|
||||
readonly timestamp: number
|
||||
readonly sessionID: string
|
||||
readonly messageID: string
|
||||
readonly prompt: {
|
||||
readonly text: string
|
||||
readonly files?: ReadonlyArray<{
|
||||
readonly uri: string
|
||||
readonly mime: string
|
||||
readonly name?: string
|
||||
readonly description?: string
|
||||
readonly source?: { readonly start: number; readonly end: number; readonly text: string }
|
||||
}>
|
||||
readonly agents?: ReadonlyArray<{
|
||||
readonly name: string
|
||||
readonly source?: { readonly start: number; readonly end: number; readonly text: string }
|
||||
}>
|
||||
}
|
||||
readonly delivery: "steer" | "queue"
|
||||
}
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
readonly type: "session.next.context.updated"
|
||||
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||
readonly data: {
|
||||
readonly timestamp: number
|
||||
readonly sessionID: string
|
||||
readonly messageID: string
|
||||
readonly text: string
|
||||
}
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
readonly type: "session.next.synthetic"
|
||||
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||
readonly data: {
|
||||
readonly timestamp: number
|
||||
readonly sessionID: string
|
||||
readonly messageID: string
|
||||
readonly text: string
|
||||
}
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
readonly type: "session.next.shell.started"
|
||||
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||
readonly data: {
|
||||
readonly timestamp: number
|
||||
readonly sessionID: string
|
||||
readonly messageID: string
|
||||
readonly callID: string
|
||||
readonly command: string
|
||||
}
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
readonly type: "session.next.shell.ended"
|
||||
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||
readonly data: {
|
||||
readonly timestamp: number
|
||||
readonly sessionID: string
|
||||
readonly callID: string
|
||||
readonly output: string
|
||||
}
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
readonly type: "session.next.step.started"
|
||||
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||
readonly data: {
|
||||
readonly timestamp: number
|
||||
readonly sessionID: string
|
||||
readonly assistantMessageID: string
|
||||
readonly agent: string
|
||||
readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string }
|
||||
readonly snapshot?: string
|
||||
}
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
readonly type: "session.next.step.ended"
|
||||
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||
readonly data: {
|
||||
readonly timestamp: number
|
||||
readonly sessionID: string
|
||||
readonly assistantMessageID: string
|
||||
readonly finish: string
|
||||
readonly cost: number
|
||||
readonly tokens: {
|
||||
readonly input: number
|
||||
readonly output: number
|
||||
readonly reasoning: number
|
||||
readonly cache: { readonly read: number; readonly write: number }
|
||||
}
|
||||
readonly snapshot?: string
|
||||
readonly files?: ReadonlyArray<string>
|
||||
}
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
readonly type: "session.next.step.failed"
|
||||
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||
readonly data: {
|
||||
readonly timestamp: number
|
||||
readonly sessionID: string
|
||||
readonly assistantMessageID: string
|
||||
readonly error: { readonly type: "unknown"; readonly message: string }
|
||||
}
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
readonly type: "session.next.text.started"
|
||||
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||
readonly data: {
|
||||
readonly timestamp: number
|
||||
readonly sessionID: string
|
||||
readonly assistantMessageID: string
|
||||
readonly textID: string
|
||||
}
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
readonly type: "session.next.text.ended"
|
||||
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||
readonly data: {
|
||||
readonly timestamp: number
|
||||
readonly sessionID: string
|
||||
readonly assistantMessageID: string
|
||||
readonly textID: string
|
||||
readonly text: string
|
||||
}
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
readonly type: "session.next.tool.input.started"
|
||||
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||
readonly data: {
|
||||
readonly timestamp: number
|
||||
readonly sessionID: string
|
||||
readonly assistantMessageID: string
|
||||
readonly callID: string
|
||||
readonly name: string
|
||||
}
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
readonly type: "session.next.tool.input.ended"
|
||||
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||
readonly data: {
|
||||
readonly timestamp: number
|
||||
readonly sessionID: string
|
||||
readonly assistantMessageID: string
|
||||
readonly callID: string
|
||||
readonly text: string
|
||||
}
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
readonly type: "session.next.tool.called"
|
||||
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||
readonly data: {
|
||||
readonly timestamp: number
|
||||
readonly sessionID: string
|
||||
readonly assistantMessageID: string
|
||||
readonly callID: string
|
||||
readonly tool: string
|
||||
readonly input: { readonly [x: string]: JsonValue }
|
||||
readonly provider: {
|
||||
readonly executed: boolean
|
||||
readonly metadata?: { readonly [x: string]: { readonly [x: string]: JsonValue } }
|
||||
}
|
||||
}
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
readonly type: "session.next.tool.progress"
|
||||
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||
readonly data: {
|
||||
readonly timestamp: number
|
||||
readonly sessionID: string
|
||||
readonly assistantMessageID: string
|
||||
readonly callID: string
|
||||
readonly structured: { readonly [x: string]: JsonValue }
|
||||
readonly content: ReadonlyArray<
|
||||
| { readonly type: "text"; readonly text: string }
|
||||
| { readonly type: "file"; readonly uri: string; readonly mime: string; readonly name?: string }
|
||||
>
|
||||
}
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
readonly type: "session.next.tool.success"
|
||||
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||
readonly data: {
|
||||
readonly timestamp: number
|
||||
readonly sessionID: string
|
||||
readonly assistantMessageID: string
|
||||
readonly callID: string
|
||||
readonly structured: { readonly [x: string]: JsonValue }
|
||||
readonly content: ReadonlyArray<
|
||||
| { readonly type: "text"; readonly text: string }
|
||||
| { readonly type: "file"; readonly uri: string; readonly mime: string; readonly name?: string }
|
||||
>
|
||||
readonly outputPaths?: ReadonlyArray<string>
|
||||
readonly result?: JsonValue
|
||||
readonly provider: {
|
||||
readonly executed: boolean
|
||||
readonly metadata?: { readonly [x: string]: { readonly [x: string]: JsonValue } }
|
||||
}
|
||||
}
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
readonly type: "session.next.tool.failed"
|
||||
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||
readonly data: {
|
||||
readonly timestamp: number
|
||||
readonly sessionID: string
|
||||
readonly assistantMessageID: string
|
||||
readonly callID: string
|
||||
readonly error: { readonly type: "unknown"; readonly message: string }
|
||||
readonly result?: JsonValue
|
||||
readonly provider: {
|
||||
readonly executed: boolean
|
||||
readonly metadata?: { readonly [x: string]: { readonly [x: string]: JsonValue } }
|
||||
}
|
||||
}
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
readonly type: "session.next.reasoning.started"
|
||||
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||
readonly data: {
|
||||
readonly timestamp: number
|
||||
readonly sessionID: string
|
||||
readonly assistantMessageID: string
|
||||
readonly reasoningID: string
|
||||
readonly providerMetadata?: { readonly [x: string]: { readonly [x: string]: JsonValue } }
|
||||
}
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
readonly type: "session.next.reasoning.ended"
|
||||
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||
readonly data: {
|
||||
readonly timestamp: number
|
||||
readonly sessionID: string
|
||||
readonly assistantMessageID: string
|
||||
readonly reasoningID: string
|
||||
readonly text: string
|
||||
readonly providerMetadata?: { readonly [x: string]: { readonly [x: string]: JsonValue } }
|
||||
}
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
readonly type: "session.next.retried"
|
||||
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||
readonly data: {
|
||||
readonly timestamp: number
|
||||
readonly sessionID: string
|
||||
readonly attempt: number
|
||||
readonly error: {
|
||||
readonly message: string
|
||||
readonly statusCode?: number
|
||||
readonly isRetryable: boolean
|
||||
readonly responseHeaders?: { readonly [x: string]: string }
|
||||
readonly responseBody?: string
|
||||
readonly metadata?: { readonly [x: string]: string }
|
||||
}
|
||||
}
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
readonly type: "session.next.compaction.started"
|
||||
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||
readonly data: {
|
||||
readonly timestamp: number
|
||||
readonly sessionID: string
|
||||
readonly messageID: string
|
||||
readonly reason: "auto" | "manual"
|
||||
}
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
readonly type: "session.next.compaction.ended"
|
||||
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||
readonly data: {
|
||||
readonly timestamp: number
|
||||
readonly sessionID: string
|
||||
readonly messageID: string
|
||||
readonly reason: "auto" | "manual"
|
||||
readonly text: string
|
||||
readonly recent: string
|
||||
}
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
readonly type: "session.next.revert.staged"
|
||||
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||
readonly data: {
|
||||
readonly timestamp: number
|
||||
readonly sessionID: string
|
||||
readonly revert: {
|
||||
readonly messageID: string
|
||||
readonly partID?: string
|
||||
readonly snapshot?: string
|
||||
readonly diff?: string
|
||||
readonly files?: ReadonlyArray<{
|
||||
readonly path: string
|
||||
readonly status: "added" | "modified" | "deleted"
|
||||
readonly additions: number
|
||||
readonly deletions: number
|
||||
readonly patch: string
|
||||
}>
|
||||
}
|
||||
}
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
readonly type: "session.next.revert.cleared"
|
||||
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||
readonly data: { readonly timestamp: number; readonly sessionID: string }
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
readonly type: "session.next.revert.committed"
|
||||
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||
readonly data: { readonly timestamp: number; readonly sessionID: string; readonly messageID: string }
|
||||
}
|
||||
>
|
||||
readonly hasMore: boolean
|
||||
}
|
||||
|
||||
export type SessionsEventsInput = {
|
||||
readonly sessionID: { readonly sessionID: string }["sessionID"]
|
||||
readonly after?: { readonly after?: number | undefined }["after"]
|
||||
readonly after?: { readonly after?: string | undefined }["after"]
|
||||
}
|
||||
|
||||
export type SessionsEventsOutput =
|
||||
@@ -1668,5 +1210,3 @@ export type SessionsMessageOutput = {
|
||||
readonly time: { readonly created: number }
|
||||
}
|
||||
}["data"]
|
||||
|
||||
export type EventsSubscribeOutput = OpenCodeEventEncoded
|
||||
|
||||
@@ -1,2 +1 @@
|
||||
export * from "./generated/index"
|
||||
export type { EventsSubscribeOutput as OpenCodeEvent } from "./generated/types"
|
||||
|
||||
@@ -20,7 +20,7 @@ import { Workspace } from "@opencode-ai/schema/workspace"
|
||||
import { Api } from "@opencode-ai/server/api"
|
||||
import { compile, emitPromise } from "@opencode-ai/httpapi-codegen"
|
||||
import { HttpApi } from "effect/unstable/httpapi"
|
||||
import { EventGroup, SessionGroup } from "../src/contract"
|
||||
import { SessionGroup } from "../src/contract"
|
||||
|
||||
test("Core and Server reuse the authoritative Schema and Protocol values", () => {
|
||||
expect(AgentV2.ID).toBe(Agent.ID)
|
||||
@@ -32,7 +32,6 @@ test("Core and Server reuse the authoritative Schema and Protocol values", () =>
|
||||
expect(CorePrompt).toBe(Prompt)
|
||||
expect(Api.groups["server.session"].identifier).toBe("server.session")
|
||||
expect(SessionGroup.identifier).toBe(Api.groups["server.session"].identifier)
|
||||
expect(EventGroup.identifier).toBe(Api.groups["server.event"].identifier)
|
||||
expect(Session.ID.create()).toStartWith("ses_")
|
||||
expect(Project.ID.global).toBe("global")
|
||||
expect(Provider.ID.anthropic).toBe("anthropic")
|
||||
|
||||
@@ -15,53 +15,7 @@ test("sessions.get returns the decoded Effect projection", async () => {
|
||||
expect(DateTime.toEpochMillis(result.time.created)).toBe(1_717_171_717_000)
|
||||
})
|
||||
|
||||
test("events.subscribe exposes and decodes the native Effect event stream", async () => {
|
||||
const httpClient = HttpClient.make((request) =>
|
||||
Effect.succeed(
|
||||
HttpClientResponse.fromWeb(
|
||||
request,
|
||||
new Response(
|
||||
`data: ${JSON.stringify({ id: "evt_connected", type: "server.connected", data: {} })}\n\n` +
|
||||
`data: ${JSON.stringify(modelSwitchedEvent)}\n\n`,
|
||||
{ headers: { "content-type": "text/event-stream" } },
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
const events = await Effect.gen(function* () {
|
||||
const client = yield* OpenCode.make({ baseUrl: "http://localhost:3000" })
|
||||
return yield* client.events.subscribe().pipe(Stream.runCollect)
|
||||
}).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise)
|
||||
|
||||
expect(Array.from(events).map((event) => event.type)).toEqual(["server.connected", "session.next.model.switched"])
|
||||
const durable = events[1]
|
||||
if (durable?.type !== "session.next.model.switched") throw new Error("Expected model event")
|
||||
expect(DateTime.toEpochMillis(durable.data.timestamp)).toBe(1_717_171_717_000)
|
||||
expect(durable.durable).toEqual({ aggregateID: "ses_test", seq: 1, version: 1 })
|
||||
})
|
||||
|
||||
test("events.subscribe terminates on Effect protocol decode failures", async () => {
|
||||
const httpClient = HttpClient.make((request) =>
|
||||
Effect.succeed(
|
||||
HttpClientResponse.fromWeb(
|
||||
request,
|
||||
new Response(`data: {"type":"server.connected"}\n\n`, {
|
||||
headers: { "content-type": "text/event-stream" },
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
const error = await Effect.gen(function* () {
|
||||
const client = yield* OpenCode.make({ baseUrl: "http://localhost:3000" })
|
||||
return yield* client.events.subscribe().pipe(Stream.runCollect, Effect.flip)
|
||||
}).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise)
|
||||
|
||||
expect(error._tag).toBe("ClientError")
|
||||
})
|
||||
|
||||
test("session methods retain decoded Effect inputs and outputs", async () => {
|
||||
const historyQueries: Array<Record<string, string>> = []
|
||||
let historyPage = 0
|
||||
const httpClient = HttpClient.make((request) => {
|
||||
const url = request.url
|
||||
if (url.includes("/event")) {
|
||||
@@ -74,18 +28,6 @@ test("session methods retain decoded Effect inputs and outputs", async () => {
|
||||
),
|
||||
)
|
||||
}
|
||||
if (url.includes("/history")) {
|
||||
historyPage++
|
||||
historyQueries.push(Object.fromEntries(request.urlParams.params))
|
||||
return Effect.succeed(
|
||||
HttpClientResponse.fromWeb(
|
||||
request,
|
||||
Response.json(
|
||||
historyPage === 1 ? { data: [modelSwitchedEvent], hasMore: true } : { data: [], hasMore: false },
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
if (url.includes("/prompt")) {
|
||||
return Effect.succeed(HttpClientResponse.fromWeb(request, Response.json(admission)))
|
||||
}
|
||||
@@ -130,18 +72,6 @@ test("session methods retain decoded Effect inputs and outputs", async () => {
|
||||
yield* client.sessions.compact({ sessionID: Session.ID.make("ses_test") })
|
||||
yield* client.sessions.wait({ sessionID: Session.ID.make("ses_test") })
|
||||
const context = yield* client.sessions.context({ sessionID: Session.ID.make("ses_test") })
|
||||
const history = yield* client.sessions.history({
|
||||
sessionID: Session.ID.make("ses_test"),
|
||||
after: 0,
|
||||
limit: 1,
|
||||
})
|
||||
const historyNext = history.hasMore
|
||||
? yield* client.sessions.history({
|
||||
sessionID: Session.ID.make("ses_test"),
|
||||
after: history.data.at(-1)?.durable?.seq,
|
||||
limit: 2,
|
||||
})
|
||||
: undefined
|
||||
const events = yield* client.sessions
|
||||
.events({ sessionID: Session.ID.make("ses_test"), after: 0 })
|
||||
.pipe(Stream.runCollect)
|
||||
@@ -150,7 +80,7 @@ test("session methods retain decoded Effect inputs and outputs", async () => {
|
||||
sessionID: Session.ID.make("ses_test"),
|
||||
messageID: SessionMessage.ID.make("msg_model"),
|
||||
})
|
||||
return { page, active, created, admitted, context, history, historyNext, events, message }
|
||||
return { page, active, created, admitted, context, events, message }
|
||||
}).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise)
|
||||
|
||||
expect(DateTime.toEpochMillis(result.page.data[0].time.created)).toBe(1_717_171_717_000)
|
||||
@@ -162,39 +92,10 @@ test("session methods retain decoded Effect inputs and outputs", async () => {
|
||||
expect(Object.getPrototypeOf(result.admitted.prompt)).toBe(Object.prototype)
|
||||
expect(DateTime.toEpochMillis(result.admitted.timeCreated)).toBe(1_717_171_717_000)
|
||||
expect(result.context).toEqual([])
|
||||
expect(DateTime.toEpochMillis(result.history.data[0].data.timestamp)).toBe(1_717_171_717_000)
|
||||
expect(result.history).toEqual(expect.objectContaining({ hasMore: true }))
|
||||
expect(result.historyNext).toEqual({ data: [], hasMore: false })
|
||||
expect(historyQueries[0]).toEqual({ limit: "1", after: "0" })
|
||||
expect(historyQueries[1]).toEqual({ limit: "2", after: "1" })
|
||||
expect(DateTime.toEpochMillis(result.events[0].data.timestamp)).toBe(1_717_171_717_000)
|
||||
expect(result.message).toEqual(expect.objectContaining({ id: "msg_model", type: "model-switched" }))
|
||||
})
|
||||
|
||||
test("sessions.history retains the typed SessionNotFoundError", async () => {
|
||||
const httpClient = HttpClient.make((request) =>
|
||||
Effect.succeed(
|
||||
HttpClientResponse.fromWeb(
|
||||
request,
|
||||
Response.json(
|
||||
{ _tag: "SessionNotFoundError", sessionID: "ses_missing", message: "Session not found" },
|
||||
{ status: 404 },
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
const error = await Effect.gen(function* () {
|
||||
const client = yield* OpenCode.make({ baseUrl: "http://localhost:3000" })
|
||||
return yield* client.sessions
|
||||
.history({
|
||||
sessionID: Session.ID.make("ses_missing"),
|
||||
})
|
||||
.pipe(Effect.flip)
|
||||
}).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise)
|
||||
|
||||
expect(error._tag).toBe("SessionNotFoundError")
|
||||
})
|
||||
|
||||
const session = {
|
||||
data: {
|
||||
id: "ses_test",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { isSessionNotFoundError, isUnauthorizedError, OpenCode } from "../src"
|
||||
import { isUnauthorizedError, OpenCode } from "../src"
|
||||
|
||||
test("sessions.get returns the wire projection", async () => {
|
||||
const client = OpenCode.make({
|
||||
@@ -17,38 +17,8 @@ test("sessions.get returns the wire projection", async () => {
|
||||
expect(result.time.created).toBe(1_717_171_717_000)
|
||||
})
|
||||
|
||||
test("events.subscribe exposes the Promise event stream wire projection", async () => {
|
||||
const client = OpenCode.make({
|
||||
baseUrl: "http://localhost:3000",
|
||||
fetch: async () =>
|
||||
new Response(
|
||||
`: heartbeat\n\ndata: ${JSON.stringify({ id: "evt_connected", type: "server.connected", data: {} })}\n\n` +
|
||||
`data: ${JSON.stringify(modelSwitchedEvent)}\n\n`,
|
||||
{ headers: { "content-type": "text/event-stream" } },
|
||||
),
|
||||
})
|
||||
const events = []
|
||||
for await (const event of client.events.subscribe()) events.push(event)
|
||||
|
||||
expect(events).toEqual([{ id: "evt_connected", type: "server.connected", data: {} }, modelSwitchedEvent])
|
||||
expect(events[1]?.type === "session.next.model.switched" && events[1].data.timestamp).toBe(1_717_171_717_000)
|
||||
})
|
||||
|
||||
test("events.subscribe terminates on malformed Promise SSE data", async () => {
|
||||
const client = OpenCode.make({
|
||||
baseUrl: "http://localhost:3000",
|
||||
fetch: async () => new Response("data: {not-json}\n\n", { headers: { "content-type": "text/event-stream" } }),
|
||||
})
|
||||
|
||||
await expect(client.events.subscribe()[Symbol.asyncIterator]().next()).rejects.toMatchObject({
|
||||
name: "ClientError",
|
||||
reason: "MalformedResponse",
|
||||
})
|
||||
})
|
||||
|
||||
test("session methods use the public HTTP contract", async () => {
|
||||
const requests: Array<{ url: string; init?: RequestInit }> = []
|
||||
let historyPage = 0
|
||||
const client = OpenCode.make({
|
||||
baseUrl: "http://localhost:3000",
|
||||
fetch: async (input, init) => {
|
||||
@@ -59,12 +29,6 @@ test("session methods use the public HTTP contract", async () => {
|
||||
headers: { "content-type": "text/event-stream" },
|
||||
})
|
||||
}
|
||||
if (url.includes("/history")) {
|
||||
historyPage++
|
||||
return Response.json(
|
||||
historyPage === 1 ? { data: [modelSwitchedEvent], hasMore: true } : { data: [], hasMore: false },
|
||||
)
|
||||
}
|
||||
if (url.includes("/prompt")) return Response.json(admission)
|
||||
if (url.includes("/context")) return Response.json({ data: [] })
|
||||
if (url.includes("/message/")) return Response.json({ data: modelSwitchedMessage })
|
||||
@@ -75,7 +39,7 @@ test("session methods use the public HTTP contract", async () => {
|
||||
},
|
||||
})
|
||||
|
||||
const page = await client.sessions.list({ limit: 10, order: "desc" })
|
||||
const page = await client.sessions.list({ limit: "10", order: "desc" })
|
||||
const active = await client.sessions.active()
|
||||
const created = await client.sessions.create({ location: { directory: "/tmp/project" } })
|
||||
await client.sessions.switchAgent({ sessionID: "ses_test", agent: "build" })
|
||||
@@ -91,13 +55,8 @@ test("session methods use the public HTTP contract", async () => {
|
||||
await client.sessions.compact({ sessionID: "ses_test" })
|
||||
await client.sessions.wait({ sessionID: "ses_test" })
|
||||
const context = await client.sessions.context({ sessionID: "ses_test" })
|
||||
const history = await client.sessions.history({ sessionID: "ses_test", after: 0, limit: 1 })
|
||||
const historyAfter = history.data.at(-1)?.durable?.seq
|
||||
const historyNext = history.hasMore
|
||||
? await client.sessions.history({ sessionID: "ses_test", after: historyAfter, limit: 2 })
|
||||
: undefined
|
||||
const events = []
|
||||
for await (const event of client.sessions.events({ sessionID: "ses_test", after: 0 })) events.push(event)
|
||||
for await (const event of client.sessions.events({ sessionID: "ses_test", after: "0" })) events.push(event)
|
||||
await client.sessions.interrupt({ sessionID: "ses_test" })
|
||||
const message = await client.sessions.message({ sessionID: "ses_test", messageID: "msg_model" })
|
||||
|
||||
@@ -106,8 +65,6 @@ test("session methods use the public HTTP contract", async () => {
|
||||
expect(created.id).toBe("ses_test")
|
||||
expect(admitted.id).toBe("msg_test")
|
||||
expect(context).toEqual([])
|
||||
expect(history).toEqual({ data: [modelSwitchedEvent], hasMore: true })
|
||||
expect(historyNext).toEqual({ data: [], hasMore: false })
|
||||
expect(events).toEqual([modelSwitchedEvent])
|
||||
expect(message).toEqual(modelSwitchedMessage)
|
||||
expect(requests.map((request) => [request.init?.method, request.url])).toEqual([
|
||||
@@ -120,8 +77,6 @@ test("session methods use the public HTTP contract", async () => {
|
||||
["POST", "http://localhost:3000/api/session/ses_test/compact"],
|
||||
["POST", "http://localhost:3000/api/session/ses_test/wait"],
|
||||
["GET", "http://localhost:3000/api/session/ses_test/context"],
|
||||
["GET", "http://localhost:3000/api/session/ses_test/history?limit=1&after=0"],
|
||||
["GET", "http://localhost:3000/api/session/ses_test/history?limit=2&after=1"],
|
||||
["GET", "http://localhost:3000/api/session/ses_test/event?after=0"],
|
||||
["POST", "http://localhost:3000/api/session/ses_test/interrupt"],
|
||||
["GET", "http://localhost:3000/api/session/ses_test/message/msg_model"],
|
||||
@@ -149,24 +104,6 @@ test("middleware errors remain declared client errors", async () => {
|
||||
}
|
||||
})
|
||||
|
||||
test("sessions.history decodes SessionNotFoundError", async () => {
|
||||
const client = OpenCode.make({
|
||||
baseUrl: "http://localhost:3000",
|
||||
fetch: async () =>
|
||||
Response.json(
|
||||
{ _tag: "SessionNotFoundError", sessionID: "ses_missing", message: "Session not found" },
|
||||
{ status: 404 },
|
||||
),
|
||||
})
|
||||
|
||||
try {
|
||||
await client.sessions.history({ sessionID: "ses_missing" })
|
||||
throw new Error("Expected request to fail")
|
||||
} catch (error) {
|
||||
expect(isSessionNotFoundError(error)).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
const session = {
|
||||
data: {
|
||||
id: "ses_test",
|
||||
|
||||
@@ -1,15 +1,10 @@
|
||||
import type { APIEvent } from "@solidjs/start/server"
|
||||
import { Resource } from "@opencode-ai/console-resource"
|
||||
import { LOCALE_HEADER, cookie, localeFromRequest, route, tag } from "~/lib/language"
|
||||
|
||||
const dataPath = "/data"
|
||||
|
||||
export async function statsProxy(evt: APIEvent) {
|
||||
const req = evt.request.clone()
|
||||
const locale = localeFromRequest(req)
|
||||
const redirect = redirectToLocalizedData(req, new URL(req.url), locale)
|
||||
if (redirect) return redirect
|
||||
|
||||
const targetUrl = new URL(req.url)
|
||||
targetUrl.protocol = "https:"
|
||||
targetUrl.hostname = Resource.App.stage === "production" ? "stats.opencode.ai" : "stats.dev.opencode.ai"
|
||||
@@ -23,13 +18,9 @@ export async function statsProxy(evt: APIEvent) {
|
||||
targetUrl.pathname = targetUrl.pathname.slice(dataPath.length)
|
||||
}
|
||||
|
||||
const requestHeaders = new Headers(req.headers)
|
||||
requestHeaders.set(LOCALE_HEADER, locale)
|
||||
requestHeaders.set("accept-language", tag(locale))
|
||||
|
||||
const response = await fetch(targetUrl, {
|
||||
method: req.method,
|
||||
headers: requestHeaders,
|
||||
headers: req.headers,
|
||||
body: req.body,
|
||||
})
|
||||
|
||||
@@ -39,7 +30,6 @@ export async function statsProxy(evt: APIEvent) {
|
||||
headers.delete("content-encoding")
|
||||
headers.delete("content-length")
|
||||
headers.delete("etag")
|
||||
appendVary(headers, "Accept-Language", "Cookie")
|
||||
|
||||
return new Response(rewriteStatsHtml(await response.text()), {
|
||||
status: response.status,
|
||||
@@ -62,60 +52,3 @@ export function statsRedirect(evt: APIEvent) {
|
||||
function rewriteStatsHtml(html: string) {
|
||||
return html.replaceAll('"/_build/', `"${dataPath}/_build/`).replaceAll("'/_build/", `'${dataPath}/_build/`)
|
||||
}
|
||||
|
||||
function redirectToLocalizedData(request: Request, url: URL, locale: ReturnType<typeof localeFromRequest>) {
|
||||
if (locale === "en") return null
|
||||
if (request.headers.get(LOCALE_HEADER)) return null
|
||||
if (request.method !== "GET" && request.method !== "HEAD") return null
|
||||
if (!acceptsHtml(request)) return null
|
||||
if (!url.pathname.startsWith(`${dataPath}/`) && url.pathname !== dataPath) return null
|
||||
if (isDataBypassPath(url.pathname)) return null
|
||||
|
||||
const next = new URL(url)
|
||||
next.pathname = route(locale, url.pathname)
|
||||
|
||||
const headers = new Headers({
|
||||
Location: next.toString(),
|
||||
})
|
||||
headers.append("set-cookie", cookie(locale))
|
||||
appendVary(headers, "Accept-Language", "Cookie")
|
||||
|
||||
return new Response(null, {
|
||||
status: 308,
|
||||
headers,
|
||||
})
|
||||
}
|
||||
|
||||
function acceptsHtml(request: Request) {
|
||||
const accept = request.headers.get("accept")
|
||||
return !accept || accept.includes("text/html") || accept.includes("*/*")
|
||||
}
|
||||
|
||||
function isDataBypassPath(pathname: string) {
|
||||
return (
|
||||
pathname.startsWith(`${dataPath}/_build/`) ||
|
||||
pathname.startsWith(`${dataPath}/api/`) ||
|
||||
pathname.startsWith(`${dataPath}/_server`) ||
|
||||
pathname === `${dataPath}/banner.jpg` ||
|
||||
pathname === `${dataPath}/banner.png`
|
||||
)
|
||||
}
|
||||
|
||||
function appendVary(headers: Headers, ...values: string[]) {
|
||||
const existing = headers
|
||||
.get("vary")
|
||||
?.split(",")
|
||||
.map((value) => value.trim())
|
||||
.filter(Boolean)
|
||||
|
||||
headers.set(
|
||||
"vary",
|
||||
values
|
||||
.reduce(
|
||||
(result, value) =>
|
||||
result.some((item) => item.toLowerCase() === value.toLowerCase()) ? result : [...result, value],
|
||||
existing ?? [],
|
||||
)
|
||||
.join(", "),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -215,16 +215,6 @@ export async function handler(
|
||||
body: reqBody,
|
||||
})
|
||||
|
||||
if (providerInfo.id.startsWith("console.")) {
|
||||
const resEndpointId = res.headers.get("x-opencode-endpoint-id")
|
||||
const resEndpointModelId = res.headers.get("x-opencode-upstream-model-id")
|
||||
if (resEndpointId && resEndpointModelId)
|
||||
logger.metric({
|
||||
provider: resEndpointId,
|
||||
"provider.model": resEndpointModelId,
|
||||
})
|
||||
}
|
||||
|
||||
if (res.status !== 200) {
|
||||
logger.metric({
|
||||
"llm.error.code": res.status,
|
||||
|
||||
+15
-81
@@ -1,9 +1,9 @@
|
||||
export * as EventV2 from "./event"
|
||||
|
||||
import { Cause, Context, Effect, Layer, Option, PubSub, Queue, Schema, Stream } from "effect"
|
||||
import { Cause, Context, Effect, Layer, Option, PubSub, Schema, Stream } from "effect"
|
||||
import { Event } from "@opencode-ai/schema/event"
|
||||
import type { Data, Definition, Payload } from "@opencode-ai/schema/event"
|
||||
import { and, asc, eq, gt, inArray } from "drizzle-orm"
|
||||
import { and, asc, eq, gt } from "drizzle-orm"
|
||||
import { Database } from "./database/database"
|
||||
import { EventSequenceTable, EventTable } from "./event/sql"
|
||||
import { Location } from "./location"
|
||||
@@ -47,71 +47,6 @@ export class InvalidDurableEventError extends Schema.TaggedErrorClass<InvalidDur
|
||||
},
|
||||
) {}
|
||||
|
||||
const decodeSerializedEvent = (event: SerializedEvent): Payload => {
|
||||
const definition = Durable.get(event.type)
|
||||
if (!definition?.durable) {
|
||||
throw new InvalidDurableEventError({ type: event.type, message: `Unknown durable event type ${event.type}` })
|
||||
}
|
||||
return {
|
||||
id: event.id,
|
||||
type: definition.type,
|
||||
durable: { aggregateID: event.aggregateID, seq: event.seq, version: definition.durable.version },
|
||||
data: Schema.decodeUnknownSync(definition.data)(event.data),
|
||||
}
|
||||
}
|
||||
|
||||
export const readAggregate = Effect.fn("EventV2.readAggregate")(function* <A>(
|
||||
db: Database.Interface["db"],
|
||||
input: {
|
||||
readonly aggregateID: string
|
||||
readonly after?: number
|
||||
readonly limit: number
|
||||
readonly manifest: {
|
||||
readonly definitions: ReadonlyMap<string, Definition>
|
||||
readonly schema: Schema.Decoder<A, never>
|
||||
}
|
||||
},
|
||||
) {
|
||||
const after = input.after ?? -1
|
||||
const rows = yield* db
|
||||
.select()
|
||||
.from(EventTable)
|
||||
.where(
|
||||
and(
|
||||
eq(EventTable.aggregate_id, input.aggregateID),
|
||||
gt(EventTable.seq, after),
|
||||
inArray(EventTable.type, Array.from(input.manifest.definitions.keys())),
|
||||
),
|
||||
)
|
||||
.orderBy(asc(EventTable.seq))
|
||||
.limit(input.limit + 1)
|
||||
.all()
|
||||
.pipe(Effect.orDie)
|
||||
const page = rows.slice(0, input.limit)
|
||||
const decode = Schema.decodeUnknownSync(input.manifest.schema)
|
||||
const events = page.map((event) =>
|
||||
decode({
|
||||
id: event.id,
|
||||
type: input.manifest.definitions.get(event.type)?.type ?? event.type,
|
||||
durable: {
|
||||
aggregateID: event.aggregate_id,
|
||||
seq: event.seq,
|
||||
version: input.manifest.definitions.get(event.type)?.durable?.version,
|
||||
},
|
||||
data: event.data,
|
||||
}),
|
||||
)
|
||||
return {
|
||||
events,
|
||||
hasMore: rows.length > input.limit,
|
||||
}
|
||||
})
|
||||
|
||||
export class SubscriberOverflowError extends Schema.TaggedErrorClass<SubscriberOverflowError>()(
|
||||
"EventV2.SubscriberOverflow",
|
||||
{ capacity: Schema.Int },
|
||||
) {}
|
||||
|
||||
export const define = Event.define
|
||||
export const versionedType = Event.versionedType
|
||||
|
||||
@@ -149,20 +84,6 @@ export interface Interface {
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/Event") {}
|
||||
|
||||
export const allBounded = (events: Interface, capacity: number) =>
|
||||
Effect.gen(function* () {
|
||||
const queue = yield* Queue.dropping<Payload, SubscriberOverflowError>(capacity)
|
||||
const unsubscribe = yield* events.listen((event) =>
|
||||
Queue.offer(queue, event).pipe(
|
||||
Effect.flatMap((accepted) =>
|
||||
accepted ? Effect.void : Queue.fail(queue, new SubscriberOverflowError({ capacity })).pipe(Effect.asVoid),
|
||||
),
|
||||
),
|
||||
)
|
||||
yield* Effect.addFinalizer(() => unsubscribe.pipe(Effect.andThen(Queue.shutdown(queue)), Effect.asVoid))
|
||||
return Stream.fromQueue(queue)
|
||||
})
|
||||
|
||||
export interface LayerOptions {
|
||||
readonly beforeAggregateRead?: (aggregateID: string) => Effect.Effect<void>
|
||||
}
|
||||
@@ -538,6 +459,19 @@ export const layerWith = (options?: LayerOptions) =>
|
||||
|
||||
const streamAll = (): Stream.Stream<Payload> => Stream.fromPubSub(pubsub.all)
|
||||
|
||||
const decodeSerializedEvent = (event: SerializedEvent) => {
|
||||
const definition = Durable.get(event.type)
|
||||
if (!definition?.durable) {
|
||||
throw new InvalidDurableEventError({ type: event.type, message: `Unknown durable event type ${event.type}` })
|
||||
}
|
||||
return {
|
||||
id: event.id,
|
||||
type: definition.type,
|
||||
durable: { aggregateID: event.aggregateID, seq: event.seq, version: definition.durable.version },
|
||||
data: Schema.decodeUnknownSync(definition.data)(event.data),
|
||||
}
|
||||
}
|
||||
|
||||
const readAfter = (aggregateID: string, after: number) =>
|
||||
(options?.beforeAggregateRead?.(aggregateID) ?? Effect.void).pipe(
|
||||
Effect.andThen(
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
// offline and drop into any transport (`res.end(...)`, Effect `response.end`,
|
||||
// etc.).
|
||||
//
|
||||
// The visual language mirrors the OpenCode app: the design tokens are a curated
|
||||
// The visual language mirrors the opencode app: the design tokens are a curated
|
||||
// subset of the OC-2 semantic tokens in `packages/ui/src/styles/theme.css`, and
|
||||
// the wordmark is the same geometry as `packages/ui/src/components/logo.tsx`.
|
||||
// Keep this file in sync with those sources when the brand changes.
|
||||
@@ -25,8 +25,8 @@ export function success(options?: CallbackPageOptions) {
|
||||
body: renderCard({
|
||||
status: "success",
|
||||
headline: "Authorization successful",
|
||||
message: provider ? `OpenCode is now connected to ${escapeHtml(provider)}.` : "OpenCode is now authorized.",
|
||||
footnote: "You can close this window.",
|
||||
message: provider ? `opencode is now connected to ${escapeHtml(provider)}.` : "opencode is now authorized.",
|
||||
footnote: "You can close this window and return to opencode.",
|
||||
}),
|
||||
script: options?.autoClose === false ? undefined : AUTO_CLOSE_SCRIPT,
|
||||
})
|
||||
@@ -40,10 +40,10 @@ export function error(detail: string, options?: CallbackPageOptions) {
|
||||
status: "error",
|
||||
headline: "Authorization failed",
|
||||
message: provider
|
||||
? `OpenCode couldn't finish connecting to ${escapeHtml(provider)}.`
|
||||
: "OpenCode couldn't complete authorization.",
|
||||
? `opencode couldn't finish connecting to ${escapeHtml(provider)}.`
|
||||
: "opencode couldn't complete authorization.",
|
||||
detail,
|
||||
footnote: "Close this window and try again from OpenCode.",
|
||||
footnote: "Close this window and try again from opencode.",
|
||||
}),
|
||||
})
|
||||
}
|
||||
@@ -100,7 +100,7 @@ function renderDocument(input: { title: string; body: string; script?: string })
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="robots" content="noindex" />
|
||||
<title>${escapeHtml(input.title)} · OpenCode</title>
|
||||
<title>${escapeHtml(input.title)} · opencode</title>
|
||||
<style>${STYLES}</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -116,8 +116,8 @@ function bootstrapScript(options: BootstrapOptions) {
|
||||
var TOKEN_URL=new URL(${scriptString(options.tokenPath)},window.location.origin).href;
|
||||
(function(){
|
||||
var card=document.getElementById("oc-card"),headline=document.getElementById("oc-headline"),message=document.getElementById("oc-message"),detail=document.getElementById("oc-detail"),footnote=document.getElementById("oc-footnote");
|
||||
function fail(text){card.dataset.status="error";headline.textContent="Authorization failed";message.textContent=PROVIDER?("OpenCode couldn't finish connecting to "+PROVIDER+"."):"OpenCode couldn't complete authorization.";if(text){detail.textContent=text;detail.hidden=false}footnote.textContent="Close this window and try again from OpenCode."}
|
||||
function ok(){card.dataset.status="success";headline.textContent="Authorization successful";message.textContent=PROVIDER?("OpenCode is now connected to "+PROVIDER+"."):"OpenCode is now authorized.";detail.hidden=true;footnote.textContent="You can close this window.";setTimeout(function(){try{window.close()}catch(e){}},2500)}
|
||||
function fail(text){card.dataset.status="error";headline.textContent="Authorization failed";message.textContent=PROVIDER?("opencode couldn't finish connecting to "+PROVIDER+"."):"opencode couldn't complete authorization.";if(text){detail.textContent=text;detail.hidden=false}footnote.textContent="Close this window and try again from opencode."}
|
||||
function ok(){card.dataset.status="success";headline.textContent="Authorization successful";message.textContent=PROVIDER?("opencode is now connected to "+PROVIDER+"."):"opencode is now authorized.";detail.hidden=true;footnote.textContent="You can close this window and return to opencode.";setTimeout(function(){try{window.close()}catch(e){}},2500)}
|
||||
try{
|
||||
var hash=new URLSearchParams((window.location.hash||"").slice(1));
|
||||
var search=new URLSearchParams(window.location.search||"");
|
||||
@@ -205,7 +205,7 @@ const STYLES = `
|
||||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
.card {
|
||||
width: min(100%, 28rem);
|
||||
width: min(100%, 25rem);
|
||||
padding: 2.25rem 2rem 1.75rem;
|
||||
background: var(--oc-card);
|
||||
border: 1px solid var(--oc-border-weak);
|
||||
@@ -249,8 +249,8 @@ const STYLES = `
|
||||
@media (prefers-reduced-motion: reduce) { .spinner { animation: none; } }
|
||||
`
|
||||
|
||||
// OpenCode wordmark — same path geometry as packages/ui/src/components/logo.tsx (Logo).
|
||||
const WORDMARK = `<svg class="wordmark" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 234 42" fill="none" aria-label="OpenCode" role="img">
|
||||
// opencode wordmark — same path geometry as packages/ui/src/components/logo.tsx (Logo).
|
||||
const WORDMARK = `<svg class="wordmark" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 234 42" fill="none" aria-label="opencode" role="img">
|
||||
<path d="M18 30H6V18H18V30Z" fill="var(--oc-icon-weak)" />
|
||||
<path d="M18 12H6V30H18V12ZM24 36H0V6H24V36Z" fill="var(--oc-icon-base)" />
|
||||
<path d="M48 30H36V18H48V30Z" fill="var(--oc-icon-weak)" />
|
||||
|
||||
@@ -10,7 +10,6 @@ import { ModelV2 } from "./model"
|
||||
import { Location } from "./location"
|
||||
import { SessionMessage } from "./session/message"
|
||||
import { Prompt } from "./session/prompt"
|
||||
import { PromptInput } from "@opencode-ai/schema/prompt-input"
|
||||
import { EventV2 } from "./event"
|
||||
import { Database } from "./database/database"
|
||||
import { SessionProjector } from "./session/projector"
|
||||
@@ -33,8 +32,6 @@ import { SessionInput } from "./session/input"
|
||||
import { Snapshot } from "./snapshot"
|
||||
import { SessionRevert } from "./session/revert"
|
||||
import { Revert } from "@opencode-ai/schema/revert"
|
||||
import { FSUtil } from "./fs-util"
|
||||
import { SessionDurable } from "@opencode-ai/schema/durable-event-manifest"
|
||||
|
||||
export const RevertState = Revert.State
|
||||
export type RevertState = Revert.State
|
||||
@@ -132,11 +129,6 @@ export interface Interface {
|
||||
sessionID: SessionSchema.ID
|
||||
after?: number
|
||||
}) => Stream.Stream<SessionEvent.DurableEvent, NotFoundError>
|
||||
readonly history: (input: {
|
||||
sessionID: SessionSchema.ID
|
||||
after?: number
|
||||
limit: number
|
||||
}) => Effect.Effect<{ events: ReadonlyArray<SessionEvent.DurableEvent>; hasMore: boolean }, NotFoundError>
|
||||
readonly switchAgent: (input: { sessionID: SessionSchema.ID; agent: string }) => Effect.Effect<void, NotFoundError>
|
||||
readonly switchModel: (input: {
|
||||
sessionID: SessionSchema.ID
|
||||
@@ -145,7 +137,7 @@ export interface Interface {
|
||||
readonly prompt: (input: {
|
||||
id?: SessionMessage.ID
|
||||
sessionID: SessionSchema.ID
|
||||
prompt: PromptInput.Prompt
|
||||
prompt: Prompt
|
||||
delivery?: SessionInput.Delivery
|
||||
resume?: boolean
|
||||
}) => Effect.Effect<SessionInput.Admitted, NotFoundError | PromptConflictError>
|
||||
@@ -353,26 +345,17 @@ export const layer = Layer.unwrap(
|
||||
.get(input.sessionID)
|
||||
.pipe(Effect.as(events.durable({ aggregateID: input.sessionID, after: input.after }))),
|
||||
).pipe(Stream.filter((event): event is SessionEvent.DurableEvent => isDurableSessionEvent(event))),
|
||||
history: Effect.fn("V2Session.history")(function* (input) {
|
||||
yield* result.get(input.sessionID)
|
||||
return yield* EventV2.readAggregate(db, {
|
||||
...input,
|
||||
aggregateID: input.sessionID,
|
||||
manifest: SessionDurable,
|
||||
})
|
||||
}),
|
||||
prompt: Effect.fn("V2Session.prompt")((input) =>
|
||||
Effect.uninterruptible(
|
||||
Effect.gen(function* () {
|
||||
yield* result.get(input.sessionID)
|
||||
const prompt = resolvePrompt(input.prompt)
|
||||
const messageID = input.id ?? SessionMessage.ID.create()
|
||||
const delivery = input.delivery ?? "steer"
|
||||
const expected = { sessionID: input.sessionID, messageID, prompt, delivery }
|
||||
const expected = { sessionID: input.sessionID, messageID, prompt: input.prompt, delivery }
|
||||
const admitted = yield* SessionInput.admit(db, events, {
|
||||
id: messageID,
|
||||
sessionID: input.sessionID,
|
||||
prompt,
|
||||
prompt: input.prompt,
|
||||
delivery,
|
||||
}).pipe(
|
||||
Effect.catchDefect((defect) =>
|
||||
@@ -469,17 +452,3 @@ export const defaultLayer = layer.pipe(
|
||||
Layer.provide(ProjectV2.defaultLayer),
|
||||
Layer.orDie,
|
||||
)
|
||||
|
||||
const resolvePrompt = (input: PromptInput.Prompt) =>
|
||||
Prompt.make({
|
||||
text: input.text,
|
||||
agents: input.agents,
|
||||
files: input.files?.map((file) => {
|
||||
const dataMime = file.uri.match(/^data:([^;,]+)[;,]/i)?.[1]
|
||||
const target = URL.canParse(file.uri) ? new URL(file.uri).pathname : (file.name ?? file.uri)
|
||||
return {
|
||||
...file,
|
||||
mime: dataMime ?? (target.endsWith("/") ? "application/x-directory" : FSUtil.mimeType(target)),
|
||||
}
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -52,7 +52,6 @@ function isSafeRelativePath(value: string) {
|
||||
|
||||
class IndexSkill extends Schema.Class<IndexSkill>("SkillDiscovery.IndexSkill")({
|
||||
name: Schema.String,
|
||||
version: Schema.optional(Schema.String),
|
||||
files: Schema.Array(Schema.String),
|
||||
}) {}
|
||||
|
||||
@@ -81,15 +80,12 @@ export const layer = Layer.effect(
|
||||
)
|
||||
|
||||
const download = Effect.fn("SkillDiscovery.download")(function* (url: string, destination: string) {
|
||||
if (yield* fs.exists(destination).pipe(Effect.orDie)) return true
|
||||
return yield* HttpClientRequest.get(url).pipe(
|
||||
if (yield* fs.exists(destination).pipe(Effect.orDie)) return
|
||||
yield* HttpClientRequest.get(url).pipe(
|
||||
http.execute,
|
||||
Effect.flatMap((response) => response.arrayBuffer),
|
||||
Effect.flatMap((body) => fs.writeWithDirs(destination, new Uint8Array(body))),
|
||||
Effect.as(true),
|
||||
Effect.catch((error) =>
|
||||
Effect.logError("failed to download skill file", { url, error }).pipe(Effect.as(false)),
|
||||
),
|
||||
Effect.catch((error) => Effect.logError("failed to download skill file", { url, error })),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -124,7 +120,6 @@ export const layer = Layer.effect(
|
||||
}
|
||||
|
||||
const skillUrl = new URL(`${encodeURIComponent(skill.name)}/`, source)
|
||||
const versionFile = path.join(root, ".opencode-version")
|
||||
const files = skill.files.map((file) => {
|
||||
if (!isSafeRelativePath(file)) return undefined
|
||||
let resource: URL
|
||||
@@ -140,66 +135,23 @@ export const layer = Layer.effect(
|
||||
return {
|
||||
url: resource.href,
|
||||
destination,
|
||||
file,
|
||||
}
|
||||
})
|
||||
if (files.some((file) => file === undefined)) {
|
||||
return []
|
||||
}
|
||||
return [{ skill, root, versionFile, files: files as { url: string; destination: string; file: string }[] }]
|
||||
return [{ skill, root, files: files as { url: string; destination: string }[] }]
|
||||
}),
|
||||
({ skill, root, versionFile, files }) =>
|
||||
({ skill, root, files }) =>
|
||||
Effect.gen(function* () {
|
||||
const version = skill.version
|
||||
const current =
|
||||
version === undefined
|
||||
? undefined
|
||||
: yield* fs.readFileStringSafe(versionFile).pipe(Effect.catch(() => Effect.succeed(undefined)))
|
||||
if (version === undefined || current === version) {
|
||||
yield* Effect.forEach(files, (file) => download(file.url, file.destination), {
|
||||
concurrency: fileConcurrency,
|
||||
discard: true,
|
||||
})
|
||||
} else {
|
||||
const token = crypto.randomUUID()
|
||||
const staging = `${root}.tmp-${token}`
|
||||
const backup = `${root}.old-${token}`
|
||||
yield* Effect.gen(function* () {
|
||||
const downloaded = yield* Effect.forEach(
|
||||
files,
|
||||
(file) => download(file.url, path.resolve(staging, file.file)),
|
||||
{ concurrency: fileConcurrency },
|
||||
)
|
||||
if (!downloaded.every(Boolean)) return
|
||||
const exists =
|
||||
(yield* fs.exists(path.join(staging, "SKILL.md")).pipe(Effect.orDie)) ||
|
||||
(yield* fs.exists(path.join(staging, `${skill.name}.md`)).pipe(Effect.orDie))
|
||||
if (!exists) return
|
||||
yield* fs.writeFileString(path.join(staging, ".opencode-version"), version)
|
||||
yield* Effect.uninterruptible(
|
||||
Effect.gen(function* () {
|
||||
const cached = yield* fs.exists(root).pipe(Effect.orDie)
|
||||
if (cached) yield* fs.rename(root, backup)
|
||||
yield* fs.rename(staging, root).pipe(
|
||||
Effect.catch((error) =>
|
||||
Effect.gen(function* () {
|
||||
if (cached) yield* fs.rename(backup, root).pipe(Effect.ignore)
|
||||
return yield* Effect.fail(error)
|
||||
}),
|
||||
),
|
||||
)
|
||||
if (cached) yield* fs.remove(backup, { recursive: true, force: true }).pipe(Effect.ignore)
|
||||
}),
|
||||
)
|
||||
}).pipe(
|
||||
Effect.catch((error) => Effect.logError("failed to refresh skill", { skill: skill.name, error })),
|
||||
Effect.ensuring(fs.remove(staging, { recursive: true, force: true }).pipe(Effect.ignore)),
|
||||
)
|
||||
}
|
||||
const exists =
|
||||
(yield* fs.exists(path.join(root, "SKILL.md")).pipe(Effect.orDie)) ||
|
||||
yield* Effect.forEach(files, (file) => download(file.url, file.destination), {
|
||||
concurrency: fileConcurrency,
|
||||
discard: true,
|
||||
})
|
||||
return (yield* fs.exists(path.join(root, "SKILL.md")).pipe(Effect.orDie)) ||
|
||||
(yield* fs.exists(path.join(root, `${skill.name}.md`)).pipe(Effect.orDie))
|
||||
return exists ? [AbsolutePath.make(root)] : []
|
||||
? [AbsolutePath.make(root)]
|
||||
: []
|
||||
}),
|
||||
{ concurrency: skillConcurrency },
|
||||
).pipe(Effect.map((directories) => directories.flat()))
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
export * as ReadTool from "./read"
|
||||
|
||||
import { ToolFailure } from "@opencode-ai/llm"
|
||||
import path from "path"
|
||||
import { Effect, Layer, Schema } from "effect"
|
||||
import { FileSystem } from "../filesystem"
|
||||
import { FSUtil } from "../fs-util"
|
||||
import { Image } from "../image"
|
||||
import { LocationMutation } from "../location-mutation"
|
||||
import { Location } from "../location"
|
||||
import { PermissionV2 } from "../permission"
|
||||
import { AbsolutePath } from "../schema"
|
||||
import { ReadToolFileSystem } from "./read-filesystem"
|
||||
@@ -28,8 +30,9 @@ const Output = Schema.Union([FileSystem.Content, ReadToolFileSystem.TextPage, Re
|
||||
export const layer = Layer.effectDiscard(
|
||||
Effect.gen(function* () {
|
||||
const tools = yield* Tools.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const reader = yield* ReadToolFileSystem.Service
|
||||
const mutation = yield* LocationMutation.Service
|
||||
const location = yield* Location.Service
|
||||
const image = yield* Image.Service
|
||||
const permission = yield* PermissionV2.Service
|
||||
|
||||
@@ -37,7 +40,7 @@ export const layer = Layer.effectDiscard(
|
||||
.register({
|
||||
[name]: Tool.make({
|
||||
description:
|
||||
"Read a text file or supported image, page through a large UTF-8 text file by line offset, or list a directory page. Relative paths resolve from the current location; absolute paths inside it are accepted, while external absolute paths require external_directory approval.",
|
||||
"Read a text file or supported image, page through a large UTF-8 text file by line offset, or list a directory page. Relative paths resolve from the current location; absolute paths are read directly.",
|
||||
input: Input,
|
||||
output: Output,
|
||||
toModelOutput: ({ input, output }) => {
|
||||
@@ -50,34 +53,27 @@ export const layer = Layer.effectDiscard(
|
||||
},
|
||||
execute: (input, context) => {
|
||||
return Effect.gen(function* () {
|
||||
const source = {
|
||||
type: "tool" as const,
|
||||
messageID: context.assistantMessageID,
|
||||
callID: context.toolCallID,
|
||||
}
|
||||
const target = yield* mutation.resolve({ path: input.path, kind: "directory" })
|
||||
const external = target.externalDirectory
|
||||
if (external)
|
||||
yield* permission.assert({
|
||||
...LocationMutation.externalDirectoryPermission(external),
|
||||
sessionID: context.sessionID,
|
||||
agent: context.agent,
|
||||
source,
|
||||
})
|
||||
const resource = target.resource
|
||||
const absolute = AbsolutePath.make(target.canonical)
|
||||
const type = yield* reader.inspect(absolute)
|
||||
const absolute = path.resolve(location.directory, input.path)
|
||||
const selected = path.isAbsolute(input.path) ? path.dirname(absolute) : location.directory
|
||||
if (!path.isAbsolute(input.path) && !FSUtil.contains(location.directory, absolute))
|
||||
return yield* Effect.die(new Error("Path escapes the allowed read root"))
|
||||
const real = yield* fs.realPath(absolute)
|
||||
const root = yield* fs.realPath(selected)
|
||||
if (!FSUtil.contains(root, real))
|
||||
return yield* Effect.die(new Error("Path escapes the allowed read root"))
|
||||
const resource = path.relative(root, real).replaceAll("\\", "/") || "."
|
||||
const target = AbsolutePath.make(real)
|
||||
const type = yield* reader.inspect(target)
|
||||
yield* permission.assert({
|
||||
action: name,
|
||||
resources: [resource],
|
||||
save: ["*"],
|
||||
sessionID: context.sessionID,
|
||||
agent: context.agent,
|
||||
source,
|
||||
source: { type: "tool", messageID: context.assistantMessageID, callID: context.toolCallID },
|
||||
})
|
||||
if (type === "directory")
|
||||
return yield* reader.list(absolute, { offset: input.offset, limit: input.limit })
|
||||
const content = yield* reader.read(absolute, resource, {
|
||||
if (type === "directory") return yield* reader.list(target, { offset: input.offset, limit: input.limit })
|
||||
const content = yield* reader.read(target, resource, {
|
||||
offset: input.offset,
|
||||
limit: input.limit,
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Cause, DateTime, Deferred, Effect, Exit, Fiber, Layer, Option, Schema, Stream } from "effect"
|
||||
import { Cause, DateTime, Deferred, Effect, Exit, Fiber, Layer, Schema, Stream } from "effect"
|
||||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
import { Event } from "@opencode-ai/schema/event"
|
||||
import { Session } from "@opencode-ai/schema/session"
|
||||
@@ -285,69 +285,6 @@ describe("EventV2", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("notifies global listeners only after a durable event is committed", () =>
|
||||
Effect.gen(function* () {
|
||||
const events = yield* EventV2.Service
|
||||
const { db } = yield* Database.Service
|
||||
const aggregateID = EventV2.ID.create()
|
||||
const observed = new Array<{ id: string; seq: number }>()
|
||||
yield* events.listen((event) =>
|
||||
event.type !== SyncMessage.type
|
||||
? Effect.void
|
||||
: db
|
||||
.select({ id: EventTable.id, seq: EventTable.seq })
|
||||
.from(EventTable)
|
||||
.where(eq(EventTable.id, event.id))
|
||||
.get()
|
||||
.pipe(
|
||||
Effect.orDie,
|
||||
Effect.tap((row) =>
|
||||
Effect.sync(() => {
|
||||
if (row) observed.push(row)
|
||||
}),
|
||||
),
|
||||
Effect.asVoid,
|
||||
),
|
||||
)
|
||||
|
||||
const event = yield* events.publish(SyncMessage, { id: aggregateID, text: "committed" })
|
||||
if (!event.durable) throw new Error("Expected durable event metadata")
|
||||
|
||||
expect(observed).toEqual([{ id: event.id, seq: event.durable.seq }])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("ends only an overflowing bounded subscriber without blocking other listeners", () =>
|
||||
Effect.gen(function* () {
|
||||
const events = yield* EventV2.Service
|
||||
const consuming = yield* Deferred.make<void>()
|
||||
const release = yield* Deferred.make<void>()
|
||||
const slowStream = yield* EventV2.allBounded(events, 1)
|
||||
const fastStream = yield* EventV2.allBounded(events, 8)
|
||||
const slow = yield* slowStream.pipe(
|
||||
Stream.runForEach(() => Deferred.succeed(consuming, undefined).pipe(Effect.andThen(Deferred.await(release)))),
|
||||
Effect.forkScoped,
|
||||
)
|
||||
const fast = yield* fastStream.pipe(Stream.take(4), Stream.runCollect, Effect.forkScoped)
|
||||
|
||||
yield* events.publish(Message, { text: "one" })
|
||||
yield* Deferred.await(consuming)
|
||||
yield* events.publish(Message, { text: "two" })
|
||||
yield* events.publish(Message, { text: "overflow" })
|
||||
const last = yield* events.publish(Message, { text: "still delivered" })
|
||||
yield* Deferred.succeed(release, undefined)
|
||||
|
||||
const slowExit = yield* Fiber.await(slow)
|
||||
expect(Exit.findErrorOption(slowExit).pipe(Option.getOrUndefined)).toBeInstanceOf(EventV2.SubscriberOverflowError)
|
||||
expect(Array.from(yield* Fiber.join(fast))).toEqual([
|
||||
expect.objectContaining({ data: { text: "one" } }),
|
||||
expect.objectContaining({ data: { text: "two" } }),
|
||||
expect.objectContaining({ data: { text: "overflow" } }),
|
||||
last,
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves observer interruption", () =>
|
||||
Effect.gen(function* () {
|
||||
const events = yield* EventV2.Service
|
||||
|
||||
@@ -1,174 +0,0 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Layer, Schema } from "effect"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { LocationServiceMap } from "@opencode-ai/core/location-layer"
|
||||
import { ProjectV2 } from "@opencode-ai/core/project"
|
||||
import { ProjectTable } from "@opencode-ai/core/project/sql"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { SessionV2 } from "@opencode-ai/core/session"
|
||||
import { SessionExecution } from "@opencode-ai/core/session/execution"
|
||||
import { SessionProjector } from "@opencode-ai/core/session/projector"
|
||||
import { SessionStore } from "@opencode-ai/core/session/store"
|
||||
import { SessionTable } from "@opencode-ai/core/session/sql"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const projects = Layer.succeed(
|
||||
ProjectV2.Service,
|
||||
ProjectV2.Service.of({
|
||||
resolve: (directory) => Effect.succeed({ id: ProjectV2.ID.global, directory }),
|
||||
directories: () => Effect.succeed([]),
|
||||
commit: () => Effect.void,
|
||||
}),
|
||||
)
|
||||
const sessions = SessionV2.layer.pipe(
|
||||
Layer.provide(LocationServiceMap.layer),
|
||||
Layer.provide(EventV2.defaultLayer),
|
||||
Layer.provide(Database.defaultLayer),
|
||||
Layer.provide(SessionStore.defaultLayer),
|
||||
Layer.provide(projects),
|
||||
Layer.provide(SessionExecution.noopLayer),
|
||||
)
|
||||
const it = testEffect(
|
||||
Layer.mergeAll(
|
||||
Database.defaultLayer,
|
||||
EventV2.defaultLayer,
|
||||
projects,
|
||||
SessionProjector.defaultLayer,
|
||||
SessionStore.defaultLayer,
|
||||
SessionExecution.noopLayer,
|
||||
sessions,
|
||||
),
|
||||
)
|
||||
const location = Location.Ref.make({ directory: AbsolutePath.make("/project") })
|
||||
|
||||
const GapEvent = EventV2.define({
|
||||
type: "test.session.history.gap",
|
||||
durable: { aggregate: "sessionID", version: 1 },
|
||||
schema: { sessionID: SessionV2.ID, value: Schema.String },
|
||||
})
|
||||
|
||||
describe("SessionV2.history", () => {
|
||||
it.effect("returns an exhausted page for a migrated Session with no event sequence", () =>
|
||||
Effect.gen(function* () {
|
||||
const db = (yield* Database.Service).db
|
||||
const session = yield* SessionV2.Service
|
||||
const sessionID = SessionV2.ID.make("ses_empty_history")
|
||||
yield* db
|
||||
.insert(ProjectTable)
|
||||
.values({ id: ProjectV2.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
|
||||
.onConflictDoNothing()
|
||||
.run()
|
||||
yield* db
|
||||
.insert(SessionTable)
|
||||
.values({
|
||||
id: sessionID,
|
||||
project_id: ProjectV2.ID.global,
|
||||
slug: "empty-history",
|
||||
directory: "/project",
|
||||
title: "Empty history",
|
||||
version: "test",
|
||||
})
|
||||
.run()
|
||||
|
||||
const first = yield* session.history({ sessionID, limit: 10 })
|
||||
|
||||
expect(first).toEqual({ events: [], hasMore: false })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("treats after as an exclusive aggregate sequence", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* SessionV2.Service
|
||||
const created = yield* session.create({ location })
|
||||
yield* session.switchAgent({ sessionID: created.id, agent: "one" })
|
||||
yield* session.switchAgent({ sessionID: created.id, agent: "two" })
|
||||
|
||||
const page = yield* session.history({ sessionID: created.id, after: 1, limit: 10 })
|
||||
|
||||
expect(page.events.map((event) => event.durable?.seq)).toEqual([2])
|
||||
expect(page.hasMore).toBe(false)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("paginates public events in aggregate order across filtered gaps without duplicates", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* SessionV2.Service
|
||||
const events = yield* EventV2.Service
|
||||
const created = yield* session.create({ location })
|
||||
yield* session.switchAgent({ sessionID: created.id, agent: "one" })
|
||||
yield* events.publish(GapEvent, { sessionID: created.id, value: "filtered" })
|
||||
yield* session.switchAgent({ sessionID: created.id, agent: "two" })
|
||||
yield* session.switchAgent({ sessionID: created.id, agent: "three" })
|
||||
|
||||
const first = yield* session.history({ sessionID: created.id, limit: 2 })
|
||||
const after = first.events.at(-1)?.durable?.seq
|
||||
const second = yield* session.history({
|
||||
sessionID: created.id,
|
||||
after,
|
||||
limit: 2,
|
||||
})
|
||||
const sequence = [...first.events, ...second.events].map((event) => event.durable?.seq)
|
||||
|
||||
expect(first.hasMore).toBe(true)
|
||||
expect(second.hasMore).toBe(false)
|
||||
expect(sequence).toEqual([1, 3, 4])
|
||||
expect(new Set(sequence).size).toBe(sequence.length)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("includes events committed between pages", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* SessionV2.Service
|
||||
const created = yield* session.create({ location })
|
||||
yield* session.switchAgent({ sessionID: created.id, agent: "one" })
|
||||
yield* session.switchAgent({ sessionID: created.id, agent: "two" })
|
||||
|
||||
const first = yield* session.history({ sessionID: created.id, limit: 1 })
|
||||
yield* session.switchAgent({ sessionID: created.id, agent: "later" })
|
||||
const second = yield* session.history({
|
||||
sessionID: created.id,
|
||||
after: first.events.at(-1)?.durable?.seq,
|
||||
limit: 10,
|
||||
})
|
||||
|
||||
expect(first.hasMore).toBe(true)
|
||||
expect([...first.events, ...second.events].map((event) => event.durable?.seq)).toEqual([1, 2, 3])
|
||||
expect(second.hasMore).toBe(false)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("reports exhaustion for exact-limit and limit-plus-one pages", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* SessionV2.Service
|
||||
const created = yield* session.create({ location })
|
||||
yield* session.switchAgent({ sessionID: created.id, agent: "one" })
|
||||
yield* session.switchAgent({ sessionID: created.id, agent: "two" })
|
||||
|
||||
const exact = yield* session.history({ sessionID: created.id, limit: 2 })
|
||||
const oneMore = yield* session.history({ sessionID: created.id, limit: 1 })
|
||||
const exhausted = yield* session.history({
|
||||
sessionID: created.id,
|
||||
after: oneMore.events.at(-1)?.durable?.seq,
|
||||
limit: 1,
|
||||
})
|
||||
|
||||
expect(exact.events).toHaveLength(2)
|
||||
expect(exact.hasMore).toBe(false)
|
||||
expect(oneMore.events).toHaveLength(1)
|
||||
expect(oneMore.hasMore).toBe(true)
|
||||
expect(exhausted.events).toHaveLength(1)
|
||||
expect(exhausted.hasMore).toBe(false)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("fails with NotFoundError for a missing Session", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* SessionV2.Service
|
||||
const error = yield* session.history({ sessionID: SessionV2.ID.make("ses_missing"), limit: 10 }).pipe(Effect.flip)
|
||||
|
||||
expect(error._tag).toBe("Session.NotFoundError")
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -173,27 +173,6 @@ describe("SessionV2.prompt", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("resolves attachment MIME before admission", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup
|
||||
const session = yield* SessionV2.Service
|
||||
|
||||
const message = yield* session.prompt({
|
||||
sessionID,
|
||||
prompt: {
|
||||
text: "Inspect this image",
|
||||
files: [{ uri: "data:image/png;base64,aGVsbG8=", name: "image.png" }],
|
||||
},
|
||||
resume: false,
|
||||
})
|
||||
|
||||
expect(message.prompt.files).toEqual([
|
||||
{ uri: "data:image/png;base64,aGVsbG8=", name: "image.png", mime: "image/png" },
|
||||
])
|
||||
expect((yield* admitted(message.id))?.prompt.files).toEqual(message.prompt.files)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("streams durable Session events after an aggregate sequence", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup
|
||||
|
||||
@@ -10,8 +10,8 @@ import { tmpdir } from "./fixture/tmpdir"
|
||||
|
||||
const base = "https://skills.example.test/catalog/"
|
||||
|
||||
async function pull(skills: unknown[], files: Record<string, string> = {}, cache?: Awaited<ReturnType<typeof tmpdir>>) {
|
||||
const tmp = cache ?? (await tmpdir())
|
||||
async function pull(skills: unknown[], files: Record<string, string> = {}) {
|
||||
const tmp = await tmpdir()
|
||||
const requests: string[] = []
|
||||
const http = Layer.succeed(
|
||||
HttpClient.HttpClient,
|
||||
@@ -101,64 +101,4 @@ describe("SkillDiscovery.pull", () => {
|
||||
await result.tmp[Symbol.asyncDispose]()
|
||||
}
|
||||
})
|
||||
|
||||
test("refreshes cached files when the version changes", async () => {
|
||||
const tmp = await tmpdir()
|
||||
try {
|
||||
const first = await pull(
|
||||
[{ name: "deploy", version: "1", files: ["SKILL.md"] }],
|
||||
{
|
||||
[`${base}deploy/SKILL.md`]: "# Old",
|
||||
},
|
||||
tmp,
|
||||
)
|
||||
const second = await pull(
|
||||
[{ name: "deploy", version: "2", files: ["SKILL.md"] }],
|
||||
{
|
||||
[`${base}deploy/SKILL.md`]: "# New",
|
||||
},
|
||||
tmp,
|
||||
)
|
||||
|
||||
expect(await fs.readFile(path.join(first.directories[0], "SKILL.md"), "utf8")).toBe("# New")
|
||||
expect(second.requests).toContain(`${base}deploy/SKILL.md`)
|
||||
const third = await pull(
|
||||
[{ name: "deploy", version: "2", files: ["SKILL.md"] }],
|
||||
{ [`${base}deploy/SKILL.md`]: "# Ignored" },
|
||||
tmp,
|
||||
)
|
||||
expect(third.requests).toEqual([`${base}index.json`])
|
||||
} finally {
|
||||
await tmp[Symbol.asyncDispose]()
|
||||
}
|
||||
})
|
||||
|
||||
test("publishes complete updates and removes stale files", async () => {
|
||||
const tmp = await tmpdir()
|
||||
try {
|
||||
const first = await pull(
|
||||
[{ name: "deploy", version: "1", files: ["SKILL.md", "old.md"] }],
|
||||
{
|
||||
[`${base}deploy/SKILL.md`]: "# Old",
|
||||
[`${base}deploy/old.md`]: "old reference",
|
||||
},
|
||||
tmp,
|
||||
)
|
||||
const root = first.directories[0]
|
||||
|
||||
await pull(
|
||||
[{ name: "deploy", version: "2", files: ["SKILL.md", "missing.md"] }],
|
||||
{ [`${base}deploy/SKILL.md`]: "# Partial" },
|
||||
tmp,
|
||||
)
|
||||
expect(await fs.readFile(path.join(root, "SKILL.md"), "utf8")).toBe("# Old")
|
||||
expect(await fs.readFile(path.join(root, "old.md"), "utf8")).toBe("old reference")
|
||||
|
||||
await pull([{ name: "deploy", version: "3", files: ["SKILL.md"] }], { [`${base}deploy/SKILL.md`]: "# New" }, tmp)
|
||||
expect(await fs.readFile(path.join(root, "SKILL.md"), "utf8")).toBe("# New")
|
||||
expect(await Bun.file(path.join(root, "old.md")).exists()).toBe(false)
|
||||
} finally {
|
||||
await tmp[Symbol.asyncDispose]()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -11,7 +11,6 @@ import { PermissionV2 } from "@opencode-ai/core/permission"
|
||||
import { SessionV2 } from "@opencode-ai/core/session"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { LocationMutation } from "@opencode-ai/core/location-mutation"
|
||||
import { location } from "./fixture/location"
|
||||
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
|
||||
import { ReadTool } from "@opencode-ai/core/tool/read"
|
||||
@@ -98,32 +97,6 @@ const infrastructure = Layer.mergeAll(
|
||||
Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(process.cwd()) }))),
|
||||
Global.layerWith({ data: Global.Path.data }),
|
||||
)
|
||||
const mutation = Layer.succeed(
|
||||
LocationMutation.Service,
|
||||
LocationMutation.Service.of({
|
||||
resolve: (input) => {
|
||||
if (input.path === missingPath)
|
||||
return Effect.fail(new LocationMutation.PathError({ path: input.path, reason: "non_directory_ancestor" }))
|
||||
const canonical = path.resolve(process.cwd(), input.path)
|
||||
const external = path.isAbsolute(input.path) && !FSUtil.contains(process.cwd(), canonical)
|
||||
const resource = external ? canonical.replaceAll("\\", "/") : path.relative(process.cwd(), canonical) || "."
|
||||
const directory = path.dirname(canonical)
|
||||
const externalResource = path.join(directory, "*").replaceAll("\\", "/")
|
||||
return Effect.succeed({
|
||||
canonical,
|
||||
resource,
|
||||
externalDirectory: external
|
||||
? {
|
||||
action: "external_directory" as const,
|
||||
directory,
|
||||
resource: externalResource,
|
||||
save: externalResource,
|
||||
}
|
||||
: undefined,
|
||||
})
|
||||
},
|
||||
}),
|
||||
)
|
||||
const unavailableImage = Layer.succeed(
|
||||
Image.Service,
|
||||
Image.Service.of({ normalize: () => Effect.fail(new Image.ResizerUnavailableError()) }),
|
||||
@@ -134,21 +107,19 @@ const read = ReadTool.layer.pipe(
|
||||
Layer.provide(permission),
|
||||
Layer.provide(config),
|
||||
Layer.provide(image),
|
||||
Layer.provide(mutation),
|
||||
Layer.provide(infrastructure),
|
||||
)
|
||||
const it = testEffect(Layer.mergeAll(registry, reader, permission, config, image, mutation, infrastructure, read))
|
||||
const it = testEffect(Layer.mergeAll(registry, reader, permission, config, image, infrastructure, read))
|
||||
const unavailableRead = ReadTool.layer.pipe(
|
||||
Layer.provide(registry),
|
||||
Layer.provide(reader),
|
||||
Layer.provide(permission),
|
||||
Layer.provide(config),
|
||||
Layer.provide(unavailableImage),
|
||||
Layer.provide(mutation),
|
||||
Layer.provide(infrastructure),
|
||||
)
|
||||
const itWithoutResizer = testEffect(
|
||||
Layer.mergeAll(registry, reader, permission, config, unavailableImage, mutation, infrastructure, unavailableRead),
|
||||
Layer.mergeAll(registry, reader, permission, config, unavailableImage, infrastructure, unavailableRead),
|
||||
)
|
||||
const sessionID = SessionV2.ID.make("ses_read_tool_test")
|
||||
|
||||
@@ -203,30 +174,6 @@ describe("ReadTool", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("asks for external_directory approval before reading an external absolute path", () =>
|
||||
Effect.gen(function* () {
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const external = path.join(path.parse(process.cwd()).root, "external-read", "notes.txt")
|
||||
|
||||
expect(
|
||||
yield* executeTool(registry, {
|
||||
sessionID,
|
||||
...toolIdentity,
|
||||
call: { type: "tool-call", id: "call-external-read", name: "read", input: { path: external } },
|
||||
}),
|
||||
).toMatchObject({ type: "json" })
|
||||
expect(assertions).toMatchObject([
|
||||
{
|
||||
sessionID,
|
||||
action: "external_directory",
|
||||
resources: [path.join(path.dirname(external), "*").replaceAll("\\", "/")],
|
||||
},
|
||||
{ sessionID, action: "read", resources: [external.replaceAll("\\", "/")], save: ["*"] },
|
||||
])
|
||||
expect(readCalls).toEqual([{ input: AbsolutePath.make(external), page: { offset: undefined, limit: undefined } }])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("returns a small PNG as native media instead of durable base64 text", () =>
|
||||
Effect.gen(function* () {
|
||||
const png = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII="
|
||||
|
||||
@@ -230,12 +230,7 @@ export function emitEffectImported(
|
||||
}
|
||||
}
|
||||
|
||||
export function emitPromise(
|
||||
contract: Contract,
|
||||
options?: {
|
||||
readonly outputTypes?: Readonly<Record<string, { readonly name: string; readonly import: string }>>
|
||||
},
|
||||
): Output {
|
||||
export function emitPromise(contract: Contract): Output {
|
||||
const groups = contract.groups
|
||||
for (const group of groups) {
|
||||
for (const endpoint of group.endpoints) assertPromiseEndpoint(endpoint)
|
||||
@@ -243,7 +238,7 @@ export function emitPromise(
|
||||
return {
|
||||
operations: operations(groups),
|
||||
files: [
|
||||
{ path: "types.ts", content: renderPromiseTypes(groups, options?.outputTypes) },
|
||||
{ path: "types.ts", content: renderPromiseTypes(groups) },
|
||||
{
|
||||
path: "client-error.ts",
|
||||
content: `export type ClientErrorReason = "Transport" | "UnexpectedStatus" | "UnsupportedContentType" | "MalformedResponse"\n\nexport class ClientError extends Error {\n override readonly name = "ClientError"\n constructor(readonly reason: ClientErrorReason, options?: ErrorOptions) {\n super(reason, options)\n }\n}\n`,
|
||||
@@ -413,17 +408,14 @@ function renderImportedProjection(groups: ReadonlyArray<Group>, endpoints: Reado
|
||||
return { imports: [...new Set(imports)], source }
|
||||
}
|
||||
|
||||
function renderPromiseTypes(
|
||||
groups: ReadonlyArray<Group>,
|
||||
outputTypes?: Readonly<Record<string, { readonly name: string; readonly import: string }>>,
|
||||
) {
|
||||
function renderPromiseTypes(groups: ReadonlyArray<Group>) {
|
||||
const types = new Map<SchemaAST.AST, string>()
|
||||
const typeOf = (schema: Schema.Top, decoded = false) => {
|
||||
const projected = decoded ? Schema.toType(schema) : Schema.toEncoded(schema)
|
||||
const cached = types.get(projected.ast)
|
||||
const typeOf = (schema: Schema.Top) => {
|
||||
const encoded = Schema.toEncoded(schema)
|
||||
const cached = types.get(encoded.ast)
|
||||
if (cached !== undefined) return cached
|
||||
const type = structuralType(projected)
|
||||
types.set(projected.ast, type)
|
||||
const type = structuralType(encoded)
|
||||
types.set(encoded.ast, type)
|
||||
return type
|
||||
}
|
||||
const errors = new Map(
|
||||
@@ -457,19 +449,17 @@ function renderPromiseTypes(
|
||||
const schema = schemas[field.source]
|
||||
if (schema === undefined)
|
||||
throw new GenerationError({ reason: `Missing input schema: ${prefix}.${field.name}` })
|
||||
return `readonly ${JSON.stringify(field.name)}${field.optional ? "?" : ""}: (${typeOf(schema, field.source === "query")})[${JSON.stringify(field.name)}]`
|
||||
return `readonly ${JSON.stringify(field.name)}${field.optional ? "?" : ""}: (${typeOf(schema)})[${JSON.stringify(field.name)}]`
|
||||
})
|
||||
.join("; ")
|
||||
const successSchema = endpoint.successes[0]
|
||||
const success =
|
||||
outputTypes?.[`${group.identifier}.${endpoint.operation.name}`]?.name ??
|
||||
typeOf(
|
||||
isStreamSchema(successSchema) && successSchema._tag === "StreamSse"
|
||||
? successSchema.sseMode === "data"
|
||||
? streamEncodedDataSchema(successSchema)
|
||||
: successSchema.events
|
||||
: successSchema,
|
||||
)
|
||||
const success = typeOf(
|
||||
isStreamSchema(successSchema) && successSchema._tag === "StreamSse"
|
||||
? successSchema.sseMode === "data"
|
||||
? streamEncodedDataSchema(successSchema)
|
||||
: successSchema.events
|
||||
: successSchema,
|
||||
)
|
||||
return [
|
||||
...(endpoint.operation.inputMode === "none" ? [] : [`export type ${prefix}Input = { ${input} }`]),
|
||||
`export type ${prefix}Output = ${endpoint.unwrapData ? `(${success})["data"]` : success}`,
|
||||
@@ -480,8 +470,7 @@ function renderPromiseTypes(
|
||||
const json = operations.includes("JsonValue")
|
||||
? "export type JsonValue = null | boolean | number | string | ReadonlyArray<JsonValue> | { readonly [key: string]: JsonValue }"
|
||||
: ""
|
||||
const imports = [...new Set(Object.values(outputTypes ?? {}).map((override) => override.import))]
|
||||
return [...imports, json, ...errorTypes, operations].filter(Boolean).join("\n\n")
|
||||
return [json, ...errorTypes, operations].filter(Boolean).join("\n\n")
|
||||
}
|
||||
|
||||
function renderPromiseClient(groups: ReadonlyArray<Group>) {
|
||||
|
||||
@@ -48,24 +48,6 @@ describe("HttpApiCodegen.generate", () => {
|
||||
)
|
||||
})
|
||||
|
||||
test("allows Promise outputs to use an authoritative imported wire type", () => {
|
||||
const contract = compileContract(
|
||||
api(HttpApiEndpoint.get("events", "/event", { success: HttpApiSchema.StreamSse({ data: Schema.Unknown }) })),
|
||||
)
|
||||
const output = emitPromise(contract, {
|
||||
outputTypes: {
|
||||
"session.events": {
|
||||
name: "EventWire",
|
||||
import: 'import type { EventWire } from "./event-wire"',
|
||||
},
|
||||
},
|
||||
})
|
||||
const types = output.files.find((file) => file.path === "types.ts")?.content
|
||||
|
||||
expect(types).toContain('import type { EventWire } from "./event-wire"')
|
||||
expect(types).toContain("export type SessionEventsOutput = EventWire")
|
||||
})
|
||||
|
||||
test("emits an Effect client against an imported authoritative API", () => {
|
||||
const output = emitEffectImported(
|
||||
compileContract(
|
||||
|
||||
@@ -407,35 +407,21 @@ const step = (state: ParserState, event: GeminiEvent) => {
|
||||
if ("thoughtSignature" in part && part.thoughtSignature && "thought" in part && part.thought)
|
||||
reasoningSignature = part.thoughtSignature
|
||||
if ("text" in part && part.text.length > 0) {
|
||||
if (part.thought) {
|
||||
lifecycle = Lifecycle.reasoningDelta(
|
||||
lifecycle,
|
||||
events,
|
||||
"reasoning-0",
|
||||
part.text,
|
||||
part.thoughtSignature ? googleMetadata({ thoughtSignature: part.thoughtSignature }) : undefined,
|
||||
)
|
||||
continue
|
||||
}
|
||||
lifecycle = Lifecycle.reasoningEnd(
|
||||
lifecycle,
|
||||
events,
|
||||
"reasoning-0",
|
||||
reasoningSignature ? googleMetadata({ thoughtSignature: reasoningSignature }) : undefined,
|
||||
)
|
||||
lifecycle = Lifecycle.textDelta(lifecycle, events, "text-0", part.text)
|
||||
lifecycle = part.thought
|
||||
? Lifecycle.reasoningDelta(
|
||||
lifecycle,
|
||||
events,
|
||||
"reasoning-0",
|
||||
part.text,
|
||||
part.thoughtSignature ? googleMetadata({ thoughtSignature: part.thoughtSignature }) : undefined,
|
||||
)
|
||||
: Lifecycle.textDelta(lifecycle, events, "text-0", part.text)
|
||||
continue
|
||||
}
|
||||
|
||||
if ("functionCall" in part) {
|
||||
const input = part.functionCall.args
|
||||
const id = `tool_${nextToolCallId++}`
|
||||
lifecycle = Lifecycle.reasoningEnd(
|
||||
lifecycle,
|
||||
events,
|
||||
"reasoning-0",
|
||||
reasoningSignature ? googleMetadata({ thoughtSignature: reasoningSignature }) : undefined,
|
||||
)
|
||||
lifecycle = Lifecycle.stepStart(lifecycle, events)
|
||||
events.push(
|
||||
LLMEvent.toolCall({
|
||||
|
||||
@@ -411,12 +411,7 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
|
||||
if (delta?.reasoning_content)
|
||||
lifecycle = Lifecycle.reasoningDelta(lifecycle, events, "reasoning-0", delta.reasoning_content)
|
||||
|
||||
if (delta?.content) {
|
||||
lifecycle = Lifecycle.reasoningEnd(lifecycle, events, "reasoning-0")
|
||||
lifecycle = Lifecycle.textDelta(lifecycle, events, "text-0", delta.content)
|
||||
}
|
||||
|
||||
if (toolDeltas.length) lifecycle = Lifecycle.reasoningEnd(lifecycle, events, "reasoning-0")
|
||||
if (delta?.content) lifecycle = Lifecycle.textDelta(lifecycle, events, "text-0", delta.content)
|
||||
|
||||
for (const tool of toolDeltas) {
|
||||
const result = ToolStream.appendOrStart(
|
||||
|
||||
@@ -53,7 +53,7 @@ const OpenAIResponsesReasoningSummaryText = Schema.Struct({
|
||||
|
||||
const OpenAIResponsesReasoningItem = Schema.Struct({
|
||||
type: Schema.tag("reasoning"),
|
||||
id: Schema.optionalKey(Schema.String),
|
||||
id: Schema.String,
|
||||
summary: Schema.Array(OpenAIResponsesReasoningSummaryText),
|
||||
encrypted_content: optionalNull(Schema.String),
|
||||
})
|
||||
@@ -101,7 +101,6 @@ type OpenAIResponsesReasoningInput = {
|
||||
summary: Array<{ type: "summary_text"; text: string }>
|
||||
encrypted_content?: string | null
|
||||
}
|
||||
type OpenAIResponsesReasoningReplay = Omit<OpenAIResponsesReasoningInput, "id">
|
||||
|
||||
const OpenAIResponsesTool = Schema.Struct({
|
||||
type: Schema.tag("function"),
|
||||
@@ -367,7 +366,7 @@ const lowerMessages = Effect.fn("OpenAIResponses.lowerMessages")(function* (requ
|
||||
|
||||
if (message.role === "assistant") {
|
||||
const content: TextPart[] = []
|
||||
const reasoningItems: Record<string, OpenAIResponsesReasoningReplay> = {}
|
||||
const reasoningItems: Record<string, OpenAIResponsesReasoningInput> = {}
|
||||
const reasoningReferences = new Set<string>()
|
||||
const hostedToolReferences = new Set<string>()
|
||||
const flushText = () => {
|
||||
@@ -384,7 +383,7 @@ const lowerMessages = Effect.fn("OpenAIResponses.lowerMessages")(function* (requ
|
||||
flushText()
|
||||
const reasoning = lowerReasoning(part)
|
||||
if (!reasoning) continue
|
||||
if (store !== false) {
|
||||
if (store !== false && reasoning.id) {
|
||||
if (!reasoningReferences.has(reasoning.id)) input.push({ type: "item_reference", id: reasoning.id })
|
||||
reasoningReferences.add(reasoning.id)
|
||||
continue
|
||||
@@ -396,13 +395,8 @@ const lowerMessages = Effect.fn("OpenAIResponses.lowerMessages")(function* (requ
|
||||
existing.encrypted_content = reasoning.encrypted_content
|
||||
continue
|
||||
}
|
||||
const replay = {
|
||||
type: reasoning.type,
|
||||
summary: reasoning.summary,
|
||||
encrypted_content: reasoning.encrypted_content,
|
||||
}
|
||||
reasoningItems[reasoning.id] = replay
|
||||
input.push(replay)
|
||||
reasoningItems[reasoning.id] = reasoning
|
||||
input.push(reasoning)
|
||||
continue
|
||||
}
|
||||
if (part.type === "tool-call") {
|
||||
@@ -980,7 +974,6 @@ export const route = Route.make({
|
||||
endpoint,
|
||||
auth,
|
||||
transport: httpTransport,
|
||||
defaults: { providerOptions: { openai: { store: false } } },
|
||||
})
|
||||
|
||||
const decodeWebSocketMessage = ProviderShared.validateWith(Schema.decodeUnknownEffect(OpenAIResponsesWebSocketMessage))
|
||||
@@ -1008,7 +1001,6 @@ export const webSocketRoute = Route.make({
|
||||
endpoint,
|
||||
auth,
|
||||
transport: webSocketTransport,
|
||||
defaults: { providerOptions: { openai: { store: false } } },
|
||||
})
|
||||
|
||||
export * as OpenAIResponses from "./openai-responses"
|
||||
|
||||
+1
-1
@@ -44,7 +44,7 @@
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Think briefly, then reply exactly with: Hello!\"}]},{\"type\":\"reasoning\",\"summary\":[],\"encrypted_content\":\"gAAAAABqEjXoGMCw3WDXpoD9151PEr2Lt8raW7KBKefQhZJGWx5f8jy152bApO6oE-Mr1BhUtfZNq3OPBVfSL4ioQ9bHREfujIBXgk9LUDBAz2Sle7KjOr9HaUV16A4HBiaFIRFjsHPS9G8yEySp1m6F1CD_WR6apyUGgugRh_y39EcOJmxPOzmiac5DVM6fraA1VpcGbqrZ1x2ANHFDOfnYTycPtPNTgzE7LjkYjDDWbT03uN1YxfP4pqjDVRzY14pA8bSZ8ys-pDv5kUFCAsw-OlU4jYKUXp-M8_6KTaRQP71LPwppt__zG_NJPfy-qUil4pOU8_NoxtxerHgLLXbfExZdzfpoGinoEjn7nj7BJDEtl-LNeNEb5c-1ZymNfVMp-Cs3fLEPkAV8rtHFtZ0MhE_07GKbGo7hTrOmkM4DydxmHsdWGNbXAG35cprslEA5P7p3GHFKnRs5hGs2eq-XcZ3yki64ZBOU_Tv6UR7nUH09gF1rdrJo3dpre6M00COwwdZ02zUP5KxCuI8FKu2jsZu9zgMVXDALsdtM5orTCVLXsn4rddWd111zE-vMjNmMMmktW2cHMjH7j1ooA-9P083koNVYiLi4UhMA64gTqgyl8MxkZekl7eFSMa7qk295NaHOKtFxzYYcZ9jdioCwSPSZ0ZZWLoNgrK7SWfRh0uaTHNcMZ3wq8ae6CguktIeVTCPTQAqJLQqd7AU0oOCKCJ7BWnC-L8UC6m7Pm9ZS958uUVeWBhgKHzMAGq9UeQB7IEeAcbMn3EDgOSfd8qCb8iwU9iG9dcu9axQwWU7pd7kd-T-He61W7z5wWgpx1KehWCxrN6kuKSo6p-uUfwVnJukreOn8BJNAzADQgz68bhmN9VGih7YcKVnLgwDwKditrjSd6-tfE0Baarj3jWENvT6ohY17R9FDrKS-2v8IIX6tGjoKJw8SRhaWLNv4vWlmxRgR0gdac3qumd0GKqsWSveNz01naA==\"},{\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"Hello!\"}]},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Now reply exactly with: Done.\"}]}],\"store\":false,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"low\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"},\"max_output_tokens\":40,\"stream\":true}"
|
||||
"body": "{\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Think briefly, then reply exactly with: Hello!\"}]},{\"type\":\"reasoning\",\"id\":\"rs_0a0794dab3b8ec7d016a1235e7ce3881958a5eca32a36a14c5\",\"summary\":[],\"encrypted_content\":\"gAAAAABqEjXoGMCw3WDXpoD9151PEr2Lt8raW7KBKefQhZJGWx5f8jy152bApO6oE-Mr1BhUtfZNq3OPBVfSL4ioQ9bHREfujIBXgk9LUDBAz2Sle7KjOr9HaUV16A4HBiaFIRFjsHPS9G8yEySp1m6F1CD_WR6apyUGgugRh_y39EcOJmxPOzmiac5DVM6fraA1VpcGbqrZ1x2ANHFDOfnYTycPtPNTgzE7LjkYjDDWbT03uN1YxfP4pqjDVRzY14pA8bSZ8ys-pDv5kUFCAsw-OlU4jYKUXp-M8_6KTaRQP71LPwppt__zG_NJPfy-qUil4pOU8_NoxtxerHgLLXbfExZdzfpoGinoEjn7nj7BJDEtl-LNeNEb5c-1ZymNfVMp-Cs3fLEPkAV8rtHFtZ0MhE_07GKbGo7hTrOmkM4DydxmHsdWGNbXAG35cprslEA5P7p3GHFKnRs5hGs2eq-XcZ3yki64ZBOU_Tv6UR7nUH09gF1rdrJo3dpre6M00COwwdZ02zUP5KxCuI8FKu2jsZu9zgMVXDALsdtM5orTCVLXsn4rddWd111zE-vMjNmMMmktW2cHMjH7j1ooA-9P083koNVYiLi4UhMA64gTqgyl8MxkZekl7eFSMa7qk295NaHOKtFxzYYcZ9jdioCwSPSZ0ZZWLoNgrK7SWfRh0uaTHNcMZ3wq8ae6CguktIeVTCPTQAqJLQqd7AU0oOCKCJ7BWnC-L8UC6m7Pm9ZS958uUVeWBhgKHzMAGq9UeQB7IEeAcbMn3EDgOSfd8qCb8iwU9iG9dcu9axQwWU7pd7kd-T-He61W7z5wWgpx1KehWCxrN6kuKSo6p-uUfwVnJukreOn8BJNAzADQgz68bhmN9VGih7YcKVnLgwDwKditrjSd6-tfE0Baarj3jWENvT6ohY17R9FDrKS-2v8IIX6tGjoKJw8SRhaWLNv4vWlmxRgR0gdac3qumd0GKqsWSveNz01naA==\"},{\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"Hello!\"}]},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Now reply exactly with: Done.\"}]}],\"store\":false,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"low\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"},\"max_output_tokens\":40,\"stream\":true}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
|
||||
@@ -347,10 +347,10 @@ describe("Gemini route", () => {
|
||||
{ type: "step-start", index: 0 },
|
||||
{ type: "reasoning-start", id: "reasoning-0" },
|
||||
{ type: "reasoning-delta", id: "reasoning-0", text: "thinking" },
|
||||
{ type: "reasoning-end", id: "reasoning-0" },
|
||||
{ type: "text-start", id: "text-0" },
|
||||
{ type: "text-delta", id: "text-0", text: "Hello" },
|
||||
{ type: "text-delta", id: "text-0", text: "!" },
|
||||
{ type: "reasoning-end", id: "reasoning-0" },
|
||||
{ type: "text-end", id: "text-0" },
|
||||
{ type: "step-finish", index: 0, reason: "stop", usage, providerMetadata: undefined },
|
||||
{
|
||||
@@ -399,9 +399,6 @@ describe("Gemini route", () => {
|
||||
providerMetadata: { google: { thoughtSignature: "thought_sig" } },
|
||||
})
|
||||
expect(toolCall).toMatchObject({ providerMetadata: { google: { thoughtSignature: "tool_sig" } } })
|
||||
expect(response.events.findIndex((event) => event.type === "reasoning-end")).toBeLessThan(
|
||||
response.events.findIndex((event) => event.type === "tool-call"),
|
||||
)
|
||||
|
||||
const prepared = yield* LLMClient.prepare<Gemini.GeminiBody>(
|
||||
LLM.request({
|
||||
|
||||
@@ -542,9 +542,9 @@ describe("OpenAI Chat route", () => {
|
||||
{ type: "step-start", index: 0 },
|
||||
{ type: "reasoning-start", id: "reasoning-0" },
|
||||
{ type: "reasoning-delta", id: "reasoning-0", text: "thinking" },
|
||||
{ type: "reasoning-end", id: "reasoning-0" },
|
||||
{ type: "text-start", id: "text-0" },
|
||||
{ type: "text-delta", id: "text-0", text: "Hello" },
|
||||
{ type: "reasoning-end", id: "reasoning-0" },
|
||||
{ type: "text-end", id: "text-0" },
|
||||
{ type: "step-finish", index: 0, reason: "stop" },
|
||||
{ type: "finish", reason: "stop" },
|
||||
|
||||
@@ -50,7 +50,6 @@ describe("OpenAI Responses route", () => {
|
||||
{ role: "system", content: "You are concise." },
|
||||
{ role: "user", content: [{ type: "input_text", text: "Say hello." }] },
|
||||
],
|
||||
store: false,
|
||||
stream: true,
|
||||
max_output_tokens: 20,
|
||||
temperature: 0,
|
||||
@@ -162,16 +161,16 @@ describe("OpenAI Responses route", () => {
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare(
|
||||
LLM.updateRequest(request, {
|
||||
model: OpenAIResponses.webSocketRoute
|
||||
.with({ endpoint: { baseURL: "https://api.openai.test/v1/" }, auth: Auth.bearer("test") })
|
||||
.model({ id: "gpt-4.1-mini" }),
|
||||
model: OpenAI.configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).responsesWebSocket(
|
||||
"gpt-4.1-mini",
|
||||
),
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.route).toBe("openai-responses-websocket")
|
||||
expect(prepared.protocol).toBe("openai-responses")
|
||||
expect(prepared.metadata).toEqual({ transport: "websocket-json" })
|
||||
expect(prepared.body).toMatchObject({ model: "gpt-4.1-mini", store: false, stream: true })
|
||||
expect(prepared.body).toMatchObject({ model: "gpt-4.1-mini", stream: true })
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -357,13 +356,7 @@ describe("OpenAI Responses route", () => {
|
||||
{ type: "function_call", call_id: "call_1", name: "lookup", arguments: '{"query":"weather"}' },
|
||||
{ type: "function_call_output", call_id: "call_1", output: '{"forecast":"sunny"}' },
|
||||
],
|
||||
store: false,
|
||||
stream: true,
|
||||
max_output_tokens: undefined,
|
||||
temperature: undefined,
|
||||
tool_choice: undefined,
|
||||
tools: undefined,
|
||||
top_p: undefined,
|
||||
})
|
||||
}),
|
||||
)
|
||||
@@ -526,6 +519,7 @@ describe("OpenAI Responses route", () => {
|
||||
},
|
||||
{
|
||||
type: "reasoning",
|
||||
id: "rs_continuation_1",
|
||||
encrypted_content: "encrypted-continuation-state",
|
||||
summary: [{ type: "summary_text", text: "I inspected the previous turn." }],
|
||||
},
|
||||
@@ -870,9 +864,7 @@ describe("OpenAI Responses route", () => {
|
||||
|
||||
it.effect("closes reasoning summary parts when storage is not disabled", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(
|
||||
LLM.updateRequest(request, { providerOptions: { openai: { store: true } } }),
|
||||
).pipe(
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
@@ -933,12 +925,12 @@ describe("OpenAI Responses route", () => {
|
||||
dynamicResponse((input) =>
|
||||
Effect.gen(function* () {
|
||||
const web = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie)
|
||||
const body = yield* Effect.promise(() => web.json())
|
||||
expect(body).toMatchObject({
|
||||
expect(yield* Effect.promise(() => web.json())).toMatchObject({
|
||||
input: [
|
||||
{ role: "user", content: [{ type: "input_text", text: "What changed?" }] },
|
||||
{
|
||||
type: "reasoning",
|
||||
id: "rs_1",
|
||||
encrypted_content: "encrypted-state",
|
||||
summary: [{ type: "summary_text", text: "Checked the previous diff." }],
|
||||
},
|
||||
@@ -946,7 +938,6 @@ describe("OpenAI Responses route", () => {
|
||||
{ role: "user", content: [{ type: "input_text", text: "Summarize it." }] },
|
||||
],
|
||||
})
|
||||
expect(body.input[1]).not.toHaveProperty("id")
|
||||
return input.respond(
|
||||
sseEvents(
|
||||
{ type: "response.output_text.delta", item_id: "msg_1", delta: "Parser now round-trips reasoning." },
|
||||
@@ -993,6 +984,7 @@ describe("OpenAI Responses route", () => {
|
||||
{ role: "assistant", content: [{ type: "output_text", text: "Before." }] },
|
||||
{
|
||||
type: "reasoning",
|
||||
id: "rs_1",
|
||||
encrypted_content: "encrypted-state",
|
||||
summary: [{ type: "summary_text", text: "Checked order." }],
|
||||
},
|
||||
@@ -1086,6 +1078,7 @@ describe("OpenAI Responses route", () => {
|
||||
expect(prepared.body.input).toEqual([
|
||||
{
|
||||
type: "reasoning",
|
||||
id: "rs_1",
|
||||
encrypted_content: "encrypted-state",
|
||||
summary: [
|
||||
{ type: "summary_text", text: "First" },
|
||||
|
||||
@@ -598,7 +598,7 @@ describe("LLMClient tools", () => {
|
||||
include: ["reasoning.encrypted_content"],
|
||||
input: [
|
||||
{ role: "user" },
|
||||
{ type: "reasoning", summary: [], encrypted_content: "encrypted-state" },
|
||||
{ type: "reasoning", id: "rs_1", summary: [], encrypted_content: "encrypted-state" },
|
||||
{ type: "function_call", call_id: "call_1", name: "get_weather" },
|
||||
{ type: "function_call_output", call_id: "call_1" },
|
||||
],
|
||||
|
||||
@@ -46,7 +46,6 @@ export class UnsupportedOperationError extends Schema.TaggedErrorClass<Unsupport
|
||||
export class ServiceFailureError extends Schema.TaggedErrorClass<ServiceFailureError>()("ACPServiceFailureError", {
|
||||
safeMessage: Schema.String,
|
||||
service: Schema.optional(Schema.String),
|
||||
errorName: Schema.optional(Schema.String),
|
||||
}) {}
|
||||
|
||||
export type Error =
|
||||
@@ -82,13 +81,7 @@ export function toRequestError(error: Error) {
|
||||
case "ACPUnsupportedOperationError":
|
||||
return RequestError.methodNotFound(error.method)
|
||||
case "ACPServiceFailureError":
|
||||
return RequestError.internalError(
|
||||
{
|
||||
...(error.service ? { service: error.service } : {}),
|
||||
...(error.errorName ? { errorName: error.errorName } : {}),
|
||||
},
|
||||
error.safeMessage,
|
||||
)
|
||||
return RequestError.internalError({ service: error.service }, error.safeMessage)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,16 +1,9 @@
|
||||
import type {
|
||||
AgentSideConnection,
|
||||
PermissionOption,
|
||||
RequestPermissionResponse,
|
||||
ToolCallContent,
|
||||
ToolCallLocation,
|
||||
ToolCallUpdate,
|
||||
} from "@agentclientprotocol/sdk"
|
||||
import type { AgentSideConnection, PermissionOption, RequestPermissionResponse } from "@agentclientprotocol/sdk"
|
||||
import type { Event, OpencodeClient } from "@opencode-ai/sdk/v2"
|
||||
import { applyPatch } from "diff"
|
||||
import { exists, readText } from "@/util/filesystem"
|
||||
import type { ACPSession } from "./session"
|
||||
import { pendingToolCall, toLocations, type ToolInput } from "./tool"
|
||||
import { toLocations, toToolKind, type ToolInput } from "./tool"
|
||||
import { Effect } from "effect"
|
||||
|
||||
type PermissionEvent = Extract<Event, { type: "permission.asked" }>
|
||||
@@ -61,11 +54,14 @@ export class Handler {
|
||||
const result = await this.input.connection
|
||||
.requestPermission({
|
||||
sessionId: permission.sessionID,
|
||||
toolCall: await permissionToolCall({
|
||||
toolCall: {
|
||||
toolCallId: permission.tool?.callID ?? permission.id,
|
||||
toolName: permission.permission,
|
||||
input: permission.metadata,
|
||||
}),
|
||||
status: "pending",
|
||||
title: permission.permission,
|
||||
rawInput: permission.metadata,
|
||||
kind: toToolKind(permission.permission),
|
||||
locations: toLocations(permission.permission, permission.metadata),
|
||||
},
|
||||
options: permissionOptions,
|
||||
})
|
||||
.catch(async () => {
|
||||
@@ -115,107 +111,6 @@ export class Handler {
|
||||
}
|
||||
}
|
||||
|
||||
async function permissionToolCall(input: {
|
||||
readonly toolCallId: string
|
||||
readonly toolName: string
|
||||
readonly input: ToolInput
|
||||
}): Promise<ToolCallUpdate> {
|
||||
const toolCall = pendingToolCall({
|
||||
toolCallId: input.toolCallId,
|
||||
toolName: input.toolName,
|
||||
state: {
|
||||
input: input.input,
|
||||
title: permissionTitle(input.toolName, input.input),
|
||||
},
|
||||
})
|
||||
const content = await permissionContent(input.toolName, input.input)
|
||||
return {
|
||||
...toolCall,
|
||||
locations: permissionLocations(input.toolName, input.input),
|
||||
...(content.length ? { content } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
function permissionTitle(toolName: string, input: ToolInput) {
|
||||
const tool = toolName.toLocaleLowerCase()
|
||||
switch (tool) {
|
||||
case "external_directory":
|
||||
return stringValue(input.description) ?? stringValue(input.command) ?? stringValue(input.parentDir)
|
||||
|
||||
case "webfetch":
|
||||
return stringValue(input.url)
|
||||
|
||||
case "websearch":
|
||||
return stringValue(input.query)
|
||||
|
||||
case "grep":
|
||||
case "glob":
|
||||
return stringValue(input.pattern)
|
||||
|
||||
case "read":
|
||||
case "edit":
|
||||
case "write":
|
||||
return editTitle(input)
|
||||
|
||||
default:
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
function editTitle(input: ToolInput) {
|
||||
const files = fileMetadata(input)
|
||||
if (files.length === 1) return files[0]?.relativePath ?? files[0]?.filePath
|
||||
if (files.length > 1) return `${files.length} files`
|
||||
return stringValue(input.filePath) ?? stringValue(input.filepath) ?? stringValue(input.path)
|
||||
}
|
||||
|
||||
function permissionLocations(toolName: string, input: ToolInput): ToolCallLocation[] {
|
||||
const files = fileMetadata(input)
|
||||
if (files.length) {
|
||||
return Array.from(
|
||||
new Set(files.flatMap((file) => [file.filePath, file.movePath].filter((path): path is string => !!path))),
|
||||
(path) => ({ path }),
|
||||
)
|
||||
}
|
||||
return toLocations(toolName, input)
|
||||
}
|
||||
|
||||
async function permissionContent(toolName: string, input: ToolInput): Promise<ToolCallContent[]> {
|
||||
if (toolName.toLocaleLowerCase() !== "edit") return []
|
||||
|
||||
const files = fileMetadata(input)
|
||||
if (files.length) return diffContentForFiles(files)
|
||||
|
||||
const filepath = stringValue(input.filepath) ?? stringValue(input.filePath)
|
||||
const diff = stringValue(input.diff)
|
||||
if (!filepath || !diff) return []
|
||||
const content = await diffContentForPatch(filepath, diff)
|
||||
return content ? [content] : []
|
||||
}
|
||||
|
||||
async function diffContentForFiles(files: PermissionFileMetadata[]) {
|
||||
const content = await Promise.all(
|
||||
files.map(async (file) => {
|
||||
if (!file.patch) return []
|
||||
const content = await diffContentForPatch(file.filePath, file.patch, file.movePath)
|
||||
return content ? [content] : []
|
||||
}),
|
||||
)
|
||||
return content.flat()
|
||||
}
|
||||
|
||||
async function diffContentForPatch(filepath: string, diff: string, displayPath = filepath) {
|
||||
const content = (await exists(filepath)) ? await readText(filepath) : ""
|
||||
const next = applyPatch(content, diff)
|
||||
if (next === false) return undefined
|
||||
return {
|
||||
type: "diff" as const,
|
||||
path: displayPath,
|
||||
oldText: content,
|
||||
newText: next,
|
||||
}
|
||||
}
|
||||
|
||||
function selectedReply(result: RequestPermissionResponse): Reply {
|
||||
if (result.outcome.outcome !== "selected") return "reject"
|
||||
if (result.outcome.optionId === "once" || result.outcome.optionId === "always") return result.outcome.optionId
|
||||
@@ -226,29 +121,4 @@ function stringValue(value: unknown) {
|
||||
return typeof value === "string" ? value : undefined
|
||||
}
|
||||
|
||||
type PermissionFileMetadata = {
|
||||
readonly filePath: string
|
||||
readonly relativePath?: string
|
||||
readonly movePath?: string
|
||||
readonly patch?: string
|
||||
}
|
||||
|
||||
function fileMetadata(input: ToolInput): PermissionFileMetadata[] {
|
||||
if (!Array.isArray(input.files)) return []
|
||||
return input.files.flatMap((file): PermissionFileMetadata[] => {
|
||||
if (!file || typeof file !== "object") return []
|
||||
const info = file as Record<string, unknown>
|
||||
const filePath = stringValue(info.filePath)
|
||||
if (!filePath) return []
|
||||
return [
|
||||
{
|
||||
filePath,
|
||||
relativePath: stringValue(info.relativePath),
|
||||
movePath: stringValue(info.movePath),
|
||||
patch: stringValue(info.patch),
|
||||
},
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
export * as ACPPermission from "./permission"
|
||||
|
||||
@@ -30,7 +30,7 @@ import {
|
||||
type SetSessionModeResponse,
|
||||
} from "@agentclientprotocol/sdk"
|
||||
import { InstallationVersion } from "@opencode-ai/core/installation/version"
|
||||
import type { AssistantMessage, Message, OpencodeClient, SessionMessageResponse } from "@opencode-ai/sdk/v2"
|
||||
import type { Message, OpencodeClient, SessionMessageResponse } from "@opencode-ai/sdk/v2"
|
||||
import { Context, Effect, Layer, ManagedRuntime } from "effect"
|
||||
import * as ACPError from "./error"
|
||||
import { buildConfigOptions, parseModelSelection } from "./config-option"
|
||||
@@ -314,6 +314,7 @@ export function make(input: {
|
||||
|
||||
yield* registerMcpServers(input.sdk, registeredMcp, params.cwd, state.id, params.mcpServers ?? [])
|
||||
yield* sendAvailableCommands(input.connection, state.id, snapshot)
|
||||
yield* replayMessages(events, messages)
|
||||
|
||||
return {
|
||||
configOptions: configOptions(snapshot, {
|
||||
@@ -520,7 +521,7 @@ export function make(input: {
|
||||
"session",
|
||||
)
|
||||
yield* sendUsageUpdate(input.usage, input.sdk, input.connection, current.id, current.cwd)
|
||||
return yield* promptResponse(response.info, params.messageId)
|
||||
return promptResponse(response.info, params.messageId)
|
||||
}
|
||||
|
||||
const known = snapshot.availableCommands.find((item) => item.name === command.name)
|
||||
@@ -542,7 +543,7 @@ export function make(input: {
|
||||
"session",
|
||||
)
|
||||
yield* sendUsageUpdate(input.usage, input.sdk, input.connection, current.id, current.cwd)
|
||||
return yield* promptResponse(response.info, params.messageId)
|
||||
return promptResponse(response.info, params.messageId)
|
||||
}
|
||||
|
||||
if (command.name === "compact") {
|
||||
@@ -562,7 +563,7 @@ export function make(input: {
|
||||
}
|
||||
|
||||
yield* sendUsageUpdate(input.usage, input.sdk, input.connection, current.id, current.cwd)
|
||||
return yield* promptResponse(undefined, params.messageId)
|
||||
return promptResponse(undefined, params.messageId)
|
||||
}),
|
||||
cancel,
|
||||
}
|
||||
@@ -694,8 +695,7 @@ type MessageInfo = {
|
||||
readonly agent?: Message["agent"]
|
||||
}
|
||||
|
||||
type AssistantError = NonNullable<AssistantMessage["error"]>
|
||||
type AssistantInfo = (UsageService.AssistantTokenCost & Pick<AssistantMessage, "error">) | undefined
|
||||
type AssistantInfo = UsageService.AssistantTokenCost | undefined
|
||||
|
||||
function request<T>(fn: () => Promise<T | SdkResponse<T>>, service?: string) {
|
||||
return Effect.tryPromise({
|
||||
@@ -811,60 +811,13 @@ function detectSlashCommand(parts: ReturnType<typeof promptContentToParts>) {
|
||||
return { name, args: rest.join(" ").trim() }
|
||||
}
|
||||
|
||||
const promptResponse = Effect.fn("ACP.promptResponse")(function* (
|
||||
info: AssistantInfo,
|
||||
messageId: string | null | undefined,
|
||||
) {
|
||||
if (!info?.error) {
|
||||
return {
|
||||
stopReason: "end_turn" as const,
|
||||
...(info ? { usage: UsageService.buildUsage(info) } : {}),
|
||||
...(messageId ? { userMessageId: messageId } : {}),
|
||||
_meta: {},
|
||||
}
|
||||
}
|
||||
|
||||
const base = {
|
||||
usage: UsageService.buildUsage(info),
|
||||
function promptResponse(info: AssistantInfo, messageId: string | null | undefined): PromptResponse {
|
||||
return {
|
||||
stopReason: "end_turn",
|
||||
...(info ? { usage: UsageService.buildUsage(info) } : {}),
|
||||
...(messageId ? { userMessageId: messageId } : {}),
|
||||
_meta: {},
|
||||
}
|
||||
|
||||
if (info.error.name === "MessageAbortedError") {
|
||||
return {
|
||||
stopReason: "cancelled" as const,
|
||||
...base,
|
||||
}
|
||||
}
|
||||
|
||||
if (info.error.name === "MessageOutputLengthError") {
|
||||
return {
|
||||
stopReason: "max_tokens" as const,
|
||||
...base,
|
||||
}
|
||||
}
|
||||
|
||||
if (info.error.name === "ContentFilterError") {
|
||||
return {
|
||||
stopReason: "refusal" as const,
|
||||
...base,
|
||||
}
|
||||
}
|
||||
|
||||
if (info.error.name === "ProviderAuthError") {
|
||||
return yield* new ACPError.AuthRequiredError({ providerId: info.error.data.providerID })
|
||||
}
|
||||
|
||||
return yield* new ACPError.ServiceFailureError({
|
||||
service: "session",
|
||||
safeMessage: promptErrorMessage(info.error),
|
||||
errorName: info.error.name,
|
||||
})
|
||||
})
|
||||
|
||||
function promptErrorMessage(error: AssistantError) {
|
||||
if ("message" in error.data && typeof error.data.message === "string") return error.data.message
|
||||
return "OpenCode prompt failed"
|
||||
}
|
||||
|
||||
function sendUsageUpdate(
|
||||
|
||||
@@ -192,8 +192,11 @@ export function completedToolUpdate(input: {
|
||||
return {
|
||||
toolCallId: input.toolCallId,
|
||||
status: "completed",
|
||||
...(input.state.title ? { title: input.state.title } : {}),
|
||||
kind: toToolKind(input.toolName),
|
||||
title: toolTitle(input.toolName, input.state.input, input.state.title),
|
||||
locations: toLocations(input.toolName, input.state.input, input.cwd),
|
||||
content: completedToolContent(input.toolName, input.state),
|
||||
rawInput: rawInput(input.toolName, input.state.input, input.cwd),
|
||||
rawOutput: completedToolRawOutput(input.state),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||
import path from "path"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { EffectBridge } from "@/effect/bridge"
|
||||
import type { InstanceContext } from "@/project/instance-context"
|
||||
@@ -133,19 +132,12 @@ export const layer = Layer.effect(
|
||||
|
||||
for (const item of yield* skill.all()) {
|
||||
if (commands[item.name]) continue
|
||||
const dir = item.location === "<built-in>" ? undefined : path.dirname(item.location)
|
||||
commands[item.name] = {
|
||||
name: item.name,
|
||||
description: item.description,
|
||||
source: "skill",
|
||||
get template() {
|
||||
if (!dir) return item.content
|
||||
return [
|
||||
item.content,
|
||||
"",
|
||||
`Base directory for this skill: ${dir}`,
|
||||
"Relative paths in this skill (e.g., scripts/, references/) are relative to this base directory.",
|
||||
].join("\n")
|
||||
return item.content
|
||||
},
|
||||
hints: [],
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ import { NamedError } from "@opencode-ai/core/util/error"
|
||||
import { InstallationVersion } from "@opencode-ai/core/installation/version"
|
||||
import { withTimeout } from "@/util/timeout"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import { McpOAuthPendingProvider, McpOAuthProvider, OAUTH_CALLBACK_PATH } from "./oauth-provider"
|
||||
import { McpOAuthProvider, OAUTH_CALLBACK_PATH } from "./oauth-provider"
|
||||
import { McpOAuthCallback } from "./oauth-callback"
|
||||
import { McpAuth } from "./auth"
|
||||
import { EventV2Bridge } from "@/event-v2-bridge"
|
||||
@@ -109,7 +109,7 @@ export type Status = Schema.Schema.Type<typeof Status>
|
||||
|
||||
// Store transports for OAuth servers to allow finishing auth
|
||||
type TransportWithAuth = StreamableHTTPClientTransport | SSEClientTransport
|
||||
const pendingOAuthTransports = new Map<string, { transport: TransportWithAuth; provider?: McpOAuthPendingProvider }>()
|
||||
const pendingOAuthTransports = new Map<string, TransportWithAuth>()
|
||||
|
||||
// Prompt cache types
|
||||
type PromptInfo = Awaited<ReturnType<MCPClient["listPrompts"]>>["prompts"][number]
|
||||
@@ -301,7 +301,7 @@ export const layer = Layer.effect(
|
||||
})
|
||||
.pipe(Effect.ignore, Effect.as(undefined))
|
||||
} else {
|
||||
pendingOAuthTransports.set(key, { transport })
|
||||
pendingOAuthTransports.set(key, transport)
|
||||
lastStatus = { status: "needs_auth" as const }
|
||||
return events
|
||||
.publish(TuiEvent.ToastShow, {
|
||||
@@ -819,7 +819,7 @@ export const layer = Layer.effect(
|
||||
.join("")
|
||||
yield* auth.updateOAuthState(mcpName, oauthState)
|
||||
let capturedUrl: URL | undefined
|
||||
const authProvider = new McpOAuthPendingProvider(
|
||||
const authProvider = new McpOAuthProvider(
|
||||
mcpName,
|
||||
mcpConfig.url,
|
||||
{
|
||||
@@ -845,16 +845,15 @@ export const layer = Layer.effect(
|
||||
return yield* Effect.tryPromise({
|
||||
try: () => {
|
||||
const client = createClient(directory)
|
||||
return client.connect(transport).then(async () => {
|
||||
await authProvider.commit()
|
||||
return { authorizationUrl: "", oauthState, client } satisfies AuthResult
|
||||
})
|
||||
return client
|
||||
.connect(transport)
|
||||
.then(() => ({ authorizationUrl: "", oauthState, client }) satisfies AuthResult)
|
||||
},
|
||||
catch: (error) => error,
|
||||
}).pipe(
|
||||
Effect.catch((error) => {
|
||||
if (error instanceof UnauthorizedError && capturedUrl) {
|
||||
pendingOAuthTransports.set(mcpName, { transport, provider: authProvider })
|
||||
pendingOAuthTransports.set(mcpName, transport)
|
||||
return Effect.succeed({ authorizationUrl: capturedUrl.toString(), oauthState } satisfies AuthResult)
|
||||
}
|
||||
return Effect.die(error)
|
||||
@@ -925,11 +924,11 @@ export const layer = Layer.effect(
|
||||
|
||||
const finishAuth = Effect.fn("MCP.finishAuth")(function* (mcpName: string, authorizationCode: string) {
|
||||
yield* requireMcpConfig(mcpName)
|
||||
const pending = pendingOAuthTransports.get(mcpName)
|
||||
if (!pending) throw new Error(`No pending OAuth flow for MCP server: ${mcpName}`)
|
||||
const transport = pendingOAuthTransports.get(mcpName)
|
||||
if (!transport) throw new Error(`No pending OAuth flow for MCP server: ${mcpName}`)
|
||||
|
||||
const result = yield* Effect.tryPromise({
|
||||
try: () => pending.transport.finishAuth(authorizationCode).then(() => true as const),
|
||||
try: () => transport.finishAuth(authorizationCode).then(() => true as const),
|
||||
catch: (error) => {
|
||||
return error
|
||||
},
|
||||
@@ -939,7 +938,6 @@ export const layer = Layer.effect(
|
||||
return { status: "failed", error: "OAuth completion failed" } satisfies Status
|
||||
}
|
||||
|
||||
yield* Effect.promise(() => pending.provider?.commit() ?? Promise.resolve())
|
||||
yield* auth.clearCodeVerifier(mcpName)
|
||||
pendingOAuthTransports.delete(mcpName)
|
||||
|
||||
|
||||
@@ -25,11 +25,11 @@ export interface McpOAuthCallbacks {
|
||||
|
||||
export class McpOAuthProvider implements OAuthClientProvider {
|
||||
constructor(
|
||||
protected mcpName: string,
|
||||
protected serverUrl: string,
|
||||
protected config: McpOAuthConfig,
|
||||
private mcpName: string,
|
||||
private serverUrl: string,
|
||||
private config: McpOAuthConfig,
|
||||
private callbacks: McpOAuthCallbacks,
|
||||
protected auth: McpAuth.Interface,
|
||||
private auth: McpAuth.Interface,
|
||||
) {}
|
||||
|
||||
get redirectUrl(): string {
|
||||
@@ -53,6 +53,7 @@ export class McpOAuthProvider implements OAuthClientProvider {
|
||||
}
|
||||
|
||||
async clientInformation(): Promise<OAuthClientInformation | undefined> {
|
||||
// Check config first (pre-registered client)
|
||||
if (this.config.clientId) {
|
||||
return {
|
||||
client_id: this.config.clientId,
|
||||
@@ -163,7 +164,10 @@ export class McpOAuthProvider implements OAuthClientProvider {
|
||||
|
||||
async invalidateCredentials(type: "all" | "client" | "tokens"): Promise<void> {
|
||||
const entry = await Effect.runPromise(this.auth.get(this.mcpName))
|
||||
if (!entry) return
|
||||
if (!entry) {
|
||||
return
|
||||
}
|
||||
|
||||
switch (type) {
|
||||
case "all":
|
||||
await Effect.runPromise(this.auth.remove(this.mcpName))
|
||||
@@ -180,63 +184,6 @@ export class McpOAuthProvider implements OAuthClientProvider {
|
||||
}
|
||||
}
|
||||
|
||||
export class McpOAuthPendingProvider extends McpOAuthProvider {
|
||||
private pendingClientInfo?: OAuthClientInformationFull
|
||||
private pendingTokens?: OAuthTokens
|
||||
|
||||
override async clientInformation(): Promise<OAuthClientInformation | undefined> {
|
||||
if (!this.config.clientId) return this.pendingClientInfo
|
||||
return {
|
||||
client_id: this.config.clientId,
|
||||
client_secret: this.config.clientSecret,
|
||||
}
|
||||
}
|
||||
|
||||
override async saveClientInformation(info: OAuthClientInformationFull): Promise<void> {
|
||||
this.pendingClientInfo = info
|
||||
}
|
||||
|
||||
override async tokens(): Promise<OAuthTokens | undefined> {
|
||||
return this.pendingTokens
|
||||
}
|
||||
|
||||
override async saveTokens(tokens: OAuthTokens): Promise<void> {
|
||||
this.pendingTokens = tokens
|
||||
}
|
||||
|
||||
override async invalidateCredentials(type: "all" | "client" | "tokens"): Promise<void> {
|
||||
if (type === "all" || type === "client") this.pendingClientInfo = undefined
|
||||
if (type === "all" || type === "tokens") this.pendingTokens = undefined
|
||||
}
|
||||
|
||||
async commit(): Promise<void> {
|
||||
if (!this.pendingTokens) return
|
||||
await Effect.runPromise(
|
||||
this.auth.set(
|
||||
this.mcpName,
|
||||
{
|
||||
tokens: {
|
||||
accessToken: this.pendingTokens.access_token,
|
||||
refreshToken: this.pendingTokens.refresh_token,
|
||||
expiresAt: this.pendingTokens.expires_in ? Date.now() / 1000 + this.pendingTokens.expires_in : undefined,
|
||||
scope: this.pendingTokens.scope,
|
||||
},
|
||||
clientInfo:
|
||||
this.pendingClientInfo && !this.config.clientId
|
||||
? {
|
||||
clientId: this.pendingClientInfo.client_id,
|
||||
clientSecret: this.pendingClientInfo.client_secret,
|
||||
clientIdIssuedAt: this.pendingClientInfo.client_id_issued_at,
|
||||
clientSecretExpiresAt: this.pendingClientInfo.client_secret_expires_at,
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
this.serverUrl,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export { OAUTH_CALLBACK_PORT, OAUTH_CALLBACK_PATH }
|
||||
|
||||
/**
|
||||
|
||||
@@ -22,8 +22,6 @@ import { RuntimeFlags } from "@/effect/runtime-flags"
|
||||
import { EventV2Bridge } from "@/event-v2-bridge"
|
||||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
import { Project } from "@opencode-ai/schema/project"
|
||||
import { SessionContextEpoch } from "@opencode-ai/core/session/context-epoch"
|
||||
import path from "path"
|
||||
|
||||
export const Info = Project.Info
|
||||
export type Info = Types.DeepMutable<Schema.Schema.Type<typeof Info>>
|
||||
@@ -231,19 +229,12 @@ export const layer = Layer.effect(
|
||||
sandboxes: [] as string[],
|
||||
time: { created: Date.now(), updated: Date.now() },
|
||||
}
|
||||
const previousWorktree =
|
||||
row &&
|
||||
projectID !== ProjectV2.ID.global &&
|
||||
existing.worktree !== worktree &&
|
||||
!(yield* fs.isDir(existing.worktree))
|
||||
? existing.worktree
|
||||
: undefined
|
||||
|
||||
if (flags.experimentalIconDiscovery) yield* discover(existing).pipe(Effect.ignore, Effect.forkIn(scope))
|
||||
|
||||
const result: Info = {
|
||||
...existing,
|
||||
worktree: projectID === ProjectV2.ID.global || previousWorktree ? worktree : existing.worktree,
|
||||
worktree: projectID === ProjectV2.ID.global ? worktree : existing.worktree,
|
||||
vcs: data.vcs?.type ?? fakeVcs,
|
||||
time: { ...existing.time, updated: Date.now() },
|
||||
}
|
||||
@@ -261,11 +252,7 @@ export const layer = Layer.effect(
|
||||
Effect.map((exists) => (exists ? s : undefined)),
|
||||
),
|
||||
{ concurrency: "unbounded" },
|
||||
).pipe(
|
||||
Effect.map((arr) =>
|
||||
arr.filter((x): x is string => x !== undefined && x !== result.worktree && x !== previousWorktree),
|
||||
),
|
||||
)
|
||||
).pipe(Effect.map((arr) => arr.filter((x): x is string => x !== undefined)))
|
||||
|
||||
yield* db
|
||||
.insert(ProjectTable)
|
||||
@@ -301,29 +288,6 @@ export const layer = Layer.effect(
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
|
||||
if (previousWorktree) {
|
||||
const sessions = yield* db
|
||||
.select({ id: SessionTable.id, directory: SessionTable.directory })
|
||||
.from(SessionTable)
|
||||
.where(eq(SessionTable.project_id, projectID))
|
||||
.all()
|
||||
.pipe(Effect.orDie)
|
||||
yield* Effect.forEach(
|
||||
sessions.filter((session) => FSUtil.contains(previousWorktree, session.directory)),
|
||||
(session) =>
|
||||
Effect.gen(function* () {
|
||||
yield* db
|
||||
.update(SessionTable)
|
||||
.set({ directory: path.join(result.worktree, path.relative(previousWorktree, session.directory)) })
|
||||
.where(eq(SessionTable.id, session.id))
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
yield* SessionContextEpoch.reset(db, session.id)
|
||||
}),
|
||||
{ concurrency: 1, discard: true },
|
||||
)
|
||||
}
|
||||
|
||||
if (projectID !== ProjectV2.ID.global) {
|
||||
yield* db
|
||||
.update(SessionTable)
|
||||
@@ -337,9 +301,6 @@ export const layer = Layer.effect(
|
||||
projectID,
|
||||
directory: data.directory,
|
||||
})
|
||||
if (previousWorktree) {
|
||||
yield* projectDirectories.remove({ projectID, directory: AbsolutePath.make(previousWorktree) })
|
||||
}
|
||||
|
||||
yield* emitUpdated(result)
|
||||
if (projectID !== ProjectV2.ID.global && data.vcs?.type === "git") {
|
||||
@@ -373,16 +334,7 @@ export const layer = Layer.effect(
|
||||
})
|
||||
|
||||
const list = Effect.fn("Project.list")(function* () {
|
||||
const projects = (yield* db.select().from(ProjectTable).all().pipe(Effect.orDie)).map(fromRow)
|
||||
return (yield* Effect.forEach(
|
||||
projects,
|
||||
Effect.fnUntraced(function* (project) {
|
||||
if (project.id === ProjectV2.ID.global) return project
|
||||
if (yield* fs.isDir(project.worktree)) return project
|
||||
return undefined
|
||||
}),
|
||||
{ concurrency: "unbounded" },
|
||||
)).filter((project): project is Info => project !== undefined)
|
||||
return (yield* db.select().from(ProjectTable).all().pipe(Effect.orDie)).map(fromRow)
|
||||
})
|
||||
|
||||
const get = Effect.fn("Project.get")(function* (id: ProjectV2.ID) {
|
||||
|
||||
@@ -13,7 +13,6 @@ const fileConcurrency = 8
|
||||
class IndexSkill extends Schema.Class<IndexSkill>("IndexSkill")({
|
||||
name: Schema.String,
|
||||
files: Schema.Array(Schema.String),
|
||||
version: Schema.optional(Schema.String),
|
||||
}) {}
|
||||
|
||||
class Index extends Schema.Class<Index>("Index")({
|
||||
@@ -77,53 +76,17 @@ export const layer: Layer.Layer<Service, never, FSUtil.Service | Path.Path | Htt
|
||||
(skill) =>
|
||||
Effect.gen(function* () {
|
||||
const root = path.join(cache, skill.name)
|
||||
const versionFile = path.join(root, ".opencode-version")
|
||||
const version = skill.version
|
||||
const current =
|
||||
version === undefined
|
||||
? undefined
|
||||
: yield* fs.readFileStringSafe(versionFile).pipe(Effect.catch(() => Effect.succeed(undefined)))
|
||||
|
||||
if (version === undefined || current === version) {
|
||||
yield* Effect.forEach(
|
||||
skill.files,
|
||||
(file) => download(new URL(file, `${host}/${skill.name}/`).href, path.join(root, file)),
|
||||
{ concurrency: fileConcurrency, discard: true },
|
||||
)
|
||||
} else {
|
||||
const token = crypto.randomUUID()
|
||||
const staging = `${root}.tmp-${token}`
|
||||
const backup = `${root}.old-${token}`
|
||||
yield* Effect.gen(function* () {
|
||||
const downloaded = yield* Effect.forEach(
|
||||
skill.files,
|
||||
(file) => download(new URL(file, `${host}/${skill.name}/`).href, path.join(staging, file)),
|
||||
{ concurrency: fileConcurrency },
|
||||
)
|
||||
if (!downloaded.every(Boolean)) return
|
||||
if (!(yield* fs.exists(path.join(staging, "SKILL.md")).pipe(Effect.orDie))) return
|
||||
yield* fs.writeFileString(path.join(staging, ".opencode-version"), version)
|
||||
yield* Effect.uninterruptible(
|
||||
Effect.gen(function* () {
|
||||
const cached = yield* fs.exists(root).pipe(Effect.orDie)
|
||||
if (cached) yield* fs.rename(root, backup)
|
||||
yield* fs.rename(staging, root).pipe(
|
||||
Effect.catch((error) =>
|
||||
Effect.gen(function* () {
|
||||
if (cached) yield* fs.rename(backup, root).pipe(Effect.ignore)
|
||||
return yield* Effect.fail(error)
|
||||
}),
|
||||
),
|
||||
)
|
||||
if (cached) yield* fs.remove(backup, { recursive: true, force: true }).pipe(Effect.ignore)
|
||||
}),
|
||||
)
|
||||
}).pipe(
|
||||
Effect.catch((error) => Effect.logError("failed to refresh skill", { skill: skill.name, error })),
|
||||
Effect.ensuring(fs.remove(staging, { recursive: true, force: true }).pipe(Effect.ignore)),
|
||||
)
|
||||
}
|
||||
return (yield* fs.exists(path.join(root, "SKILL.md")).pipe(Effect.orDie)) ? root : null
|
||||
yield* Effect.forEach(
|
||||
skill.files,
|
||||
(file) => download(new URL(file, `${host}/${skill.name}/`).href, path.join(root, file)),
|
||||
{
|
||||
concurrency: fileConcurrency,
|
||||
},
|
||||
)
|
||||
|
||||
const md = path.join(root, "SKILL.md")
|
||||
return (yield* fs.exists(md).pipe(Effect.orDie)) ? root : null
|
||||
}),
|
||||
{ concurrency: skillConcurrency },
|
||||
)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||
import path from "path"
|
||||
import { pathToFileURL } from "url"
|
||||
import { Effect, Layer, Context, Schema } from "effect"
|
||||
import { NamedError } from "@opencode-ai/core/util/error"
|
||||
import type { Agent } from "@/agent/agent"
|
||||
@@ -16,7 +17,6 @@ import { RuntimeFlags } from "@/effect/runtime-flags"
|
||||
import { Glob } from "@opencode-ai/core/util/glob"
|
||||
import { Discovery } from "./discovery"
|
||||
import { isRecord } from "@/util/record"
|
||||
import { escapeHtml } from "@/util/html"
|
||||
|
||||
const CLAUDE_EXTERNAL_DIR = ".claude"
|
||||
const AGENTS_EXTERNAL_DIR = ".agents"
|
||||
@@ -339,7 +339,7 @@ export function fmt(list: Info[], opts: { verbose: boolean }) {
|
||||
" <skill>",
|
||||
` <name>${skill.name}</name>`,
|
||||
` <description>${skill.description}</description>`,
|
||||
` <location>${escapeHtml(skill.location)}</location>`,
|
||||
` <location>${pathToFileURL(skill.location).href}</location>`,
|
||||
" </skill>",
|
||||
]),
|
||||
"</available_skills>",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { afterEach, describe, expect, it } from "bun:test"
|
||||
import { describe, expect, it } from "bun:test"
|
||||
import type {
|
||||
AgentSideConnection,
|
||||
RequestPermissionRequest,
|
||||
@@ -6,22 +6,13 @@ import type {
|
||||
SessionUpdate,
|
||||
} from "@agentclientprotocol/sdk"
|
||||
import type { Event, OpencodeClient } from "@opencode-ai/sdk/v2"
|
||||
import { createTwoFilesPatch } from "diff"
|
||||
import { Effect, ManagedRuntime } from "effect"
|
||||
import { mkdtemp, rm } from "node:fs/promises"
|
||||
import { tmpdir } from "node:os"
|
||||
import path from "node:path"
|
||||
import { ACPEvent } from "@/acp/event"
|
||||
import { ACPSession } from "@/acp/session"
|
||||
|
||||
type PermissionEvent = Extract<Event, { type: "permission.asked" }>
|
||||
type PermissionReplyParams = Parameters<OpencodeClient["permission"]["reply"]>[0]
|
||||
type SessionUpdateParams = Parameters<AgentSideConnection["sessionUpdate"]>[0]
|
||||
const cleanupDirs: string[] = []
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(cleanupDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })))
|
||||
})
|
||||
|
||||
const pollUntil = async (
|
||||
check: () => boolean | Promise<boolean>,
|
||||
@@ -146,14 +137,6 @@ function textFromUpdates(updates: SessionUpdateParams[], sessionId: string) {
|
||||
.join("")
|
||||
}
|
||||
|
||||
async function tempFile(name: string, content: string) {
|
||||
const dir = await mkdtemp(path.join(tmpdir(), "opencode-acp-permission-"))
|
||||
cleanupDirs.push(dir)
|
||||
const file = path.join(dir, name)
|
||||
await Bun.write(file, content)
|
||||
return file
|
||||
}
|
||||
|
||||
describe("acp permissions", () => {
|
||||
it("sends requestPermission and replies with the selected outcome", async () => {
|
||||
const harness = createHarness()
|
||||
@@ -168,7 +151,7 @@ describe("acp permissions", () => {
|
||||
toolCall: {
|
||||
toolCallId: "call_1",
|
||||
status: "pending",
|
||||
title: "printf hello",
|
||||
title: "bash",
|
||||
rawInput: { command: "printf hello" },
|
||||
kind: "execute",
|
||||
locations: [],
|
||||
@@ -182,116 +165,6 @@ describe("acp permissions", () => {
|
||||
expect(harness.replies).toEqual([{ requestID: "perm_1", reply: "once", directory: "/workspace" }])
|
||||
})
|
||||
|
||||
it("uses permission metadata for non-shell titles", async () => {
|
||||
const harness = createHarness()
|
||||
await createSession(harness.session, "ses_a")
|
||||
|
||||
harness.subscription.handle(
|
||||
permissionAsked("ses_a", "perm_fetch", {
|
||||
permission: "webfetch",
|
||||
metadata: {
|
||||
url: "https://example.com/docs",
|
||||
format: "markdown",
|
||||
},
|
||||
tool: { messageID: "msg_1", callID: "call_1" },
|
||||
}),
|
||||
)
|
||||
|
||||
await pollUntil(() => harness.replies.length === 1, "webfetch permission was never replied")
|
||||
|
||||
expect(harness.requests[0]?.toolCall).toMatchObject({
|
||||
toolCallId: "call_1",
|
||||
title: "https://example.com/docs",
|
||||
kind: "fetch",
|
||||
rawInput: { url: "https://example.com/docs", format: "markdown" },
|
||||
})
|
||||
})
|
||||
|
||||
it("includes a diff content block for edit permission metadata", async () => {
|
||||
const filepath = await tempFile("file.ts", "before\n")
|
||||
const harness = createHarness()
|
||||
await createSession(harness.session, "ses_a")
|
||||
|
||||
harness.subscription.handle(
|
||||
permissionAsked("ses_a", "perm_edit", {
|
||||
permission: "edit",
|
||||
metadata: {
|
||||
filepath,
|
||||
diff: createTwoFilesPatch(filepath, filepath, "before\n", "after\n"),
|
||||
},
|
||||
tool: { messageID: "msg_1", callID: "call_1" },
|
||||
}),
|
||||
)
|
||||
|
||||
await pollUntil(() => harness.replies.length === 1, "edit permission was never replied")
|
||||
|
||||
expect(harness.requests[0]?.toolCall).toMatchObject({
|
||||
toolCallId: "call_1",
|
||||
title: filepath,
|
||||
kind: "edit",
|
||||
locations: [{ path: filepath }],
|
||||
content: [
|
||||
{
|
||||
type: "diff",
|
||||
path: filepath,
|
||||
oldText: "before\n",
|
||||
newText: "after\n",
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
it("includes per-file diff blocks and locations for apply_patch permission metadata", async () => {
|
||||
const first = await tempFile("first.ts", "one\n")
|
||||
const second = await tempFile("second.ts", "alpha\n")
|
||||
const harness = createHarness()
|
||||
await createSession(harness.session, "ses_a")
|
||||
|
||||
harness.subscription.handle(
|
||||
permissionAsked("ses_a", "perm_patch", {
|
||||
permission: "edit",
|
||||
metadata: {
|
||||
filepath: "first.ts, second.ts",
|
||||
files: [
|
||||
{
|
||||
filePath: first,
|
||||
relativePath: "first.ts",
|
||||
patch: createTwoFilesPatch(first, first, "one\n", "two\n"),
|
||||
},
|
||||
{
|
||||
filePath: second,
|
||||
relativePath: "second.ts",
|
||||
patch: createTwoFilesPatch(second, second, "alpha\n", "beta\n"),
|
||||
},
|
||||
],
|
||||
},
|
||||
tool: { messageID: "msg_1", callID: "call_1" },
|
||||
}),
|
||||
)
|
||||
|
||||
await pollUntil(() => harness.replies.length === 1, "apply_patch permission was never replied")
|
||||
|
||||
expect(harness.requests[0]?.toolCall).toMatchObject({
|
||||
toolCallId: "call_1",
|
||||
title: "2 files",
|
||||
locations: [{ path: first }, { path: second }],
|
||||
content: [
|
||||
{
|
||||
type: "diff",
|
||||
path: first,
|
||||
oldText: "one\n",
|
||||
newText: "two\n",
|
||||
},
|
||||
{
|
||||
type: "diff",
|
||||
path: second,
|
||||
oldText: "alpha\n",
|
||||
newText: "beta\n",
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
it("forwards external_directory metadata and locations to requestPermission", async () => {
|
||||
const harness = createHarness()
|
||||
await createSession(harness.session, "ses_a")
|
||||
@@ -316,7 +189,7 @@ describe("acp permissions", () => {
|
||||
toolCall: {
|
||||
toolCallId: "call_1",
|
||||
status: "pending",
|
||||
title: "Create external directory",
|
||||
title: "external_directory",
|
||||
rawInput: {
|
||||
command: "mkdir -p /tmp/outside",
|
||||
description: "Create external directory",
|
||||
|
||||
@@ -10,7 +10,7 @@ import type {
|
||||
SessionConfigSelectOption,
|
||||
SetSessionConfigOptionResponse,
|
||||
} from "@agentclientprotocol/sdk"
|
||||
import type { AssistantMessage, OpencodeClient } from "@opencode-ai/sdk/v2"
|
||||
import type { OpencodeClient } from "@opencode-ai/sdk/v2"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { Effect } from "effect"
|
||||
@@ -144,10 +144,7 @@ const provider: Provider.Info = {
|
||||
describe("ACP service sessions", () => {
|
||||
const makeService = (
|
||||
messages: readonly { info: unknown; parts: readonly unknown[] }[] = [],
|
||||
options?: {
|
||||
abort?: (input: { sessionID: string }) => Promise<{ data: boolean }>
|
||||
prompt?: (input: unknown) => Promise<{ data: { info: ReturnType<typeof assistantInfo> } }>
|
||||
},
|
||||
options?: { abort?: (input: { sessionID: string }) => Promise<{ data: boolean }> },
|
||||
) => {
|
||||
const updates: SessionNotification[] = []
|
||||
const mcpAdds: string[] = []
|
||||
@@ -196,21 +193,19 @@ describe("ACP service sessions", () => {
|
||||
data: input.directory ? sessions.filter((session) => session.directory === input.directory) : sessions,
|
||||
}),
|
||||
messages: () => Promise.resolve({ data: messages }),
|
||||
prompt:
|
||||
options?.prompt ??
|
||||
((input: unknown) => {
|
||||
prompts.push(input)
|
||||
return Promise.resolve({
|
||||
data: {
|
||||
info: assistantInfo({
|
||||
input: 100,
|
||||
output: 40,
|
||||
reasoning: 7,
|
||||
cache: { read: 11, write: 13 },
|
||||
}),
|
||||
},
|
||||
})
|
||||
}),
|
||||
prompt: (input: unknown) => {
|
||||
prompts.push(input)
|
||||
return Promise.resolve({
|
||||
data: {
|
||||
info: assistantInfo({
|
||||
input: 100,
|
||||
output: 40,
|
||||
reasoning: 7,
|
||||
cache: { read: 11, write: 13 },
|
||||
}),
|
||||
},
|
||||
})
|
||||
},
|
||||
command: (input: unknown) => {
|
||||
commands.push(input)
|
||||
return Promise.resolve({
|
||||
@@ -394,29 +389,15 @@ describe("ACP service sessions", () => {
|
||||
expect(second.sessions.map((session) => session.sessionId)).toEqual(["ses_2", "ses_1"])
|
||||
})
|
||||
|
||||
it("resumes a session and stores restored state without replaying transcript chunks", async () => {
|
||||
const { service, updates } = makeService([
|
||||
it("resumes a session and stores restored state", async () => {
|
||||
const { service } = makeService([
|
||||
{
|
||||
info: {
|
||||
id: "msg_user",
|
||||
sessionID: "ses_resume",
|
||||
role: "user",
|
||||
model: { providerID: "test", modelID: "test-model", variant: "high" },
|
||||
agent: "plan",
|
||||
},
|
||||
parts: [{ id: "part_user", sessionID: "ses_resume", messageID: "msg_user", type: "text", text: "hello" }],
|
||||
},
|
||||
{
|
||||
info: { id: "msg_assistant", sessionID: "ses_resume", role: "assistant" },
|
||||
parts: [
|
||||
{
|
||||
id: "part_assistant",
|
||||
sessionID: "ses_resume",
|
||||
messageID: "msg_assistant",
|
||||
type: "text",
|
||||
text: "hi there",
|
||||
},
|
||||
],
|
||||
parts: [],
|
||||
},
|
||||
])
|
||||
const resumed = await Effect.runPromise(
|
||||
@@ -428,11 +409,6 @@ describe("ACP service sessions", () => {
|
||||
|
||||
expect(select(resumed, "effort")?.currentValue).toBe("high")
|
||||
expect(select(updated, "effort")?.currentValue).toBe("default")
|
||||
expect(
|
||||
updates
|
||||
.map((item) => item.update)
|
||||
.filter((item) => item.sessionUpdate === "user_message_chunk" || item.sessionUpdate === "agent_message_chunk"),
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
it("closes local ACP state and aborts the backing session best-effort", async () => {
|
||||
@@ -1018,52 +994,6 @@ describe("ACP service sessions", () => {
|
||||
expect(usageUpdates).toEqual([session.sessionId])
|
||||
})
|
||||
|
||||
it("maps assistant prompt errors to request errors instead of end turn", async () => {
|
||||
const { service } = makeService([], {
|
||||
prompt: () =>
|
||||
Promise.resolve({
|
||||
data: {
|
||||
info: assistantInfo(
|
||||
{ input: 8, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
{ name: "APIError", data: { message: "Provider request failed", isRetryable: false } },
|
||||
),
|
||||
},
|
||||
}),
|
||||
})
|
||||
const session = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] }))
|
||||
|
||||
const error = await Effect.runPromise(
|
||||
service
|
||||
.prompt({ sessionId: session.sessionId, prompt: [{ type: "text", text: "hello" }] })
|
||||
.pipe(Effect.mapError(ACPError.toRequestError), Effect.flip),
|
||||
)
|
||||
|
||||
expect(error.code).toBe(-32603)
|
||||
expect(error.message).toBe("Internal error: Provider request failed")
|
||||
expect(error.data).toEqual({ service: "session", errorName: "APIError" })
|
||||
})
|
||||
|
||||
it("maps aborted assistant prompt errors to cancelled", async () => {
|
||||
const { service } = makeService([], {
|
||||
prompt: () =>
|
||||
Promise.resolve({
|
||||
data: {
|
||||
info: assistantInfo(
|
||||
{ input: 8, output: 1, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
{ name: "MessageAbortedError", data: { message: "Aborted" } },
|
||||
),
|
||||
},
|
||||
}),
|
||||
})
|
||||
const session = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] }))
|
||||
|
||||
const result = await Effect.runPromise(
|
||||
service.prompt({ sessionId: session.sessionId, prompt: [{ type: "text", text: "hello" }] }),
|
||||
)
|
||||
|
||||
expect(result.stopReason).toBe("cancelled")
|
||||
})
|
||||
|
||||
it("prompt maps assistant and user audience annotations", async () => {
|
||||
const { service, prompts } = makeService()
|
||||
const session = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] }))
|
||||
@@ -1215,17 +1145,13 @@ describe("ACP service sessions", () => {
|
||||
})
|
||||
})
|
||||
|
||||
function assistantInfo(
|
||||
tokens: UsageService.AssistantTokenCost["tokens"],
|
||||
error?: AssistantMessage["error"],
|
||||
): UsageService.AssistantMessage & Pick<AssistantMessage, "error"> {
|
||||
function assistantInfo(tokens: UsageService.AssistantTokenCost["tokens"]): UsageService.AssistantMessage {
|
||||
return {
|
||||
role: "assistant",
|
||||
providerID: "test",
|
||||
modelID: "test-model",
|
||||
cost: 0,
|
||||
tokens,
|
||||
...(error ? { error } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,11 +2,9 @@ import { resolve } from "path"
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import {
|
||||
completedToolContent,
|
||||
completedToolUpdate,
|
||||
completedToolRawOutput,
|
||||
extractImageAttachments,
|
||||
imageContents,
|
||||
pendingToolCall,
|
||||
shellOutputSnapshot,
|
||||
toLocations,
|
||||
toToolKind,
|
||||
@@ -113,85 +111,6 @@ describe("acp tool conversion", () => {
|
||||
])
|
||||
})
|
||||
|
||||
test("sends completed tool calls as partial updates", () => {
|
||||
expect(
|
||||
pendingToolCall({
|
||||
toolCallId: "tool-1",
|
||||
toolName: "edit",
|
||||
state: {
|
||||
input: {
|
||||
filePath: "/tmp/file.ts",
|
||||
oldString: "before",
|
||||
newString: "after",
|
||||
},
|
||||
},
|
||||
}),
|
||||
).toMatchObject({
|
||||
kind: "edit",
|
||||
locations: [{ path: "/tmp/file.ts" }],
|
||||
rawInput: {
|
||||
filePath: "/tmp/file.ts",
|
||||
oldString: "before",
|
||||
newString: "after",
|
||||
},
|
||||
})
|
||||
|
||||
expect(
|
||||
completedToolUpdate({
|
||||
toolCallId: "tool-1",
|
||||
toolName: "edit",
|
||||
state: {
|
||||
status: "completed",
|
||||
input: {
|
||||
filePath: "/tmp/file.ts",
|
||||
oldString: "before",
|
||||
newString: "after",
|
||||
},
|
||||
output: "Edit applied successfully.",
|
||||
},
|
||||
}),
|
||||
).toEqual({
|
||||
toolCallId: "tool-1",
|
||||
status: "completed",
|
||||
content: [
|
||||
{
|
||||
type: "content",
|
||||
content: { type: "text", text: "Edit applied successfully." },
|
||||
},
|
||||
{
|
||||
type: "diff",
|
||||
path: "/tmp/file.ts",
|
||||
oldText: "before",
|
||||
newText: "after",
|
||||
},
|
||||
],
|
||||
rawOutput: {
|
||||
output: "Edit applied successfully.",
|
||||
},
|
||||
})
|
||||
|
||||
expect(
|
||||
completedToolUpdate({
|
||||
toolCallId: "tool-1",
|
||||
toolName: "edit",
|
||||
state: {
|
||||
status: "completed",
|
||||
input: {
|
||||
filePath: "/tmp/file.ts",
|
||||
oldString: "before",
|
||||
newString: "after",
|
||||
},
|
||||
title: "file.ts",
|
||||
output: "Edit applied successfully.",
|
||||
},
|
||||
}),
|
||||
).toMatchObject({
|
||||
toolCallId: "tool-1",
|
||||
status: "completed",
|
||||
title: "file.ts",
|
||||
})
|
||||
})
|
||||
|
||||
test("uses clean read display text for completed content", () => {
|
||||
const output = [
|
||||
"<path>/tmp/file.ts</path>",
|
||||
|
||||
+1
-1
@@ -33,7 +33,7 @@
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"What is the weather in Paris?\"}]},{\"type\":\"reasoning\",\"summary\":[],\"encrypted_content\":\"gAAAAABqEhTUCQT4XELlBu6r5VHqqtu5Il5WdX4m1upE8li0mPmIwgIykAmUTZWiE0213kmviuAgIrmhhiL4B8DXbWQD2vOEkQMhpZq_UCqc22SOg-4DpQLrebMWkzgAPL618VPu9mXNUIH9BW1sRhPdDSbbtK5_bitzsn-FMJGcO3UN7Ga2RW1Rdvt1M3m7J4MRlTutH8cwY8SthzgvOFEBS-_IrAhiwKVz4Se9Jlu3pVNMqhPF7kdrQOfDYui0v-AT8VrHBVomqekJl_dWESww0eWo6bS1PxZB4cLQHWp9JJi5pEECvU9Ntcz3GxuGJEtTKq5mFcRvCanXHOwZGmbBcWMNdVyikk3fxgIE2g9t8rCKJmhNXznMERtrfG2tey19qWbsVbo2YmBbg_5N02AA4NmEVvdfgHJx58nOfEEc2OZYk0YQ1fHBOkpBnwY61hxtrWFdj48QnTEKuvjAyNpX-KKFmMzL4531yLbEEzpaERlr11fDeoMpKofUoMsg3Jz8aTaZ1CpzI3O7iFzGDEV6gKh8vQYGrKOaOXnfBVDXDo8iJhZywpcQY6xB4NNf4pyyjFkR-vjgvBYV2hejlq2V1j8vQHgy8CsZJ6lW5oaTNMfP76MAHlwUwyMYj-cFmuX0epJdDWv8GDznUpOS-v2X5eNsvyx9qvvcTEMLsKJ--3_odisilj4vPhw16P9fB8eLmvESZmJRYmWM4mO7hPTVXOooOa-zxRHGhRQH9ouUea9UHSuH1A0o54qTEPr-JqYlQggugW449IuYW4HSMNMyeGdUNJfodWRu5cL0VPgk6zwTU3ArBq28FDgG7NZMk3njfCId351GZ8VRlTMA6U522_6FFaZ8-5gxsidOm0WULOwyTTo54tJsJFv2pgYUKs0VFWSwi3rvNMVMOgwOVIdSgZt1hFTxBImZh8HUIXUPvdOVKZzQmWT5M6uOTUsm5xsufhj8m79RuYZh2J0bkVOBzZ1As8zH-4v_r9d7e8464EuWXCln_6LAJdrTYgE2gVfHK0zeUaAMbIKhirOf0AVQZyfVsGvJ_CPqrPE_QSECeSA2D4TSa5Tc_IRY-Fb2_HKNCMEP2uvy\"},{\"type\":\"function_call\",\"call_id\":\"call_Ix5Urx04RtKsUJ75K0vTTgFF\",\"name\":\"get_weather\",\"arguments\":\"{\\\"city\\\":{}}\"},{\"type\":\"function_call_output\",\"call_id\":\"call_Ix5Urx04RtKsUJ75K0vTTgFF\",\"output\":\"{\\\"temperature\\\":22,\\\"condition\\\":\\\"sunny\\\"}\"}],\"instructions\":\"Answer using tools when appropriate.\\nUse the get_weather tool exactly once to look up Paris, then reply with exactly: Paris is sunny.\",\"tools\":[{\"type\":\"function\",\"name\":\"get_weather\",\"description\":\"Get the current weather for a city.\",\"parameters\":{\"$schema\":\"http://json-schema.org/draft-07/schema#\",\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":false}],\"store\":false,\"prompt_cache_key\":\"session-recorded-openai-oauth-loop\",\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"},\"stream\":true}"
|
||||
"body": "{\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"What is the weather in Paris?\"}]},{\"type\":\"reasoning\",\"id\":\"rs_0812d6cbe7a2b19b016a1214d32f6881998bcd9ff2e739d7f2\",\"summary\":[],\"encrypted_content\":\"gAAAAABqEhTUCQT4XELlBu6r5VHqqtu5Il5WdX4m1upE8li0mPmIwgIykAmUTZWiE0213kmviuAgIrmhhiL4B8DXbWQD2vOEkQMhpZq_UCqc22SOg-4DpQLrebMWkzgAPL618VPu9mXNUIH9BW1sRhPdDSbbtK5_bitzsn-FMJGcO3UN7Ga2RW1Rdvt1M3m7J4MRlTutH8cwY8SthzgvOFEBS-_IrAhiwKVz4Se9Jlu3pVNMqhPF7kdrQOfDYui0v-AT8VrHBVomqekJl_dWESww0eWo6bS1PxZB4cLQHWp9JJi5pEECvU9Ntcz3GxuGJEtTKq5mFcRvCanXHOwZGmbBcWMNdVyikk3fxgIE2g9t8rCKJmhNXznMERtrfG2tey19qWbsVbo2YmBbg_5N02AA4NmEVvdfgHJx58nOfEEc2OZYk0YQ1fHBOkpBnwY61hxtrWFdj48QnTEKuvjAyNpX-KKFmMzL4531yLbEEzpaERlr11fDeoMpKofUoMsg3Jz8aTaZ1CpzI3O7iFzGDEV6gKh8vQYGrKOaOXnfBVDXDo8iJhZywpcQY6xB4NNf4pyyjFkR-vjgvBYV2hejlq2V1j8vQHgy8CsZJ6lW5oaTNMfP76MAHlwUwyMYj-cFmuX0epJdDWv8GDznUpOS-v2X5eNsvyx9qvvcTEMLsKJ--3_odisilj4vPhw16P9fB8eLmvESZmJRYmWM4mO7hPTVXOooOa-zxRHGhRQH9ouUea9UHSuH1A0o54qTEPr-JqYlQggugW449IuYW4HSMNMyeGdUNJfodWRu5cL0VPgk6zwTU3ArBq28FDgG7NZMk3njfCId351GZ8VRlTMA6U522_6FFaZ8-5gxsidOm0WULOwyTTo54tJsJFv2pgYUKs0VFWSwi3rvNMVMOgwOVIdSgZt1hFTxBImZh8HUIXUPvdOVKZzQmWT5M6uOTUsm5xsufhj8m79RuYZh2J0bkVOBzZ1As8zH-4v_r9d7e8464EuWXCln_6LAJdrTYgE2gVfHK0zeUaAMbIKhirOf0AVQZyfVsGvJ_CPqrPE_QSECeSA2D4TSa5Tc_IRY-Fb2_HKNCMEP2uvy\"},{\"type\":\"function_call\",\"call_id\":\"call_Ix5Urx04RtKsUJ75K0vTTgFF\",\"name\":\"get_weather\",\"arguments\":\"{\\\"city\\\":{}}\"},{\"type\":\"function_call_output\",\"call_id\":\"call_Ix5Urx04RtKsUJ75K0vTTgFF\",\"output\":\"{\\\"temperature\\\":22,\\\"condition\\\":\\\"sunny\\\"}\"}],\"instructions\":\"Answer using tools when appropriate.\\nUse the get_weather tool exactly once to look up Paris, then reply with exactly: Paris is sunny.\",\"tools\":[{\"type\":\"function\",\"name\":\"get_weather\",\"description\":\"Get the current weather for a city.\",\"parameters\":{\"$schema\":\"http://json-schema.org/draft-07/schema#\",\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":false}],\"store\":false,\"prompt_cache_key\":\"session-recorded-openai-oauth-loop\",\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"},\"stream\":true}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
|
||||
+1
-1
@@ -35,7 +35,7 @@
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"model\":\"gpt-5.2-codex\",\"input\":[{\"role\":\"system\",\"content\":\"Answer using tools when appropriate.\\nUse the get_weather tool exactly once to look up Paris, then reply with exactly: Paris is sunny.\"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"What is the weather in Paris?\"}]},{\"type\":\"reasoning\",\"summary\":[],\"encrypted_content\":\"gAAAAABqEhTTGeallj_mC3ciDydiTVJLA6bjJfitoj4ftFfWwlxekFNaf_cDNWP3pE6qsvK9gKJNRfbAbpaEVf1qjAhQx53witrmt6H3KaaNJm3wXHG5sEi9gp3nLWK4T76tcVYHG1x6mbbTjEjCvhIuEkn_7Q7lJ1BErkEURYBBMPmkKya2-YuL8XP14Yrko9BA1t56BkwK5U3TFse4nwHI1qi82hdkX_aYAtz6YgbTpf-dvOCBGfeApxWLFotkt355Qy2b6MmPaH6cQwrvLJXOqEzGkwxFcs3mLEKLV103gd8Z5e_OapjJHTv_LarN-WN9C7nCQ0BBHClk4ND3SDdGb-XV665r23RB40GJ3Q9brJALGaJhij4uceXZNYbakZVOxgqLuDnX6EgABwEzrZb7vhVAKCewVYkLDu0LiS1rIvcFT8HpovxaBU2F2kVG7TRvzYewCW9zXWnAR048p5pUvi6zfMzapk8bnl4uM_uD45gp1sMzeSHryai1U0AUO2cLeQV1pA7KJoJBwWlHxo0YNPbDidI2KfByIoI0A7oiKoZ32vJkiwx3BEGePnzb-JQnv1eDXwlimICVKEVPk1BxpUZ2XBoWdUGYR77u5NGmZ2sKh4OM-qIaB0VaChGsCsJLyQ5_MCkeOm9EMjg1cXbIHDzs9jpF2BXlowY1Vw_L-Ve6nzwK7ZcyHM3ij27wEXYO2On6zbN_AqOvX_CFAjI7ktCYF2guftXuVpFCuiqRyDZ6i2RHXMhR77CoPT97sAvXDejN8feNtidqq4OH5uLa3BHYvW0UKfNlBCOL6A6927l4iTKURZznq_mVjLgTHWv9k-ByxP0hC5sIQHyB5hJaD8_svMr4Aqz_vH9Z8HShgjK47NsMQKxGGgaXdnq3xEdwydM-hTG4Pi35o6Kt0bbJ5KTRQ2ObjmnVTG7J__QTKMTrK2S6Ro4VIMrYzaai7BTLa8MGNotj\"},{\"type\":\"function_call\",\"call_id\":\"call_hwPdXfzZmrdySXU2ZmrL51Ln\",\"name\":\"get_weather\",\"arguments\":\"{\\\"city\\\":{}}\"},{\"type\":\"function_call_output\",\"call_id\":\"call_hwPdXfzZmrdySXU2ZmrL51Ln\",\"output\":\"{\\\"temperature\\\":22,\\\"condition\\\":\\\"sunny\\\"}\"}],\"tools\":[{\"type\":\"function\",\"name\":\"get_weather\",\"description\":\"Get the current weather for a city.\",\"parameters\":{\"$schema\":\"http://json-schema.org/draft-07/schema#\",\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":false}],\"store\":false,\"prompt_cache_key\":\"session-recorded-opencode-loop\",\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"max_output_tokens\":32000,\"stream\":true}"
|
||||
"body": "{\"model\":\"gpt-5.2-codex\",\"input\":[{\"role\":\"system\",\"content\":\"Answer using tools when appropriate.\\nUse the get_weather tool exactly once to look up Paris, then reply with exactly: Paris is sunny.\"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"What is the weather in Paris?\"}]},{\"type\":\"reasoning\",\"id\":\"rs_0fdce240b46054ad016a1214d326848196b269feebe1844759\",\"summary\":[],\"encrypted_content\":\"gAAAAABqEhTTGeallj_mC3ciDydiTVJLA6bjJfitoj4ftFfWwlxekFNaf_cDNWP3pE6qsvK9gKJNRfbAbpaEVf1qjAhQx53witrmt6H3KaaNJm3wXHG5sEi9gp3nLWK4T76tcVYHG1x6mbbTjEjCvhIuEkn_7Q7lJ1BErkEURYBBMPmkKya2-YuL8XP14Yrko9BA1t56BkwK5U3TFse4nwHI1qi82hdkX_aYAtz6YgbTpf-dvOCBGfeApxWLFotkt355Qy2b6MmPaH6cQwrvLJXOqEzGkwxFcs3mLEKLV103gd8Z5e_OapjJHTv_LarN-WN9C7nCQ0BBHClk4ND3SDdGb-XV665r23RB40GJ3Q9brJALGaJhij4uceXZNYbakZVOxgqLuDnX6EgABwEzrZb7vhVAKCewVYkLDu0LiS1rIvcFT8HpovxaBU2F2kVG7TRvzYewCW9zXWnAR048p5pUvi6zfMzapk8bnl4uM_uD45gp1sMzeSHryai1U0AUO2cLeQV1pA7KJoJBwWlHxo0YNPbDidI2KfByIoI0A7oiKoZ32vJkiwx3BEGePnzb-JQnv1eDXwlimICVKEVPk1BxpUZ2XBoWdUGYR77u5NGmZ2sKh4OM-qIaB0VaChGsCsJLyQ5_MCkeOm9EMjg1cXbIHDzs9jpF2BXlowY1Vw_L-Ve6nzwK7ZcyHM3ij27wEXYO2On6zbN_AqOvX_CFAjI7ktCYF2guftXuVpFCuiqRyDZ6i2RHXMhR77CoPT97sAvXDejN8feNtidqq4OH5uLa3BHYvW0UKfNlBCOL6A6927l4iTKURZznq_mVjLgTHWv9k-ByxP0hC5sIQHyB5hJaD8_svMr4Aqz_vH9Z8HShgjK47NsMQKxGGgaXdnq3xEdwydM-hTG4Pi35o6Kt0bbJ5KTRQ2ObjmnVTG7J__QTKMTrK2S6Ro4VIMrYzaai7BTLa8MGNotj\"},{\"type\":\"function_call\",\"call_id\":\"call_hwPdXfzZmrdySXU2ZmrL51Ln\",\"name\":\"get_weather\",\"arguments\":\"{\\\"city\\\":{}}\"},{\"type\":\"function_call_output\",\"call_id\":\"call_hwPdXfzZmrdySXU2ZmrL51Ln\",\"output\":\"{\\\"temperature\\\":22,\\\"condition\\\":\\\"sunny\\\"}\"}],\"tools\":[{\"type\":\"function\",\"name\":\"get_weather\",\"description\":\"Get the current weather for a city.\",\"parameters\":{\"$schema\":\"http://json-schema.org/draft-07/schema#\",\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":false}],\"store\":false,\"prompt_cache_key\":\"session-recorded-opencode-loop\",\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"max_output_tokens\":32000,\"stream\":true}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
|
||||
@@ -23,8 +23,6 @@ let simulateAuthFlow = true
|
||||
let connectSucceedsImmediately = false
|
||||
let serverCapabilities: { tools?: object; resources?: object } = { tools: {} }
|
||||
let listToolsCalls = 0
|
||||
let finishAuthFails = false
|
||||
let finishAuthStoresCredentials = false
|
||||
|
||||
// Mock the transport constructors to simulate OAuth auto-auth on 401
|
||||
void mock.module("@modelcontextprotocol/sdk/client/streamableHttp.js", () => ({
|
||||
@@ -34,10 +32,6 @@ void mock.module("@modelcontextprotocol/sdk/client/streamableHttp.js", () => ({
|
||||
state?: () => Promise<string>
|
||||
redirectToAuthorization?: (url: URL) => Promise<void>
|
||||
saveCodeVerifier?: (v: string) => Promise<void>
|
||||
tokens?: () => Promise<{ access_token: string } | undefined>
|
||||
clientInformation?: () => Promise<{ client_id: string } | undefined>
|
||||
saveClientInformation?: (info: { client_id: string; client_secret?: string }) => Promise<void>
|
||||
saveTokens?: (tokens: { access_token: string; token_type: string }) => Promise<void>
|
||||
}
|
||||
| undefined
|
||||
constructor(url: URL, options?: { authProvider?: unknown }) {
|
||||
@@ -55,8 +49,6 @@ void mock.module("@modelcontextprotocol/sdk/client/streamableHttp.js", () => ({
|
||||
// It calls auth() which eventually calls provider.state(), then
|
||||
// provider.redirectToAuthorization(), then throws UnauthorizedError.
|
||||
if (simulateAuthFlow && this.authProvider) {
|
||||
if (await this.authProvider.tokens?.()) throw new MockUnauthorizedError()
|
||||
if (await this.authProvider.clientInformation?.()) throw new MockUnauthorizedError()
|
||||
// The SDK calls provider.state() to get the OAuth state parameter
|
||||
if (this.authProvider.state) {
|
||||
await this.authProvider.state()
|
||||
@@ -73,14 +65,7 @@ void mock.module("@modelcontextprotocol/sdk/client/streamableHttp.js", () => ({
|
||||
}
|
||||
throw new MockUnauthorizedError()
|
||||
}
|
||||
async finishAuth(_code: string) {
|
||||
if (finishAuthFails) throw new Error("Token exchange failed")
|
||||
if (finishAuthStoresCredentials) {
|
||||
await this.authProvider?.saveClientInformation?.({ client_id: "replacement-client" })
|
||||
await this.authProvider?.saveTokens?.({ access_token: "replacement-token", token_type: "Bearer" })
|
||||
}
|
||||
}
|
||||
async close() {}
|
||||
async finishAuth(_code: string) {}
|
||||
},
|
||||
}))
|
||||
|
||||
@@ -140,8 +125,6 @@ beforeEach(() => {
|
||||
connectSucceedsImmediately = false
|
||||
serverCapabilities = { tools: {} }
|
||||
listToolsCalls = 0
|
||||
finishAuthFails = false
|
||||
finishAuthStoresCredentials = false
|
||||
})
|
||||
|
||||
// Import modules after mocking
|
||||
@@ -150,7 +133,6 @@ const { EventV2Bridge } = await import("../../src/event-v2-bridge")
|
||||
const { Config } = await import("../../src/config/config")
|
||||
const { McpAuth } = await import("../../src/mcp/auth")
|
||||
const { McpOAuthProvider } = await import("../../src/mcp/oauth-provider")
|
||||
const { McpOAuthCallback } = await import("../../src/mcp/oauth-callback")
|
||||
const { FSUtil } = await import("@opencode-ai/core/fs-util")
|
||||
const { CrossSpawnSpawner } = await import("@opencode-ai/core/cross-spawn-spawner")
|
||||
|
||||
@@ -245,59 +227,6 @@ mcpTest.instance("state() returns existing state when one is saved", () =>
|
||||
}),
|
||||
)
|
||||
|
||||
mcpTest.instance(
|
||||
"failed reauthentication preserves existing credentials",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.addFinalizer(() => Effect.promise(() => McpOAuthCallback.stop()).pipe(Effect.ignore))
|
||||
const mcp = yield* MCP.Service
|
||||
const auth = yield* McpAuth.Service
|
||||
const name = "test-reauth-failure"
|
||||
const url = "https://example.com/mcp"
|
||||
const clientInfo = { clientId: "dynamic-client", clientSecret: "dynamic-secret" }
|
||||
|
||||
yield* auth.updateClientInfo(name, clientInfo, url)
|
||||
yield* auth.updateTokens(name, { accessToken: "working-token" }, url)
|
||||
expect((yield* mcp.startAuth(name)).authorizationUrl).toContain("https://auth.example.com/authorize")
|
||||
finishAuthFails = true
|
||||
|
||||
expect(yield* mcp.finishAuth(name, "invalid-code")).toEqual({
|
||||
status: "failed",
|
||||
error: "OAuth completion failed",
|
||||
})
|
||||
const entry = yield* auth.get(name)
|
||||
expect(entry?.tokens?.accessToken).toBe("working-token")
|
||||
expect(entry?.clientInfo).toEqual(clientInfo)
|
||||
}),
|
||||
{ config: config("test-reauth-failure") },
|
||||
)
|
||||
|
||||
mcpTest.instance(
|
||||
"successful reauthentication commits replacement credentials",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.addFinalizer(() => Effect.promise(() => McpOAuthCallback.stop()).pipe(Effect.ignore))
|
||||
const mcp = yield* MCP.Service
|
||||
const auth = yield* McpAuth.Service
|
||||
const name = "test-reauth-success"
|
||||
const url = "https://example.com/mcp"
|
||||
|
||||
yield* auth.updateClientInfo(name, { clientId: "old-client" }, url)
|
||||
yield* auth.updateTokens(name, { accessToken: "old-token" }, url)
|
||||
expect((yield* mcp.startAuth(name)).authorizationUrl).toContain("https://auth.example.com/authorize")
|
||||
expect((yield* auth.get(name))?.tokens?.accessToken).toBe("old-token")
|
||||
finishAuthStoresCredentials = true
|
||||
connectSucceedsImmediately = true
|
||||
|
||||
expect((yield* mcp.finishAuth(name, "valid-code")).status).toBe("connected")
|
||||
const entry = yield* auth.get(name)
|
||||
expect(entry?.tokens?.accessToken).toBe("replacement-token")
|
||||
expect(entry?.clientInfo?.clientId).toBe("replacement-client")
|
||||
expect(entry?.serverUrl).toBe(url)
|
||||
}),
|
||||
{ config: config("test-reauth-success") },
|
||||
)
|
||||
|
||||
mcpTest.instance(
|
||||
"auth status only reports credentials stored for the configured server URL",
|
||||
() =>
|
||||
|
||||
@@ -6,7 +6,7 @@ import path from "path"
|
||||
import { tmpdirScoped } from "../fixture/fixture"
|
||||
import { GlobalBus } from "../../src/bus/global"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { ProjectDirectoryTable, ProjectTable } from "@opencode-ai/core/project/sql"
|
||||
import { ProjectTable } from "@opencode-ai/core/project/sql"
|
||||
import { SessionTable } from "@opencode-ai/core/session/sql"
|
||||
import { WorkspaceTable } from "@opencode-ai/core/control-plane/workspace.sql"
|
||||
import { eq } from "drizzle-orm"
|
||||
@@ -23,7 +23,6 @@ import { ProjectDirectories } from "@opencode-ai/core/project/directories"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { RuntimeFlags } from "@/effect/runtime-flags"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
|
||||
const encoder = new TextEncoder()
|
||||
|
||||
@@ -404,87 +403,6 @@ describe("Project.fromDirectory with worktrees", () => {
|
||||
expect(result.project.sandboxes).not.toContain(tmp)
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("relocates a project and its sessions when the primary checkout moved", () =>
|
||||
Effect.gen(function* () {
|
||||
const { db } = yield* Database.Service
|
||||
const project = yield* Project.Service
|
||||
const tmp = yield* tmpdirScoped({ git: true })
|
||||
const original = yield* project.fromDirectory(tmp)
|
||||
const moved = `${tmp}-moved`
|
||||
const rootSession = SessionID.make(`ses_${crypto.randomUUID()}`)
|
||||
const nestedSession = SessionID.make(`ses_${crypto.randomUUID()}`)
|
||||
yield* Effect.addFinalizer(() => Effect.promise(() => $`rm -rf ${moved}`.quiet().nothrow()).pipe(Effect.ignore))
|
||||
yield* db
|
||||
.insert(SessionTable)
|
||||
.values([
|
||||
{
|
||||
id: rootSession,
|
||||
project_id: original.project.id,
|
||||
slug: rootSession,
|
||||
directory: tmp,
|
||||
title: "root",
|
||||
version: "test",
|
||||
time_created: 1,
|
||||
time_updated: 1,
|
||||
},
|
||||
{
|
||||
id: nestedSession,
|
||||
project_id: original.project.id,
|
||||
slug: nestedSession,
|
||||
directory: path.join(tmp, "packages", "app"),
|
||||
path: "packages/app",
|
||||
title: "nested",
|
||||
version: "test",
|
||||
time_created: 2,
|
||||
time_updated: 2,
|
||||
},
|
||||
])
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
yield* Effect.promise(() => $`mv ${tmp} ${moved}`.quiet())
|
||||
|
||||
const result = yield* project.fromDirectory(moved)
|
||||
const sessions = yield* db
|
||||
.select({ id: SessionTable.id, directory: SessionTable.directory, path: SessionTable.path })
|
||||
.from(SessionTable)
|
||||
.where(eq(SessionTable.project_id, original.project.id))
|
||||
.all()
|
||||
.pipe(Effect.orDie)
|
||||
const directories = yield* db
|
||||
.select({ directory: ProjectDirectoryTable.directory })
|
||||
.from(ProjectDirectoryTable)
|
||||
.where(eq(ProjectDirectoryTable.project_id, original.project.id))
|
||||
.all()
|
||||
.pipe(Effect.orDie)
|
||||
|
||||
expect(result.project.worktree).toBe(moved)
|
||||
expect(result.project.sandboxes).not.toContain(tmp)
|
||||
expect(result.project.sandboxes).not.toContain(moved)
|
||||
expect(directories.map((item) => item.directory)).toEqual([AbsolutePath.make(moved)])
|
||||
expect(sessions).toContainEqual({ id: rootSession, directory: moved, path: null })
|
||||
expect(sessions).toContainEqual({
|
||||
id: nestedSession,
|
||||
directory: path.join(moved, "packages", "app"),
|
||||
path: "packages/app",
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("omits projects whose primary checkout no longer exists", () =>
|
||||
Effect.gen(function* () {
|
||||
const project = yield* Project.Service
|
||||
const tmp = yield* tmpdirScoped({ git: true })
|
||||
const original = yield* project.fromDirectory(tmp)
|
||||
const moved = `${tmp}-moved`
|
||||
yield* Effect.addFinalizer(() => Effect.promise(() => $`rm -rf ${moved}`.quiet().nothrow()).pipe(Effect.ignore))
|
||||
yield* Effect.promise(() => $`mv ${tmp} ${moved}`.quiet())
|
||||
|
||||
const result = (yield* project.list()).find((item) => item.id === original.project.id)
|
||||
|
||||
expect(result).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
describe("Project.discover", () => {
|
||||
|
||||
@@ -1067,40 +1067,6 @@ const scenarios: Scenario[] = [
|
||||
headers: ctx.headers(),
|
||||
}))
|
||||
.status(400, undefined, "none"),
|
||||
http.protected
|
||||
.get("/api/session/{sessionID}/history", "v2.session.history")
|
||||
.seeded((ctx) => ctx.session({ title: "Session history" }))
|
||||
.at((ctx) => ({
|
||||
path: `${route("/api/session/{sessionID}/history", { sessionID: ctx.state.id })}?${new URLSearchParams({
|
||||
after: "0",
|
||||
limit: "2",
|
||||
})}`,
|
||||
headers: ctx.headers(),
|
||||
}))
|
||||
.json(
|
||||
200,
|
||||
(body) => {
|
||||
object(body)
|
||||
array(body.data)
|
||||
check(typeof body.hasMore === "boolean", "Expected a history exhaustion signal")
|
||||
},
|
||||
"none",
|
||||
),
|
||||
http.protected
|
||||
.get("/api/session/{sessionID}/history", "v2.session.history.missing")
|
||||
.at((ctx) => ({
|
||||
path: route("/api/session/{sessionID}/history", { sessionID: "ses_httpapi_missing" }),
|
||||
headers: ctx.headers(),
|
||||
}))
|
||||
.json(404, object, "status"),
|
||||
http.protected
|
||||
.get("/api/session/{sessionID}/history", "v2.session.history.invalid")
|
||||
.seeded((ctx) => ctx.session({ title: "Invalid history sequence" }))
|
||||
.at((ctx) => ({
|
||||
path: `${route("/api/session/{sessionID}/history", { sessionID: ctx.state.id })}?after=-1`,
|
||||
headers: ctx.headers(),
|
||||
}))
|
||||
.json(400, object, "status"),
|
||||
http.protected
|
||||
.get("/api/session/{sessionID}/event", "v2.session.events.missing")
|
||||
.at((ctx) => ({
|
||||
|
||||
@@ -27,44 +27,13 @@ const Event = Schema.Struct({
|
||||
data: Schema.Unknown,
|
||||
})
|
||||
|
||||
async function* eventStream(body: ReadableStream<Uint8Array>) {
|
||||
const reader = body.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
let buffer = ""
|
||||
try {
|
||||
while (true) {
|
||||
const boundary = buffer.match(/(?:\r\n|\r|\n){2}/)
|
||||
if (!boundary || boundary.index === undefined) {
|
||||
const value = await reader.read()
|
||||
if (value.done) return
|
||||
buffer += decoder.decode(value.value, { stream: true })
|
||||
continue
|
||||
}
|
||||
|
||||
const record = buffer.slice(0, boundary.index)
|
||||
buffer = buffer.slice(boundary.index + boundary[0].length)
|
||||
const data = record
|
||||
.split(/\r\n|\r|\n/)
|
||||
.filter((line) => line.startsWith("data:"))
|
||||
.map((line) => line.slice(5).replace(/^ /, ""))
|
||||
if (data.length) yield Schema.decodeUnknownSync(Event)(JSON.parse(data.join("\n")))
|
||||
}
|
||||
} finally {
|
||||
try {
|
||||
await reader.cancel()
|
||||
} finally {
|
||||
reader.releaseLock()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function readEvent(reader: AsyncIterator<typeof Event.Type>) {
|
||||
const value = await reader.next()
|
||||
async function readEvent(reader: ReadableStreamDefaultReader<Uint8Array>) {
|
||||
const value = await reader.read()
|
||||
if (value.done) throw new Error("event stream closed")
|
||||
return value.value
|
||||
return Schema.decodeUnknownSync(Event)(JSON.parse(new TextDecoder().decode(value.value).replace(/^data: /, "")))
|
||||
}
|
||||
|
||||
async function readEventType(reader: AsyncIterator<typeof Event.Type>, type: string) {
|
||||
async function readEventType(reader: ReadableStreamDefaultReader<Uint8Array>, type: string) {
|
||||
for (let index = 0; index < 20; index++) {
|
||||
const event = await readEvent(reader)
|
||||
if (event.type === type) return event
|
||||
@@ -109,7 +78,7 @@ describe("v2 location HttpApi", () => {
|
||||
await using subscriber = await tmpdir({ git: true })
|
||||
await using publisher = await tmpdir({ git: true })
|
||||
const response = await request("/api/event", subscriber.path)
|
||||
const reader = eventStream(response.body!)
|
||||
const reader = response.body!.getReader()
|
||||
const connected = await readEvent(reader)
|
||||
expect(connected.type).toBe("server.connected")
|
||||
expect(connected.location).toBeUndefined()
|
||||
@@ -121,6 +90,6 @@ describe("v2 location HttpApi", () => {
|
||||
location: { directory: publisher.path },
|
||||
data: { sessionID: expect.any(String) },
|
||||
})
|
||||
await reader.return(undefined)
|
||||
await reader.cancel()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -115,9 +115,10 @@ const storedSession = {
|
||||
const openAIResponses = {
|
||||
user: (text: string) => ({ role: "user", content: [{ type: "input_text", text }] }),
|
||||
assistant: (text: string) => ({ role: "assistant", content: [{ type: "output_text", text }] }),
|
||||
openaiReasoning: (text: string, encryptedContent: string) => ({
|
||||
openaiReasoning: (text: string, options: { readonly itemId: string; readonly encryptedContent: string }) => ({
|
||||
type: "reasoning",
|
||||
encrypted_content: encryptedContent,
|
||||
id: options.itemId,
|
||||
encrypted_content: options.encryptedContent,
|
||||
summary: [{ type: "summary_text", text }],
|
||||
}),
|
||||
}
|
||||
@@ -656,7 +657,10 @@ describe("session.llm-native.request", () => {
|
||||
expectedBody: {
|
||||
input: [
|
||||
openAIResponses.user("What changed?"),
|
||||
openAIResponses.openaiReasoning("Checked the previous diff.", "encrypted-state"),
|
||||
openAIResponses.openaiReasoning("Checked the previous diff.", {
|
||||
itemId: "rs_1",
|
||||
encryptedContent: "encrypted-state",
|
||||
}),
|
||||
openAIResponses.assistant("The parser changed."),
|
||||
openAIResponses.user("Summarize it."),
|
||||
],
|
||||
@@ -679,7 +683,7 @@ describe("session.llm-native.request", () => {
|
||||
],
|
||||
providerOptions: { openai: { store: false, include: ["reasoning.encrypted_content"] } },
|
||||
expectedBody: {
|
||||
input: [{ type: "reasoning", summary: [], encrypted_content: "encrypted-state" }],
|
||||
input: [{ type: "reasoning", id: "rs_1", summary: [], encrypted_content: "encrypted-state" }],
|
||||
include: ["reasoning.encrypted_content"],
|
||||
store: false,
|
||||
},
|
||||
|
||||
@@ -11,10 +11,6 @@ import { testEffect } from "../lib/effect"
|
||||
let CLOUDFLARE_SKILLS_URL: string
|
||||
let server: ReturnType<typeof Bun.serve>
|
||||
let downloadCount = 0
|
||||
let mutableVersion = "1"
|
||||
let mutableContent = "# Old"
|
||||
let mutableDownloadCount = 0
|
||||
let mutableFiles = ["SKILL.md"]
|
||||
|
||||
const fixturePath = path.join(import.meta.dir, "../fixture/skills")
|
||||
const cacheDir = path.join(Global.Path.cache, "skills")
|
||||
@@ -28,15 +24,6 @@ beforeAll(async () => {
|
||||
async fetch(req) {
|
||||
const url = new URL(req.url)
|
||||
|
||||
if (url.pathname === "/mutable/index.json") {
|
||||
return Response.json({ skills: [{ name: "mutable", version: mutableVersion, files: mutableFiles }] })
|
||||
}
|
||||
if (url.pathname === "/mutable/mutable/SKILL.md") {
|
||||
mutableDownloadCount++
|
||||
return new Response(mutableContent)
|
||||
}
|
||||
if (url.pathname === "/mutable/mutable/old.md") return new Response("old reference")
|
||||
|
||||
// route /.well-known/skills/* to the fixture directory
|
||||
if (url.pathname.startsWith("/.well-known/skills/")) {
|
||||
const filePath = url.pathname.replace("/.well-known/skills/", "")
|
||||
@@ -149,37 +136,4 @@ describe("Discovery.pull", () => {
|
||||
expect(downloadCount).toBe(firstCount)
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("refreshes a remote skill when its version changes", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.promise(() => rm(cacheDir, { recursive: true, force: true }))
|
||||
mutableVersion = "1"
|
||||
mutableContent = "# Old"
|
||||
mutableDownloadCount = 0
|
||||
mutableFiles = ["SKILL.md", "old.md"]
|
||||
const discovery = yield* Discovery.Service
|
||||
const url = `http://localhost:${server.port}/mutable/`
|
||||
|
||||
const first = yield* discovery.pull(url)
|
||||
expect(yield* Effect.promise(() => Bun.file(path.join(first[0], "SKILL.md")).text())).toBe("# Old")
|
||||
|
||||
mutableVersion = "2"
|
||||
mutableContent = "# Partial"
|
||||
mutableFiles = ["SKILL.md", "missing.md"]
|
||||
const second = yield* discovery.pull(url)
|
||||
expect(yield* Effect.promise(() => Bun.file(path.join(second[0], "SKILL.md")).text())).toBe("# Old")
|
||||
expect(yield* Effect.promise(() => Bun.file(path.join(second[0], "old.md")).text())).toBe("old reference")
|
||||
|
||||
mutableVersion = "3"
|
||||
mutableContent = "# New"
|
||||
mutableFiles = ["SKILL.md"]
|
||||
yield* discovery.pull(url)
|
||||
expect(yield* Effect.promise(() => Bun.file(path.join(second[0], "SKILL.md")).text())).toBe("# New")
|
||||
expect(yield* Effect.promise(() => Bun.file(path.join(second[0], "old.md")).exists())).toBe(false)
|
||||
expect(mutableDownloadCount).toBe(3)
|
||||
|
||||
yield* discovery.pull(url)
|
||||
expect(mutableDownloadCount).toBe(3)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -77,33 +77,6 @@ const withHome = <A, E, R>(home: string, self: Effect.Effect<A, E, R>) =>
|
||||
)
|
||||
|
||||
describe("skill", () => {
|
||||
it.effect("formats verbose locations as XML-safe filesystem paths", () =>
|
||||
Effect.sync(() => {
|
||||
const output = Skill.fmt(
|
||||
[
|
||||
{
|
||||
name: "tagged-skill",
|
||||
description: "A tagged skill.",
|
||||
location: "/tmp/plugin.git#v1.3.0/SKILL.md",
|
||||
content: "",
|
||||
},
|
||||
{
|
||||
name: "built-in-skill",
|
||||
description: "A built-in skill.",
|
||||
location: "<built-in>",
|
||||
content: "",
|
||||
},
|
||||
],
|
||||
{ verbose: true },
|
||||
)
|
||||
|
||||
expect(output).toContain("<location>/tmp/plugin.git#v1.3.0/SKILL.md</location>")
|
||||
expect(output).toContain("<location><built-in></location>")
|
||||
expect(output).not.toContain("file://")
|
||||
expect(output).not.toContain("%23")
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("discovers skills from .opencode/skill/ directory", () =>
|
||||
provideTmpdirInstance(
|
||||
(dir) =>
|
||||
|
||||
@@ -3,7 +3,7 @@ import { EventManifest } from "@opencode-ai/schema/event-manifest"
|
||||
import { Location } from "@opencode-ai/schema/location"
|
||||
import type { Definition } from "@opencode-ai/schema/event"
|
||||
import { Schema } from "effect"
|
||||
import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
|
||||
import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
||||
|
||||
const fields = {
|
||||
id: Event.ID,
|
||||
@@ -12,9 +12,15 @@ const fields = {
|
||||
location: Schema.optional(Location.Ref),
|
||||
}
|
||||
|
||||
const schema = <const Definitions extends ReadonlyArray<Definition>>(definitions: Definitions) =>
|
||||
const schema = (definitions: ReadonlyArray<Definition>) =>
|
||||
Schema.Union([
|
||||
...definitions,
|
||||
...definitions.map((definition) =>
|
||||
Schema.Struct({
|
||||
...fields,
|
||||
type: Schema.Literal(definition.type),
|
||||
data: definition.data,
|
||||
}).annotate({ identifier: `V2Event.${definition.type}` }),
|
||||
),
|
||||
...(definitions.some((definition) => definition.type === "server.connected")
|
||||
? []
|
||||
: [
|
||||
@@ -26,14 +32,14 @@ const schema = <const Definitions extends ReadonlyArray<Definition>>(definitions
|
||||
]),
|
||||
]).annotate({ identifier: "V2Event" })
|
||||
|
||||
const make = <const Definitions extends ReadonlyArray<Definition>>(definitions: Definitions) => {
|
||||
const make = (definitions: ReadonlyArray<Definition>) => {
|
||||
const EventSchema = schema(definitions)
|
||||
return {
|
||||
schema: EventSchema,
|
||||
group: HttpApiGroup.make("server.event")
|
||||
.add(
|
||||
HttpApiEndpoint.get("event.subscribe", "/api/event", {
|
||||
success: HttpApiSchema.StreamSse({ data: EventSchema }),
|
||||
success: EventSchema,
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "v2.event.subscribe",
|
||||
@@ -46,11 +52,8 @@ const make = <const Definitions extends ReadonlyArray<Definition>>(definitions:
|
||||
}
|
||||
}
|
||||
|
||||
export const makeEventGroup = <const Definitions extends ReadonlyArray<Definition>>(definitions: Definitions) =>
|
||||
make(definitions).group
|
||||
export const makeEventGroup = (definitions: ReadonlyArray<Definition>) => make(definitions).group
|
||||
|
||||
const event = make(EventManifest.ServerDefinitions)
|
||||
export const EventGroup = event.group
|
||||
export const OpenCodeEvent = event.schema
|
||||
export type OpenCodeEvent = typeof OpenCodeEvent.Type
|
||||
export type OpenCodeEventEncoded = typeof OpenCodeEvent.Encoded
|
||||
export type Event = typeof event.schema.Type
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { SessionMessage } from "@opencode-ai/schema/session-message"
|
||||
import { SessionInput } from "@opencode-ai/schema/session-input"
|
||||
import { PromptInput } from "@opencode-ai/schema/prompt-input"
|
||||
import { Prompt } from "@opencode-ai/schema/prompt"
|
||||
import { Session } from "@opencode-ai/schema/session"
|
||||
import { Project } from "@opencode-ai/schema/project"
|
||||
import { AbsolutePath, NonNegativeInt, PositiveInt, RelativePath, statics } from "@opencode-ai/schema/schema"
|
||||
import { Workspace } from "@opencode-ai/schema/workspace"
|
||||
import { Context, Effect, Encoding, Result, Schema, Struct } from "effect"
|
||||
import { Context, Encoding, Result, Schema, Struct } from "effect"
|
||||
import { HttpApiEndpoint, HttpApiGroup, HttpApiMiddleware, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
|
||||
import {
|
||||
ConflictError,
|
||||
@@ -60,7 +60,6 @@ const SessionsCursorInput = Schema.Union([
|
||||
const SessionsCursorJson = Schema.fromJsonString(SessionsCursorInput)
|
||||
const encodeSessionsCursor = Schema.encodeSync(SessionsCursorJson)
|
||||
const decodeSessionsCursor = Schema.decodeUnknownEffect(SessionsCursorJson)
|
||||
const invalidCursor = "Invalid cursor" as const
|
||||
|
||||
export const SessionsCursor = Schema.String.pipe(
|
||||
Schema.brand("SessionsCursor"),
|
||||
@@ -68,13 +67,7 @@ export const SessionsCursor = Schema.String.pipe(
|
||||
const make = schema.make.bind(schema)
|
||||
return {
|
||||
make: (input: typeof SessionsCursorInput.Type) => make(Encoding.encodeBase64Url(encodeSessionsCursor(input))),
|
||||
parse: (input: string) =>
|
||||
Effect.suspend(() => {
|
||||
const result = Encoding.decodeBase64UrlString(input)
|
||||
return Result.isFailure(result)
|
||||
? Effect.fail(invalidCursor)
|
||||
: decodeSessionsCursor(result.success).pipe(Effect.mapError(() => invalidCursor))
|
||||
}),
|
||||
parse: (input: string) => decodeSessionsCursor(Result.getOrThrow(Encoding.decodeBase64UrlString(input))),
|
||||
}
|
||||
}),
|
||||
)
|
||||
@@ -84,13 +77,6 @@ const SessionActive = Schema.Struct({
|
||||
type: Schema.Literal("running"),
|
||||
}).annotate({ identifier: "SessionActive" })
|
||||
|
||||
const SessionHistoryLimit = PositiveInt.check(Schema.isLessThanOrEqualTo(100))
|
||||
|
||||
export const SessionHistoryQuery = Schema.Struct({
|
||||
limit: Schema.NumberFromString.pipe(Schema.decodeTo(SessionHistoryLimit), Schema.optional),
|
||||
after: Schema.NumberFromString.pipe(Schema.decodeTo(NonNegativeInt), Schema.optional),
|
||||
})
|
||||
|
||||
const SessionsQueryCursor = SessionsCursor.annotate({
|
||||
description: "Opaque pagination cursor returned as cursor.previous or cursor.next in the previous response.",
|
||||
})
|
||||
@@ -206,7 +192,7 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
|
||||
params: { sessionID: Session.ID },
|
||||
payload: Schema.Struct({
|
||||
id: SessionMessage.ID.pipe(Schema.optional),
|
||||
prompt: PromptInput.Prompt,
|
||||
prompt: Prompt,
|
||||
delivery: SessionInput.Delivery.pipe(Schema.optional),
|
||||
resume: Schema.Boolean.pipe(Schema.optional),
|
||||
}),
|
||||
@@ -303,26 +289,6 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
|
||||
}),
|
||||
),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.get("session.history", "/api/session/:sessionID/history", {
|
||||
params: { sessionID: Session.ID },
|
||||
query: SessionHistoryQuery,
|
||||
success: Schema.Struct({
|
||||
data: Schema.Array(SessionEvent.Durable),
|
||||
hasMore: Schema.Boolean,
|
||||
}).annotate({ identifier: "SessionHistory" }),
|
||||
error: SessionNotFoundError,
|
||||
})
|
||||
.middleware(sessionLocationMiddleware)
|
||||
.annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "v2.session.history",
|
||||
summary: "Get session history",
|
||||
description:
|
||||
"Read one finite page of public durable Session events after an exclusive aggregate sequence. Newly committed events may appear on later pages.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.get("session.events", "/api/session/:sessionID/event", {
|
||||
params: { sessionID: Session.ID },
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { SessionHistoryQuery, SessionsCursor } from "../src/groups/session"
|
||||
import { Effect } from "effect"
|
||||
import { SessionsCursor } from "../src/groups/session"
|
||||
import { Session } from "@opencode-ai/schema/session"
|
||||
|
||||
describe("SessionsCursor", () => {
|
||||
@@ -16,11 +16,3 @@ describe("SessionsCursor", () => {
|
||||
expect(await Effect.runPromise(SessionsCursor.parse(cursor))).toEqual(input)
|
||||
})
|
||||
})
|
||||
|
||||
describe("SessionHistoryQuery", () => {
|
||||
test("decodes numeric paging inputs", async () => {
|
||||
const query = await Effect.runPromise(Schema.decodeUnknownEffect(SessionHistoryQuery)({ after: "3", limit: "10" }))
|
||||
|
||||
expect(query).toEqual({ after: 3, limit: 10 })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4,11 +4,6 @@ import { Event } from "./event"
|
||||
import { SessionEvent } from "./session-event"
|
||||
import { SessionV1 } from "./session-v1"
|
||||
|
||||
export const SessionDurable = {
|
||||
definitions: Event.durable(SessionEvent.DurableDefinitions),
|
||||
schema: SessionEvent.Durable,
|
||||
} as const
|
||||
|
||||
export const Durable = Event.durable([
|
||||
...SessionV1.Event.Definitions.filter((definition) => definition.durable !== undefined),
|
||||
...SessionEvent.DurableDefinitions,
|
||||
|
||||
@@ -55,7 +55,7 @@ export function define<
|
||||
id: ID,
|
||||
metadata: optional(Schema.Record(Schema.String, Schema.Unknown)),
|
||||
type: Schema.Literal(input.type),
|
||||
durable: optional(Schema.Struct({ aggregateID: Schema.String, seq: Schema.Int, version: Schema.Int })),
|
||||
durable: optional(Schema.Struct({ aggregateID: Schema.String, seq: Schema.Number, version: Schema.Number })),
|
||||
location: optional(Location.Ref),
|
||||
data,
|
||||
})
|
||||
@@ -95,7 +95,7 @@ export function versionedType(type: string, version: number) {
|
||||
return `${type}.${version}`
|
||||
}
|
||||
|
||||
export function durable<const Definitions extends ReadonlyArray<Definition>>(definitions: Definitions) {
|
||||
export function durable(definitions: ReadonlyArray<Definition>) {
|
||||
return readonlyMap(
|
||||
definitions.reduce((result, definition) => {
|
||||
if (!definition.durable) return result
|
||||
@@ -103,7 +103,7 @@ export function durable<const Definitions extends ReadonlyArray<Definition>>(def
|
||||
if (result.has(key)) throw new Error(`Duplicate durable event definition for ${key}`)
|
||||
result.set(key, definition)
|
||||
return result
|
||||
}, new Map<string, Definitions[number]>()),
|
||||
}, new Map<string, Definition>()),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -24,5 +24,4 @@ export { PtyTicket } from "./pty-ticket"
|
||||
export { Question } from "./question"
|
||||
export { Workspace } from "./workspace"
|
||||
export { Prompt, Source, FileAttachment, AgentAttachment } from "./prompt"
|
||||
export { PromptInput } from "./prompt-input"
|
||||
export * from "./schema"
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
export * as PromptInput from "./prompt-input"
|
||||
|
||||
import { Schema } from "effect"
|
||||
import { AgentAttachment, Source } from "./prompt"
|
||||
import { optional, statics } from "./schema"
|
||||
|
||||
export interface FileAttachment extends Schema.Schema.Type<typeof FileAttachment> {}
|
||||
export const FileAttachment = Schema.Struct({
|
||||
uri: Schema.String,
|
||||
name: Schema.String.pipe(optional),
|
||||
description: Schema.String.pipe(optional),
|
||||
source: Source.pipe(optional),
|
||||
})
|
||||
.annotate({ identifier: "PromptInput.FileAttachment" })
|
||||
.pipe(
|
||||
statics((schema) => ({
|
||||
create: (input: FileAttachment) => schema.make(input),
|
||||
})),
|
||||
)
|
||||
|
||||
export interface Prompt extends Schema.Schema.Type<typeof Prompt> {}
|
||||
export const Prompt = Schema.Struct({
|
||||
text: Schema.String,
|
||||
files: Schema.Array(FileAttachment).pipe(optional),
|
||||
agents: Schema.Array(AgentAttachment).pipe(optional),
|
||||
}).annotate({ identifier: "PromptInput" })
|
||||
@@ -511,9 +511,7 @@ export const Definitions = Event.inventory(
|
||||
RevertEvent.Committed,
|
||||
)
|
||||
|
||||
export const Durable = Schema.Union(DurableDefinitions, { mode: "oneOf" })
|
||||
.pipe(Schema.toTaggedUnion("type"))
|
||||
.annotate({ identifier: "SessionDurableEvent" })
|
||||
export const Durable = Schema.Union(DurableDefinitions, { mode: "oneOf" }).pipe(Schema.toTaggedUnion("type"))
|
||||
export type DurableEvent = typeof Durable.Type
|
||||
|
||||
export const All = Schema.Union(Definitions, { mode: "oneOf" }).pipe(Schema.toTaggedUnion("type"))
|
||||
|
||||
@@ -14,4 +14,3 @@ export {
|
||||
SessionInput,
|
||||
SessionMessage,
|
||||
} from "@opencode-ai/client/effect"
|
||||
export type { OpenCodeEvent } from "@opencode-ai/client/effect"
|
||||
|
||||
@@ -3,8 +3,7 @@ import { mkdtemp, rm } from "node:fs/promises"
|
||||
import { tmpdir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { Deferred, Effect, Latch, Option, Schema, Stream } from "effect"
|
||||
import type { OpenCodeEvent } from "../src"
|
||||
import { Effect, Option, Schema, Stream } from "effect"
|
||||
|
||||
test("embedded client uses the real router and handlers", async () => {
|
||||
const directory = await mkdtemp(join(tmpdir(), "opencode-embedded-"))
|
||||
@@ -104,88 +103,6 @@ test("embedded client uses the real router and handlers", async () => {
|
||||
}
|
||||
})
|
||||
|
||||
test("Location-owned runner events reach the ready global client", async () => {
|
||||
const directory = await mkdtemp(join(tmpdir(), "opencode-embedded-events-"))
|
||||
const database = Flag.OPENCODE_DB
|
||||
Flag.OPENCODE_DB = join(directory, "opencode.sqlite")
|
||||
const { AbsolutePath, Location, OpenCode, Prompt, Session } = await import("../src")
|
||||
const sessionID = Session.ID.make(`ses_embedded_${crypto.randomUUID()}`)
|
||||
|
||||
try {
|
||||
const program = Effect.gen(function* () {
|
||||
const opencode = yield* OpenCode.create()
|
||||
const connected = yield* Latch.make(false)
|
||||
const prompted = yield* Deferred.make<OpenCodeEvent>()
|
||||
yield* opencode.events.subscribe().pipe(
|
||||
Stream.runForEach((event) =>
|
||||
event.type === "server.connected"
|
||||
? connected.open
|
||||
: event.type === "session.next.prompted" && event.data.sessionID === sessionID
|
||||
? Deferred.succeed(prompted, event).pipe(Effect.asVoid)
|
||||
: Effect.void,
|
||||
),
|
||||
Effect.forkScoped,
|
||||
)
|
||||
yield* connected.await
|
||||
yield* opencode.sessions.create({
|
||||
id: sessionID,
|
||||
location: Location.Ref.make({ directory: AbsolutePath.make(directory) }),
|
||||
})
|
||||
yield* opencode.sessions.prompt({ sessionID, prompt: Prompt.make({ text: "Observe this input" }) })
|
||||
|
||||
const event = yield* Deferred.await(prompted).pipe(Effect.timeout("4 seconds"))
|
||||
expect(event.durable).toEqual(expect.objectContaining({ aggregateID: sessionID, seq: expect.any(Number) }))
|
||||
})
|
||||
await Effect.runPromise(Effect.scoped(program))
|
||||
} finally {
|
||||
Flag.OPENCODE_DB = database
|
||||
await rm(directory, { recursive: true, force: true })
|
||||
}
|
||||
}, 10_000)
|
||||
|
||||
test("independent embedded hosts do not share live notifications", async () => {
|
||||
const directory = await mkdtemp(join(tmpdir(), "opencode-embedded-hosts-"))
|
||||
const database = Flag.OPENCODE_DB
|
||||
Flag.OPENCODE_DB = join(directory, "opencode.sqlite")
|
||||
const { AbsolutePath, Agent, Location, OpenCode, Session } = await import("../src")
|
||||
const sessionID = Session.ID.make(`ses_embedded_${crypto.randomUUID()}`)
|
||||
|
||||
try {
|
||||
const program = Effect.gen(function* () {
|
||||
const first = yield* OpenCode.create()
|
||||
const second = yield* OpenCode.create()
|
||||
const firstReady = yield* Latch.make(false)
|
||||
const secondReady = yield* Latch.make(false)
|
||||
const firstEvent = yield* Latch.make(false)
|
||||
const secondEvent = yield* Latch.make(false)
|
||||
const observe = (ready: Latch.Latch, event: Latch.Latch) =>
|
||||
Stream.runForEach((notification: OpenCodeEvent) =>
|
||||
notification.type === "server.connected"
|
||||
? ready.open
|
||||
: notification.type === "session.next.agent.switched" && notification.data.sessionID === sessionID
|
||||
? event.open
|
||||
: Effect.void,
|
||||
)
|
||||
|
||||
yield* first.events.subscribe().pipe(observe(firstReady, firstEvent), Effect.forkScoped)
|
||||
yield* second.events.subscribe().pipe(observe(secondReady, secondEvent), Effect.forkScoped)
|
||||
yield* Effect.all([firstReady.await, secondReady.await], { discard: true })
|
||||
yield* first.sessions.create({
|
||||
id: sessionID,
|
||||
location: Location.Ref.make({ directory: AbsolutePath.make(directory) }),
|
||||
})
|
||||
yield* first.sessions.switchAgent({ sessionID, agent: Agent.ID.make("plan") })
|
||||
|
||||
yield* firstEvent.await.pipe(Effect.timeout("2 seconds"))
|
||||
expect(Option.isNone(yield* secondEvent.await.pipe(Effect.timeoutOption("100 millis")))).toBe(true)
|
||||
})
|
||||
await Effect.runPromise(Effect.scoped(program))
|
||||
} finally {
|
||||
Flag.OPENCODE_DB = database
|
||||
await rm(directory, { recursive: true, force: true })
|
||||
}
|
||||
}, 10_000)
|
||||
|
||||
test("embedded client is available as a Layer service", async () => {
|
||||
const directory = await mkdtemp(join(tmpdir(), "opencode-embedded-layer-"))
|
||||
const database = Flag.OPENCODE_DB
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"test": "bun test",
|
||||
"typecheck": "tsgo --noEmit",
|
||||
"build": "bun ./script/build.ts"
|
||||
},
|
||||
|
||||
@@ -13,37 +13,6 @@ const opencode = path.resolve(dir, "../../opencode")
|
||||
|
||||
await $`bun dev generate > ${dir}/openapi.json`.cwd(opencode)
|
||||
|
||||
const document = (await Bun.file("./openapi.json").json()) as {
|
||||
components?: { schemas?: Record<string, unknown> }
|
||||
[key: string]: unknown
|
||||
}
|
||||
const schemas = document.components?.schemas
|
||||
if (schemas) {
|
||||
const reachable = new Set<string>()
|
||||
const visit = (value: unknown) => {
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach(visit)
|
||||
return
|
||||
}
|
||||
if (typeof value !== "object" || value === null) return
|
||||
for (const [key, child] of Object.entries(value)) {
|
||||
if (key === "$ref" && typeof child === "string" && child.startsWith("#/components/schemas/")) {
|
||||
const name = child.slice("#/components/schemas/".length)
|
||||
if (reachable.has(name)) continue
|
||||
reachable.add(name)
|
||||
visit(schemas[name])
|
||||
} else {
|
||||
visit(child)
|
||||
}
|
||||
}
|
||||
}
|
||||
visit({ ...document, components: { ...document.components, schemas: undefined } })
|
||||
for (const name of Object.keys(schemas)) {
|
||||
if (/^SessionNext\w+1$/.test(name) && !reachable.has(name)) delete schemas[name]
|
||||
}
|
||||
await Bun.write("./openapi.json", JSON.stringify(document))
|
||||
}
|
||||
|
||||
await createClient({
|
||||
input: "./openapi.json",
|
||||
output: {
|
||||
@@ -71,29 +40,6 @@ await createClient({
|
||||
],
|
||||
})
|
||||
|
||||
const generatedTypes = await Bun.file("./src/v2/gen/types.gen.ts").text()
|
||||
if (/export type SessionNext\w+1 =/.test(generatedTypes)) {
|
||||
throw new Error("Session history generated duplicate Session event variants")
|
||||
}
|
||||
const historyTypesPatched = generatedTypes.replace(
|
||||
/(export type V2SessionHistoryData = \{[\s\S]*?query\?: \{\s*limit\?: )string([;,]\s*after\?: )string/,
|
||||
"$1number$2number",
|
||||
)
|
||||
if (historyTypesPatched === generatedTypes) {
|
||||
throw new Error("Session history numeric query patch did not apply")
|
||||
}
|
||||
await Bun.write("./src/v2/gen/types.gen.ts", historyTypesPatched)
|
||||
|
||||
const generatedSdk = await Bun.file("./src/v2/gen/sdk.gen.ts").text()
|
||||
const historySdkPatched = generatedSdk.replace(
|
||||
/(Get session history[\s\S]*?parameters: \{\s*sessionID: string[;,]\s*limit\?: )string([;,]\s*after\?: )string/,
|
||||
"$1number$2number",
|
||||
)
|
||||
if (historySdkPatched === generatedSdk) {
|
||||
throw new Error("Session history numeric SDK patch did not apply")
|
||||
}
|
||||
await Bun.write("./src/v2/gen/sdk.gen.ts", historySdkPatched)
|
||||
|
||||
// Patch a @hey-api/openapi-ts codegen bug: SseFn incorrectly passes the
|
||||
// endpoint's TError into the second generic of ServerSentEventsResult, which
|
||||
// is the AsyncGenerator's TReturn slot. Iterator return values have nothing
|
||||
|
||||
@@ -142,7 +142,7 @@ import type {
|
||||
ProjectListResponses,
|
||||
ProjectUpdateErrors,
|
||||
ProjectUpdateResponses,
|
||||
PromptInput,
|
||||
Prompt,
|
||||
ProviderAuthErrors,
|
||||
ProviderAuthResponses,
|
||||
ProviderListErrors,
|
||||
@@ -345,8 +345,6 @@ import type {
|
||||
V2SessionEventsResponses,
|
||||
V2SessionGetErrors,
|
||||
V2SessionGetResponses,
|
||||
V2SessionHistoryErrors,
|
||||
V2SessionHistoryResponses,
|
||||
V2SessionInterruptErrors,
|
||||
V2SessionInterruptResponses,
|
||||
V2SessionListErrors,
|
||||
@@ -5623,7 +5621,7 @@ export class Session3 extends HeyApiClient {
|
||||
parameters: {
|
||||
sessionID: string
|
||||
id?: string
|
||||
prompt?: PromptInput
|
||||
prompt?: Prompt
|
||||
delivery?: "steer" | "queue"
|
||||
resume?: boolean
|
||||
},
|
||||
@@ -5712,38 +5710,6 @@ export class Session3 extends HeyApiClient {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Get session history
|
||||
*
|
||||
* Read one finite page of public durable Session events after an exclusive aggregate sequence. Newly committed events may appear on later pages.
|
||||
*/
|
||||
public history<ThrowOnError extends boolean = false>(
|
||||
parameters: {
|
||||
sessionID: string
|
||||
limit?: number
|
||||
after?: number
|
||||
},
|
||||
options?: Options<never, ThrowOnError>,
|
||||
) {
|
||||
const params = buildClientParams(
|
||||
[parameters],
|
||||
[
|
||||
{
|
||||
args: [
|
||||
{ in: "path", key: "sessionID" },
|
||||
{ in: "query", key: "limit" },
|
||||
{ in: "query", key: "after" },
|
||||
],
|
||||
},
|
||||
],
|
||||
)
|
||||
return (options?.client ?? this.client).get<V2SessionHistoryResponses, V2SessionHistoryErrors, ThrowOnError>({
|
||||
url: "/api/session/{sessionID}/history",
|
||||
...options,
|
||||
...params,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to session events
|
||||
*
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,12 +0,0 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import type { V2SessionHistoryData } from "../src/v2/gen/types.gen"
|
||||
|
||||
test("uses numeric Session history positions", () => {
|
||||
const input = {
|
||||
path: { sessionID: "ses_test" },
|
||||
query: { after: 1, limit: 50 },
|
||||
url: "/api/session/{sessionID}/history",
|
||||
} satisfies V2SessionHistoryData
|
||||
|
||||
expect(input.query.after).toBe(1)
|
||||
})
|
||||
+3661
-1194
File diff suppressed because it is too large
Load Diff
@@ -1,19 +1,16 @@
|
||||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
import { OpenCodeEvent } from "@opencode-ai/protocol/groups/event"
|
||||
import { Effect, Schema, Stream } from "effect"
|
||||
import { Effect, Stream } from "effect"
|
||||
import { HttpServerResponse } from "effect/unstable/http"
|
||||
import { HttpApiBuilder } from "effect/unstable/httpapi"
|
||||
import * as Sse from "effect/unstable/encoding/Sse"
|
||||
import { Api } from "../api"
|
||||
|
||||
const subscriberCapacity = 256
|
||||
|
||||
function eventData(data: unknown): Sse.Event {
|
||||
return {
|
||||
_tag: "Event",
|
||||
event: "message",
|
||||
id: undefined,
|
||||
data: JSON.stringify(Schema.encodeUnknownSync(OpenCodeEvent)(data)),
|
||||
data: JSON.stringify(data),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,16 +24,13 @@ export const EventHandler = HttpApiBuilder.group(Api, "server.event", (handlers)
|
||||
type: "server.connected",
|
||||
data: {},
|
||||
}
|
||||
const output = Stream.unwrap(
|
||||
Effect.gen(function* () {
|
||||
// Acquiring the bounded stream installs its listener before readiness is observable.
|
||||
const live = yield* EventV2.allBounded(events, subscriberCapacity)
|
||||
return Stream.make(connected).pipe(Stream.concat(live))
|
||||
}),
|
||||
).pipe(Stream.map(eventData), Stream.pipeThroughChannel(Sse.encode()))
|
||||
const heartbeat = Stream.tick("15 seconds").pipe(Stream.map(() => ": heartbeat\n\n"))
|
||||
return HttpServerResponse.stream(
|
||||
output.pipe(Stream.merge(heartbeat, { haltStrategy: "left" }), Stream.encodeText),
|
||||
Stream.make(connected).pipe(
|
||||
Stream.concat(events.all()),
|
||||
Stream.map(eventData),
|
||||
Stream.pipeThroughChannel(Sse.encode()),
|
||||
Stream.encodeText,
|
||||
),
|
||||
{
|
||||
contentType: "text/event-stream",
|
||||
headers: {
|
||||
|
||||
@@ -14,7 +14,6 @@ import {
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
|
||||
const DefaultSessionsLimit = 50
|
||||
const DefaultSessionHistoryLimit = 50
|
||||
|
||||
export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handlers) =>
|
||||
Effect.gen(function* () {
|
||||
@@ -329,31 +328,6 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
|
||||
}
|
||||
}),
|
||||
)
|
||||
.handle(
|
||||
"session.history",
|
||||
Effect.fn(function* (ctx) {
|
||||
return yield* session
|
||||
.history({
|
||||
sessionID: ctx.params.sessionID,
|
||||
after: ctx.query.after,
|
||||
limit: ctx.query.limit ?? DefaultSessionHistoryLimit,
|
||||
})
|
||||
.pipe(
|
||||
Effect.map((page) => ({
|
||||
data: page.events,
|
||||
hasMore: page.hasMore,
|
||||
})),
|
||||
Effect.catchTag(
|
||||
"Session.NotFoundError",
|
||||
(error) =>
|
||||
new SessionNotFoundError({
|
||||
sessionID: error.sessionID,
|
||||
message: `Session not found: ${error.sessionID}`,
|
||||
}),
|
||||
),
|
||||
)
|
||||
}),
|
||||
)
|
||||
.handle(
|
||||
"session.events",
|
||||
Effect.fn((ctx) =>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
export function inlineCodeKind(text: string): "path" | "url" | undefined {
|
||||
if (/^https?:\/\//i.test(text)) return "url"
|
||||
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(text)) return
|
||||
if (text === "/") return
|
||||
if (/^\/[a-z][a-z0-9-]*$/i.test(text)) return
|
||||
if (/\s/.test(text)) return
|
||||
if (/[()\[\]{}*+=<>|&^"';]/.test(text)) return
|
||||
|
||||
@@ -240,7 +240,6 @@ const en = {
|
||||
"model.noPeersDescription": "Peer rankings appear after usage lands.",
|
||||
"model.noUsageLastWeek": "No usage last week",
|
||||
"model.newThisWeek": "New this week",
|
||||
"model.sameAsPreviousWeek": "Same as previous week",
|
||||
"model.vsPreviousWeek": "{{change}} vs previous week",
|
||||
"model.pdf": "PDF",
|
||||
"format.users": "users",
|
||||
|
||||
@@ -221,7 +221,6 @@ export const dict = {
|
||||
"model.noPeersDescription": "تظهر ترتيبات النماذج المشابهة بعد وصول الاستخدام.",
|
||||
"model.noUsageLastWeek": "لا يوجد استخدام الأسبوع الماضي",
|
||||
"model.newThisWeek": "جديد هذا الأسبوع",
|
||||
"model.sameAsPreviousWeek": "دون تغيير عن الأسبوع السابق",
|
||||
"model.vsPreviousWeek": "{{change}} مقارنة بالأسبوع السابق",
|
||||
"model.pdf": "PDF",
|
||||
"format.users": "مستخدمون",
|
||||
|
||||
@@ -223,7 +223,6 @@ export const dict = {
|
||||
"model.noPeersDescription": "Os rankings de pares aparecem depois que o uso chega.",
|
||||
"model.noUsageLastWeek": "Sem uso na semana passada",
|
||||
"model.newThisWeek": "Novo esta semana",
|
||||
"model.sameAsPreviousWeek": "Igual à semana anterior",
|
||||
"model.vsPreviousWeek": "{{change}} vs semana anterior",
|
||||
"model.pdf": "PDF",
|
||||
"format.users": "usuários",
|
||||
|
||||
@@ -222,7 +222,6 @@ export const dict = {
|
||||
"model.noPeersDescription": "Ranglister over lignende modeller vises, når brug lander.",
|
||||
"model.noUsageLastWeek": "Ingen brug sidste uge",
|
||||
"model.newThisWeek": "Ny denne uge",
|
||||
"model.sameAsPreviousWeek": "Samme som forrige uge",
|
||||
"model.vsPreviousWeek": "{{change}} vs forrige uge",
|
||||
"model.pdf": "PDF",
|
||||
"format.users": "brugere",
|
||||
|
||||
@@ -223,7 +223,6 @@ export const dict = {
|
||||
"model.noPeersDescription": "Vergleichsrankings erscheinen, nachdem Nutzung eingegangen ist.",
|
||||
"model.noUsageLastWeek": "Keine Nutzung letzte Woche",
|
||||
"model.newThisWeek": "Neu diese Woche",
|
||||
"model.sameAsPreviousWeek": "Unverändert zur vorherigen Woche",
|
||||
"model.vsPreviousWeek": "{{change}} ggü. vorheriger Woche",
|
||||
"model.pdf": "PDF",
|
||||
"format.users": "Nutzer",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user