From 08ea08e830b636b5419b6689a9c01b14ee6f4bc2 Mon Sep 17 00:00:00 2001 From: Brendan Allan Date: Thu, 16 Jul 2026 06:24:08 +0000 Subject: [PATCH] refactor(app): route backend through adapters --- bun.lock | 1 - .../app/src/components/command-palette.ts | 6 +- .../components/dialog-connect-provider.tsx | 195 ++- .../src/components/dialog-custom-provider.tsx | 25 +- .../src/components/dialog-edit-project.tsx | 6 +- packages/app/src/components/dialog-fork.tsx | 20 +- .../components/dialog-select-directory-v2.tsx | 27 +- .../components/dialog-select-directory.tsx | 7 +- .../directory-picker-domain.test.ts | 21 +- .../src/components/directory-picker-domain.ts | 32 +- .../app/src/components/file-tree-v2-model.ts | 2 +- packages/app/src/components/file-tree-v2.tsx | 2 +- packages/app/src/components/file-tree.tsx | 2 +- packages/app/src/components/prompt-input.tsx | 4 +- .../prompt-input/build-request-parts.test.ts | 2 + .../prompt-input/build-request-parts.ts | 4 +- .../components/prompt-input/submit.test.ts | 46 +- .../app/src/components/prompt-input/submit.ts | 146 ++- .../session/session-context-breakdown.test.ts | 2 +- .../session/session-context-breakdown.ts | 10 +- .../session/session-context-metrics.test.ts | 2 +- .../session/session-context-metrics.ts | 12 +- .../session/session-context-tab.tsx | 2 +- .../app/src/components/settings-general.tsx | 10 +- .../app/src/components/settings-providers.tsx | 51 +- .../src/components/settings-v2/general.tsx | 10 +- .../src/components/settings-v2/providers.tsx | 51 +- .../src/components/settings-v2/servers.tsx | 4 +- packages/app/src/components/terminal.tsx | 71 +- .../app/src/components/titlebar-tab-nav.tsx | 10 +- packages/app/src/components/titlebar.tsx | 5 +- .../app/src/context/backend-client.test.ts | 70 ++ packages/app/src/context/backend-client.ts | 64 + packages/app/src/context/backend-v1.test.ts | 273 ++++- packages/app/src/context/backend-v1.ts | 507 +++++--- packages/app/src/context/backend-v2.test.ts | 653 ++++++++-- packages/app/src/context/backend-v2.ts | 1090 ++++++++++++----- .../app/src/context/backend.test-fixture.ts | 56 + packages/app/src/context/backend.ts | 639 ++++++++-- packages/app/src/context/directory-sync.ts | 34 +- packages/app/src/context/file.tsx | 42 +- .../app/src/context/file/content-cache.ts | 2 +- packages/app/src/context/file/tree-store.ts | 2 +- packages/app/src/context/file/types.ts | 2 +- packages/app/src/context/file/watcher.ts | 12 +- .../src/context/global-sync/bootstrap.test.ts | 96 +- .../app/src/context/global-sync/bootstrap.ts | 261 ++-- .../context/global-sync/child-store.test.ts | 5 +- .../src/context/global-sync/child-store.ts | 9 +- .../context/global-sync/event-reducer.test.ts | 19 +- .../src/context/global-sync/event-reducer.ts | 143 ++- packages/app/src/context/global-sync/mcp.ts | 5 +- .../context/global-sync/session-cache.test.ts | 37 +- .../src/context/global-sync/session-cache.ts | 23 +- .../context/global-sync/session-trim.test.ts | 4 +- .../src/context/global-sync/session-trim.ts | 16 +- packages/app/src/context/global-sync/types.ts | 92 +- .../app/src/context/global-sync/utils.test.ts | 35 +- packages/app/src/context/global-sync/utils.ts | 37 +- packages/app/src/context/global.tsx | 47 +- packages/app/src/context/layout.tsx | 11 +- packages/app/src/context/local.tsx | 10 +- packages/app/src/context/models.tsx | 4 +- packages/app/src/context/notification.tsx | 19 +- .../context/permission-auto-respond.test.ts | 2 +- packages/app/src/context/permission.tsx | 30 +- packages/app/src/context/prompt-state.ts | 2 +- packages/app/src/context/server-sdk.test.ts | 119 +- packages/app/src/context/server-sdk.tsx | 97 +- .../app/src/context/server-session.test.ts | 186 ++- packages/app/src/context/server-session.ts | 311 +++-- packages/app/src/context/server-sync.tsx | 165 +-- .../app/src/context/sync-optimistic.test.ts | 2 +- packages/app/src/context/sync.tsx | 24 +- packages/app/src/context/tabs.tsx | 6 +- packages/app/src/context/terminal.tsx | 66 +- .../app/src/hooks/provider-catalog.test.ts | 6 +- packages/app/src/hooks/provider-catalog.ts | 8 +- packages/app/src/pages/home.tsx | 12 +- packages/app/src/pages/layout.tsx | 149 ++- packages/app/src/pages/layout/helpers.test.ts | 12 +- packages/app/src/pages/layout/helpers.ts | 13 +- .../app/src/pages/layout/sidebar-items.tsx | 5 +- .../src/pages/layout/sidebar-workspace.tsx | 4 +- packages/app/src/pages/session.tsx | 100 +- .../composer/session-composer-state.test.ts | 12 +- .../composer/session-composer-state.ts | 14 +- .../composer/session-permission-dock.tsx | 2 +- .../composer/session-question-dock.tsx | 21 +- .../session/composer/session-request-tree.ts | 6 +- packages/app/src/pages/session/review-tab.tsx | 14 +- .../session/session-model-helpers.test.ts | 2 +- .../pages/session/session-model-helpers.ts | 2 +- .../src/pages/session/session-side-panel.tsx | 2 +- .../session/timeline/message-timeline.tsx | 78 +- .../src/pages/session/timeline/model.test.ts | 6 +- .../app/src/pages/session/timeline/model.ts | 2 +- .../src/pages/session/timeline/projection.ts | 8 +- .../app/src/pages/session/timeline/rows.ts | 13 +- .../pages/session/timeline/timeline-row.ts | 2 +- .../pages/session/usage-exceeded-dialogs.tsx | 14 +- .../pages/session/use-session-commands.tsx | 87 +- .../pages/session/use-session-hash-scroll.ts | 2 +- .../src/pages/session/v2/review-diff-kinds.ts | 2 +- .../src/pages/session/v2/review-panel-v2.tsx | 14 +- packages/app/src/utils/diffs.test.ts | 3 +- packages/app/src/utils/diffs.ts | 7 +- packages/app/src/utils/prompt.test.ts | 2 +- packages/app/src/utils/prompt.ts | 5 +- packages/app/src/utils/server-errors.test.ts | 2 +- packages/app/src/utils/server-health.test.ts | 88 +- packages/app/src/utils/server-health.ts | 81 +- packages/app/src/utils/server.ts | 25 - .../src/utils/terminal-websocket-url.test.ts | 52 - .../app/src/utils/terminal-websocket-url.ts | 28 - packages/session-ui/package.json | 1 - .../session-ui/src/components/file-media.tsx | 2 +- .../src/components/message-file.test.ts | 2 +- .../session-ui/src/components/message-file.ts | 2 +- .../session-ui/src/components/message-nav.tsx | 2 +- .../src/components/message-part.tsx | 2 +- .../session-ui/src/components/session-diff.ts | 2 +- .../src/components/session-retry.tsx | 2 +- .../src/components/session-review.tsx | 2 +- .../src/components/session-turn.tsx | 12 +- .../timeline-playground.stories.tsx | 2 +- packages/session-ui/src/context/data-types.ts | 212 ++++ packages/session-ui/src/context/data.tsx | 6 +- packages/session-ui/src/pierre/media.ts | 2 +- .../session-review-file-preview-v2.tsx | 2 +- 130 files changed, 5312 insertions(+), 1963 deletions(-) create mode 100644 packages/app/src/context/backend-client.test.ts create mode 100644 packages/app/src/context/backend-client.ts create mode 100644 packages/app/src/context/backend.test-fixture.ts delete mode 100644 packages/app/src/utils/terminal-websocket-url.test.ts delete mode 100644 packages/app/src/utils/terminal-websocket-url.ts create mode 100644 packages/session-ui/src/context/data-types.ts diff --git a/bun.lock b/bun.lock index 7270405a1f..9d186a007c 100644 --- a/bun.lock +++ b/bun.lock @@ -840,7 +840,6 @@ "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/core": "workspace:*", - "@opencode-ai/sdk": "workspace:*", "@opencode-ai/ui": "workspace:*", "@pierre/diffs": "catalog:", "@shikijs/stream": "catalog:", diff --git a/packages/app/src/components/command-palette.ts b/packages/app/src/components/command-palette.ts index b61a61977e..d0a50975a8 100644 --- a/packages/app/src/components/command-palette.ts +++ b/packages/app/src/components/command-palette.ts @@ -169,7 +169,7 @@ export function createCommandPaletteModel(props: { filesOnly?: () => boolean; on const name = store.vcs?.branch ?? getFilename(directory) return `${kind} : ${name || path}` }, - load: (directory) => serverSDK.client.session.list({ directory, roots: true }), + load: async (directory) => (await serverSDK.backend).common.sessions.list({ location: { directory }, roots: true }), untitled: () => language.t("command.session.new"), category: () => language.t("command.category.session"), }) @@ -233,7 +233,7 @@ function createCommandEntry(option: CommandOption, category: string): CommandPal function createSessionEntries(props: { workspaces: () => string[] label: (directory: string) => string - load: (directory: string) => ReturnType + load: (directory: string) => ReturnType["common"]["sessions"]["list"]> untitled: () => string category: () => string }) { @@ -263,7 +263,7 @@ function createSessionEntries(props: { return props .load(directory) .then((result) => - (result.data ?? []) + result.items .filter((session) => !!session?.id) .map((session) => ({ id: session.id, diff --git a/packages/app/src/components/dialog-connect-provider.tsx b/packages/app/src/components/dialog-connect-provider.tsx index 6499642b38..a95cab542f 100644 --- a/packages/app/src/components/dialog-connect-provider.tsx +++ b/packages/app/src/components/dialog-connect-provider.tsx @@ -1,4 +1,4 @@ -import type { ProviderAuthAuthorization, ProviderAuthMethod } from "@opencode-ai/sdk/v2/client" +import type { ProviderAuthorization, ProviderAuthMethod } from "@/context/backend" import { Button } from "@opencode-ai/ui/button" import { useDialog } from "@opencode-ai/ui/context/dialog" import { Dialog } from "@opencode-ai/ui/dialog" @@ -31,6 +31,7 @@ import { popularProviders, useProviders } from "@/hooks/use-providers" import { CustomProviderForm } from "./dialog-custom-provider" const CUSTOM_ID = "_custom" +type AuthMethod = ProviderAuthMethod & { readonly id?: string } export function useProviderConnectController(options: { onBack?: () => void } = {}) { const [store, setStore] = createStore({ selected: undefined as string | undefined }) @@ -176,6 +177,7 @@ function ProviderConnection(props: { const providers = useProviders(props.directory) const alive = { value: true } + const connected = { value: false } const timer = { current: undefined as ReturnType | undefined } onCleanup(() => { @@ -188,7 +190,16 @@ function ProviderConnection(props: { const provider = createMemo( () => providers.all().get(props.provider) ?? serverSync().data.provider.all.get(props.provider)!, ) - const fallback = createMemo(() => [ + const integrationID = () => { + const value = provider() + if (!("integrationID" in value) || typeof value.integrationID !== "string") return props.provider + return value.integrationID + } + const location = () => { + const directory = props.directory?.() + return directory ? { location: { directory } } : {} + } + const fallback = createMemo(() => [ { type: "api" as const, label: language.t("provider.connect.method.apiKey"), @@ -197,19 +208,52 @@ function ProviderConnection(props: { const [auth] = createResource( () => props.provider, async () => { + const backend = await serverSDK().backend + if (backend.version === "v2") { + const capability = backend.capabilities.integrationsV2 + if (!capability) throw new Error("Server does not support provider integrations") + const integration = await capability.get({ ...location(), integrationID: integrationID() }) + if (!alive.value) return fallback() + return ( + integration?.methods.flatMap((method): AuthMethod[] => { + if (method.type === "environment") return [] + if (method.type === "key") return [{ type: "api", label: method.label }] + return [{ type: "oauth", id: method.id, label: method.label, prompts: method.prompts }] + }) ?? fallback() + ) + } + const cached = serverSync().data.provider_auth[props.provider] if (cached) return cached - const res = await serverSDK().client.provider.auth() + const capability = backend.capabilities.providerAuthV1 + if (!capability) throw new Error("Server does not support provider authentication") + const result = await capability.methods(location()) if (!alive.value) return fallback() - serverSync().set("provider_auth", res.data ?? {}) - return res.data?.[props.provider] ?? fallback() + const normalized = Object.fromEntries( + Object.entries(result).map(([id, methods]) => [ + id, + methods.map((method) => ({ + ...method, + prompts: method.prompts?.map((prompt) => + prompt.type === "select" + ? { ...prompt, options: prompt.options.map((option) => ({ ...option })) } + : { ...prompt }, + ), + })), + ]), + ) + serverSync().set("provider_auth", normalized) + return normalized[props.provider] ?? fallback() }, ) const loading = createMemo(() => auth.loading && !serverSync().data.provider_auth[props.provider]) - const methods = createMemo(() => auth.latest ?? serverSync().data.provider_auth[props.provider] ?? fallback()) + const methods = createMemo(() => [ + ...(auth.latest ?? serverSync().data.provider_auth[props.provider] ?? fallback()), + ]) const [store, setStore] = createStore({ methodIndex: undefined as undefined | number, - authorization: undefined as undefined | ProviderAuthAuthorization, + authorization: undefined as undefined | ProviderAuthorization, + attemptID: undefined as string | undefined, promptInputs: undefined as undefined | Record, state: "pending" as undefined | "pending" | "complete" | "error" | "prompt", error: undefined as string | undefined, @@ -221,7 +265,7 @@ function ProviderConnection(props: { | { type: "auth.prompt" } | { type: "auth.inputs"; inputs: Record } | { type: "auth.pending" } - | { type: "auth.complete"; authorization: ProviderAuthAuthorization } + | { type: "auth.complete"; authorization: ProviderAuthorization; attemptID?: string } | { type: "auth.error"; error: string } function dispatch(action: Action) { @@ -230,6 +274,7 @@ function ProviderConnection(props: { if (action.type === "method.select") { draft.methodIndex = action.index draft.authorization = undefined + draft.attemptID = undefined draft.promptInputs = undefined draft.state = undefined draft.error = undefined @@ -238,6 +283,7 @@ function ProviderConnection(props: { if (action.type === "method.reset") { draft.methodIndex = undefined draft.authorization = undefined + draft.attemptID = undefined draft.promptInputs = undefined draft.state = undefined draft.error = undefined @@ -262,6 +308,7 @@ function ProviderConnection(props: { if (action.type === "auth.complete") { draft.state = "complete" draft.authorization = action.authorization + draft.attemptID = action.attemptID draft.error = undefined return } @@ -273,6 +320,16 @@ function ProviderConnection(props: { const method = createMemo(() => (store.methodIndex !== undefined ? methods().at(store.methodIndex!) : undefined)) + onCleanup(() => { + if (!store.attemptID || connected.value) return + void (async () => { + const backend = await serverSDK().backend + await backend.capabilities.integrationsV2 + ?.cancelAttempt({ ...location(), attemptID: store.attemptID! }) + .catch(() => undefined) + })() + }) + const methodLabel = (value?: { type?: string; label?: string }) => { if (!value) return "" if (value.type === "api") return language.t("provider.connect.method.apiKey") @@ -322,15 +379,33 @@ function ProviderConnection(props: { } dispatch({ type: "auth.pending" }) const start = Date.now() - await serverSDK() - .client.provider.oauth.authorize( - { - providerID: props.provider, - method: index, - inputs, - }, - { throwOnError: true }, - ) + const backend = await serverSDK().backend + const request = + backend.version === "v1" + ? (() => { + const capability = backend.capabilities.providerAuthV1 + if (!capability) throw new Error("Server does not support provider authentication") + return capability.authorize({ ...location(), providerID: props.provider, method: index, values: inputs }) + })() + : (() => { + const capability = backend.capabilities.integrationsV2 + if (!capability) throw new Error("Server does not support provider integrations") + if (!method.id) throw new Error("Provider OAuth method is missing an ID") + return capability + .connectOauth({ + ...location(), + integrationID: integrationID(), + methodID: method.id, + values: inputs ?? {}, + }) + .then((attempt) => ({ + url: attempt.url, + method: attempt.mode, + instructions: attempt.instructions, + attemptID: attempt.attemptID, + })) + })() + await request .then((x) => { if (!alive.value) return const elapsed = Date.now() - start @@ -341,11 +416,19 @@ function ProviderConnection(props: { timer.current = setTimeout(() => { timer.current = undefined if (!alive.value) return - dispatch({ type: "auth.complete", authorization: x.data! }) + dispatch({ + type: "auth.complete", + authorization: x, + attemptID: "attemptID" in x && typeof x.attemptID === "string" ? x.attemptID : undefined, + }) }, delay) return } - dispatch({ type: "auth.complete", authorization: x.data! }) + dispatch({ + type: "auth.complete", + authorization: x, + attemptID: "attemptID" in x && typeof x.attemptID === "string" ? x.attemptID : undefined, + }) }) .catch((e) => { if (!alive.value) return @@ -445,7 +528,7 @@ function ProviderConnection(props: {
x.value} current={select()?.options.find((x) => x.value === formStore.value[select()!.key])} onSelect={(value) => { @@ -498,7 +581,10 @@ function ProviderConnection(props: { }) async function complete() { - await serverSDK().client.global.dispose() + const backend = await serverSDK().backend + if (backend.version === "v1") await backend.capabilities.runtimeV1?.disposeAll() + if (backend.version === "v2") await serverSync().refreshProviders() + connected.value = true dialog.close() showToast({ variant: "success", @@ -570,14 +656,20 @@ function ProviderConnection(props: { } setFormStore("error", undefined) - await serverSDK().client.auth.set({ - providerID: props.provider, - auth: { - type: "api", + const backend = await serverSDK().backend + if (backend.version === "v1") { + const capability = backend.capabilities.providerAuthV1 + if (!capability) throw new Error("Server does not support provider authentication") + await capability.setApiKey({ providerID: props.provider, key: apiKey, metadata: store.promptInputs }) + } else { + const capability = backend.capabilities.integrationsV2 + if (!capability) throw new Error("Server does not support provider integrations") + await capability.connectKey({ + ...location(), + integrationID: integrationID(), key: apiKey, - ...(store.promptInputs ? { metadata: store.promptInputs } : {}), - }, - }) + }) + } await complete() } @@ -642,13 +734,20 @@ function ProviderConnection(props: { } setFormStore("error", undefined) - const result = await serverSDK() - .client.provider.oauth.callback({ - providerID: props.provider, - method: store.methodIndex, - code, - }) - .then((value) => (value.error ? { ok: false as const, error: value.error } : { ok: true as const })) + const result = await (async () => { + const backend = await serverSDK().backend + if (backend.version === "v1") { + const capability = backend.capabilities.providerAuthV1 + if (!capability) throw new Error("Server does not support provider authentication") + await capability.callback({ providerID: props.provider, method: store.methodIndex!, code }) + return + } + const capability = backend.capabilities.integrationsV2 + if (!capability) throw new Error("Server does not support provider integrations") + if (!store.attemptID) throw new Error("Provider OAuth attempt is missing") + await capability.completeAttempt({ ...location(), attemptID: store.attemptID, code }) + })() + .then(() => ({ ok: true as const })) .catch((error) => ({ ok: false as const, error })) if (result.ok) { await complete() @@ -695,12 +794,26 @@ function ProviderConnection(props: { onMount(() => { void (async () => { - const result = await serverSDK() - .client.provider.oauth.callback({ - providerID: props.provider, - method: store.methodIndex, - }) - .then((value) => (value.error ? { ok: false as const, error: value.error } : { ok: true as const })) + const result = await (async () => { + const backend = await serverSDK().backend + if (backend.version === "v1") { + const capability = backend.capabilities.providerAuthV1 + if (!capability) throw new Error("Server does not support provider authentication") + await capability.callback({ providerID: props.provider, method: store.methodIndex! }) + return + } + const capability = backend.capabilities.integrationsV2 + if (!capability) throw new Error("Server does not support provider integrations") + if (!store.attemptID) throw new Error("Provider OAuth attempt is missing") + while (alive.value) { + const status = await capability.attemptStatus({ ...location(), attemptID: store.attemptID }) + if (status.status === "complete") return + if (status.status === "failed") throw new Error(status.error ?? "Authorization failed") + if (status.status === "expired") throw new Error("Authorization expired") + await new Promise((resolve) => setTimeout(resolve, 500)) + } + })() + .then(() => ({ ok: true as const })) .catch((error) => ({ ok: false as const, error })) if (!alive.value) return diff --git a/packages/app/src/components/dialog-custom-provider.tsx b/packages/app/src/components/dialog-custom-provider.tsx index fb5aa04260..698e14d95f 100644 --- a/packages/app/src/components/dialog-custom-provider.tsx +++ b/packages/app/src/components/dialog-custom-provider.tsx @@ -118,7 +118,7 @@ export function CustomProviderForm() { const output = validateCustomProvider({ form, t: language.t, - disabledProviders: serverSync().data.config.disabled_providers ?? [], + disabledProviders: [...(serverSync().data.config.disabledProviders ?? [])], existingProviderIDs: new Set(serverSync().data.provider.all.keys()), }) batch(() => { @@ -131,23 +131,26 @@ export function CustomProviderForm() { const saveMutation = useMutation(() => ({ mutationFn: async (result: NonNullable>) => { - const disabledProviders = serverSync().data.config.disabled_providers ?? [] + const disabledProviders = serverSync().data.config.disabledProviders ?? [] const nextDisabled = disabledProviders.filter((id) => id !== result.providerID) + const backend = await serverSDK().backend - if (result.key) { - await serverSDK().client.auth.set({ - providerID: result.providerID, - auth: { - type: "api", - key: result.key, - }, - }) + if (result.key && backend.version === "v1") { + const capability = backend.capabilities.providerAuthV1 + if (!capability) throw new Error("Server does not support provider authentication") + await capability.setApiKey({ providerID: result.providerID, key: result.key }) } await serverSync().updateConfig({ provider: { [result.providerID]: result.config }, - disabled_providers: nextDisabled, + disabledProviders: nextDisabled, }) + if (result.key && backend.version === "v2") { + const capability = backend.capabilities.integrationsV2 + if (!capability) throw new Error("Server does not support provider integrations") + await capability.connectKey({ integrationID: result.providerID, key: result.key }) + await serverSync().refreshProviders() + } return result }, onSuccess: (result) => { diff --git a/packages/app/src/components/dialog-edit-project.tsx b/packages/app/src/components/dialog-edit-project.tsx index b861492af5..22bd139bca 100644 --- a/packages/app/src/components/dialog-edit-project.tsx +++ b/packages/app/src/components/dialog-edit-project.tsx @@ -80,9 +80,11 @@ export function DialogEditProject(props: { project: LocalProject; server: Server const start = store.startup.trim() if (props.project.id && props.project.id !== "global") { - await serverSDK().client.project.update({ + const editing = (await serverSDK().backend).capabilities.projectEditing + if (!editing) throw new Error("Project editing is not supported by this server") + await editing.update({ projectID: props.project.id, - directory: props.project.worktree, + location: { directory: props.project.worktree }, name, icon: { color: store.color || "", override: store.iconOverride || "" }, commands: { start }, diff --git a/packages/app/src/components/dialog-fork.tsx b/packages/app/src/components/dialog-fork.tsx index 601f03084c..b30fd24a14 100644 --- a/packages/app/src/components/dialog-fork.tsx +++ b/packages/app/src/components/dialog-fork.tsx @@ -8,7 +8,7 @@ import { Dialog } from "@opencode-ai/ui/dialog" import { List } from "@opencode-ai/ui/list" import { showToast } from "@/utils/toast" import { extractPromptFromParts } from "@/utils/prompt" -import type { TextPart as SDKTextPart } from "@opencode-ai/sdk/v2/client" +import type { AppPart } from "@/context/backend" import { base64Encode } from "@opencode-ai/core/util/encode" import { useLanguage } from "@/context/language" @@ -42,7 +42,9 @@ export const DialogFork: Component = () => { if (message.role !== "user") continue const parts = sync().data.part[message.id] ?? [] - const textPart = parts.find((x): x is SDKTextPart => x.type === "text" && !x.synthetic && !x.ignored) + const textPart = parts.find( + (x): x is Extract => x.type === "text" && !x.synthetic && !x.ignored, + ) if (!textPart) continue result.push({ @@ -69,15 +71,15 @@ export const DialogFork: Component = () => { const dir = base64Encode(sdk().directory) sdk() - .client.session.fork({ sessionID, messageID: item.id }) + .backend.then((client) => { + const capability = client.capabilities.sessionActionsV1 + if (!capability) throw new Error("Session forking is not supported by this server") + return capability.fork({ location: { directory: sdk().directory }, sessionID, messageID: item.id }) + }) .then((forked) => { - if (!forked.data) { - showToast({ title: language.t("common.requestFailed") }) - return - } dialog.close() - prompt.set(restored, undefined, { dir, id: forked.data.id }) - navigate(`/${dir}/session/${forked.data.id}`) + prompt.set(restored, undefined, { dir, id: forked.id }) + navigate(`/${dir}/session/${forked.id}`) }) .catch((err: unknown) => { const message = err instanceof Error ? err.message : String(err) diff --git a/packages/app/src/components/dialog-select-directory-v2.tsx b/packages/app/src/components/dialog-select-directory-v2.tsx index 69d46ddcb6..91192a331b 100644 --- a/packages/app/src/components/dialog-select-directory-v2.tsx +++ b/packages/app/src/components/dialog-select-directory-v2.tsx @@ -68,9 +68,8 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) { const [fallbackPath] = createResource( () => (missingBase() ? true : undefined), () => - sdk.client.path - .get() - .then((result) => result.data) + sdk.backend + .then((client) => client.capabilities.pathInfo?.get()) .catch(() => undefined), { initialValue: undefined }, ) @@ -83,20 +82,26 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) { fallbackPath()?.home || fallbackPath()?.directory, ) - const search = createDirectorySearch({ sdk, home, base: () => root() || start() }) + const search = createDirectorySearch({ backend: sdk.backend, home, base: () => root() || start() }) const [suggestions] = createResource(input, async (value) => { const typed = cleanPickerInput(value).replace(/\/+$/, "") const current = displayPickerPath(root(), value, home()).replace(/\/+$/, "") if (!typed || typed === current) return { query: value, items: [] } const directories = (await search(value)).map((absolute) => ({ absolute, type: "directory" as const })) if (!policy.includeFiles) return { query: value, items: directories.slice(0, 5) } - const files = await sdk.client.find - .files({ directory: root(), query: pickerFileSearchQuery(root(), value, home()), type: "file", limit: 20 }) - .then((result) => result.data ?? []) + const files = await sdk.backend + .then((client) => + client.common.files.find({ + location: { directory: root() }, + query: pickerFileSearchQuery(root(), value, home()), + type: "file", + limit: 20, + }), + ) .catch(() => []) const results = [ ...directories, - ...files.map((path) => ({ absolute: absoluteTreePath(root(), path), type: "file" as const })), + ...files.map((file) => ({ absolute: file.absolute ?? absoluteTreePath(root(), file.path), type: "file" as const })), ] return { query: value, @@ -115,9 +120,9 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) { existing ?? loads.schedule(`${generation}:${key}`, eager ? "background" : "user", () => { if (!activeTreeNavigation(generation, navigation)) return Promise.resolve(undefined) - return sdk.client.file - .list({ directory: absolute, path: "" }) - .then((result) => result.data ?? []) + return sdk.backend + .then((client) => client.common.files.list({ location: { directory: absolute }, path: "" })) + .then((nodes) => nodes.map((node) => ({ name: node.name ?? node.path, type: node.type }))) .catch(() => undefined) }) listings.set(key, request) diff --git a/packages/app/src/components/dialog-select-directory.tsx b/packages/app/src/components/dialog-select-directory.tsx index 8ba09a9f90..fa1b7292ed 100644 --- a/packages/app/src/components/dialog-select-directory.tsx +++ b/packages/app/src/components/dialog-select-directory.tsx @@ -60,9 +60,8 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) { const [fallbackPath] = createResource( () => (missingBase() ? true : undefined), async () => { - return sdk.client.path - .get() - .then((x) => x.data) + return sdk.backend + .then((client) => client.capabilities.pathInfo?.get()) .catch(() => undefined) }, { initialValue: undefined }, @@ -74,7 +73,7 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) { ) const directories = createDirectorySearch({ - sdk, + backend: sdk.backend, home, base: start, }) diff --git a/packages/app/src/components/directory-picker-domain.test.ts b/packages/app/src/components/directory-picker-domain.test.ts index 5746410610..b88bf364d7 100644 --- a/packages/app/src/components/directory-picker-domain.test.ts +++ b/packages/app/src/components/directory-picker-domain.test.ts @@ -21,6 +21,7 @@ import { pickerRoot, pickerAbsoluteInput, } from "./directory-picker-domain" +import { createAppClient } from "@/context/backend.test-fixture" test("maps server directory entries into Pierre paths", () => { expect( @@ -132,18 +133,20 @@ test("scopes file autocomplete to the current browser root", () => { test("resolves directory autocomplete from the current browser root", async () => { const directories: string[] = [] - const sdk = { - client: { - find: { - files: (input: { directory: string }) => { - directories.push(input.directory) - return Promise.resolve({ data: [] }) + const backend = Promise.resolve( + createAppClient({ + common: { + files: { + find: (input) => { + directories.push(input.location?.directory ?? "") + return Promise.resolve([]) + }, }, }, - }, - } as unknown as Parameters[0]["sdk"] + }), + ) let base = "/repo" - const search = createDirectorySearch({ sdk, home: () => "/home/luke", base: () => base }) + const search = createDirectorySearch({ backend, home: () => "/home/luke", base: () => base }) await search("components") base = "/repo/src" diff --git a/packages/app/src/components/directory-picker-domain.ts b/packages/app/src/components/directory-picker-domain.ts index 9900265962..4e206ac7dc 100644 --- a/packages/app/src/components/directory-picker-domain.ts +++ b/packages/app/src/components/directory-picker-domain.ts @@ -247,7 +247,7 @@ export function nativePickerPath(path: string) { } import { getFilename } from "@opencode-ai/core/util/path" import fuzzysort from "fuzzysort" -import { ServerSDK } from "@/context/server-sdk" +import type { AppClient } from "@/context/backend" export function cleanPickerInput(value: string) { const first = (value ?? "").split(/\r?\n/)[0] ?? "" @@ -321,7 +321,11 @@ export function displayPickerPath(path: string, input: string, home: string) { return pickerTilde(value, home) || value } -export function createDirectorySearch(args: { sdk: ServerSDK; base: () => string | undefined; home: () => string }) { +export function createDirectorySearch(args: { + backend: Promise + base: () => string | undefined + home: () => string +}) { const cache = new Map>>() let current = 0 @@ -342,14 +346,16 @@ export function createDirectorySearch(args: { sdk: ServerSDK; base: () => string const key = trimPickerPath(directory) const existing = cache.get(key) if (existing) return existing - const request = args.sdk.client.file - .list({ directory: key, path: "" }) - .then((result) => result.data ?? []) + const request = args.backend + .then((client) => client.common.files.list({ location: { directory: key }, path: "" })) .catch(() => []) .then((nodes) => nodes .filter((node) => node.type === "directory") - .map((node) => ({ name: node.name, absolute: trimPickerPath(normalizePickerDrive(node.absolute)) })), + .map((node) => ({ + name: node.name ?? getFilename(node.path), + absolute: trimPickerPath(normalizePickerDrive(node.absolute ?? joinPickerPath(key, node.path))), + })), ) cache.set(key, request) return request @@ -371,12 +377,18 @@ export function createDirectorySearch(args: { sdk: ServerSDK; base: () => string const pathInput = raw.startsWith("~") || !!pickerRoot(raw) || raw.includes("/") const query = normalizePickerDrive(input.path) if (!pathInput) { - const results = await args.sdk.client.find - .files({ directory: input.directory, query, type: "directory", limit: 50 }) - .then((result) => result.data ?? []) + const results = await args.backend + .then((client) => + client.common.files.find({ + location: { directory: input.directory }, + query, + type: "directory", + limit: 50, + }), + ) .catch(() => []) if (!active()) return [] - return results.map((path) => joinPickerPath(input.directory, path)).slice(0, 50) + return results.map((item) => item.absolute ?? joinPickerPath(input.directory, item.path)).slice(0, 50) } const segments = query.replace(/^\/+/, "").split("/") const head = segments.slice(0, -1).filter((part) => part && part !== ".") diff --git a/packages/app/src/components/file-tree-v2-model.ts b/packages/app/src/components/file-tree-v2-model.ts index 2127b6800f..ada34261cf 100644 --- a/packages/app/src/components/file-tree-v2-model.ts +++ b/packages/app/src/components/file-tree-v2-model.ts @@ -1,4 +1,4 @@ -import type { FileNode } from "@opencode-ai/sdk/v2" +import type { AppFileNode as FileNode } from "@/context/backend" export type FileTreeV2Model = { children: ReadonlyMap diff --git a/packages/app/src/components/file-tree-v2.tsx b/packages/app/src/components/file-tree-v2.tsx index c712a80443..ce732064cc 100644 --- a/packages/app/src/components/file-tree-v2.tsx +++ b/packages/app/src/components/file-tree-v2.tsx @@ -12,7 +12,7 @@ import { type ParentProps, } from "solid-js" import { Dynamic } from "solid-js/web" -import type { FileNode } from "@opencode-ai/sdk/v2" +import type { AppFileNode as FileNode } from "@/context/backend" import { Icon } from "@opencode-ai/ui/v2/icon" import { pathToFileUrl, withFileDragImage, type Kind } from "@/components/file-tree" import { createVirtualizer, defaultRangeExtractor } from "@tanstack/solid-virtual" diff --git a/packages/app/src/components/file-tree.tsx b/packages/app/src/components/file-tree.tsx index 718e2f3493..555328222a 100644 --- a/packages/app/src/components/file-tree.tsx +++ b/packages/app/src/components/file-tree.tsx @@ -17,7 +17,7 @@ import { type ParentProps, } from "solid-js" import { Dynamic } from "solid-js/web" -import type { FileNode } from "@opencode-ai/sdk/v2" +import type { AppFileNode as FileNode } from "@/context/backend" const MAX_DEPTH = 128 diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index bbb71e8722..02809a6f0f 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -74,7 +74,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 type { ReferenceInfo } from "@opencode-ai/sdk/v2/client" +import type { AppReference as ReferenceInfo } from "@/context/backend" export type PromptInputState = ReturnType @@ -678,7 +678,7 @@ export const PromptInput: Component = (props) => { type: "resource", name: resource.name, uri: resource.uri, - client: resource.client, + client: resource.server, display: resource.name, description: resource.description, mime: resource.mimeType, diff --git a/packages/app/src/components/prompt-input/build-request-parts.test.ts b/packages/app/src/components/prompt-input/build-request-parts.test.ts index ab84cb6eae..38bc486860 100644 --- a/packages/app/src/components/prompt-input/build-request-parts.test.ts +++ b/packages/app/src/components/prompt-input/build-request-parts.test.ts @@ -46,6 +46,8 @@ describe("buildRequestParts", () => { ).toBe(true) expect(result.optimisticParts).toHaveLength(result.requestParts.length) + expect(result.optimisticParts.map((part) => part.id)).toEqual(result.requestParts.map((part) => part.id)) + expect(result.optimisticParts.map((part) => part.type)).toEqual(result.requestParts.map((part) => part.type)) expect(result.optimisticParts.every((part) => part.sessionID === "ses_1" && part.messageID === "msg_1")).toBe(true) }) diff --git a/packages/app/src/components/prompt-input/build-request-parts.ts b/packages/app/src/components/prompt-input/build-request-parts.ts index c5bdd730c4..cd2501122a 100644 --- a/packages/app/src/components/prompt-input/build-request-parts.ts +++ b/packages/app/src/components/prompt-input/build-request-parts.ts @@ -1,12 +1,12 @@ import { getFilename } from "@opencode-ai/core/util/path" -import { type AgentPartInput, type FilePartInput, type Part, type TextPartInput } from "@opencode-ai/sdk/v2/client" +import type { AppPart as Part, PromptPart } from "@/context/backend" import type { FileSelection } from "@/context/file" import { encodeFilePath } from "@/context/file/path" import type { AgentPart, FileAttachmentPart, ImageAttachmentPart, Prompt } from "@/context/prompt" import { Identifier } from "@/utils/id" import { createCommentMetadata, formatCommentNote } from "@/utils/comment-note" -type PromptRequestPart = (TextPartInput | FilePartInput | AgentPartInput) & { id: string } +type PromptRequestPart = PromptPart type ContextFile = { key: string diff --git a/packages/app/src/components/prompt-input/submit.test.ts b/packages/app/src/components/prompt-input/submit.test.ts index ad9fd31db8..d5c33cae40 100644 --- a/packages/app/src/components/prompt-input/submit.test.ts +++ b/packages/app/src/components/prompt-input/submit.test.ts @@ -81,6 +81,34 @@ const clientFor = (directory: string) => { beforeAll(async () => { const rootClient = clientFor("/repo/main") + const backend = Promise.resolve({ + common: { + sessions: { + create: async (input: { location?: { directory?: string } }) => { + const directory = input.location?.directory ?? "/repo/main" + createdSessions.push(directory) + return { + id: `session-${createdSessions.length}`, + projectID: "project", + location: { directory }, + title: `New session ${createdSessions.length}`, + cost: 0, + time: { created: Date.now() }, + } + }, + prompt: async () => undefined, + command: async () => undefined, + interrupt: async () => undefined, + }, + }, + capabilities: { + sessionExtrasV1: { + shell: async (input: { location?: { directory?: string } }) => { + sentShell.push(input.location?.directory ?? "/repo/main") + }, + }, + }, + }) mock.module("@solidjs/router", () => ({ useNavigate: () => () => undefined, @@ -89,13 +117,6 @@ beforeAll(async () => { useSearchParams: () => [search, () => undefined], })) - mock.module("@opencode-ai/sdk/v2/client", () => ({ - createOpencodeClient: (input: { directory: string }) => { - createdClients.push(input.directory) - return clientFor(input.directory) - }, - })) - mock.module("@opencode-ai/ui/toast", () => ({ Toast: { Region: () => null }, showToast: () => 0, @@ -161,6 +182,7 @@ beforeAll(async () => { scope: "local", directory: "/repo/main", client: rootClient, + backend, url: "http://localhost:4096", createClient(opts: any) { return clientFor(opts.directory) @@ -282,7 +304,7 @@ describe("prompt submit worktree selection", () => { selected = "/repo/worktree-b" await submit.handleSubmit(event) - expect(createdClients).toEqual(["/repo/worktree-a", "/repo/worktree-b"]) + expect(createdClients).toEqual([]) expect(createdSessions).toEqual(["/repo/worktree-a", "/repo/worktree-b"]) expect(sentShell).toEqual(["/repo/worktree-a", "/repo/worktree-b"]) expect(syncedDirectories).toEqual(["/repo/worktree-a", "/repo/worktree-a", "/repo/worktree-b", "/repo/worktree-b"]) @@ -441,7 +463,13 @@ describe("prompt submit worktree selection", () => { await submit.handleSubmit(event) - expect(storedSessions["/repo/worktree-a"]).toEqual([{ id: "session-1", title: "New session 1" }]) + expect(storedSessions["/repo/worktree-a"]).toEqual([ + expect.objectContaining({ + id: "session-1", + title: "New session 1", + location: { directory: "/repo/worktree-a" }, + }), + ]) expect(optimisticSeeded).toEqual([true]) }) }) diff --git a/packages/app/src/components/prompt-input/submit.ts b/packages/app/src/components/prompt-input/submit.ts index 3517252e5c..626dcaa9cf 100644 --- a/packages/app/src/components/prompt-input/submit.ts +++ b/packages/app/src/components/prompt-input/submit.ts @@ -1,4 +1,3 @@ -import type { Message, Session } from "@opencode-ai/sdk/v2/client" import { showToast } from "@/utils/toast" import { base64Encode } from "@opencode-ai/core/util/encode" import { Binary } from "@opencode-ai/core/util/binary" @@ -11,7 +10,7 @@ import { useLayout } from "@/context/layout" import { useLocal, type ModelSelection } from "@/context/local" import { usePermission } from "@/context/permission" import { type ContextItem, type ImageAttachmentPart, type Prompt, type usePrompt } from "@/context/prompt" -import { useSDK, type DirectorySDK } from "@/context/sdk" +import { useSDK } from "@/context/sdk" import { useSync, type DirectorySync } from "@/context/sync" import { Identifier } from "@/utils/id" import { Worktree as WorktreeState } from "@/utils/worktree" @@ -20,6 +19,7 @@ import { setCursorPosition } from "./editor-dom" import { formatServerError } from "@/utils/server-errors" import { ScopedKey } from "@/utils/server-scope" import { createPromptSubmissionState } from "./submission-state" +import type { AppClient, AppMessage, AppSession } from "@/context/backend" type PendingPrompt = { abort: AbortController @@ -39,13 +39,14 @@ export type FollowupDraft = { } type FollowupSendInput = { - client: DirectorySDK["client"] + backend: Promise serverSync: ServerSync sync: DirectorySync draft: FollowupDraft messageID?: string optimisticBusy?: boolean before?: () => Promise | boolean + commitRevert?: boolean } const draftText = (prompt: Prompt) => prompt.map((part) => ("content" in part ? part.content : "")).join("") @@ -53,6 +54,8 @@ const draftText = (prompt: Prompt) => prompt.map((part) => ("content" in part ? const draftImages = (prompt: Prompt) => prompt.filter((part): part is ImageAttachmentPart => part.type === "image") export async function sendFollowupDraft(input: FollowupSendInput) { + const backend = await input.backend + const location = { directory: input.draft.sessionDirectory } const text = draftText(input.draft.prompt) const images = draftImages(input.draft.prompt) const setBusy = () => { @@ -81,19 +84,26 @@ export async function sendFollowupDraft(input: FollowupSendInput) { return false } - await input.client.session.command({ + if (input.commitRevert) + await backend.capabilities.sessionExtrasV2?.commitRevert({ location, sessionID: input.draft.sessionID }) + const capability = backend.capabilities.sessionActionsV1 + if (!capability) throw new Error("Commands are not supported by this server") + await capability.command({ + location, sessionID: input.draft.sessionID, + id: input.messageID, command: cmd, arguments: tail.join(" "), agent: input.draft.agent, - model: `${input.draft.model.providerID}/${input.draft.model.modelID}`, - variant: input.draft.variant, - parts: images.map((attachment) => ({ - id: Identifier.ascending("part"), - type: "file" as const, + model: { + providerID: input.draft.model.providerID, + id: input.draft.model.modelID, + variant: input.draft.variant, + }, + files: images.map((attachment) => ({ + uri: attachment.dataUrl, mime: attachment.mime, - url: attachment.dataUrl, - filename: attachment.filename, + name: attachment.filename, })), }) return true @@ -114,7 +124,7 @@ export async function sendFollowupDraft(input: FollowupSendInput) { sessionDirectory: input.draft.sessionDirectory, }) - const message: Message = { + const message: AppMessage = { id: messageID, sessionID: input.draft.sessionID, role: "user", @@ -152,13 +162,22 @@ export async function sendFollowupDraft(input: FollowupSendInput) { return false } - await input.client.session.promptAsync({ + if (input.commitRevert) + await backend.capabilities.sessionExtrasV2?.commitRevert({ location, sessionID: input.draft.sessionID }) + await backend.common.sessions.prompt({ + location, sessionID: input.draft.sessionID, - agent: input.draft.agent, - model: input.draft.model, - messageID, + id: messageID, + text, parts: requestParts, - variant: input.draft.variant, + selection: { + agent: input.draft.agent, + model: { + providerID: input.draft.model.providerID, + id: input.draft.model.modelID, + variant: input.draft.variant, + }, + }, }) return true } catch (err) { @@ -172,7 +191,7 @@ export async function sendFollowupDraft(input: FollowupSendInput) { type PromptSubmitInput = { prompt: ReturnType - info: Accessor<{ id: string } | undefined> + info: Accessor<{ id: string; revert?: { messageID: string } } | undefined> imageAttachments: Accessor commentCount: Accessor autoAccept: Accessor @@ -233,9 +252,9 @@ export function createPromptSubmit(input: PromptSubmitInput) { return Promise.resolve() } return sdk() - .client.session.abort({ - sessionID, - }) + .backend.then((client) => + client.common.sessions.interrupt({ location: { directory: sdk().directory }, sessionID }), + ) .catch(() => {}) } @@ -262,10 +281,10 @@ export function createPromptSubmit(input: PromptSubmitInput) { } } - const seed = (dir: string, info: Session) => { + const seed = (dir: string, info: AppSession) => { serverSync().session.remember(info) const [, setStore] = serverSync().child(dir) - setStore("session", (list: Session[]) => { + setStore("session", (list: AppSession[]) => { const result = Binary.search(list, info.id, (item) => item.id) const next = [...list] if (result.found) { @@ -313,19 +332,18 @@ export function createPromptSubmit(input: PromptSubmitInput) { input.resetHistoryNavigation() const projectDirectory = sdk().directory + const backend = await sdk().backend const isNewSession = !params.id const shouldAutoAccept = isNewSession && input.autoAccept() const worktreeSelection = input.newSessionWorktree?.() || "main" let sessionDirectory = projectDirectory - let client = sdk().client if (isNewSession) { if (worktreeSelection === "create") { - const createdWorktree = await client.worktree - .create({ directory: projectDirectory }) - .then((x) => x.data) - .catch((err) => { + const createdWorktree = await backend.capabilities.worktreesV1 + ?.create({ location: { directory: projectDirectory } }) + .catch((err: unknown) => { showToast({ title: language.t("prompt.toast.worktreeCreateFailed.title"), description: errorMessage(err), @@ -349,10 +367,6 @@ export function createPromptSubmit(input: PromptSubmitInput) { } if (sessionDirectory !== projectDirectory) { - client = sdk().createClient({ - directory: sessionDirectory, - throwOnError: true, - }) serverSync().child(sessionDirectory) } @@ -361,9 +375,10 @@ export function createPromptSubmit(input: PromptSubmitInput) { let session = input.info() if (!session && isNewSession) { - const created = await client.session - .create() - .then((x) => x.data ?? undefined) + const created = await sdk() + .backend.then((backend) => + backend.common.sessions.create({ location: { directory: sessionDirectory } }), + ) .catch((err) => { showToast({ title: language.t("prompt.toast.sessionCreateFailed.title"), @@ -447,12 +462,22 @@ export function createPromptSubmit(input: PromptSubmitInput) { if (mode === "shell") { clearInput() - client.session - .shell({ - sessionID: session.id, - agent, - model, - command: text, + sdk() + .backend.then(async (backend) => { + const location = { directory: sessionDirectory } + if (session.revert) + await backend.capabilities.sessionExtrasV2?.commitRevert({ location, sessionID: session.id }) + if (backend.capabilities.sessionExtrasV1) + return backend.capabilities.sessionExtrasV1.shell({ + location, + sessionID: session.id, + agent, + model: { id: model.modelID, providerID: model.providerID }, + command: text, + }) + if (backend.capabilities.sessionExtrasV2?.shell) + return backend.capabilities.sessionExtrasV2.shell({ location, sessionID: session.id, command: text }) + throw new Error("Shell prompts are not supported by this server") }) .catch((err) => { showToast({ @@ -470,21 +495,27 @@ export function createPromptSubmit(input: PromptSubmitInput) { const customCommand = sync().data.command.find((c) => c.name === commandName) if (customCommand) { clearInput() - client.session - .command({ - sessionID: session.id, - command: commandName, - arguments: args.join(" "), - agent, - model: `${model.providerID}/${model.modelID}`, - variant, - parts: images.map((attachment) => ({ - id: Identifier.ascending("part"), - type: "file" as const, - mime: attachment.mime, - url: attachment.dataUrl, - filename: attachment.filename, - })), + sdk() + .backend.then(async (backend) => { + const location = { directory: sessionDirectory } + if (session.revert) + await backend.capabilities.sessionExtrasV2?.commitRevert({ location, sessionID: session.id }) + const capability = backend.capabilities.sessionActionsV1 + if (!capability) throw new Error("Commands are not supported by this server") + return capability.command({ + location, + sessionID: session.id, + id: Identifier.ascending("message"), + command: commandName, + arguments: args.join(" "), + agent, + model: { id: model.modelID, providerID: model.providerID, variant }, + files: images.map((attachment) => ({ + uri: attachment.dataUrl, + mime: attachment.mime, + name: attachment.filename, + })), + }) }) .catch((err) => { showToast({ @@ -570,12 +601,13 @@ export function createPromptSubmit(input: PromptSubmitInput) { } void sendFollowupDraft({ - client, + backend: sdk().backend, sync: sync(), serverSync: serverSync(), draft, messageID, optimisticBusy: sessionDirectory === projectDirectory, + commitRevert: !!session.revert, before: waitForWorktree, }).catch((err) => { pending.delete(pendingKey(session.id)) diff --git a/packages/app/src/components/session/session-context-breakdown.test.ts b/packages/app/src/components/session/session-context-breakdown.test.ts index f38aecb55d..d03c8d64a2 100644 --- a/packages/app/src/components/session/session-context-breakdown.test.ts +++ b/packages/app/src/components/session/session-context-breakdown.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test" -import type { Message, Part } from "@opencode-ai/sdk/v2/client" +import type { AppMessage as Message, AppPart as Part } from "@/context/backend" import { estimateSessionContextBreakdown } from "./session-context-breakdown" const user = (id: string) => { diff --git a/packages/app/src/components/session/session-context-breakdown.ts b/packages/app/src/components/session/session-context-breakdown.ts index e263b2957b..bad6d3244a 100644 --- a/packages/app/src/components/session/session-context-breakdown.ts +++ b/packages/app/src/components/session/session-context-breakdown.ts @@ -1,4 +1,4 @@ -import type { Message, Part } from "@opencode-ai/sdk/v2/client" +import type { AppMessage, AppPart } from "@/context/backend" export type SessionContextBreakdownKey = "system" | "user" | "assistant" | "tool" | "other" @@ -13,14 +13,14 @@ const estimateTokens = (chars: number) => Math.ceil(chars / 4) const toPercent = (tokens: number, input: number) => (tokens / input) * 100 const toPercentLabel = (tokens: number, input: number) => Math.round(toPercent(tokens, input) * 10) / 10 -const charsFromUserPart = (part: Part) => { +const charsFromUserPart = (part: AppPart) => { if (part.type === "text") return part.text.length if (part.type === "file") return part.source?.text.value.length ?? 0 if (part.type === "agent") return part.source?.value.length ?? 0 return 0 } -const charsFromAssistantPart = (part: Part) => { +const charsFromAssistantPart = (part: AppPart) => { if (part.type === "text") return { assistant: part.text.length, tool: 0 } if (part.type === "reasoning") return { assistant: part.text.length, tool: 0 } if (part.type !== "tool") return { assistant: 0, tool: 0 } @@ -68,8 +68,8 @@ const build = ( } export function estimateSessionContextBreakdown(args: { - messages: Message[] - parts: Record + messages: AppMessage[] + parts: Record input: number systemPrompt?: string }) { diff --git a/packages/app/src/components/session/session-context-metrics.test.ts b/packages/app/src/components/session/session-context-metrics.test.ts index 37cc0d5b5d..551b539ca6 100644 --- a/packages/app/src/components/session/session-context-metrics.test.ts +++ b/packages/app/src/components/session/session-context-metrics.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test" -import type { Message } from "@opencode-ai/sdk/v2/client" +import type { AppMessage as Message } from "@/context/backend" import { getSessionContext } from "./session-context-metrics" const assistant = ( diff --git a/packages/app/src/components/session/session-context-metrics.ts b/packages/app/src/components/session/session-context-metrics.ts index 30dc9e958b..993ff5db43 100644 --- a/packages/app/src/components/session/session-context-metrics.ts +++ b/packages/app/src/components/session/session-context-metrics.ts @@ -1,4 +1,4 @@ -import type { AssistantMessage, Message } from "@opencode-ai/sdk/v2/client" +import type { AppAssistantMessage, AppMessage } from "@/context/backend" type Provider = { id: string @@ -14,7 +14,7 @@ type Model = { } type Context = { - message: AssistantMessage + message: AppAssistantMessage provider?: Provider model?: Model providerLabel: string @@ -25,11 +25,11 @@ type Context = { usage: number | null } -const tokenTotal = (msg: AssistantMessage) => { +const tokenTotal = (msg: AppAssistantMessage) => { return msg.tokens.input + msg.tokens.output + msg.tokens.reasoning + msg.tokens.cache.read + msg.tokens.cache.write } -const lastAssistantWithTokens = (messages: Message[]) => { +const lastAssistantWithTokens = (messages: AppMessage[]) => { for (let i = messages.length - 1; i >= 0; i--) { const msg = messages[i] if (msg.role !== "assistant") continue @@ -38,7 +38,7 @@ const lastAssistantWithTokens = (messages: Message[]) => { } } -const build = (messages: Message[] = [], providers: Provider[] = []): Context | undefined => { +const build = (messages: AppMessage[] = [], providers: Provider[] = []): Context | undefined => { const message = lastAssistantWithTokens(messages) if (!message) return undefined @@ -60,6 +60,6 @@ const build = (messages: Message[] = [], providers: Provider[] = []): Context | } } -export function getSessionContext(messages: Message[] = [], providers: Provider[] = []) { +export function getSessionContext(messages: AppMessage[] = [], providers: Provider[] = []) { return build(messages, providers) } diff --git a/packages/app/src/components/session/session-context-tab.tsx b/packages/app/src/components/session/session-context-tab.tsx index 25ad5ab230..e41bf24bd6 100644 --- a/packages/app/src/components/session/session-context-tab.tsx +++ b/packages/app/src/components/session/session-context-tab.tsx @@ -10,7 +10,7 @@ import { StickyAccordionHeader } from "@opencode-ai/ui/sticky-accordion-header" import { File } from "@opencode-ai/session-ui/file" import { Markdown } from "@opencode-ai/session-ui/markdown" import { ScrollView } from "@opencode-ai/ui/scroll-view" -import type { Message, Part, UserMessage } from "@opencode-ai/sdk/v2/client" +import type { AppMessage as Message, AppPart as Part, AppUserMessage as UserMessage } from "@/context/backend" import { useLanguage } from "@/context/language" import { useProviders } from "@/hooks/use-providers" import { useSDK } from "@/context/sdk" diff --git a/packages/app/src/components/settings-general.tsx b/packages/app/src/components/settings-general.tsx index c424a62a89..9da00b327a 100644 --- a/packages/app/src/components/settings-general.tsx +++ b/packages/app/src/components/settings-general.tsx @@ -126,11 +126,11 @@ export const SettingsGeneral: Component = () => { const serverSdk = useServerSDK() const [shells] = createResource( - () => - serverSdk() - .client.pty.shells() - .then((res) => res.data ?? []) - .catch(() => [] as ShellOption[]), + async () => { + const capability = (await serverSdk().backend).capabilities.shellDiscovery + if (!capability) return [] + return capability.list().catch(() => [] as ShellOption[]) + }, { initialValue: [] as ShellOption[] }, ) diff --git a/packages/app/src/components/settings-providers.tsx b/packages/app/src/components/settings-providers.tsx index 25683cb4f5..7430f8a454 100644 --- a/packages/app/src/components/settings-providers.tsx +++ b/packages/app/src/components/settings-providers.tsx @@ -12,6 +12,7 @@ import { DialogConnectProvider, useProviderConnectController } from "./dialog-co import { DialogCustomProvider } from "./dialog-custom-provider" import { SettingsList } from "./settings-list" import { SettingsServerPicker, SettingsServerScope } from "./settings-server-picker" +import { credentialConnectionIDs } from "@/context/backend" type ProviderSource = "env" | "api" | "config" | "custom" type ProviderItem = ReturnType["connected"]>[number] @@ -96,12 +97,12 @@ const SettingsProvidersContent: Component<{ onBack?: () => void }> = (props) => } const disableProvider = async (providerID: string, name: string) => { - const before = serverSync().data.config.disabled_providers ?? [] + const before = serverSync().data.config.disabledProviders ?? [] const next = before.includes(providerID) ? before : [...before, providerID] - serverSync().set("config", "disabled_providers", next) + serverSync().set("config", "disabledProviders", next) await serverSync() - .updateConfig({ disabled_providers: next }) + .updateConfig({ disabledProviders: next }) .then(() => { showToast({ variant: "success", @@ -111,29 +112,47 @@ const SettingsProvidersContent: Component<{ onBack?: () => void }> = (props) => }) }) .catch((err: unknown) => { - serverSync().set("config", "disabled_providers", before) + serverSync().set("config", "disabledProviders", before) const message = err instanceof Error ? err.message : String(err) showToast({ title: language.t("common.requestFailed"), description: message }) }) } - const disconnect = async (providerID: string, name: string) => { - if (isConfigCustom(providerID)) { - await serverSDK() - .client.auth.remove({ providerID }) - .catch(() => undefined) - await disableProvider(providerID, name) + const disconnect = async (item: ProviderItem) => { + const backend = await serverSDK().backend + const remove = async () => { + if (backend.version === "v1") { + const capability = backend.capabilities.providerAuthV1 + if (!capability) throw new Error("Server does not support provider authentication") + await capability.remove({ providerID: item.id }) + return + } + const capability = backend.capabilities.integrationsV2 + if (!capability) throw new Error("Server does not support provider integrations") + const integrationID = + "integrationID" in item && typeof item.integrationID === "string" ? item.integrationID : item.id + const integration = await capability.get({ integrationID }) + await Promise.all( + credentialConnectionIDs(integration?.connections ?? []).map((credentialID) => + capability.removeCredential({ credentialID }), + ), + ) + } + + if (isConfigCustom(item.id)) { + await remove().catch(() => undefined) + await disableProvider(item.id, item.name) return } - await serverSDK() - .client.auth.remove({ providerID }) + await remove() .then(async () => { - await serverSDK().client.global.dispose() + if (backend.version === "v1") await backend.capabilities.runtimeV1?.disposeAll() + if (backend.version === "v2") await serverSync().refreshProviders() showToast({ variant: "success", icon: "circle-check", - title: language.t("provider.disconnect.toast.disconnected.title", { provider: name }), - description: language.t("provider.disconnect.toast.disconnected.description", { provider: name }), + title: language.t("provider.disconnect.toast.disconnected.title", { provider: item.name }), + description: language.t("provider.disconnect.toast.disconnected.description", { provider: item.name }), }) }) .catch((err: unknown) => { @@ -179,7 +198,7 @@ const SettingsProvidersContent: Component<{ onBack?: () => void }> = (props) => } > - diff --git a/packages/app/src/components/settings-v2/general.tsx b/packages/app/src/components/settings-v2/general.tsx index 69dfb77fb0..a28624d7f3 100644 --- a/packages/app/src/components/settings-v2/general.tsx +++ b/packages/app/src/components/settings-v2/general.tsx @@ -121,11 +121,11 @@ export const SettingsGeneralV2: Component<{ const themeOptions = createMemo(() => theme.ids().map((id) => ({ id, name: theme.name(id) }))) const [shells] = createResource( - () => - serverSdk() - .client.pty.shells() - .then((res) => res.data ?? []) - .catch(() => [] as ShellOption[]), + async () => { + const capability = (await serverSdk().backend).capabilities.shellDiscovery + if (!capability) return [] + return capability.list().catch(() => [] as ShellOption[]) + }, { initialValue: [] as ShellOption[] }, ) diff --git a/packages/app/src/components/settings-v2/providers.tsx b/packages/app/src/components/settings-v2/providers.tsx index 65680654f5..28b9d40cd7 100644 --- a/packages/app/src/components/settings-v2/providers.tsx +++ b/packages/app/src/components/settings-v2/providers.tsx @@ -12,6 +12,7 @@ import { DialogConnectProvider, useProviderConnectController } from "../dialog-c import { DialogCustomProvider } from "../dialog-custom-provider" import { SettingsListV2 } from "./parts/list" import "./settings-v2.css" +import { credentialConnectionIDs } from "@/context/backend" type ProviderSource = "env" | "api" | "config" | "custom" type ProviderItem = ReturnType["connected"]>[number] @@ -90,12 +91,12 @@ export const SettingsProvidersV2: Component<{ onBack?: () => void }> = (props) = } const disableProvider = async (providerID: string, name: string) => { - const before = serverSync().data.config.disabled_providers ?? [] + const before = serverSync().data.config.disabledProviders ?? [] const next = before.includes(providerID) ? before : [...before, providerID] - serverSync().set("config", "disabled_providers", next) + serverSync().set("config", "disabledProviders", next) await serverSync() - .updateConfig({ disabled_providers: next }) + .updateConfig({ disabledProviders: next }) .then(() => { showToast({ variant: "success", @@ -105,29 +106,47 @@ export const SettingsProvidersV2: Component<{ onBack?: () => void }> = (props) = }) }) .catch((err: unknown) => { - serverSync().set("config", "disabled_providers", before) + serverSync().set("config", "disabledProviders", before) const message = err instanceof Error ? err.message : String(err) showToast({ title: language.t("common.requestFailed"), description: message }) }) } - const disconnect = async (providerID: string, name: string) => { - if (isConfigCustom(providerID)) { - await serverSdk() - .client.auth.remove({ providerID }) - .catch(() => undefined) - await disableProvider(providerID, name) + const disconnect = async (item: ProviderItem) => { + const backend = await serverSdk().backend + const remove = async () => { + if (backend.version === "v1") { + const capability = backend.capabilities.providerAuthV1 + if (!capability) throw new Error("Server does not support provider authentication") + await capability.remove({ providerID: item.id }) + return + } + const capability = backend.capabilities.integrationsV2 + if (!capability) throw new Error("Server does not support provider integrations") + const integrationID = + "integrationID" in item && typeof item.integrationID === "string" ? item.integrationID : item.id + const integration = await capability.get({ integrationID }) + await Promise.all( + credentialConnectionIDs(integration?.connections ?? []).map((credentialID) => + capability.removeCredential({ credentialID }), + ), + ) + } + + if (isConfigCustom(item.id)) { + await remove().catch(() => undefined) + await disableProvider(item.id, item.name) return } - await serverSdk() - .client.auth.remove({ providerID }) + await remove() .then(async () => { - await serverSdk().client.global.dispose() + if (backend.version === "v1") await backend.capabilities.runtimeV1?.disposeAll() + if (backend.version === "v2") await serverSync().refreshProviders() showToast({ variant: "success", icon: "circle-check", - title: language.t("provider.disconnect.toast.disconnected.title", { provider: name }), - description: language.t("provider.disconnect.toast.disconnected.description", { provider: name }), + title: language.t("provider.disconnect.toast.disconnected.title", { provider: item.name }), + description: language.t("provider.disconnect.toast.disconnected.description", { provider: item.name }), }) }) .catch((err: unknown) => { @@ -175,7 +194,7 @@ export const SettingsProvidersV2: Component<{ onBack?: () => void }> = (props) = } > - void disconnect(item.id, item.name)}> + void disconnect(item)}> {language.t("common.disconnect")} diff --git a/packages/app/src/components/settings-v2/servers.tsx b/packages/app/src/components/settings-v2/servers.tsx index b93f700d24..2972da1f4d 100644 --- a/packages/app/src/components/settings-v2/servers.tsx +++ b/packages/app/src/components/settings-v2/servers.tsx @@ -110,8 +110,8 @@ export const SettingsServersV2: Component = () => {
{serverName(item)} - v{health()?.version} - • + v{health()?.installationVersion} + • {language.t("server.row.noUsername")}} diff --git a/packages/app/src/components/terminal.tsx b/packages/app/src/components/terminal.tsx index c5faf85bff..8efe95488a 100644 --- a/packages/app/src/components/terminal.tsx +++ b/packages/app/src/components/terminal.tsx @@ -11,12 +11,10 @@ import { matchKeybind, parseKeybind } from "@/context/command" import { useLanguage } from "@/context/language" import { usePlatform } from "@/context/platform" import { useSDK } from "@/context/sdk" -import { useServerSDK } from "@/context/server-sdk" import { terminalFontFamily, useSettings } from "@/context/settings" import type { LocalPTY } from "@/context/terminal" import { disposeIfDisposable, getHoveredLinkText, setOptionIfSupported } from "@/utils/runtime-adapters" import { terminalWriter } from "@/utils/terminal-writer" -import { terminalWebSocketURL } from "@/utils/terminal-websocket-url" const TOGGLE_TERMINAL_ID = "terminal.toggle" const DEFAULT_TOGGLE_TERMINAL_KEYBIND = "ctrl+`" @@ -174,16 +172,8 @@ export const Terminal = (props: TerminalProps) => { const settings = useSettings() const theme = useTheme() const language = useLanguage() - // Terminal captures its connection for the PTY lifetime, so callers must key it per server/session. - const connection = useServerSDK()().server const directory = sdk().directory - const client = sdk().client - const url = sdk().url - const auth = connection.http - const username = auth?.username ?? "opencode" - const password = auth?.password ?? "" - const authToken = connection.type === "http" ? connection.authToken : false - const sameOrigin = new URL(url, location.href).origin === location.origin + const backend = sdk().backend let container!: HTMLDivElement const [local, others] = splitProps(props, ["pty", "class", "classList", "autoFocus", "onConnect", "onConnectError"]) const id = local.pty.id @@ -233,11 +223,14 @@ export const Terminal = (props: TerminalProps) => { } const pushSize = (cols: number, rows: number) => { - return client.pty - .update({ - ptyID: id, - size: { cols, rows }, - }) + return backend + .then((client) => + client.common.pty.update({ + ptyID: id, + size: { cols, rows }, + location: { directory }, + }), + ) .catch((err) => { debugTerminal("failed to sync terminal size", err) }) @@ -490,33 +483,32 @@ export const Terminal = (props: TerminalProps) => { } const gone = () => - client.pty - .get({ ptyID: id }, { throwOnError: false }) - .then((result) => result.response.status === 404) + backend + .then((client) => { + const transport = client.capabilities.ptyTransport + if (transport) return transport.exists({ ptyID: id, location: { directory } }).then((exists) => !exists) + return client.common.pty.get({ ptyID: id, location: { directory } }).then(() => false) + }) .catch((err) => { debugTerminal("failed to inspect terminal session", err) return false }) const connectToken = async () => { - const result = await client.pty - .connectToken( - { ptyID: id, directory }, - { - throwOnError: false, - headers: { "x-opencode-ticket": "1" }, - }, - ) + const transport = (await backend).capabilities.ptyTransport + if (!transport) return + const result = await transport + .connectToken({ ptyID: id, location: { directory } }) .catch((err: unknown) => { if (err instanceof Error && err.message.includes("Request is not supported")) return throw err }) if (!result) return - if (result.response.status === 200 && result.data?.ticket) return result.data.ticket - if (result.response.status === 404 || result.response.status === 405) return - if (result.response.status === 403) + if (result.status === 200 && result.ticket) return result.ticket + if (result.status === 404 || result.status === 405) return + if (result.status === 403) throw new Error("PTY connect ticket rejected by origin or CSRF checks. Check the server CORS config.") - throw new Error(`PTY connect ticket failed with ${result.response.status}`) + throw new Error(`PTY connect ticket failed with ${result.status}`) } const retry = (err: unknown) => { @@ -549,18 +541,13 @@ export const Terminal = (props: TerminalProps) => { if (once.value) return if (disposed) return + const transport = (await backend).capabilities.ptyTransport + if (!transport) { + fail(new Error("PTY transport is not supported by this server")) + return + } const socket = new WebSocket( - terminalWebSocketURL({ - url, - id, - directory, - cursor: seek, - ticket, - sameOrigin, - username, - password, - authToken, - }), + transport.connectURL({ ptyID: id, location: { directory }, cursor: seek, ticket }), ) socket.binaryType = "arraybuffer" ws = socket diff --git a/packages/app/src/components/titlebar-tab-nav.tsx b/packages/app/src/components/titlebar-tab-nav.tsx index a397046f9b..613c721ba0 100644 --- a/packages/app/src/components/titlebar-tab-nav.tsx +++ b/packages/app/src/components/titlebar-tab-nav.tsx @@ -9,7 +9,7 @@ import { ServerConnection, serverName } from "@/context/server" import { displayName, projectForSession } from "@/pages/layout/helpers" import { SessionTabAvatar } from "@/pages/layout/session-tab-avatar" import { showToast } from "@/utils/toast" -import type { Session } from "@opencode-ai/sdk/v2" +import type { AppSession } from "@/context/backend" import { canOpenTabRename, forwardTabRef } from "./titlebar-tab-gesture" import { TabPreviewPopover } from "./titlebar-tab-popover" import "./titlebar-tab-nav.css" @@ -21,7 +21,7 @@ export function TabNavItem(props: { ref?: Ref href: string server: ServerConnection.Key - session: () => Session | undefined + session: () => AppSession | undefined fallbackTitle?: string onTitleChange?: (title: string) => void onTitleChangeFailed?: (title: string) => void @@ -120,8 +120,10 @@ export function TabNavItem(props: { const ctx = serverCtx() const session = props.session() if (!ctx || !session) return - const client = ctx.sdk.createClient({ directory: session.directory, throwOnError: true }) - await client.session.update({ sessionID: session.id, title }) + const client = await ctx.sdk.backend + const capability = client.capabilities.sessionActionsV1 + if (!capability) throw new Error("Session renaming is not supported by this server") + await capability.rename({ location: { directory: session.directory }, sessionID: session.id, title }) } const closeRename = async (save: boolean) => { diff --git a/packages/app/src/components/titlebar.tsx b/packages/app/src/components/titlebar.tsx index 994d69a60f..589d4c50d0 100644 --- a/packages/app/src/components/titlebar.tsx +++ b/packages/app/src/components/titlebar.tsx @@ -267,9 +267,8 @@ export function Titlebar(props: { update?: TitlebarUpdate }) { return conn ? { route, sdk: global.ensureServerCtx(conn).sdk } : undefined }, ({ route, sdk }) => - sdk.client.session - .get({ sessionID: route.sessionId }) - .then((x) => x.data) + sdk.backend + .then((client) => client.common.sessions.get({ sessionID: route.sessionId })) .catch(() => {}), ) diff --git a/packages/app/src/context/backend-client.test.ts b/packages/app/src/context/backend-client.test.ts new file mode 100644 index 0000000000..6e50226c0f --- /dev/null +++ b/packages/app/src/context/backend-client.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, test } from "bun:test" +import type { ServerHealth } from "@/utils/server-health" +import { backendIdentity, createBackendForServer } from "./backend-client" + +const server = { + type: "http" as const, + http: { + url: "http://localhost:4096", + username: "user", + password: "secret", + }, + authToken: false, +} + +function setup(health: ServerHealth) { + const requests: Request[] = [] + const fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const request = input instanceof Request ? input : new Request(input, init) + requests.push(request) + if (new URL(request.url).pathname === "/project") + return new Response("[]", { headers: { "content-type": "application/json" } }) + return new Response(JSON.stringify({ healthy: true, version: "1.2.3" }), { + headers: { "content-type": "application/json" }, + }) + }) as typeof globalThis.fetch + return { + requests, + fetch, + backend: createBackendForServer({ server, browserUrl: "https://app.example.test", fetch, health: Promise.resolve(health) }), + } +} + +describe("createBackendForServer", () => { + test("changes backend identity when credentials for the same URL change", () => { + expect(backendIdentity(server)).not.toBe( + backendIdentity({ ...server, http: { ...server.http, password: "replacement" } }), + ) + }) + + test("selects v2 and configures fetch and authentication", async () => { + const result = setup({ healthy: true, version: "v2" }) + const backend = await result.backend + + expect(backend.version).toBe("v2") + expect(result.requests).toHaveLength(0) + await backend.common.health.get() + expect(new URL(result.requests[0].url).pathname).toBe("/api/health") + expect(result.requests[0].headers.get("authorization")).toBe(`Basic ${btoa("user:secret")}`) + + expect(backend.capabilities.projectList).toBeUndefined() + expect(backend.capabilities.vcs).toBeUndefined() + expect(backend.capabilities.mcp).toBeUndefined() + expect(result.requests).toHaveLength(1) + expect(backend.version).toBe("v2") + }) + + test("selects v1 after fallback detection", async () => { + const result = setup({ healthy: true, version: "v1", installationVersion: "1.2.3" }) + const backend = await result.backend + + expect(backend.version).toBe("v1") + expect(result.requests).toHaveLength(0) + await backend.common.health.get() + expect(new URL(result.requests[0].url).pathname).toBe("/global/health") + expect(result.requests[0].headers.get("authorization")).toBe(`Basic ${btoa("user:secret")}`) + expect(backend.capabilities.projectList).toBeDefined() + expect(backend.capabilities.vcs).toBeDefined() + expect(backend.capabilities.mcp).toBeDefined() + }) +}) diff --git a/packages/app/src/context/backend-client.ts b/packages/app/src/context/backend-client.ts new file mode 100644 index 0000000000..69c46f82ed --- /dev/null +++ b/packages/app/src/context/backend-client.ts @@ -0,0 +1,64 @@ +import { OpenCode } from "@opencode-ai/client" +import { createOpencodeClient } from "@opencode-ai/sdk-v1/v2/client" +import type { ServerHealth } from "@/utils/server-health" +import { authTokenFromCredentials } from "@/utils/server" +import type { ServerConnection } from "./server" +import type { LocationRef } from "./backend" +import { createV1Backend } from "./backend-v1" +import { createV2Backend } from "./backend-v2" + +function options(server: ServerConnection.HttpBase, fetch: typeof globalThis.fetch) { + return { + baseUrl: server.url, + fetch, + headers: server.password + ? { + Authorization: `Basic ${authTokenFromCredentials({ username: server.username, password: server.password })}`, + } + : undefined, + } +} + +export function createV1RawClient(server: ServerConnection.HttpBase, fetch: typeof globalThis.fetch) { + return createOpencodeClient(options(server, fetch)) +} + +export function createV2RawClient(server: ServerConnection.HttpBase, fetch: typeof globalThis.fetch) { + return OpenCode.make(options(server, fetch)) +} + +export function backendIdentity(server: ServerConnection.Any) { + return `${server.type}\n${server.http.url}\n${server.http.username ?? ""}\n${server.http.password ?? ""}\n${ + server.type === "http" && server.authToken === true ? "token" : "" + }` +} + +export async function createBackendForServer(input: { + server: ServerConnection.Any + browserUrl: string + fetch: typeof globalThis.fetch + eventFetch?: typeof globalThis.fetch + health: Promise + defaultLocation?: LocationRef +}) { + const health = await input.health + const eventFetch = input.eventFetch ?? input.fetch + const transport = { + baseUrl: input.server.http.url, + fetch: input.fetch, + username: input.server.http.username, + password: input.server.http.password, + sameOrigin: new URL(input.server.http.url, input.browserUrl).origin === new URL(input.browserUrl).origin, + authToken: input.server.type === "http" && input.server.authToken === true, + } + if (health.version === "v2") + return createV2Backend( + createV2RawClient(input.server.http, input.fetch), + transport, + input.defaultLocation, + createV2RawClient(input.server.http, eventFetch), + ) + const legacy = createV1RawClient(input.server.http, input.fetch) + const eventLegacy = eventFetch === input.fetch ? legacy : createV1RawClient(input.server.http, eventFetch) + return createV1Backend(legacy, input.defaultLocation, eventLegacy, transport) +} diff --git a/packages/app/src/context/backend-v1.test.ts b/packages/app/src/context/backend-v1.test.ts index daabb15a99..22b40c6e71 100644 --- a/packages/app/src/context/backend-v1.test.ts +++ b/packages/app/src/context/backend-v1.test.ts @@ -1,8 +1,13 @@ import { describe, expect, test } from "bun:test" import { createOpencodeClient } from "@opencode-ai/sdk-v1/v2/client" +import type { PtyTransportConfig } from "./backend" import { createV1Backend } from "./backend-v1" -function setup(respond: (request: Request) => Response | Promise) { +function setup( + respond: (request: Request) => Response | Promise, + withDefault = false, + transport?: Partial>, +) { const requests: Request[] = [] const fetch = Object.assign( async (input: RequestInfo | URL, init?: RequestInit) => { @@ -12,9 +17,22 @@ function setup(respond: (request: Request) => Response | Promise) { }, { preconnect: globalThis.fetch.preconnect }, ) satisfies typeof globalThis.fetch + const client = createOpencodeClient({ baseUrl: "http://localhost", fetch }) return { requests, - backend: createV1Backend(createOpencodeClient({ baseUrl: "http://localhost", fetch })), + backend: createV1Backend( + client, + withDefault ? { directory: "/default", workspaceID: "default-workspace" } : undefined, + client, + { + baseUrl: "http://localhost", + fetch, + username: "user", + password: "secret", + sameOrigin: transport?.sameOrigin ?? false, + authToken: transport?.authToken ?? false, + }, + ), } } @@ -35,6 +53,21 @@ const session = { } describe("createV1Backend", () => { + test("preserves location in OAuth callbacks", async () => { + const setupResult = setup(() => json({})) + + await setupResult.backend.capabilities.providerAuthV1?.callback({ + providerID: "provider", + method: 1, + code: "code", + location: { directory: "/repo", workspaceID: "workspace" }, + }) + + const url = new URL(setupResult.requests[0].url) + expect(url.searchParams.get("directory")).toBe("/repo") + expect(url.searchParams.get("workspace")).toBe("workspace") + }) + test("normalizes session pagination and location", async () => { const setupResult = setup(() => json([session], { "x-next-cursor": "456" })) @@ -49,9 +82,13 @@ describe("createV1Backend", () => { items: [ { id: "ses_1", + slug: "one", + version: "1", parentID: undefined, projectID: "project", location: { directory: "/repo", workspaceID: undefined }, + directory: "/repo", + workspaceID: undefined, title: "Session", cost: 0, tokens: undefined, @@ -60,7 +97,7 @@ describe("createV1Backend", () => { revert: undefined, }, ], - next: "456", + older: "456", }) const url = new URL(setupResult.requests[0].url) expect(url.pathname).toBe("/experimental/session") @@ -75,6 +112,7 @@ describe("createV1Backend", () => { await setupResult.backend.common.sessions.prompt({ sessionID: "ses_1", + location: { directory: "/explicit", workspaceID: "explicit-workspace" }, id: "msg_1", text: "hello", selection: { @@ -87,6 +125,8 @@ describe("createV1Backend", () => { const request = setupResult.requests[0] expect(new URL(request.url).pathname).toBe("/session/ses_1/prompt_async") + expect(new URL(request.url).searchParams.get("directory")).toBe("/explicit") + expect(new URL(request.url).searchParams.get("workspace")).toBe("explicit-workspace") expect(await request.json()).toEqual({ messageID: "msg_1", model: { providerID: "provider", modelID: "model" }, @@ -100,6 +140,29 @@ describe("createV1Backend", () => { }) }) + test("preserves ordered prompt part IDs and metadata", async () => { + const setupResult = setup(() => new Response(null, { status: 204 })) + + await setupResult.backend.common.sessions.prompt({ + sessionID: "ses_1", + id: "msg_1", + text: "visible", + parts: [ + { id: "part_text", type: "text", text: "visible" }, + { id: "part_note", type: "text", text: "note", synthetic: true, metadata: { source: "review" } }, + { id: "part_file", type: "file", mime: "text/plain", url: "file:///repo/a.ts", filename: "a.ts" }, + { id: "part_agent", type: "agent", name: "build", source: { value: "@build", start: 7, end: 13 } }, + ], + }) + + expect((await setupResult.requests[0].json()).parts).toEqual([ + { id: "part_text", type: "text", text: "visible" }, + { id: "part_note", type: "text", text: "note", synthetic: true, metadata: { source: "review" } }, + { id: "part_file", type: "file", mime: "text/plain", url: "file:///repo/a.ts", filename: "a.ts" }, + { id: "part_agent", type: "agent", name: "build", source: { value: "@build", start: 7, end: 13 } }, + ]) + }) + test("combines mixed file search and decodes binary content", async () => { const setupResult = setup((request) => { const url = new URL(request.url) @@ -122,6 +185,139 @@ describe("createV1Backend", () => { expect(content.mimeType).toBe("application/octet-stream") }) + test("preserves project, provider, and file metadata", async () => { + const setupResult = setup((request) => { + const path = new URL(request.url).pathname + if (path === "/project") + return json([ + { id: "project", worktree: "/repo", vcs: "git", time: { created: 1, initialized: 2 }, sandboxes: [] }, + ]) + if (path === "/provider") + return json({ + all: [{ id: "provider", name: "Provider", source: "config", env: [], options: {}, models: {} }], + connected: [], + default: {}, + }) + return json([{ name: "a.txt", path: "a.txt", absolute: "/repo/a.txt", type: "file", ignored: false }]) + }) + + const projectList = setupResult.backend.capabilities.projectList + if (!projectList) throw new Error("Missing project list capability") + const projects = await projectList.list() + const providers = await setupResult.backend.common.catalog.providers() + const files = await setupResult.backend.common.files.list({}) + + expect(projects[0]).toMatchObject({ vcs: "git", time: { created: 1, initialized: 2 } }) + expect(providers.providers.get("provider")?.source).toBe("config") + expect(files).toEqual([{ name: "a.txt", path: "a.txt", absolute: "/repo/a.txt", type: "file", ignored: false }]) + }) + + test("uses explicit session locations and preserves mutation confirmations", async () => { + const setupResult = setup((request) => { + const path = new URL(request.url).pathname + if (request.method === "DELETE" || path.startsWith("/experimental/worktree")) return json(true) + if (request.method === "GET" && path === "/session/ses_1/message") return json([], { "x-next-cursor": "older" }) + return json(session) + }, true) + + const history = await setupResult.backend.common.sessions.history({ + sessionID: "ses_1", + location: { directory: "/explicit", workspaceID: "explicit-workspace" }, + }) + const removed = await setupResult.backend.capabilities.sessionActionsV1?.remove({ sessionID: "ses_1" }) + const reverted = await setupResult.backend.capabilities.sessionExtrasV1?.revert({ + sessionID: "ses_1", + messageID: "msg_1", + }) + const cleared = await setupResult.backend.capabilities.sessionExtrasV1?.clearRevert({ sessionID: "ses_1" }) + const worktreeRemoved = await setupResult.backend.capabilities.worktreesV1?.remove({ directory: "/copy" }) + const worktreeReset = await setupResult.backend.capabilities.worktreesV1?.reset({ directory: "/copy" }) + + expect(history.older).toBe("older") + expect(removed).toBe(true) + expect(reverted?.id).toBe("ses_1") + expect(cleared?.id).toBe("ses_1") + expect(worktreeRemoved).toBe(true) + expect(worktreeReset).toBe(true) + const urls = setupResult.requests.map((request) => new URL(request.url)) + expect(urls[0].searchParams.get("directory")).toBe("/explicit") + expect(urls[1].searchParams.get("directory")).toBe("/default") + }) + + test("normalizes idle and compatibility events", async () => { + const events = [ + { type: "session.status", properties: { sessionID: "ses_1", status: { type: "idle" } } }, + { + type: "todo.updated", + properties: { sessionID: "ses_1", todos: [{ content: "Ship", status: "pending", priority: "high" }] }, + }, + { + type: "message.part.delta", + properties: { sessionID: "ses_1", messageID: "msg_1", partID: "part_1", field: "text", delta: "hi" }, + }, + { type: "message.part.removed", properties: { sessionID: "ses_1", messageID: "msg_1", partID: "part_1" } }, + { type: "worktree.ready", properties: { name: "copy", branch: "copy" } }, + { type: "lsp.updated", properties: {} }, + { type: "reference.updated", properties: {} }, + { type: "mcp.tools.changed", properties: { server: "docs" } }, + { type: "server.instance.disposed", properties: { directory: "/repo" } }, + ] + const setupResult = setup( + () => + new Response(events.map((payload) => `data: ${JSON.stringify({ directory: "/repo", payload })}\n\n`).join(""), { + headers: { "content-type": "text/event-stream" }, + }), + ) + const iterator = setupResult.backend.common.events.subscribe()[Symbol.asyncIterator]() + const result = await Promise.all(events.map(() => iterator.next())) + + expect(result.map((item) => item.value?.event.type)).toEqual([ + "session.activity", + "todo.updated", + "timeline.delta", + "timeline.part.removed", + "worktree.ready", + "lsp.updated", + "reference.updated", + "mcp.updated", + "instance.disposed", + ]) + expect(result[0].value?.event).toEqual({ type: "session.activity", sessionID: "ses_1", activity: { type: "idle" } }) + expect(result[1].value?.event).toMatchObject({ todos: [{ priority: "high" }] }) + }) + + test("updates the V1 projection cache for deltas and removals", async () => { + const info = { + id: "msg_1", + sessionID: "ses_1", + role: "user", + time: { created: 1 }, + agent: "build", + model: { providerID: "p", modelID: "m" }, + } + const part = { id: "part_1", sessionID: "ses_1", messageID: "msg_1", type: "text", text: "a" } + const events = [ + { type: "message.updated", properties: { info } }, + { type: "message.part.updated", properties: { sessionID: "ses_1", part } }, + { type: "message.part.delta", properties: { sessionID: "ses_1", messageID: "msg_1", partID: "part_1", field: "text", delta: "b" } }, + { type: "message.updated", properties: { info } }, + { type: "message.part.removed", properties: { sessionID: "ses_1", messageID: "msg_1", partID: "part_1" } }, + { type: "message.updated", properties: { info } }, + ] + const setupResult = setup(() => new Response(events.map((payload) => `data: ${JSON.stringify({ directory: "/repo", payload })}\n\n`).join(""), { headers: { "content-type": "text/event-stream" } })) + const iterator = setupResult.backend.common.events.subscribe()[Symbol.asyncIterator]() + await iterator.next() + await iterator.next() + await iterator.next() + const afterDelta = await iterator.next() + await iterator.next() + const afterRemoval = await iterator.next() + + expect(afterDelta.value?.event).toMatchObject({ item: { content: [{ id: "part_1", text: "ab" }] } }) + expect(afterRemoval.value?.event).toMatchObject({ item: { content: [] } }) + expect(setupResult.requests).toHaveLength(1) + }) + test("merges global config updates with untouched fields", async () => { const bodies: unknown[] = [] const setupResult = setup(async (request) => { @@ -137,4 +333,75 @@ describe("createV1Backend", () => { expect(bodies).toEqual([{ autoupdate: true, model: "new", disabled_providers: ["two"] }]) }) + + test("uses legacy PTY endpoints, location queries, status, tickets, and auth fallback", async () => { + const setupResult = setup((request) => { + const path = new URL(request.url).pathname + if (path.endsWith("/connect-token")) return new Response(null, { status: 405 }) + if (request.method === "GET") return new Response(null, { status: 404 }) + return new Response(null, { status: 204 }) + }) + + await setupResult.backend.common.permissions.reply({ + sessionID: "ses_1", + requestID: "per_1", + reply: "once", + location: { directory: "/explicit", workspaceID: "workspace" }, + }) + await setupResult.backend.common.questions.reject({ + sessionID: "ses_1", + requestID: "que_1", + location: { directory: "/explicit", workspaceID: "workspace" }, + }) + const transport = setupResult.backend.capabilities.ptyTransport + const ticket = await transport?.connectToken({ + ptyID: "pty_1", + location: { directory: "/explicit", workspaceID: "workspace" }, + }) + const exists = await transport?.exists({ + ptyID: "pty_1", + location: { directory: "/explicit", workspaceID: "workspace" }, + }) + + expect(ticket).toEqual({ status: 405, ticket: undefined }) + expect(exists).toBe(false) + expect(setupResult.requests.map((request) => new URL(request.url).searchParams.get("directory"))).toEqual([ + "/explicit", + "/explicit", + "/explicit", + "/explicit", + ]) + expect(setupResult.requests[2].headers.get("x-opencode-ticket")).toBe("1") + + const fallback = transport?.connectURL({ + ptyID: "pty/1", + location: { directory: "/explicit", workspaceID: "workspace" }, + cursor: 12, + }) + expect(fallback?.pathname).toBe("/pty/pty%2F1/connect") + expect(fallback?.protocol).toBe("ws:") + expect(fallback?.searchParams.get("directory")).toBe("/explicit") + expect(fallback?.searchParams.get("workspace")).toBe("workspace") + expect(fallback?.searchParams.get("cursor")).toBe("12") + expect(fallback?.searchParams.get("auth_token")).toBe(btoa("user:secret")) + + const ticketURL = transport?.connectURL({ + ptyID: "pty_1", + location: { directory: "/explicit" }, + cursor: -1, + ticket: "ticket value", + }) + expect(ticketURL?.searchParams.get("ticket")).toBe("ticket value") + expect(ticketURL?.searchParams.has("auth_token")).toBe(false) + }) + + test("preserves same-origin auth-token policy for PTY URLs", () => { + const saved = setup(() => new Response(), false, { sameOrigin: true }).backend.capabilities.ptyTransport + const token = setup(() => new Response(), false, { sameOrigin: true, authToken: true }).backend.capabilities + .ptyTransport + const input = { ptyID: "pty_1", location: { directory: "/repo" }, cursor: 0 } + + expect(saved?.connectURL(input).searchParams.has("auth_token")).toBe(false) + expect(token?.connectURL(input).searchParams.get("auth_token")).toBe(btoa("user:secret")) + }) }) diff --git a/packages/app/src/context/backend-v1.ts b/packages/app/src/context/backend-v1.ts index b5bc0e86d2..b54bf3f4b3 100644 --- a/packages/app/src/context/backend-v1.ts +++ b/packages/app/src/context/backend-v1.ts @@ -34,6 +34,7 @@ import type { Page, PromptFile, PromptInput, + PtyTransportConfig, ProviderCatalog, RequestOptions, SessionActivity, @@ -47,7 +48,12 @@ type CachedMessage = { parts: Part[] } -export function createV1Backend(client: OpencodeClient, defaultLocation?: LocationRef): AppClient { +export function createV1Backend( + client: OpencodeClient, + defaultLocation?: LocationRef, + eventClient: OpencodeClient = client, + transportConfig?: PtyTransportConfig, +): AppClient { const messages = new Map() const options = (input?: RequestOptions) => ({ signal: input?.signal, throwOnError: true as const }) @@ -57,13 +63,58 @@ export function createV1Backend(client: OpencodeClient, defaultLocation?: Locati return toTimelineItem(input.info, input.parts) } - const loadMessage = async (input: { sessionID: string; messageID: string }, request?: RequestOptions) => { + const loadMessage = async ( + input: LocationInput & { sessionID: string; messageID: string }, + request?: RequestOptions, + force?: boolean, + ) => { const cached = messages.get(input.messageID) - if (cached) return cached - const result = await client.session.message({ ...input, ...legacyLocation(defaultLocation) }, options(request)) + if (cached && !force) return cached + const result = await client.session.message( + { sessionID: input.sessionID, messageID: input.messageID, ...location(input) }, + options(request), + ) messages.set(input.messageID, result.data) return result.data } + const sessionActions = { + remove: async (input: LocationInput & { sessionID: string }, request?: RequestOptions) => { + const result = await client.session.delete( + { sessionID: input.sessionID, ...location(input) }, + options(request), + ) + return result.data + }, + fork: async (input: LocationInput & { sessionID: string; messageID?: string }, request?: RequestOptions) => { + const result = await client.session.fork( + { sessionID: input.sessionID, messageID: input.messageID, ...location(input) }, + options(request), + ) + return toSession(result.data) + }, + rename: async (input: LocationInput & { sessionID: string; title: string }, request?: RequestOptions) => { + await client.session.update( + { sessionID: input.sessionID, title: input.title, ...location(input) }, + options(request), + ) + }, + command: async (input: CommandInput, request?: RequestOptions) => { + await client.session.command( + { + sessionID: input.sessionID, + messageID: input.id, + command: input.command, + arguments: input.arguments ?? "", + agent: input.agent, + model: input.model && `${input.model.providerID}/${input.model.id}`, + variant: input.model?.variant, + parts: input.files?.map(toFilePart), + ...location(input), + }, + options(request), + ) + }, + } return { version: "v1", @@ -75,10 +126,6 @@ export function createV1Backend(client: OpencodeClient, defaultLocation?: Locati }, }, projects: { - list: async (request) => { - const result = await client.project.list(undefined, options(request)) - return result.data.map(toProject) - }, current: async (input, request) => { const params = location(input) const [project, path] = await Promise.all([ @@ -130,7 +177,7 @@ export function createV1Backend(client: OpencodeClient, defaultLocation?: Locati ) return { items: result.data.map(toSession), - next: result.response.headers.get("x-next-cursor") ?? undefined, + older: result.response.headers.get("x-next-cursor") ?? undefined, } }, create: async (input, request) => { @@ -148,22 +195,6 @@ export function createV1Backend(client: OpencodeClient, defaultLocation?: Locati const result = await client.session.get({ sessionID: input.sessionID, ...location(input) }, options(request)) return toSession(result.data) }, - remove: async (input, request) => { - await client.session.delete({ sessionID: input.sessionID, ...location(input) }, options(request)) - }, - fork: async (input, request) => { - const result = await client.session.fork( - { sessionID: input.sessionID, messageID: input.messageID, ...location(input) }, - options(request), - ) - return toSession(result.data) - }, - rename: async (input, request) => { - await client.session.update( - { sessionID: input.sessionID, title: input.title, ...location(input) }, - options(request), - ) - }, interrupt: async (input, request) => { await client.session.abort({ sessionID: input.sessionID, ...location(input) }, options(request)) }, @@ -182,16 +213,16 @@ export function createV1Backend(client: OpencodeClient, defaultLocation?: Locati sessionID: input.sessionID, limit: input.limit, before: input.cursor, - ...legacyLocation(defaultLocation), + ...location(input), }, options(request), ) return { items: result.data.map(cache), - next: result.response.headers.get("x-next-cursor") ?? undefined, + older: result.response.headers.get("x-next-cursor") ?? undefined, } }, - message: async (input, request) => cache(await loadMessage(input, request)), + message: async (input, request) => cache(await loadMessage(input, request, true)), prompt: async (input, request) => { await client.session.promptAsync( { @@ -204,23 +235,7 @@ export function createV1Backend(client: OpencodeClient, defaultLocation?: Locati }, variant: input.selection?.model?.variant, parts: toPromptParts(input), - ...legacyLocation(defaultLocation), - }, - options(request), - ) - }, - command: async (input, request) => { - await client.session.command( - { - sessionID: input.sessionID, - messageID: input.id, - command: input.command, - arguments: input.arguments ?? "", - agent: input.agent, - model: input.model && `${input.model.providerID}/${input.model.id}`, - variant: input.model?.variant, - parts: input.files?.map(toFilePart), - ...legacyLocation(defaultLocation), + ...location(input), }, options(request), ) @@ -229,7 +244,13 @@ export function createV1Backend(client: OpencodeClient, defaultLocation?: Locati files: { list: async (input, request) => { const result = await client.file.list({ path: input.path ?? "", ...location(input) }, options(request)) - return result.data.map((item) => ({ path: item.path, type: item.type })) + return result.data.map((item) => ({ + path: item.path, + name: item.name, + absolute: item.absolute, + type: item.type, + ignored: false, + })) }, find: async (input, request) => { const find = async (type: FileEntry["type"]) => { @@ -259,7 +280,7 @@ export function createV1Backend(client: OpencodeClient, defaultLocation?: Locati requestID: input.requestID, reply: input.reply, message: input.message, - ...legacyLocation(defaultLocation), + ...location(input), }, options(request), ) @@ -275,52 +296,13 @@ export function createV1Backend(client: OpencodeClient, defaultLocation?: Locati { requestID: input.requestID, answers: input.answers.map((answer) => [...answer]), - ...legacyLocation(defaultLocation), + ...location(input), }, options(request), ) }, reject: async (input, request) => { - await client.question.reject( - { requestID: input.requestID, ...legacyLocation(defaultLocation) }, - options(request), - ) - }, - }, - vcs: { - status: async (input, request) => { - const result = await client.vcs.status(location(input), options(request)) - return result.data - }, - diff: async (input, request) => { - const result = await client.vcs.diff( - { - mode: input.mode === "working" ? "git" : "branch", - context: input.context, - ...location(input), - }, - options(request), - ) - return result.data - }, - }, - mcp: { - list: async (input, request) => { - const result = await client.mcp.status(location(input), options(request)) - return Object.entries(result.data).map(([name, status]) => ({ name, status })) - }, - resources: async (input, request) => { - const result = await client.experimental.resource.list(location(input), options(request)) - return { - resources: Object.values(result.data).map((item) => ({ - server: item.client, - name: item.name, - uri: item.uri, - description: item.description, - mimeType: item.mimeType, - })), - templates: [], - } + await client.question.reject({ requestID: input.requestID, ...location(input) }, options(request)) }, }, pty: { @@ -365,7 +347,7 @@ export function createV1Backend(client: OpencodeClient, defaultLocation?: Locati events: { subscribe: (request) => ({ async *[Symbol.asyncIterator]() { - const result = await client.global.event(options(request)) + const result = await eventClient.global.event(options(request)) for await (const input of result.stream) { const event = await toEvent(input, messages, loadMessage) yield { @@ -379,11 +361,51 @@ export function createV1Backend(client: OpencodeClient, defaultLocation?: Locati }, }), }, - disposeLocation: async (input, request) => { - await client.instance.dispose(location(input), options(request)) - }, }, capabilities: { + projectList: { + list: async (request) => { + const result = await client.project.list(undefined, options(request)) + return result.data.map(toProject) + }, + }, + vcs: { + status: async (input, request) => { + const result = await client.vcs.status(location(input), options(request)) + return result.data + }, + diff: async (input, request) => { + const result = await client.vcs.diff( + { + mode: input.mode === "working" ? "git" : "branch", + context: input.context, + ...location(input), + }, + options(request), + ) + return result.data + }, + }, + mcp: { + list: async (input, request) => { + const result = await client.mcp.status(location(input), options(request)) + return Object.entries(result.data).map(([name, status]) => ({ name, status })) + }, + resources: async (input, request) => { + const result = await client.experimental.resource.list(location(input), options(request)) + return { + resources: Object.values(result.data).map((item) => ({ + server: item.client, + name: item.name, + uri: item.uri, + description: item.description, + mimeType: item.mimeType, + })), + templates: [], + } + }, + }, + sessionActionsV1: sessionActions, configuration: { getGlobal: async (request) => { const result = await client.global.config.get(options(request)) @@ -468,57 +490,74 @@ export function createV1Backend(client: OpencodeClient, defaultLocation?: Locati return { directory: result.data.directory, branch: result.data.branch } }, remove: async (input, request) => { - await client.worktree.remove( + const result = await client.worktree.remove( { ...location(input), worktreeRemoveInput: { directory: input.directory } }, options(request), ) + return result.data }, reset: async (input, request) => { - await client.worktree.reset( + const result = await client.worktree.reset( { ...location(input), worktreeResetInput: { directory: input.directory } }, options(request), ) + return result.data }, }, sessionExtrasV1: { - archive: async (sessionID, archivedAt, request) => { + archive: async (input, request) => { await client.session.update( - { sessionID, time: { archived: archivedAt }, ...legacyLocation(defaultLocation) }, + { sessionID: input.sessionID, time: { archived: input.archivedAt }, ...location(input) }, options(request), ) }, - share: async (sessionID, request) => { - const result = await client.session.share({ sessionID, ...legacyLocation(defaultLocation) }, options(request)) - if (!result.data.share) throw new Error(`Session ${sessionID} was shared without a URL`) + share: async (input, request) => { + const result = await client.session.share( + { sessionID: input.sessionID, ...location(input) }, + options(request), + ) + if (!result.data.share) throw new Error(`Session ${input.sessionID} was shared without a URL`) return result.data.share.url }, - unshare: async (sessionID, request) => { - await client.session.unshare({ sessionID, ...legacyLocation(defaultLocation) }, options(request)) + unshare: async (input, request) => { + await client.session.unshare({ sessionID: input.sessionID, ...location(input) }, options(request)) }, - diff: async (sessionID, request) => { - const result = await client.session.diff({ sessionID, ...legacyLocation(defaultLocation) }, options(request)) + diff: async (input, request) => { + const result = await client.session.diff({ sessionID: input.sessionID, ...location(input) }, options(request)) return result.data.flatMap((item) => (item.file ? [{ ...item, file: item.file }] : [])) }, - todos: async (sessionID, request) => { - const result = await client.session.todo({ sessionID, ...legacyLocation(defaultLocation) }, options(request)) - return result.data.map((item) => ({ content: item.content, status: item.status })) + todos: async (input, request) => { + const result = await client.session.todo({ sessionID: input.sessionID, ...location(input) }, options(request)) + return result.data.map((item) => ({ + content: item.content, + status: item.status, + priority: item.priority, + })) }, - summarize: async (sessionID, model, request) => { + summarize: async (input, request) => { await client.session.summarize( { - sessionID, - providerID: model.providerID, - modelID: model.id, - ...legacyLocation(defaultLocation), + sessionID: input.sessionID, + providerID: input.model.providerID, + modelID: input.model.id, + ...location(input), }, options(request), ) }, - revert: async (sessionID, messageID, request) => { - await client.session.revert({ sessionID, messageID, ...legacyLocation(defaultLocation) }, options(request)) + revert: async (input, request) => { + const result = await client.session.revert( + { sessionID: input.sessionID, messageID: input.messageID, ...location(input) }, + options(request), + ) + return toSession(result.data) }, - clearRevert: async (sessionID, request) => { - await client.session.unrevert({ sessionID, ...legacyLocation(defaultLocation) }, options(request)) + clearRevert: async (input, request) => { + const result = await client.session.unrevert( + { sessionID: input.sessionID, ...location(input) }, + options(request), + ) + return toSession(result.data) }, shell: async (input, request) => { await client.session.shell( @@ -569,11 +608,26 @@ export function createV1Backend(client: OpencodeClient, defaultLocation?: Locati return toDecoratedFile(result.data) }, }, - ptyTransport: { + ptyTransport: transportConfig && { connectToken: async (input, request) => { - const result = await client.pty.connectToken({ ptyID: input.ptyID, ...location(input) }, options(request)) - return { ticket: result.data.ticket } + const result = await client.pty.connectToken( + { ptyID: input.ptyID, ...location(input) }, + { + signal: request?.signal, + throwOnError: false, + headers: { "x-opencode-ticket": "1" }, + }, + ) + return { status: result.response.status, ticket: result.data?.ticket } }, + exists: async (input, request) => { + const result = await client.pty.get( + { ptyID: input.ptyID, ...location(input) }, + { signal: request?.signal, throwOnError: false }, + ) + return result.response.status !== 404 + }, + connectURL: (input) => ptyConnectURL(transportConfig, "/pty", input, legacyLocation(input.location)), }, shellDiscovery: { list: async (input, request) => { @@ -582,6 +636,9 @@ export function createV1Backend(client: OpencodeClient, defaultLocation?: Locati }, }, runtimeV1: { + disposeLocation: async (input, request) => { + await client.instance.dispose(location(input), options(request)) + }, disposeAll: async (request) => { await client.global.dispose(options(request)) }, @@ -590,10 +647,31 @@ export function createV1Backend(client: OpencodeClient, defaultLocation?: Locati } } +function ptyConnectURL( + config: PtyTransportConfig, + root: string, + input: { ptyID: string; cursor: number; ticket?: string }, + location: { directory?: string; workspace?: string }, +) { + const url = new URL(`${config.baseUrl.replace(/\/+$/, "")}${root}/${encodeURIComponent(input.ptyID)}/connect`) + if (location.directory) url.searchParams.set("directory", location.directory) + if (location.workspace) url.searchParams.set("workspace", location.workspace) + url.searchParams.set("cursor", String(input.cursor)) + url.protocol = url.protocol === "https:" ? "wss:" : "ws:" + if (input.ticket) { + url.searchParams.set("ticket", input.ticket) + return url + } + if (config.password && (!config.sameOrigin || config.authToken)) + url.searchParams.set("auth_token", btoa(`${config.username ?? "opencode"}:${config.password}`)) + return url +} + function legacyLocation(input?: LocationRef) { + if (!input) return {} return { - directory: input?.directory, - workspace: input?.workspaceID, + directory: input.directory, + workspace: input.workspaceID, } } @@ -609,6 +687,8 @@ function toProject(input: Project): AppProject { return { id: input.id, worktree: input.worktree, + vcs: input.vcs, + time: { ...input.time, updated: input.time.updated ?? input.time.created }, name: input.name, icon: input.icon, commands: input.commands, @@ -619,9 +699,13 @@ function toProject(input: Project): AppProject { function toSession(input: Session): AppSession { return { id: input.id, + slug: input.slug, + version: input.version, parentID: input.parentID, projectID: input.projectID, location: { directory: input.directory, workspaceID: input.workspaceID }, + directory: input.directory, + workspaceID: input.workspaceID, title: input.title, cost: input.cost ?? 0, tokens: input.tokens, @@ -657,6 +741,7 @@ function toProvider(input: Provider): AppProvider { return { id: input.id, name: input.name, + source: input.source, models: Object.fromEntries( Object.entries(input.models).flatMap(([id, model]) => model.status === "deprecated" ? [] : [[id, toModel(model)]], @@ -714,8 +799,8 @@ function toReference(input: import("@opencode-ai/sdk-v1/v2/client").ReferenceInf return input } -function toActivity(input: import("@opencode-ai/sdk-v1/v2/client").SessionStatus): SessionActivity | undefined { - if (input.type === "idle") return +function toActivity(input: import("@opencode-ai/sdk-v1/v2/client").SessionStatus): SessionActivity { + if (input.type === "idle") return { type: "idle" } if (input.type === "busy") return { type: "running" } return input } @@ -734,7 +819,10 @@ function toTimelineItem(info: Message, parts: readonly Part[]): TimelineItem { providerID: info.model.providerID, variant: info.model.variant, }, - raw: { info, parts }, + format: info.format, + summary: info.summary, + system: info.system, + tools: info.tools, } } return { @@ -749,7 +837,12 @@ function toTimelineItem(info: Message, parts: readonly Part[]): TimelineItem { model: { id: info.modelID, providerID: info.providerID, variant: info.variant }, tokens: info.tokens, error: info.error, - raw: { info, parts }, + mode: info.mode, + path: info.path, + cost: info.cost, + structured: info.structured, + finish: info.finish, + summary: info.summary, } } @@ -763,9 +856,11 @@ function toTimelineContent(input: Part): TimelineContent[] { synthetic: input.synthetic, ignored: input.ignored, metadata: input.metadata, + time: input.time, }, ] - if (input.type === "reasoning") return [{ type: input.type, id: input.id, text: input.text }] + if (input.type === "reasoning") + return [{ type: input.type, id: input.id, text: input.text, metadata: input.metadata, time: input.time }] if (input.type === "file") return [ { @@ -794,14 +889,52 @@ function toTimelineContent(input: Part): TimelineContent[] { }, ] if (input.type === "tool") - return [{ type: input.type, id: input.id, callID: input.callID, tool: input.tool, state: toToolState(input.state) }] + return [ + { + type: input.type, + id: input.id, + callID: input.callID, + tool: input.tool, + state: toToolState(input.state), + metadata: input.metadata, + }, + ] + if (input.type === "subtask") + return [ + { + type: input.type, + id: input.id, + prompt: input.prompt, + description: input.description, + agent: input.agent, + model: input.model && { id: input.model.modelID, providerID: input.model.providerID }, + command: input.command, + }, + ] + if (input.type === "step-start") return [{ type: input.type, id: input.id, snapshot: input.snapshot }] + if (input.type === "step-finish") + return [ + { + type: input.type, + id: input.id, + reason: input.reason, + snapshot: input.snapshot, + cost: input.cost, + tokens: input.tokens, + }, + ] + if (input.type === "snapshot") return [{ type: input.type, id: input.id, snapshot: input.snapshot }] + if (input.type === "patch") return [{ type: input.type, id: input.id, hash: input.hash, files: input.files }] + if (input.type === "retry") + return [{ type: input.type, id: input.id, attempt: input.attempt, error: input.error, time: input.time }] + if (input.type === "compaction") return [{ type: input.type, id: input.id, auto: input.auto }] return [] } function toToolState(input: import("@opencode-ai/sdk-v1/v2/client").ToolState): ToolState { if (input.status === "pending") return { status: input.status, input: input.input, raw: input.raw } if (input.status === "running") - return { status: input.status, input: input.input, title: input.title, metadata: input.metadata } + return { status: input.status, input: input.input, title: input.title, metadata: input.metadata, time: input.time } if (input.status === "completed") return { status: input.status, @@ -809,8 +942,10 @@ function toToolState(input: import("@opencode-ai/sdk-v1/v2/client").ToolState): output: input.output, title: input.title, metadata: input.metadata, + time: input.time, + attachments: input.attachments, } - return { status: input.status, input: input.input, error: input.error, metadata: input.metadata } + return { status: input.status, input: input.input, error: input.error, metadata: input.metadata, time: input.time } } function toFilePart(input: PromptFile) { @@ -830,6 +965,7 @@ function toFilePart(input: PromptFile) { } function toPromptParts(input: PromptInput) { + if (input.parts) return input.parts.map(toPromptPart) return [ { type: "text" as const, text: input.text }, ...(input.files?.map(toFilePart) ?? []), @@ -844,6 +980,11 @@ function toPromptParts(input: PromptInput) { ] } +function toPromptPart(input: NonNullable[number]) { + if (input.type === "file" && input.source?.type === "resource") return { ...input, source: undefined } + return { ...input } +} + function toFileContent(input: import("@opencode-ai/sdk-v1/v2/client").FileContent): FileContent { if (input.encoding !== "base64") { return { bytes: new TextEncoder().encode(input.content), kind: input.type, mimeType: input.mimeType } @@ -862,7 +1003,7 @@ function toDecoratedFile(input: import("@opencode-ai/sdk-v1/v2/client").FileCont diff: input.diff, encoding: input.encoding, mimeType: input.mimeType, - patch: input.patch && { hunks: input.patch.hunks.map((hunk) => ({ lines: hunk.lines })) }, + patch: input.patch, } } @@ -872,7 +1013,10 @@ function toPermission(input: import("@opencode-ai/sdk-v1/v2/client").PermissionR sessionID: input.sessionID, action: input.permission, resources: input.patterns, - metadata: input.metadata, + permission: input.permission, + patterns: input.patterns, + always: input.always, + metadata: input.metadata ?? {}, } } @@ -880,7 +1024,11 @@ function toQuestion(input: import("@opencode-ai/sdk-v1/v2/client").QuestionReque return { id: input.id, sessionID: input.sessionID, - questions: input.questions, + questions: input.questions.map((question) => ({ + ...question, + header: question.header ?? "", + options: question.options.map((option) => ({ ...option, description: option.description ?? "" })), + })), } } @@ -891,22 +1039,54 @@ function toPty(input: import("@opencode-ai/sdk-v1/v2/client").Pty) { async function toEvent( envelope: GlobalEvent, messages: Map, - loadMessage: (input: { sessionID: string; messageID: string }) => Promise, + loadMessage: (input: LocationInput & { sessionID: string; messageID: string }) => Promise, ): Promise { const input = envelope.payload as Event - if (input.type === "server.connected") return { type: input.type } + const eventLocation = + envelope.directory === "global" + ? undefined + : { location: { directory: envelope.directory, workspaceID: envelope.workspace } } + if (input.type === "server.connected") { + messages.clear() + return { type: input.type } + } if (input.type === "global.disposed") return { type: "server.disposed" } if (input.type === "server.instance.disposed") - return { type: "server.disposed", location: { directory: input.properties.directory } } + return { type: "instance.disposed", location: { directory: input.properties.directory } } if (input.type === "project.updated") return { type: input.type, project: toProject(input.properties) } if (input.type === "session.created" || input.type === "session.updated") return { type: input.type, session: toSession(input.properties.info) } - if (input.type === "session.deleted") return { type: input.type, sessionID: input.properties.sessionID } + if (input.type === "session.deleted") { + for (const [messageID, message] of messages) { + if (message.info.sessionID === input.properties.sessionID) messages.delete(messageID) + } + return { type: input.type, sessionID: input.properties.sessionID } + } if (input.type === "session.status") { const activity = toActivity(input.properties.status) - if (activity) return { type: "session.activity", sessionID: input.properties.sessionID, activity } - return { type: "unknown", raw: input } + if (activity.type === "idle") { + for (const [messageID, message] of messages) { + if (message.info.sessionID === input.properties.sessionID) messages.delete(messageID) + } + } + return { type: "session.activity", sessionID: input.properties.sessionID, activity } } + if (input.type === "session.diff") + return { + type: input.type, + sessionID: input.properties.sessionID, + diff: input.properties.diff.flatMap((item) => (item.file ? [{ ...item, file: item.file }] : [])), + } + if (input.type === "todo.updated") + return { + type: input.type, + sessionID: input.properties.sessionID, + todos: input.properties.todos.map((todo) => ({ + content: todo.content, + status: todo.status, + priority: todo.priority, + })), + } if (input.type === "session.error") return { type: input.type, sessionID: input.properties.sessionID, error: input.properties.error } if (input.type === "message.updated") { @@ -917,7 +1097,7 @@ async function toEvent( } if (input.type === "message.part.updated") { const messageID = input.properties.part.messageID - const value = await loadMessage({ sessionID: input.properties.sessionID, messageID }) + const value = await loadMessage({ sessionID: input.properties.sessionID, messageID, ...eventLocation }) const parts = [...value.parts.filter((part) => part.id !== input.properties.part.id), input.properties.part] const next = { info: value.info, parts } messages.set(messageID, next) @@ -928,10 +1108,40 @@ async function toEvent( return { type: "timeline.removed", sessionID: input.properties.sessionID, itemID: input.properties.messageID } } if (input.type === "message.part.removed") { - const value = await loadMessage({ sessionID: input.properties.sessionID, messageID: input.properties.messageID }) - const next = { info: value.info, parts: value.parts.filter((part) => part.id !== input.properties.partID) } - messages.set(input.properties.messageID, next) - return { type: "timeline.updated", item: toTimelineItem(next.info, next.parts) } + const cached = messages.get(input.properties.messageID) + if (cached) + messages.set(input.properties.messageID, { + ...cached, + parts: cached.parts.filter((part) => part.id !== input.properties.partID), + }) + return { + type: "timeline.part.removed", + sessionID: input.properties.sessionID, + itemID: input.properties.messageID, + contentID: input.properties.partID, + } + } + if (input.type === "message.part.delta") { + const cached = messages.get(input.properties.messageID) + const part = cached?.parts.find((part) => part.id === input.properties.partID) + if (cached && part) { + const current = part[input.properties.field as keyof Part] + if (typeof current === "string") + messages.set(input.properties.messageID, { + ...cached, + parts: cached.parts.map((item) => + item.id === part.id ? { ...item, [input.properties.field]: current + input.properties.delta } : item, + ), + }) + } + return { + type: "timeline.delta", + sessionID: input.properties.sessionID, + itemID: input.properties.messageID, + contentID: input.properties.partID, + field: input.properties.field, + delta: input.properties.delta, + } } if (input.type === "permission.asked") return { type: "permission.requested", request: toPermission(input.properties) } @@ -942,8 +1152,11 @@ async function toEvent( id: input.properties.id, sessionID: input.properties.sessionID, action: input.properties.action, - resources: input.properties.resources, - metadata: input.properties.metadata, + resources: [...input.properties.resources], + permission: input.properties.action, + patterns: [...input.properties.resources], + always: [], + metadata: input.properties.metadata ?? {}, }, } if (input.type === "permission.replied" || input.type === "permission.v2.replied") @@ -961,6 +1174,12 @@ async function toEvent( if (input.type === "file.watcher.updated") return { type: "file.changed", path: input.properties.file, change: input.properties.event } if (input.type === "vcs.branch.updated") return { type: input.type, branch: input.properties.branch } + if (input.type === "worktree.ready") + return { type: input.type, name: input.properties.name, branch: input.properties.branch } + if (input.type === "worktree.failed") return { type: input.type, message: input.properties.message } + if (input.type === "lsp.updated") return { type: input.type } + if (input.type === "reference.updated") return { type: input.type } + if (input.type === "mcp.tools.changed") return { type: "mcp.updated", server: input.properties.server } if (input.type === "pty.exited") return { type: input.type, ptyID: input.properties.id } return { type: "unknown", raw: input } } diff --git a/packages/app/src/context/backend-v2.test.ts b/packages/app/src/context/backend-v2.test.ts index 5256002a55..246ad0f4df 100644 --- a/packages/app/src/context/backend-v2.test.ts +++ b/packages/app/src/context/backend-v2.test.ts @@ -1,8 +1,12 @@ import { describe, expect, test } from "bun:test" import { OpenCode } from "@opencode-ai/client" +import { credentialConnectionIDs, type PtyTransportConfig } from "./backend" import { createV2Backend } from "./backend-v2" -function setup(respond: (request: Request) => Response | Promise) { +function setup( + respond: (request: Request) => Response | Promise, + transport?: Partial>, +) { const requests: Request[] = [] const fetch = Object.assign( async (input: RequestInfo | URL, init?: RequestInit) => { @@ -12,12 +16,22 @@ function setup(respond: (request: Request) => Response | Promise) { }, { preconnect: globalThis.fetch.preconnect }, ) satisfies typeof globalThis.fetch + const client = OpenCode.make({ baseUrl: "http://localhost", fetch }) return { requests, - backend: createV2Backend(OpenCode.make({ baseUrl: "http://localhost", fetch }), { - directory: "/default", - workspaceID: "default-workspace", - }), + backend: createV2Backend( + client, + { + baseUrl: "http://localhost", + fetch, + username: "user", + password: transport && "password" in transport ? transport.password : "secret", + sameOrigin: transport?.sameOrigin ?? false, + authToken: transport?.authToken ?? false, + }, + { directory: "/default", workspaceID: "default-workspace" }, + client, + ), } } @@ -36,12 +50,45 @@ const session = { } describe("createV2Backend", () => { + test("uses no legacy endpoints for bootstrap and common reads", async () => { + const setupResult = setup((request) => { + const path = new URL(request.url).pathname + if (path === "/api/health") return json({ healthy: true, version: "v2" }) + if (path === "/api/location") + return json({ + directory: "/default", + workspaceID: "default-workspace", + project: { id: "project", directory: "/default" }, + }) + return json({ location: { directory: "/default" }, data: [] }) + }) + + await Promise.all([ + setupResult.backend.common.health.get(), + setupResult.backend.common.projects.current(), + setupResult.backend.common.catalog.providers(), + setupResult.backend.common.catalog.agents(), + setupResult.backend.common.commands.list(), + setupResult.backend.common.references.list(), + setupResult.backend.common.files.list({}), + setupResult.backend.common.files.find({ query: "src" }), + setupResult.backend.common.permissions.pending(), + setupResult.backend.common.questions.pending(), + setupResult.backend.common.pty.list(), + ]) + + const paths = setupResult.requests.map((request) => new URL(request.url).pathname) + expect(paths.every((path) => path.startsWith("/api/"))).toBe(true) + expect(paths.some((path) => path === "/project" || path.startsWith("/vcs") || path.startsWith("/mcp"))).toBe( + false, + ) + }) + test("normalizes session pages and preserves location precedence", async () => { const setupResult = setup(() => json({ data: [session], cursor: { previous: "before", next: "after" } })) const result = await setupResult.backend.common.sessions.list({ location: { directory: "/explicit", workspaceID: "explicit-workspace" }, - roots: true, limit: 10, cursor: "cursor", }) @@ -50,9 +97,13 @@ describe("createV2Backend", () => { items: [ { id: "ses_1", + slug: "ses_1", + version: "", parentID: undefined, projectID: "project", location: { directory: "/repo", workspaceID: "workspace" }, + directory: "/repo", + workspaceID: "workspace", title: "Session", cost: 1.5, tokens: { input: 1, output: 2, reasoning: 3, cache: { read: 4, write: 5 } }, @@ -60,27 +111,187 @@ describe("createV2Backend", () => { revert: undefined, }, ], - previous: "before", - next: "after", + newer: "before", + older: "after", }) const url = new URL(setupResult.requests[0].url) expect(url.pathname).toBe("/api/session") expect(url.searchParams.get("directory")).toBe("/explicit") expect(url.searchParams.get("workspace")).toBe("explicit-workspace") - expect(url.searchParams.get("parentID")).toBe("null") + expect(url.searchParams.has("parentID")).toBe(false) expect(url.searchParams.get("cursor")).toBe("cursor") }) - test("uses the default location and maps binary file responses", async () => { - const setupResult = setup(() => new Response(Uint8Array.from([0, 1, 2]))) + test("paginates native sessions until roots:true contains only the requested roots", async () => { + const child = { ...session, id: "child", parentID: "root_1" } + const root1 = { ...session, id: "root_1" } + const root2 = { ...session, id: "root_2" } + const setupResult = setup((request) => { + const cursor = new URL(request.url).searchParams.get("cursor") + if (!cursor) return json({ data: [child], cursor: { next: "one" } }) + if (cursor === "one") return json({ data: [root1], cursor: { next: "two" } }) + return json({ data: [root2], cursor: { next: "three" } }) + }) - const result = await setupResult.backend.common.files.read({ path: "dir/a.bin" }) + const result = await setupResult.backend.common.sessions.list({ roots: true, limit: 2 }) + + expect(result.items.map((item) => item.id)).toEqual(["root_1", "root_2"]) + expect(result.older).toBe("three") + expect(setupResult.requests.map((request) => new URL(request.url).searchParams.get("limit"))).toEqual(["1", "1", "1"]) + }) + + test("uses only native endpoints for bootstrap operations and binary file reads", async () => { + const setupResult = setup((request) => { + if (new URL(request.url).pathname === "/api/fs/read/dir%2Fa.txt") + return new Response(Uint8Array.from([0, 1, 255]), { headers: { "content-type": "application/octet-stream" } }) + return json({ location: { directory: "/default" }, data: [] }) + }) + + await setupResult.backend.common.files.list({ path: "dir" }) + const content = await setupResult.backend.common.files.read({ path: "dir/a.txt" }) - expect([...result.bytes]).toEqual([0, 1, 2]) const url = new URL(setupResult.requests[0].url) - expect(url.pathname).toBe("/api/fs/read/dir/a.bin") + expect(url.pathname).toBe("/api/fs/list") expect(url.searchParams.get("location[directory]")).toBe("/default") expect(url.searchParams.get("location[workspace]")).toBe("default-workspace") + expect(content).toEqual({ + bytes: Uint8Array.from([0, 1, 255]), + kind: "binary", + mimeType: "application/octet-stream", + }) + const readURL = new URL(setupResult.requests[1].url) + expect(readURL.pathname).toBe("/api/fs/read/dir%2Fa.txt") + expect(readURL.searchParams.get("location[directory]")).toBe("/default") + expect(readURL.searchParams.get("location[workspace]")).toBe("default-workspace") + expect(setupResult.requests[1].headers.get("authorization")).toBe(`Basic ${btoa("user:secret")}`) + expect(setupResult.requests.every((request) => new URL(request.url).pathname.startsWith("/api/"))).toBe(true) + expect(setupResult.backend.version).toBe("v2") + expect(Object.keys(setupResult.backend.capabilities).sort()).toEqual([ + "integrationsV2", + "projectCopiesV2", + "ptyTransport", + "savedPermissionsV2", + "sessionExtrasV2", + ]) + expect(setupResult.backend.capabilities.providerAuthV1).toBeUndefined() + expect(setupResult.backend.capabilities.worktreesV1).toBeUndefined() + expect(setupResult.backend.capabilities.sessionExtrasV1).toBeUndefined() + expect(setupResult.backend.capabilities.runtimeV1).toBeUndefined() + expect(setupResult.backend.capabilities.projectList).toBeUndefined() + expect(setupResult.backend.capabilities.vcs).toBeUndefined() + expect(setupResult.backend.capabilities.mcp).toBeUndefined() + expect(setupResult.backend.capabilities.sessionExtrasV2?.move).toBeUndefined() + expect(setupResult.backend.capabilities.projectCopiesV2?.directories).toBeUndefined() + }) + + test("uses native PTY endpoints and preserves status through the v2 adapter", async () => { + const setupResult = setup((request) => + request.method === "GET" ? new Response(null, { status: 404 }) : new Response(null, { status: 403 }), + ) + const transport = setupResult.backend.capabilities.ptyTransport + + const ticket = await transport?.connectToken({ + ptyID: "pty_1", + location: { directory: "/explicit", workspaceID: "workspace" }, + }) + const exists = await transport?.exists({ + ptyID: "pty_1", + location: { directory: "/explicit", workspaceID: "workspace" }, + }) + + expect(ticket).toEqual({ status: 403, ticket: undefined }) + expect(exists).toBe(false) + expect(setupResult.requests[0].headers.get("x-opencode-ticket")).toBe("1") + const tokenURL = new URL(setupResult.requests[0].url) + const existsURL = new URL(setupResult.requests[1].url) + expect(tokenURL.pathname).toBe("/api/pty/pty_1/connect-token") + expect(existsURL.pathname).toBe("/api/pty/pty_1") + expect(tokenURL.searchParams.get("location[directory]")).toBe("/explicit") + expect(tokenURL.searchParams.get("location[workspace]")).toBe("workspace") + expect(setupResult.requests[0].headers.get("authorization")).toBe(`Basic ${btoa("user:secret")}`) + + expect(() => + transport?.connectURL({ + ptyID: "pty/1", + location: { directory: "/explicit", workspaceID: "workspace" }, + cursor: 8, + }), + ).toThrow("require a ticket") + + const ticketURL = transport?.connectURL({ + ptyID: "pty_1", + location: { directory: "/explicit" }, + cursor: 0, + ticket: "ticket value", + }) + expect(ticketURL?.searchParams.get("ticket")).toBe("ticket value") + expect(ticketURL?.searchParams.has("auth_token")).toBe(false) + }) + + test("allows ticketless same-origin native PTY URLs without credential queries", () => { + const transport = setup(() => new Response(), { sameOrigin: true, password: undefined }).backend.capabilities + .ptyTransport + const url = transport?.connectURL({ ptyID: "pty_1", location: { directory: "/repo" }, cursor: 0 }) + + expect(url?.searchParams.has("auth_token")).toBe(false) + expect(url?.pathname).toBe("/api/pty/pty_1/connect") + }) + + test("preserves native provider integration IDs", async () => { + const setupResult = setup((request) => { + const path = new URL(request.url).pathname + if (path === "/api/provider") + return json({ + location: { directory: "/default" }, + data: [ + { + id: "provider", + integrationID: "integration", + name: "Provider", + api: { type: "native", settings: {} }, + request: { headers: {}, body: {} }, + }, + ], + }) + return json({ location: { directory: "/default" }, data: [] }) + }) + + const result = await setupResult.backend.common.catalog.providers() + + expect(result.providers.get("provider")?.integrationID).toBe("integration") + }) + + test("preserves integration connection kinds", async () => { + const setupResult = setup(() => + json({ + location: { directory: "/default" }, + data: [ + { + id: "integration", + name: "Integration", + methods: [], + connections: [ + { type: "credential", id: "credential", label: "Saved" }, + { type: "environment", name: "TOKEN" }, + ], + }, + ], + }), + ) + + const integrations = await setupResult.backend.capabilities.integrationsV2?.list() + expect(integrations).toEqual([ + { + id: "integration", + name: "Integration", + methods: [], + connections: [ + { id: "credential", label: "Saved", kind: "credential" }, + { id: "TOKEN", label: "TOKEN", kind: "environment" }, + ], + }, + ]) + expect(credentialConnectionIDs(integrations?.[0]?.connections ?? [])).toEqual(["credential"]) }) test("switches prompt selection before admission", async () => { @@ -115,17 +326,82 @@ describe("createV2Backend", () => { ]) expect(await setupResult.requests[2].json()).toEqual({ id: "msg_1", - text: "hello", - files: [ - { - uri: "data:text/plain;base64,aGk=", - name: "hi.txt", - mention: { start: 0, end: 2, text: "hi" }, - }, - ], + prompt: { + text: "hello", + files: [ + { + uri: "data:text/plain;base64,aGk=", + mime: "application/octet-stream", + name: "hi.txt", + source: { start: 0, end: 2, text: "hi" }, + }, + ], + }, }) }) + test("serializes selection and prompt admission per session", async () => { + const firstPrompt = Promise.withResolvers() + const firstPromptStarted = Promise.withResolvers() + const setupResult = setup(async (request) => { + const path = new URL(request.url).pathname + if (path.endsWith("/prompt") && setupResult.requests.filter((item) => item.url.endsWith("/prompt")).length === 1) { + firstPromptStarted.resolve() + await firstPrompt.promise + } + return path.endsWith("/prompt") + ? json({ data: { id: "msg", sessionID: "ses_1", timeCreated: 1, type: "user", data: { text: "" } } }) + : new Response(null, { status: 204 }) + }) + + const first = setupResult.backend.common.sessions.prompt({ + sessionID: "ses_1", + id: "msg_1", + text: "first", + selection: { agent: "build" }, + }) + await firstPromptStarted.promise + const second = setupResult.backend.common.sessions.prompt({ + sessionID: "ses_1", + id: "msg_2", + text: "second", + selection: { agent: "plan" }, + }) + + expect(setupResult.requests.map((item) => new URL(item.url).pathname)).toEqual([ + "/api/session/ses_1/agent", + "/api/session/ses_1/prompt", + ]) + firstPrompt.resolve() + await Promise.all([first, second]) + expect(setupResult.requests.map((item) => new URL(item.url).pathname)).toEqual([ + "/api/session/ses_1/agent", + "/api/session/ses_1/prompt", + "/api/session/ses_1/agent", + "/api/session/ses_1/prompt", + ]) + }) + + test("does not switch a selection already present in session state", async () => { + const setupResult = setup((request) => + request.url.endsWith("/prompt") + ? json({ data: { id: "msg", sessionID: "ses_1", timeCreated: 1, type: "user", data: { text: "" } } }) + : json({ data: { ...session, agent: "build", model: { id: "model", providerID: "provider" } } }), + ) + await setupResult.backend.common.sessions.get({ sessionID: "ses_1" }) + await setupResult.backend.common.sessions.prompt({ + sessionID: "ses_1", + id: "msg_1", + text: "hello", + selection: { agent: "build", model: { id: "model", providerID: "provider" } }, + }) + + expect(setupResult.requests.map((item) => new URL(item.url).pathname)).toEqual([ + "/api/session/ses_1", + "/api/session/ses_1/prompt", + ]) + }) + test("requests file staging when selected revert files are present", async () => { const setupResult = setup(() => json({ data: { messageID: "msg_1", files: [] } })) @@ -138,75 +414,266 @@ describe("createV2Backend", () => { expect(await setupResult.requests[0].json()).toEqual({ messageID: "msg_1", files: true }) }) - test("normalizes retry events before message projection refresh", async () => { + test("maps ordered app prompt parts to the native prompt shape", async () => { const setupResult = setup(() => - new Response( - `data: ${JSON.stringify({ - id: "evt_retry", - created: 1, - type: "session.retry.scheduled", - durable: { aggregateID: "ses_1", seq: 3, version: 1 }, - location: { directory: "/repo" }, - data: { - sessionID: "ses_1", - assistantMessageID: "msg_1", - attempt: 2, - at: 1234, - error: { type: "rate_limit", message: "try again" }, - }, - })}\n\n`, - { headers: { "content-type": "text/event-stream" } }, - ), + json({ data: { admittedSeq: 1, id: "msg_1", sessionID: "ses_1", timeCreated: 1, type: "user", data: {} } }), + ) + + await setupResult.backend.common.sessions.prompt({ + sessionID: "ses_1", + id: "msg_1", + text: "visible", + parts: [ + { id: "part_text", type: "text", text: "visible" }, + { id: "part_note", type: "text", text: "note", synthetic: true, metadata: { source: "review" } }, + { id: "part_file", type: "file", mime: "text/plain", url: "file:///repo/a.ts", filename: "a.ts" }, + { id: "part_agent", type: "agent", name: "build", source: { value: "@build", start: 7, end: 13 } }, + ], + }) + + expect(await setupResult.requests[0].json()).toEqual({ + id: "msg_1", + prompt: { + text: "visiblenote", + files: [{ uri: "file:///repo/a.ts", mime: "text/plain", name: "a.ts" }], + agents: [{ name: "build", source: { text: "@build", start: 7, end: 13 } }], + }, + }) + }) + + test("commits a staged revert through the V2 capability", async () => { + const setupResult = setup(() => new Response(null, { status: 204 })) + + await setupResult.backend.capabilities.sessionExtrasV2?.commitRevert({ sessionID: "ses_1" }) + + expect(new URL(setupResult.requests[0].url).pathname).toBe("/api/session/ses_1/revert/commit") + }) + + test("normalizes current session activity events without projection refresh", async () => { + const setupResult = setup( + () => + new Response( + `data: ${JSON.stringify({ + id: "evt_started", + type: "session.next.step.started", + durable: { aggregateID: "ses_1", seq: 3, version: 1 }, + location: { directory: "/repo" }, + data: { + timestamp: 1, + sessionID: "ses_1", + assistantMessageID: "msg_1", + agent: "build", + model: { id: "model", providerID: "provider" }, + }, + })}\n\n`, + { headers: { "content-type": "text/event-stream" } }, + ), ) const result = await setupResult.backend.common.events.subscribe()[Symbol.asyncIterator]().next() - expect(result.value).toEqual({ + expect(result.value).toMatchObject({ location: { directory: "/repo" }, event: { type: "session.activity", sessionID: "ses_1", - activity: { type: "retry", attempt: 2, message: "try again", next: 1234 }, + activity: { type: "running" }, }, }) expect(setupResult.requests).toHaveLength(1) }) + test("maps completed native steps to idle activity", async () => { + const setupResult = setup( + () => + new Response( + `data: ${JSON.stringify({ + id: "evt_ended", + type: "session.next.step.ended", + data: { + timestamp: 2, + sessionID: "ses_1", + assistantMessageID: "msg_1", + finish: "stop", + cost: 1, + tokens: { input: 1, output: 1, reasoning: 0, cache: { read: 0, write: 0 } }, + }, + })}\n\n`, + { headers: { "content-type": "text/event-stream" } }, + ), + ) + + const result = await setupResult.backend.common.events.subscribe()[Symbol.asyncIterator]().next() + + expect(result.value?.event).toEqual({ + type: "session.activity", + sessionID: "ses_1", + activity: { type: "idle" }, + }) + }) + + test("projects failed steps as completed assistant errors and clears running", async () => { + const events = [ + { + id: "start", + type: "session.next.step.started", + data: { timestamp: 1, sessionID: "ses_1", assistantMessageID: "msg_1", agent: "build", model: { id: "m", providerID: "p" } }, + }, + { id: "text", type: "session.next.text.started", data: { timestamp: 2, sessionID: "ses_1", assistantMessageID: "msg_1", textID: "text_1" } }, + { id: "delta", type: "session.next.text.delta", data: { timestamp: 3, sessionID: "ses_1", assistantMessageID: "msg_1", textID: "text_1", delta: "partial" } }, + { id: "failed", type: "session.next.step.failed", data: { timestamp: 4, sessionID: "ses_1", assistantMessageID: "msg_1", error: { type: "unknown", message: "boom" } } }, + ] + const setupResult = setup(() => new Response(events.map((event) => `data: ${JSON.stringify(event)}\n\n`).join(""), { headers: { "content-type": "text/event-stream" } })) + const iterator = setupResult.backend.common.events.subscribe()[Symbol.asyncIterator]() + await iterator.next() + await iterator.next() + await iterator.next() + + expect((await iterator.next()).value?.event).toMatchObject({ + type: "session.activity", + activity: { type: "idle" }, + item: { completed: 4, error: { data: { message: "boom" } }, content: [{ id: "text_1", text: "partial" }] }, + }) + }) + + test("does not infer assistant parents across history pages or direct fetches", async () => { + const assistant = { id: "assistant", type: "assistant", time: { created: 2 }, agent: "build", model: { id: "m", providerID: "p" }, content: [] } + const user = { id: "user", type: "user", time: { created: 1 }, text: "hello" } + const setupResult = setup((request) => { + const url = new URL(request.url) + if (url.pathname.endsWith("/message/assistant")) return json({ data: assistant }) + if (url.searchParams.get("cursor")) return json({ data: [user], cursor: {} }) + return json({ data: [assistant], cursor: { next: "older" } }) + }) + + const first = await setupResult.backend.common.sessions.history({ sessionID: "ses_1" }) + await setupResult.backend.common.sessions.history({ sessionID: "ses_1", cursor: first.older }) + const direct = await setupResult.backend.common.sessions.message({ sessionID: "ses_1", messageID: "assistant" }) + + expect(first.items[0]).toMatchObject({ type: "assistant", parentID: undefined }) + expect(direct).toMatchObject({ type: "assistant", parentID: undefined }) + }) + + test("normalizes lifecycle and provider refresh events without HTTP fallbacks", async () => { + const events = [ + { id: "moved", type: "session.next.moved", data: { timestamp: 1, sessionID: "ses_1", location: { directory: "/next" } } }, + { id: "revert", type: "session.next.revert.staged", data: { timestamp: 2, sessionID: "ses_1", revert: { messageID: "msg_1" } } }, + { id: "integration", type: "integration.updated", data: {} }, + { id: "unknown", type: "session.next.context.updated", data: { timestamp: 3, sessionID: "ses_1", messageID: "msg_1", text: "x" } }, + ] + const setupResult = setup(() => new Response(events.map((event) => `data: ${JSON.stringify(event)}\n\n`).join(""), { headers: { "content-type": "text/event-stream" } })) + const iterator = setupResult.backend.common.events.subscribe()[Symbol.asyncIterator]() + + expect((await iterator.next()).value?.event).toEqual({ type: "session.moved", sessionID: "ses_1", location: { directory: "/next" } }) + expect((await iterator.next()).value?.event).toEqual({ type: "session.revert", sessionID: "ses_1", revert: { messageID: "msg_1" } }) + expect((await iterator.next()).value?.event).toEqual({ type: "provider.updated" }) + expect((await iterator.next()).value?.event.type).toBe("unknown") + expect(setupResult.requests).toHaveLength(1) + }) + + test("normalizes native session create, update, and delete lifecycle events", async () => { + const info = { + id: "ses_1", + slug: "one", + version: "2", + projectID: "project", + directory: "/repo", + title: "Session", + time: { created: 1, updated: 2 }, + } + const events = [ + { id: "created", type: "session.created", data: { sessionID: "ses_1", info } }, + { id: "updated", type: "session.updated", data: { sessionID: "ses_1", info: { ...info, title: "Renamed" } } }, + { id: "deleted", type: "session.deleted", data: { sessionID: "ses_1", info } }, + ] + const setupResult = setup(() => new Response(events.map((event) => `data: ${JSON.stringify(event)}\n\n`).join(""), { headers: { "content-type": "text/event-stream" } })) + const iterator = setupResult.backend.common.events.subscribe()[Symbol.asyncIterator]() + + expect((await iterator.next()).value?.event).toMatchObject({ type: "session.created", session: { id: "ses_1", title: "Session" } }) + expect((await iterator.next()).value?.event).toMatchObject({ type: "session.updated", session: { id: "ses_1", title: "Renamed" } }) + expect((await iterator.next()).value?.event).toEqual({ type: "session.deleted", sessionID: "ses_1" }) + }) + test("normalizes durable session log events through the event mapper", async () => { - const setupResult = setup(() => - new Response( - `data: ${JSON.stringify({ - id: "evt_started", - created: 1, - type: "session.execution.started", - durable: { aggregateID: "ses_1", seq: 7, version: 1 }, - data: { sessionID: "ses_1" }, - })}\n\n`, - { headers: { "content-type": "text/event-stream" } }, - ), + const setupResult = setup( + () => + new Response( + `data: ${JSON.stringify({ + id: "evt_started", + type: "session.next.step.started", + durable: { aggregateID: "ses_1", seq: 7, version: 1 }, + data: { + timestamp: 1, + sessionID: "ses_1", + assistantMessageID: "msg_1", + agent: "build", + model: { id: "model", providerID: "provider" }, + }, + })}\n\n`, + { headers: { "content-type": "text/event-stream" } }, + ), ) const capability = setupResult.backend.capabilities.sessionExtrasV2 if (!capability) throw new Error("Missing V2 session capability") const result = await capability.log({ sessionID: "ses_1" })[Symbol.asyncIterator]().next() - expect(result.value).toEqual({ + expect(result.value).toMatchObject({ sequence: 7, event: { type: "session.activity", sessionID: "ses_1", activity: { type: "running" } }, }) - expect(new URL(setupResult.requests[0].url).pathname).toBe("/api/experimental/session/ses_1/log") + expect(new URL(setupResult.requests[0].url).pathname).toBe("/api/session/ses_1/event") }) - test("refreshes message projections for streamed V2 fragments", async () => { + test("normalizes native todo and part removal events", async () => { + const events = [ + { + id: "evt_todo", + type: "todo.updated", + data: { sessionID: "ses_1", todos: [{ content: "Ship", status: "pending", priority: "high" }] }, + }, + { + id: "evt_removed", + type: "message.part.removed", + data: { sessionID: "ses_1", messageID: "msg_1", partID: "part_1" }, + }, + ] + const setupResult = setup( + () => + new Response(events.map((event) => `data: ${JSON.stringify(event)}\n\n`).join(""), { + headers: { "content-type": "text/event-stream" }, + }), + ) + const iterator = setupResult.backend.common.events.subscribe()[Symbol.asyncIterator]() + + expect((await iterator.next()).value?.event).toEqual({ + type: "todo.updated", + sessionID: "ses_1", + todos: [{ content: "Ship", status: "pending", priority: "high" }], + }) + expect((await iterator.next()).value?.event).toEqual({ + type: "timeline.part.removed", + sessionID: "ses_1", + itemID: "msg_1", + contentID: "part_1", + }) + }) + + test("preserves streamed V2 timeline deltas without projection refresh", async () => { const setupResult = setup((request) => { if (new URL(request.url).pathname === "/api/event") { return new Response( `data: ${JSON.stringify({ id: "evt_1", - created: 1, - type: "session.text.delta", + type: "session.next.text.delta", location: { directory: "/repo" }, - data: { sessionID: "ses_1", assistantMessageID: "msg_1", ordinal: 0, delta: "hi" }, + data: { + timestamp: 1, + sessionID: "ses_1", + assistantMessageID: "msg_1", + textID: "text_1", + delta: "hi", + }, })}\n\n`, { headers: { "content-type": "text/event-stream" } }, ) @@ -218,7 +685,7 @@ describe("createV2Backend", () => { time: { created: 1 }, agent: "build", model: { id: "model", providerID: "provider" }, - content: [{ type: "text", text: "hello" }], + content: [{ type: "text", id: "text_1", text: "hello" }], }, }) }) @@ -228,29 +695,51 @@ describe("createV2Backend", () => { expect(result.value).toEqual({ location: { directory: "/repo" }, event: { - type: "timeline.updated", - item: { - type: "assistant", - id: "msg_1", - sessionID: "ses_1", - created: 1, - completed: undefined, - content: [{ type: "text", id: "msg_1:text:0", text: "hello" }], - agent: "build", - model: { id: "model", providerID: "provider" }, - tokens: undefined, - error: undefined, - raw: { - id: "msg_1", - type: "assistant", - time: { created: 1 }, - agent: "build", - model: { id: "model", providerID: "provider" }, - content: [{ type: "text", text: "hello" }], - }, - }, + type: "timeline.delta", + sessionID: "ses_1", + itemID: "msg_1", + contentID: "text_1", + field: "text", + delta: "hi", }, }) - expect(new URL(setupResult.requests[1].url).pathname).toBe("/api/session/ses_1/message/msg_1") + expect(setupResult.requests).toHaveLength(1) + }) + + test("projects native fragment starts without blocking the event stream on HTTP", async () => { + const events = [ + { + id: "evt_step", + type: "session.next.step.started", + data: { + timestamp: 1, + sessionID: "ses_1", + assistantMessageID: "msg_1", + agent: "build", + model: { id: "model", providerID: "provider" }, + }, + }, + { + id: "evt_text", + type: "session.next.text.started", + data: { timestamp: 2, sessionID: "ses_1", assistantMessageID: "msg_1", textID: "text_1" }, + }, + ] + const setupResult = setup( + () => + new Response(events.map((event) => `data: ${JSON.stringify(event)}\n\n`).join(""), { + headers: { "content-type": "text/event-stream" }, + }), + ) + const iterator = setupResult.backend.common.events.subscribe()[Symbol.asyncIterator]() + + await iterator.next() + expect((await iterator.next()).value?.event).toEqual({ + type: "timeline.content.updated", + sessionID: "ses_1", + itemID: "msg_1", + content: { type: "text", id: "text_1", text: "" }, + }) + expect(setupResult.requests).toHaveLength(1) }) }) diff --git a/packages/app/src/context/backend-v2.ts b/packages/app/src/context/backend-v2.ts index 3eea631c12..ee3915b6da 100644 --- a/packages/app/src/context/backend-v2.ts +++ b/packages/app/src/context/backend-v2.ts @@ -36,6 +36,8 @@ import type { LocationRef, PendingSessionInput, PromptFile, + PromptInput, + PtyTransportConfig, RequestOptions, SessionActivity, SessionLogItem, @@ -44,30 +46,43 @@ import type { TimelineItem, } from "./backend" -export function createV2Backend(client: OpenCodeClient, defaultLocation?: LocationRef): AppClient { +export function createV2Backend( + client: OpenCodeClient, + transportConfig: PtyTransportConfig, + defaultLocation?: LocationRef, + eventClient: OpenCodeClient = client, +): AppClient { const request = (input?: RequestOptions) => ({ signal: input?.signal }) const location = (input?: LocationInput) => toLocation(input?.location ?? defaultLocation) - - const loadSession = async (sessionID: string, options?: RequestOptions) => - toSession(await client.session.get({ sessionID }, request(options))) - const loadMessage = async (sessionID: string, messageID: string, options?: RequestOptions) => - toTimelineItem(await client.session.message({ sessionID, messageID }, request(options)), sessionID) + const projectedContent = new Map() + const projectedItems = new Map() + const projectedSessions = new Map() + const projectedPrompts = new Map() + const selections = new Map() + const sessionAdmissions = new Map>() + const projectSession = (input: SessionInfo) => { + const result = toSession(input) + projectedSessions.set(result.id, result) + selections.set(result.id, { agent: input.agent, model: input.model }) + return result + } + const loadSession = async (input: LocationInput & { sessionID: string }, options?: RequestOptions) => + projectSession(await client.session.get({ sessionID: input.sessionID }, request(options))) return { version: "v2", common: { health: { get: (options) => client.health.get(request(options)) }, projects: { - list: async (options) => (await client.project.list(request(options))).map(toProject), - current: (input, options) => client.project.current({ location: location(input) }, request(options)), + current: async (input, options) => + (await client.location.get({ location: location(input) }, request(options))).project, }, catalog: { providers: async (input, options) => { const params = { location: location(input) } - const [providers, models, selected] = await Promise.all([ + const [providers, models] = await Promise.all([ client.provider.list(params, request(options)), client.model.list(params, request(options)), - client.model.default(params, request(options)), ]) return { providers: new Map( @@ -76,6 +91,7 @@ export function createV2Backend(client: OpenCodeClient, defaultLocation?: Locati { id: provider.id, name: provider.name, + integrationID: provider.integrationID, models: Object.fromEntries( models.data .filter((model) => model.providerID === provider.id && model.status !== "deprecated") @@ -85,7 +101,7 @@ export function createV2Backend(client: OpenCodeClient, defaultLocation?: Locati ]), ), connected: providers.data.filter((provider) => !provider.disabled).map((provider) => provider.id), - defaults: selected.data ? { [selected.data.providerID]: selected.data.id } : {}, + defaults: {}, } }, agents: async (input, options) => @@ -110,106 +126,157 @@ export function createV2Backend(client: OpenCodeClient, defaultLocation?: Locati }, sessions: { list: async (input, options) => { - const result = await client.session.list( - { - workspace: input?.location?.workspaceID ?? defaultLocation?.workspaceID, - directory: input?.location?.directory ?? defaultLocation?.directory, - parentID: input?.roots ? null : undefined, - limit: input?.limit, - search: input?.search, - cursor: input?.cursor, - }, - request(options), - ) + const params = { + workspace: input?.location?.workspaceID ?? defaultLocation?.workspaceID, + directory: input?.location?.directory ?? defaultLocation?.directory, + search: input?.search, + } + const results = new Map() + const target = input?.limit ?? Number.POSITIVE_INFINITY + let cursor = input?.cursor + let newer: string | undefined + const cursors = new Set() + do { + if (cursor) { + if (cursors.has(cursor)) break + cursors.add(cursor) + } + const result = await client.session.list( + { ...params, limit: input?.roots ? 1 : input?.limit, cursor }, + request(options), + ) + if (newer === undefined) newer = result.cursor.previous ?? undefined + result.data.filter((item) => !input?.roots || !item.parentID).forEach((item) => results.set(item.id, item)) + cursor = result.cursor.next ?? undefined + if (!input?.roots || results.size >= target) break + } while (cursor) return { - items: result.data.map(toSession), - previous: result.cursor.previous ?? undefined, - next: result.cursor.next ?? undefined, + items: [...results.values()].slice(0, target).map(projectSession), + newer, + older: cursor, } }, create: async (input, options) => - toSession( + projectSession( await client.session.create( { location: input?.location ?? defaultLocation, agent: input?.agent, model: input?.model }, request(options), ), ), - get: (input, options) => loadSession(input.sessionID, options), - remove: async (input, options) => { - await client.session.remove({ sessionID: input.sessionID }, request(options)) - }, - fork: async (input, options) => - toSession(await client.session.fork({ sessionID: input.sessionID, messageID: input.messageID }, request(options))), - rename: async (input, options) => { - await client.session.rename({ sessionID: input.sessionID, title: input.title }, request(options)) - }, + get: loadSession, interrupt: async (input, options) => { await client.session.interrupt({ sessionID: input.sessionID }, request(options)) }, - activity: async (_input, options) => { - const result = await client.session.active(request(options)) - return Object.fromEntries(Object.keys(result).map((sessionID) => [sessionID, { type: "running" } as const])) - }, + activity: (_input, options) => client.session.active(request(options)), history: async (input, options) => { const result = await client.message.list( { sessionID: input.sessionID, limit: input.limit, cursor: input.cursor }, request(options), ) return { - items: result.data.map((item) => toTimelineItem(item, input.sessionID)), - previous: result.cursor.previous ?? undefined, - next: result.cursor.next ?? undefined, + items: result.data.map((item) => + toTimelineItem(item, input.sessionID, undefined, projectedPrompts.get(item.id)?.content), + ), + newer: result.cursor.previous ?? undefined, + older: result.cursor.next ?? undefined, } }, - message: (input, options) => loadMessage(input.sessionID, input.messageID, options), - prompt: async (input, options) => { - if (input.selection?.agent) - await client.session.switchAgent( - { sessionID: input.sessionID, agent: input.selection.agent }, - request(options), + message: async (input, options) => + toTimelineItem( + await client.session.message({ sessionID: input.sessionID, messageID: input.messageID }, request(options)), + input.sessionID, + undefined, + projectedPrompts.get(input.messageID)?.content, + ), + prompt: (input, options) => { + const previous = sessionAdmissions.get(input.sessionID) ?? Promise.resolve() + const admission = previous.catch(() => undefined).then(async () => { + const selected = selections.get(input.sessionID) + if (input.selection?.agent && input.selection.agent !== selected?.agent) { + await client.session.switchAgent( + { sessionID: input.sessionID, agent: input.selection.agent }, + request(options), + ) + selections.set(input.sessionID, { ...selected, agent: input.selection.agent }) + } + const current = selections.get(input.sessionID) + if (input.selection?.model && !sameModel(input.selection.model, current?.model)) { + await client.session.switchModel( + { sessionID: input.sessionID, model: input.selection.model }, + request(options), + ) + selections.set(input.sessionID, { ...current, model: input.selection.model }) + } + const parts = input.parts + if (parts) projectedPrompts.set(input.id, { sessionID: input.sessionID, content: toPromptContent(parts) }) + const response = await transportConfig.fetch( + nativeSessionURL(transportConfig, input.sessionID, "/prompt"), + { + method: "POST", + signal: options?.signal, + headers: transportHeaders(transportConfig, false, true), + body: JSON.stringify({ + id: input.id, + prompt: { + text: + parts + ?.filter((part) => part.type === "text") + .map((part) => part.text) + .join("") ?? input.text, + files: + parts + ?.filter((part) => part.type === "file") + .map((file) => ({ + uri: file.url, + mime: file.mime, + name: file.filename, + source: file.source && { + start: file.source.text.start, + end: file.source.text.end, + text: file.source.text.value, + }, + })) ?? input.files?.map(toPromptFile), + agents: + parts + ?.filter((part) => part.type === "agent") + .map((agent) => ({ + name: agent.name, + source: agent.source && { + start: agent.source.start, + end: agent.source.end, + text: agent.source.value, + }, + })) ?? + input.agents?.map((agent) => ({ + name: agent.name, + source: + agent.start === undefined || agent.end === undefined || agent.text === undefined + ? undefined + : { start: agent.start, end: agent.end, text: agent.text }, + })), + }, + delivery: input.delivery, + }), + }, ) - if (input.selection?.model) - await client.session.switchModel( - { sessionID: input.sessionID, model: input.selection.model }, - request(options), - ) - await client.session.prompt( - { - sessionID: input.sessionID, - id: input.id, - text: input.text, - files: input.files?.map(toPromptFile), - agents: input.agents?.map((agent) => ({ - name: agent.name, - mention: - agent.start === undefined || agent.end === undefined || agent.text === undefined - ? undefined - : { start: agent.start, end: agent.end, text: agent.text }, - })), - delivery: input.delivery, - }, - request(options), - ) - }, - command: async (input, options) => { - await client.session.command( - { - sessionID: input.sessionID, - id: input.id, - command: input.command, - arguments: input.arguments, - agent: input.agent, - model: input.model, - files: input.files?.map(toPromptFile), - delivery: input.delivery, - }, - request(options), - ) + if (response.ok) return + projectedPrompts.delete(input.id) + throw new Error(`Failed to submit prompt: ${response.status} ${response.statusText}`) + }) + sessionAdmissions.set(input.sessionID, admission) + return admission.finally(() => { + if (sessionAdmissions.get(input.sessionID) === admission) sessionAdmissions.delete(input.sessionID) + }) }, }, files: { list: async (input, options) => - (await client.file.list({ location: location(input), path: input.path }, request(options))).data, + (await client.file.list({ location: location(input), path: input.path }, request(options))).data.map((item) => ({ + ...item, + name: item.path.split(/[\\/]/).at(-1) ?? item.path, + absolute: item.path, + ignored: false, + })), find: async (input, options) => ( await client.file.find( @@ -217,9 +284,19 @@ export function createV2Backend(client: OpenCodeClient, defaultLocation?: Locati request(options), ) ).data, - read: async (input, options) => ({ - bytes: await client.file.read({ location: location(input), path: input.path }, request(options)), - }), + read: async (input, options) => { + const response = await transportConfig.fetch( + nativeFileURL(transportConfig, input.path, input.location ?? defaultLocation), + { signal: options?.signal, headers: transportHeaders(transportConfig) }, + ) + if (!response.ok) throw new Error(`Failed to read file: ${response.status} ${response.statusText}`) + const mimeType = response.headers.get("content-type") ?? undefined + return { + bytes: new Uint8Array(await response.arrayBuffer()), + kind: mimeType?.startsWith("text/") ? "text" : "binary", + mimeType, + } + }, }, permissions: { pending: async (input, options) => @@ -238,18 +315,6 @@ export function createV2Backend(client: OpenCodeClient, defaultLocation?: Locati await client.question.reject(input, request(options)) }, }, - vcs: { - status: async (input, options) => - (await client.vcs.status({ location: location(input) }, request(options))).data, - diff: async (input, options) => - (await client.vcs.diff({ ...input, location: location(input) }, request(options))).data.map(toFileDiff), - }, - mcp: { - list: async (input, options) => - (await client.mcp.list({ location: location(input) }, request(options))).data.map(toMcpServer), - resources: async (input, options) => - (await client.mcp.resource.catalog({ location: location(input) }, request(options))).data, - }, pty: { list: async (input, options) => (await client.pty.list({ location: location(input) }, request(options))).data.map(toPty), @@ -287,20 +352,50 @@ export function createV2Backend(client: OpenCodeClient, defaultLocation?: Locati events: { subscribe: (options) => ({ async *[Symbol.asyncIterator]() { - for await (const input of client.event.subscribe(request(options))) { + for await (const input of eventClient.event.subscribe(request(options))) { yield { location: input.location, - event: await toEvent(input, loadSession, loadMessage, options), + event: toEvent(input, projectedContent, projectedItems, projectedSessions, projectedPrompts), } } }, }), }, - disposeLocation: async (input, options) => { - await client.debug.location.evict({ location: location(input) }, request(options)) - }, }, capabilities: { + ptyTransport: { + connectToken: async (input, options) => { + const response = await transportConfig.fetch( + nativePtyURL(transportConfig, input.ptyID, "/connect-token", input.location ?? defaultLocation), + { + method: "POST", + signal: options?.signal, + headers: transportHeaders(transportConfig, true), + }, + ) + const body = response.ok ? ((await response.json()) as { data?: { ticket?: string } }) : undefined + return { status: response.status, ticket: body?.data?.ticket } + }, + exists: async (input, options) => { + const response = await transportConfig.fetch( + nativePtyURL(transportConfig, input.ptyID, "", input.location ?? defaultLocation), + { signal: options?.signal, headers: transportHeaders(transportConfig) }, + ) + return response.status !== 404 + }, + connectURL: (input) => { + const url = nativePtyURL(transportConfig, input.ptyID, "/connect", input.location) + url.searchParams.set("cursor", String(input.cursor)) + url.protocol = url.protocol === "https:" ? "wss:" : "ws:" + if (input.ticket) { + url.searchParams.set("ticket", input.ticket) + return url + } + if (!transportConfig.sameOrigin || transportConfig.password || transportConfig.authToken) + throw new Error("Native PTY connections require a ticket for cross-origin or authenticated access") + return url + }, + }, integrationsV2: { list: async (input, options) => (await client.integration.list({ location: location(input) }, request(options))).data.map(toIntegration), @@ -354,57 +449,27 @@ export function createV2Backend(client: OpenCodeClient, defaultLocation?: Locati sessionExtrasV2: { switchAgent: async (input, options) => { await client.session.switchAgent(input, request(options)) + selections.set(input.sessionID, { ...selections.get(input.sessionID), agent: input.agent }) }, switchModel: async (input, options) => { await client.session.switchModel(input, request(options)) + selections.set(input.sessionID, { ...selections.get(input.sessionID), model: input.model }) }, - move: async (input, options) => { - await client.session.move( - { sessionID: input.sessionID, destination: { directory: input.directory }, moveChanges: input.moveChanges }, - request(options), - ) - }, - skill: async (input, options) => { - await client.session.skill(input, request(options)) - }, - synthetic: async (input, options) => - toPending( - await client.session.synthetic( - { ...input, metadata: input.metadata && toClientRecord(input.metadata) }, - request(options), - ), - ), - shell: async (input, options) => { - await client.session.shell(input, request(options)) - }, - compact: async (input, options) => toPending(await client.session.compact(input, request(options))), wait: async (input, options) => { await client.session.wait(input, request(options)) }, context: async (input, options) => (await client.session.context(input, request(options))).map((item) => toTimelineItem(item, input.sessionID)), - pending: async (input, options) => - (await client.session.pending.list(input, request(options))).map(toPending), - instructionEntries: (input, options) => client.session.instructions.entry.list(input, request(options)), - putInstructionEntry: async (input, options) => { - await client.session.instructions.entry.put({ ...input, value: toClientJson(input.value) }, request(options)) - }, - removeInstructionEntry: async (input, options) => { - await client.session.instructions.entry.remove(input, request(options)) - }, log: (input, options) => ({ async *[Symbol.asyncIterator]() { - for await (const item of client.session.log(input, request(options))) { + for await (const item of nativeSessionEvents(transportConfig, input.sessionID, input.after, options)) { yield { - sequence: "durable" in item ? item.durable.seq : (item.seq ?? 0), - event: await toLogEvent(item, loadSession, loadMessage, options), + sequence: eventSequence(item), + event: toEvent(item, projectedContent, projectedItems, projectedSessions, projectedPrompts), } satisfies SessionLogItem } }, }), - background: async (input, options) => { - await client.session.background(input, request(options)) - }, stageRevert: async (input, options) => { const result = await client.session.revert.stage( { sessionID: input.sessionID, messageID: input.messageID, files: input.files?.length ? true : undefined }, @@ -420,8 +485,6 @@ export function createV2Backend(client: OpenCodeClient, defaultLocation?: Locati }, }, projectCopiesV2: { - directories: (input, options) => - client.project.directories({ projectID: input.projectID, location: location(input) }, request(options)), create: (input, options) => client.projectCopy.create({ ...input, location: location(input) }, request(options)), remove: async (input, options) => { await client.projectCopy.remove({ ...input, location: location(input) }, request(options)) @@ -430,98 +493,12 @@ export function createV2Backend(client: OpenCodeClient, defaultLocation?: Locati await client.projectCopy.refresh({ ...input, location: location(input) }, request(options)) }, }, - formsV2: { - pending: async (input, options) => - (await client.form.request.list({ location: location(input) }, request(options))).data.map(toForm), - list: async (input, options) => (await client.form.list(input, request(options))).map(toForm), - create: async (input, options) => { - if (input.fields.length === 0) throw new Error("A form requires at least one field") - const fields: FormCreateInput["fields"] = [ - fromFormField(input.fields[0]), - ...input.fields.slice(1).map(fromFormField), - ] - return toForm( - await client.form.create( - { ...input, metadata: input.metadata && toClientRecord(input.metadata), fields }, - request(options), - ), - ) - }, - get: async (input, options) => toForm(await client.form.get(input, request(options))), - state: (input, options) => client.form.state(input, request(options)), - reply: async (input, options) => { - await client.form.reply({ ...input, answer: { ...input.answer } }, request(options)) - }, - cancel: async (input, options) => { - await client.form.cancel(input, request(options)) - }, - }, savedPermissionsV2: { list: (input, options) => client.permission.saved.list(input, request(options)), remove: async (input, options) => { await client.permission.saved.remove(input, request(options)) }, }, - shellsV2: { - list: async (input, options) => - (await client.shell.list({ location: location(input) }, request(options))).data.map(toShell), - create: async (input, options) => - toShell( - ( - await client.shell.create( - { - ...input, - metadata: input.metadata && toClientRecord(input.metadata), - location: location(input), - }, - request(options), - ) - ).data, - ), - get: async (input, options) => - toShell((await client.shell.get({ id: input.id, location: location(input) }, request(options))).data), - setTimeout: async (input, options) => - toShell( - ( - await client.shell.timeout( - { id: input.id, timeout: input.timeout, location: location(input) }, - request(options), - ) - ).data, - ), - output: async (input, options) => - ( - await client.shell.output( - { id: input.id, cursor: input.cursor, limit: input.limit, location: location(input) }, - request(options), - ) - ).data, - remove: async (input, options) => { - await client.shell.remove({ id: input.id, location: location(input) }, request(options)) - }, - }, - discoveryV2: { - server: (options) => client.server.get(request(options)), - location: (input, options) => client.location.get({ location: location(input) }, request(options)), - plugins: async (input, options) => - (await client.plugin.list({ location: location(input) }, request(options))).data, - skills: async (input, options) => { - const result = await client.skill.list({ location: location(input) }, request(options)) - return result.data.map((item) => ({ - ...item, - location: { directory: item.location }, - })) - }, - models: async (input, options) => - (await client.model.list({ location: location(input) }, request(options))).data.map(toModel), - defaultModel: async (input, options) => { - const result = await client.model.default({ location: location(input) }, request(options)) - return result.data ? toModel(result.data) : null - }, - generateText: async (input, options) => - (await client.generate.text({ ...input, location: location(input) }, request(options))).text, - loadedLocations: (options) => client.debug.location.list(request(options)), - }, }, } } @@ -531,10 +508,46 @@ function toLocation(input?: LocationRef) { return { directory: input.directory, workspace: input.workspaceID } } +function sameModel( + left: { id: string; providerID: string; variant?: string }, + right?: { id: string; providerID: string; variant?: string }, +) { + return left.id === right?.id && left.providerID === right.providerID && left.variant === right.variant +} + +function nativeSessionURL(config: PtyTransportConfig, sessionID: string, suffix: string) { + return new URL(`${config.baseUrl.replace(/\/+$/, "")}/api/session/${encodeURIComponent(sessionID)}${suffix}`) +} + +function nativePtyURL(config: PtyTransportConfig, ptyID: string, suffix: string, location?: LocationRef) { + const url = new URL(`${config.baseUrl.replace(/\/+$/, "")}/api/pty/${encodeURIComponent(ptyID)}${suffix}`) + if (location?.directory) url.searchParams.set("location[directory]", location.directory) + if (location?.workspaceID) url.searchParams.set("location[workspace]", location.workspaceID) + return url +} + +function nativeFileURL(config: PtyTransportConfig, path: string, location?: LocationRef) { + const url = new URL(`${config.baseUrl.replace(/\/+$/, "")}/api/fs/read/${encodeURIComponent(path)}`) + if (location?.directory) url.searchParams.set("location[directory]", location.directory) + if (location?.workspaceID) url.searchParams.set("location[workspace]", location.workspaceID) + return url +} + +function transportHeaders(config: PtyTransportConfig, ticket = false, json = false) { + const headers = new Headers() + if (config.password) + headers.set("authorization", `Basic ${btoa(`${config.username ?? "opencode"}:${config.password}`)}`) + if (ticket) headers.set("x-opencode-ticket", "1") + if (json) headers.set("content-type", "application/json") + return headers +} + function toProject(input: Project): AppProject { return { id: input.id, worktree: input.worktree, + vcs: input.vcs === "git" ? "git" : undefined, + time: input.time, name: input.name, icon: input.icon, commands: input.commands, @@ -545,9 +558,13 @@ function toProject(input: Project): AppProject { function toSession(input: SessionInfo): AppSession { return { id: input.id, + slug: input.id, + version: "", parentID: input.parentID, projectID: input.projectID, location: input.location, + directory: input.location.directory, + workspaceID: input.location.workspaceID, title: input.title, cost: input.cost, tokens: input.tokens, @@ -592,20 +609,26 @@ function toAgent(input: AgentInfo): AppAgent { } } -function toTimelineItem(input: SessionMessageInfo, sessionID: string): TimelineItem { +function toTimelineItem( + input: SessionMessageInfo, + sessionID: string, + parentID?: string, + projectedPrompt?: TimelineContent[], +): TimelineItem { if (input.type === "user") { return { type: "user", id: input.id, sessionID, created: input.time.created, - content: [ + content: projectedPrompt ?? [ { type: "text", id: `${input.id}:text`, text: input.text }, ...(input.files?.map((file, index) => ({ type: "file" as const, id: `${input.id}:file:${index}`, uri: file.source.type === "uri" ? file.source.uri : `data:${file.mime};base64,${file.data}`, name: file.name, + mime: file.mime, })) ?? []), ...(input.agents?.map((agent, index) => ({ type: "agent" as const, @@ -614,24 +637,24 @@ function toTimelineItem(input: SessionMessageInfo, sessionID: string): TimelineI source: agent.mention, })) ?? []), ], - raw: input, } } - if (input.type === "assistant") return toAssistant(input, sessionID) + if (input.type === "assistant") return toAssistant(input, sessionID, parentID) const type = input.type === "agent-switched" ? "agent-switch" : input.type === "model-switched" ? "model-switch" : input.type - return { type, id: input.id, sessionID, created: input.time.created, raw: input } + return { type, id: input.id, sessionID, created: input.time.created } } -function toAssistant(input: SessionMessageAssistant, sessionID: string): TimelineItem { +function toAssistant(input: SessionMessageAssistant, sessionID: string, parentID?: string): TimelineItem { return { type: "assistant", id: input.id, sessionID, + parentID, created: input.time.created, completed: input.time.completed, content: input.content.flatMap((item, index): TimelineContent[] => { @@ -653,7 +676,12 @@ function toAssistant(input: SessionMessageAssistant, sessionID: string): Timelin type: "tool", id: item.id, tool: item.name, - state: { status: "running", input: item.state.input, metadata: item.state.structured }, + state: { + status: "running", + input: item.state.input, + metadata: item.state.structured, + time: { start: input.time.created }, + }, }, ] if (item.state.status === "error") @@ -667,6 +695,7 @@ function toAssistant(input: SessionMessageAssistant, sessionID: string): Timelin input: item.state.input, error: item.state.error.message, metadata: item.state.structured, + time: { start: input.time.created, end: input.time.completed ?? input.time.created }, }, }, ] @@ -681,7 +710,9 @@ function toAssistant(input: SessionMessageAssistant, sessionID: string): Timelin output: item.state.content .map((content) => (content.type === "text" ? content.text : content.uri)) .join("\n"), + title: "", metadata: item.state.structured, + time: { start: input.time.created, end: input.time.completed ?? input.time.created }, }, }, ] @@ -689,31 +720,77 @@ function toAssistant(input: SessionMessageAssistant, sessionID: string): Timelin agent: input.agent, model: input.model, tokens: input.tokens, - error: input.error, - raw: input, + error: input.error && { name: "UnknownError", data: { message: input.error.message } }, } } function toPromptFile(input: PromptFile) { return { uri: input.uri, + mime: input.mime ?? "application/octet-stream", name: input.name, - mention: input.source && { start: input.source.start, end: input.source.end, text: input.source.text }, + source: input.source && { start: input.source.start, end: input.source.end, text: input.source.text }, } } +function toPromptContent(parts: NonNullable): TimelineContent[] { + return parts.map((part) => { + if (part.type === "text") return { ...part } + if (part.type === "agent") + return { + type: part.type, + id: part.id, + name: part.name, + source: part.source && { text: part.source.value, start: part.source.start, end: part.source.end }, + } + return { + type: part.type, + id: part.id, + uri: part.url, + name: part.filename, + mime: part.mime, + source: + part.source?.type === "resource" + ? { + type: part.source.type, + clientName: part.source.clientName, + uri: part.source.uri, + text: { text: part.source.text.value, start: part.source.text.start, end: part.source.text.end }, + } + : part.source && { + type: part.source.type, + path: part.source.path, + name: part.source.type === "symbol" ? part.source.name : undefined, + kind: part.source.type === "symbol" ? part.source.kind : undefined, + text: { text: part.source.text.value, start: part.source.text.start, end: part.source.text.end }, + }, + } + }) +} + function toPermission(input: PermissionV2Request): AppPermissionRequest { return { id: input.id, sessionID: input.sessionID, action: input.action, resources: input.resources, - metadata: input.metadata, + permission: input.action, + patterns: [...input.resources], + always: [], + metadata: input.metadata ? { ...input.metadata } : {}, } } function toQuestion(input: QuestionV2Request): AppQuestionRequest { - return { id: input.id, sessionID: input.sessionID, questions: input.questions } + return { + id: input.id, + sessionID: input.sessionID, + questions: input.questions.map((question) => ({ + ...question, + header: question.header ?? "", + options: question.options.map((option) => ({ ...option, description: option.description ?? "" })), + })), + } } function toFileDiff(input: { file: string; patch: string; additions: number; deletions: number; status: "added" | "deleted" | "modified" }): AppFileDiff { @@ -739,8 +816,8 @@ function toIntegration(input: IntegrationInfo) { }), connections: input.connections.map((connection): IntegrationConnection => connection.type === "credential" - ? { id: connection.id, label: connection.label } - : { id: connection.name, label: connection.name }, + ? { id: connection.id, label: connection.label, kind: "credential" } + : { id: connection.name, label: connection.name, kind: "environment" }, ), } } @@ -856,115 +933,458 @@ function toShell(input: ShellInfo1): ShellProcess { } } -async function toEvent( +type NativeEventData = { + sessionID: string + assistantMessageID: string + messageID: string + partID: string + requestID: string + textID: string + reasoningID: string + callID: string + timestamp: number + agent: string + model: { id: string; providerID: string; variant?: string } + finish: string + cost: number + tokens: import("./backend").TokenUsage + error: { message: string } + location: LocationRef + revert: { messageID: string } + prompt: { text: string; files?: { uri: string; name?: string; mime?: string }[]; agents?: { name: string; source?: import("./backend").SourceText }[] } + text: string + delta: string + field: string + name: string + tool: string + input: Record + structured: Record + content: import("./backend").ToolOutputContent[] + outputPaths: string[] + result: unknown + provider: import("./backend").ToolProviderInfo + providerMetadata: Record + info: unknown + todos: { content: string; status: string; priority: string }[] + diff: { file?: string; patch?: string; additions: number; deletions: number; status?: "added" | "deleted" | "modified" }[] +} + +function toEvent( input: OpenCodeEvent, - loadSession: (sessionID: string, options?: RequestOptions) => Promise, - loadMessage: (sessionID: string, messageID: string, options?: RequestOptions) => Promise, - options?: RequestOptions, -): Promise { - if (input.type === "server.connected") return { type: input.type } - if (input.type === "session.deleted") return { type: input.type, sessionID: input.data.sessionID } - if (input.type === "session.error") return { type: input.type, sessionID: input.data.sessionID, error: input.data.error } - if (input.type === "session.status") { - const activity = toActivity(input.data.status) - return activity - ? { type: "session.activity", sessionID: input.data.sessionID, activity } - : { type: "unknown", raw: input } + projectedContent: Map, + projectedItems: Map, + projectedSessions: Map, + projectedPrompts: Map, +): AppEvent { + const type = input.type as string + const data = input.data as unknown as NativeEventData + if (type === "server.connected") { + projectedContent.clear() + projectedItems.clear() + projectedSessions.clear() + projectedPrompts.clear() + return { type: "server.connected" } } - if (input.type === "session.execution.started") - return { type: "session.activity", sessionID: input.data.sessionID, activity: { type: "running" } } - if (input.type === "session.retry.scheduled") + if (type === "session.created" || type === "session.updated") { + const session = eventSession(data.info) + if (!session) return { type: "unknown", raw: input } + projectedSessions.set(session.id, session) + return { type: type === "session.created" ? "session.created" : "session.updated", session } + } + if (type === "session.deleted") { + projectedSessions.delete(data.sessionID) + clearSessionProjection(data.sessionID, projectedContent, projectedItems) + clearSessionPrompts(data.sessionID, projectedPrompts) + return { type: "session.deleted", sessionID: data.sessionID } + } + if (type === "session.next.moved") { + const current = projectedSessions.get(data.sessionID) + if (current) + projectedSessions.set(current.id, { + ...current, + location: data.location, + directory: data.location.directory, + workspaceID: data.location.workspaceID, + }) + return { type: "session.moved", sessionID: data.sessionID, location: data.location } + } + if (type === "session.next.revert.staged") { + const revert = { messageID: data.revert.messageID } + const current = projectedSessions.get(data.sessionID) + if (current) projectedSessions.set(current.id, { ...current, revert }) + return { type: "session.revert", sessionID: data.sessionID, revert } + } + if (type === "session.next.revert.cleared" || type === "session.next.revert.committed") { + const current = projectedSessions.get(data.sessionID) + if (current) projectedSessions.set(current.id, { ...current, revert: undefined }) + return { type: "session.revert", sessionID: data.sessionID } + } + if ( + type === "integration.updated" || + type === "integration.connection.updated" || + type === "catalog.updated" || + type === "models-dev.refreshed" + ) + return { type: "provider.updated" } + if (type === "permission.v2.asked") + return { type: "permission.requested", request: toPermission(input.data as PermissionV2Request) } + if (type === "permission.v2.replied") + return { type: "permission.replied", sessionID: data.sessionID, requestID: data.requestID } + if (type === "question.v2.asked") + return { type: "question.requested", request: toQuestion(input.data as QuestionV2Request) } + if (type === "question.v2.replied" || type === "question.v2.rejected") return { - type: "session.activity", - sessionID: input.data.sessionID, - activity: { - type: "retry", - attempt: input.data.attempt, - message: input.data.error.message, - next: input.data.at, + type: type === "question.v2.replied" ? "question.replied" : "question.rejected", + sessionID: data.sessionID, + requestID: data.requestID, + } + if (type === "file.watcher.updated" || type === "filesystem.changed") { + const changed = input.data as unknown as { file: string; event: "add" | "change" | "unlink" } + return { type: "file.changed", path: changed.file, change: changed.event } + } + if (type === "pty.exited") return { type: "pty.exited", ptyID: (input.data as unknown as { id: string }).id } + if (type === "message.removed") + return { type: "timeline.removed", sessionID: data.sessionID, itemID: data.messageID } + if (type === "session.next.prompted" || type === "session.next.prompt.admitted") + return { + type: "timeline.updated", + item: { + type: "user", + id: data.messageID, + sessionID: data.sessionID, + created: data.timestamp, + content: projectedPrompts.get(data.messageID)?.content ?? [ + { type: "text", id: `${data.messageID}:text`, text: data.prompt.text }, + ...(data.prompt.files?.map((file, index) => ({ + type: "file" as const, + id: `${data.messageID}:file:${index}`, + uri: file.uri, + name: file.name, + mime: file.mime, + })) ?? []), + ...(data.prompt.agents?.map((agent, index) => ({ + type: "agent" as const, + id: `${data.messageID}:agent:${index}`, + name: agent.name, + source: agent.source, + })) ?? []), + ], }, } - if (input.type === "session.execution.failed") - return { type: "session.error", sessionID: input.data.sessionID, error: input.data.error } - if (input.type === "session.idle") return { type: "unknown", raw: input } - if (input.type === "permission.v2.asked") return { type: "permission.requested", request: toPermission(input.data) } - if (input.type === "permission.v2.replied") - return { type: "permission.replied", sessionID: input.data.sessionID, requestID: input.data.requestID } - if (input.type === "question.v2.asked") return { type: "question.requested", request: toQuestion(input.data) } - if (input.type === "question.v2.replied" || input.type === "question.v2.rejected") - return { - type: input.type === "question.v2.replied" ? "question.replied" : "question.rejected", - sessionID: input.data.sessionID, - requestID: input.data.requestID, + if (type === "session.next.step.started") { + const item = { + type: "assistant" as const, + id: data.assistantMessageID, + sessionID: data.sessionID, + created: data.timestamp, + content: [] as TimelineContent[], + agent: data.agent, + model: data.model, } - if (input.type === "filesystem.changed") - return { type: "file.changed", path: input.data.file, change: input.data.event } - if (input.type === "vcs.branch.updated") return { type: input.type, branch: input.data.branch } - if (input.type === "pty.exited") return { type: input.type, ptyID: input.data.id } - if (input.type === "message.removed") - return { type: "timeline.removed", sessionID: input.data.sessionID, itemID: input.data.messageID } - if (input.type === "message.updated") - return { - type: "timeline.updated", - item: await loadMessage(input.data.sessionID, input.data.info.id, options), - } - if (input.type === "message.part.updated") - return { - type: "timeline.updated", - item: await loadMessage(input.data.sessionID, input.data.part.messageID, options), - } - - const sessionID = eventSessionID(input) - const messageID = eventMessageID(input) - if (sessionID && messageID) - return { type: "timeline.updated", item: await loadMessage(sessionID, messageID, options) } - if (sessionID && isSessionProjectionEvent(input.type)) { - const session = await loadSession(sessionID, options) - return { type: input.type === "session.created" ? "session.created" : "session.updated", session } + projectedItems.set(item.id, item) + return { type: "session.activity", sessionID: data.sessionID, activity: { type: "running" }, item } } + if (type === "session.next.step.ended") { + const current = projectedItems.get(data.assistantMessageID) + const item = + current?.type === "assistant" + ? { + ...current, + completed: data.timestamp, + finish: data.finish, + cost: data.cost, + tokens: data.tokens, + content: projectedMessageContent(data.assistantMessageID, projectedContent), + } + : undefined + projectedItems.delete(data.assistantMessageID) + clearMessageContent(data.assistantMessageID, projectedContent) + clearSessionPrompts(data.sessionID, projectedPrompts) + return { type: "session.activity", sessionID: data.sessionID, activity: { type: "idle" }, item } + } + if (type === "session.next.step.failed") { + const current = projectedItems.get(data.assistantMessageID) + const item = + current?.type === "assistant" + ? { + ...current, + completed: data.timestamp, + error: { name: "UnknownError" as const, data: { message: data.error.message } }, + content: projectedMessageContent(data.assistantMessageID, projectedContent), + } + : undefined + projectedItems.delete(data.assistantMessageID) + clearMessageContent(data.assistantMessageID, projectedContent) + clearSessionPrompts(data.sessionID, projectedPrompts) + return { type: "session.activity", sessionID: data.sessionID, activity: { type: "idle" }, item } + } + if (type === "session.next.text.started" || type === "session.next.text.ended") { + const content = { + type: "text" as const, + id: data.textID, + text: type === "session.next.text.ended" ? data.text : "", + } + projectedContent.set(`${data.assistantMessageID}:${content.id}`, content) + return { + type: "timeline.content.updated", + sessionID: data.sessionID, + itemID: data.assistantMessageID, + content, + } + } + if (type === "session.next.reasoning.started" || type === "session.next.reasoning.ended") { + const content = { + type: "reasoning" as const, + id: data.reasoningID, + text: type === "session.next.reasoning.ended" ? data.text : "", + metadata: data.providerMetadata, + time: { start: data.timestamp, end: type === "session.next.reasoning.ended" ? data.timestamp : undefined }, + } + projectedContent.set(`${data.assistantMessageID}:${content.id}`, content) + return { + type: "timeline.content.updated", + sessionID: data.sessionID, + itemID: data.assistantMessageID, + content, + } + } + if ( + type === "session.next.tool.input.started" || + type === "session.next.tool.input.ended" || + type === "session.next.tool.called" || + type === "session.next.tool.progress" || + type === "session.next.tool.success" || + type === "session.next.tool.failed" + ) { + const key = `${data.assistantMessageID}:${data.callID}` + const previous = projectedContent.get(key) + const tool = previous?.type === "tool" ? previous.tool : data.name ?? data.tool + const priorInput = previous?.type === "tool" ? previous.state.input : {} + const state = (() => { + if (type === "session.next.tool.input.started") + return { status: "pending" as const, input: {}, raw: "" } + if (type === "session.next.tool.input.ended") + return { status: "pending" as const, input: {}, raw: data.text } + if (type === "session.next.tool.called") + return { + status: "running" as const, + input: data.input, + time: { start: data.timestamp }, + provider: data.provider, + } + if (type === "session.next.tool.progress") + return { + status: "running" as const, + input: priorInput, + metadata: data.structured, + time: { start: data.timestamp }, + content: data.content, + } + if (type === "session.next.tool.failed") + return { + status: "error" as const, + input: priorInput, + error: data.error.message, + time: { start: data.timestamp, end: data.timestamp }, + result: data.result, + provider: data.provider, + } + return { + status: "completed" as const, + input: priorInput, + output: data.content.map((content) => (content.type === "text" ? content.text : content.uri)).join("\n"), + title: "", + metadata: data.structured, + time: { start: data.timestamp, end: data.timestamp }, + content: data.content, + outputPaths: data.outputPaths, + result: data.result, + provider: data.provider, + attachments: data.content.flatMap((content, index) => + content.type === "file" + ? [ + { + id: `${data.callID}:attachment:${index}`, + sessionID: data.sessionID, + messageID: data.assistantMessageID, + type: "file" as const, + mime: content.mime, + filename: content.name, + url: content.uri, + }, + ] + : [], + ), + } + })() + const content = { type: "tool" as const, id: data.callID, callID: data.callID, tool, state } + projectedContent.set(key, content) + return { + type: "timeline.content.updated", + sessionID: data.sessionID, + itemID: data.assistantMessageID, + content, + } + } + if (type === "todo.updated") + return { + type: "todo.updated", + sessionID: data.sessionID, + todos: data.todos.map((todo) => ({ + content: todo.content, + status: todo.status, + priority: todo.priority, + })), + } + if (type === "reference.updated") return { type: "reference.updated" } + if (type === "session.diff") + return { + type: "session.diff", + sessionID: data.sessionID, + diff: data.diff.flatMap((item) => (item.file ? [{ ...item, file: item.file }] : [])), + } + if (type === "message.part.removed") { + projectedContent.delete(`${data.messageID}:${data.partID}`) + return { + type: "timeline.part.removed", + sessionID: data.sessionID, + itemID: data.messageID, + contentID: data.partID, + } + } + if (type === "message.part.delta") + return projectDelta( + { + type: "timeline.delta", + sessionID: data.sessionID, + itemID: data.messageID, + contentID: data.partID, + field: data.field, + delta: data.delta, + }, + projectedContent, + ) + if (type === "session.next.text.delta" || type === "session.next.reasoning.delta") + return projectDelta( + { + type: "timeline.delta", + sessionID: data.sessionID, + itemID: data.assistantMessageID, + contentID: type === "session.next.text.delta" ? data.textID : data.reasoningID, + field: "text", + delta: data.delta, + }, + projectedContent, + ) return { type: "unknown", raw: input } } -function toActivity(input: import("@opencode-ai/client").SessionStatus): SessionActivity | undefined { - if (input.type === "idle") return - if (input.type === "busy") return { type: "running" } - return input +function clearSessionPrompts( + sessionID: string, + projected: Map, +) { + for (const [messageID, prompt] of projected) if (prompt.sessionID === sessionID) projected.delete(messageID) } -function eventSessionID(input: OpenCodeEvent) { - if (!("data" in input) || typeof input.data !== "object" || input.data === null || !("sessionID" in input.data)) return - return typeof input.data.sessionID === "string" ? input.data.sessionID : undefined +function projectDelta(event: Extract, projected: Map) { + const key = `${event.itemID}:${event.contentID}` + const content = projected.get(key) + if (content && event.field === "text" && "text" in content) + projected.set(key, { ...content, text: content.text + event.delta }) + return event } -function eventMessageID(input: OpenCodeEvent) { - if (!("data" in input) || typeof input.data !== "object" || input.data === null) return - if ("assistantMessageID" in input.data && typeof input.data.assistantMessageID === "string") - return input.data.assistantMessageID - if ("messageID" in input.data && typeof input.data.messageID === "string") return input.data.messageID - if (input.type === "session.input.promoted") return input.data.inputID +function projectedMessageContent(messageID: string, projected: Map) { + return [...projected.entries()].filter(([key]) => key.startsWith(`${messageID}:`)).map(([, content]) => content) } -function isSessionProjectionEvent(type: string) { - return [ - "session.created", - "session.updated", - "session.agent.selected", - "session.model.selected", - "session.moved", - "session.renamed", - "session.usage.updated", - "session.revert.staged", - "session.revert.cleared", - "session.revert.committed", - ].includes(type) +function clearMessageContent(messageID: string, projected: Map) { + for (const key of projected.keys()) if (key.startsWith(`${messageID}:`)) projected.delete(key) } -async function toLogEvent( - input: import("@opencode-ai/client").SessionLogOutput, - loadSession: (sessionID: string, options?: RequestOptions) => Promise, - loadMessage: (sessionID: string, messageID: string, options?: RequestOptions) => Promise, +function clearSessionProjection( + sessionID: string, + projectedContent: Map, + projectedItems: Map, +) { + const messages = [...projectedItems.values()].filter((item) => item.sessionID === sessionID).map((item) => item.id) + messages.forEach((messageID) => clearMessageContent(messageID, projectedContent)) + for (const [id, item] of projectedItems) if (item.sessionID === sessionID) projectedItems.delete(id) +} + +function eventSession(input: unknown): AppSession | undefined { + const info = record(input) + if (!info || typeof info.id !== "string" || typeof info.projectID !== "string" || typeof info.title !== "string") return + const location = record(info.location) + const directory = + typeof location?.directory === "string" + ? location.directory + : typeof info.directory === "string" + ? info.directory + : undefined + if (!directory) return + const time = record(info.time) + if (typeof time?.created !== "number" || typeof time.updated !== "number") return + const workspaceID = + typeof location?.workspaceID === "string" + ? location.workspaceID + : typeof info.workspaceID === "string" + ? info.workspaceID + : undefined + return { + id: info.id, + slug: typeof info.slug === "string" ? info.slug : info.id, + version: typeof info.version === "string" ? info.version : "", + parentID: typeof info.parentID === "string" ? info.parentID : undefined, + projectID: info.projectID, + location: { directory, workspaceID }, + directory, + workspaceID, + title: info.title, + time: { + created: time.created, + updated: time.updated, + archived: typeof time.archived === "number" ? time.archived : undefined, + }, + } +} + +function record(input: unknown): Record | undefined { + if (!input || typeof input !== "object" || Array.isArray(input)) return + return input as Record +} + +function eventSequence(input: OpenCodeEvent) { + const durable = record(record(input)?.durable) + return typeof durable?.seq === "number" ? durable.seq : 0 +} + +async function* nativeSessionEvents( + config: PtyTransportConfig, + sessionID: string, + after: number | undefined, options?: RequestOptions, -): Promise { - if (input.type === "log.synced") return { type: "unknown", raw: input } - return toEvent(input, loadSession, loadMessage, options) +): AsyncIterable { + const url = nativeSessionURL(config, sessionID, "/event") + if (after !== undefined) url.searchParams.set("after", String(after)) + const response = await config.fetch(url, { signal: options?.signal, headers: transportHeaders(config) }) + if (!response.ok) throw new Error(`Failed to read session events: ${response.status} ${response.statusText}`) + if (!response.body) return + const reader = response.body.pipeThrough(new TextDecoderStream()).getReader() + let buffer = "" + while (true) { + const result = await reader.read() + buffer += result.value ?? "" + const records = buffer.split("\n\n") + buffer = records.pop() ?? "" + for (const entry of records) { + const payload = entry + .split("\n") + .filter((line) => line.startsWith("data:")) + .map((line) => line.slice(5).trimStart()) + .join("\n") + if (payload) yield JSON.parse(payload) as OpenCodeEvent + } + if (result.done) break + } } diff --git a/packages/app/src/context/backend.test-fixture.ts b/packages/app/src/context/backend.test-fixture.ts new file mode 100644 index 0000000000..7f600edbb5 --- /dev/null +++ b/packages/app/src/context/backend.test-fixture.ts @@ -0,0 +1,56 @@ +import type { AppClient, Capabilities, CommonClient } from "./backend" + +type PartialApi = { + [K in keyof T]?: T[K] extends (...args: never[]) => unknown ? T[K] : PartialApi +} + +export function createAppClient(input: { + version?: AppClient["version"] + common?: PartialApi + capabilities?: Capabilities +} = {}): AppClient { + const unsupported = async () => { + throw new Error("Backend fixture method is not configured") + } + const defaults: CommonClient = { + health: { get: unsupported }, + projects: { current: unsupported }, + catalog: { providers: unsupported, agents: unsupported }, + commands: { list: unsupported }, + references: { list: unsupported }, + sessions: { + list: unsupported, + create: unsupported, + get: unsupported, + interrupt: unsupported, + activity: unsupported, + history: unsupported, + message: unsupported, + prompt: unsupported, + }, + files: { list: unsupported, find: unsupported, read: unsupported }, + permissions: { pending: unsupported, reply: unsupported }, + questions: { pending: unsupported, reply: unsupported, reject: unsupported }, + pty: { list: unsupported, create: unsupported, get: unsupported, update: unsupported, remove: unsupported }, + events: { + async *subscribe() {}, + }, + } + return { + version: input.version ?? "v1", + capabilities: input.capabilities ?? {}, + common: { + health: { ...defaults.health, ...input.common?.health }, + projects: { ...defaults.projects, ...input.common?.projects }, + catalog: { ...defaults.catalog, ...input.common?.catalog }, + commands: { ...defaults.commands, ...input.common?.commands }, + references: { ...defaults.references, ...input.common?.references }, + sessions: { ...defaults.sessions, ...input.common?.sessions }, + files: { ...defaults.files, ...input.common?.files }, + permissions: { ...defaults.permissions, ...input.common?.permissions }, + questions: { ...defaults.questions, ...input.common?.questions }, + pty: { ...defaults.pty, ...input.common?.pty }, + events: { ...defaults.events, ...input.common?.events }, + }, + } +} diff --git a/packages/app/src/context/backend.ts b/packages/app/src/context/backend.ts index 6f17b6d464..8938c22bea 100644 --- a/packages/app/src/context/backend.ts +++ b/packages/app/src/context/backend.ts @@ -21,8 +21,10 @@ export type ModelRef = { export type Page = { readonly items: readonly T[] - readonly previous?: string - readonly next?: string + /** Cursor for items older than this page. */ + readonly older?: string + /** Cursor for items newer than this page. */ + readonly newer?: string } export type Health = { @@ -34,6 +36,12 @@ export type Health = { export type AppProject = { readonly id: string readonly worktree: string + readonly vcs?: "git" + readonly time: { + readonly created: number + readonly updated: number + readonly initialized?: number + } readonly name?: string readonly icon?: { readonly url?: string @@ -43,7 +51,7 @@ export type AppProject = { readonly commands?: { readonly start?: string } - readonly sandboxes: readonly string[] + sandboxes: string[] } export type CurrentProject = { @@ -83,6 +91,8 @@ export type AppModel = { export type AppProvider = { readonly id: string readonly name: string + readonly source?: "env" | "config" | "custom" | "api" + readonly integrationID?: string readonly models: Readonly> } @@ -137,15 +147,19 @@ export type TokenUsage = { export type AppSession = { readonly id: string + readonly slug: string + readonly version: string readonly parentID?: string readonly projectID: string - readonly location: LocationRef - readonly title: string - readonly cost: number + readonly location?: LocationRef + readonly directory: string + readonly workspaceID?: string + title: string + readonly cost?: number readonly tokens?: TokenUsage readonly time: { readonly created: number - readonly updated?: number + readonly updated: number readonly archived?: number } readonly share?: { @@ -157,6 +171,8 @@ export type AppSession = { } export type SessionActivity = + | { readonly type: "idle" } + | { readonly type: "busy" } | { readonly type: "running" } | { readonly type: "retry" @@ -205,28 +221,139 @@ export type ToolState = | { readonly status: "pending" readonly input: Readonly> - readonly raw?: string + readonly raw: string } | { readonly status: "running" readonly input: Readonly> readonly title?: string readonly metadata?: Readonly> + readonly time: { readonly start: number } + readonly content?: readonly ToolOutputContent[] + readonly provider?: ToolProviderInfo } | { readonly status: "completed" readonly input: Readonly> readonly output: string - readonly title?: string - readonly metadata?: Readonly> + readonly title: string + readonly metadata: Readonly> + readonly time: { readonly start: number; readonly end: number; readonly compacted?: number } + readonly attachments?: AppFilePart[] + readonly content?: readonly ToolOutputContent[] + readonly outputPaths?: readonly string[] + readonly result?: unknown + readonly provider?: ToolProviderInfo } | { readonly status: "error" readonly input: Readonly> readonly error: string readonly metadata?: Readonly> + readonly time: { readonly start: number; readonly end: number } + readonly content?: readonly ToolOutputContent[] + readonly result?: unknown + readonly provider?: ToolProviderInfo } +export type ToolOutputContent = + | { readonly type: "text"; readonly text: string } + | { readonly type: "file"; readonly uri: string; readonly mime: string; readonly name?: string } + +export type ToolProviderInfo = { + readonly executed: boolean + readonly metadata?: Readonly>>> + readonly resultMetadata?: Readonly>>> +} + +export type AppMessage = AppUserMessage | AppAssistantMessage + +export type AppUserMessage = { + readonly id: string + readonly sessionID: string + readonly role: "user" + readonly time: { readonly created: number } + readonly format?: + | { readonly type: "text" } + | { readonly type: "json_schema"; readonly schema: Readonly>; readonly retryCount?: number } + readonly summary?: { + readonly title?: string + readonly body?: string + readonly diffs: (Omit & { readonly file?: string })[] + } + readonly agent: string + readonly model: { readonly providerID: string; readonly modelID: string; readonly variant?: string } + readonly system?: string + readonly tools?: Readonly> +} + +export type AppAssistantMessage = { + readonly id: string + readonly sessionID: string + readonly role: "assistant" + readonly time: { readonly created: number; readonly completed?: number } + readonly parentID: string + readonly modelID: string + readonly providerID: string + readonly mode: string + readonly agent: string + readonly path: { readonly cwd: string; readonly root: string } + readonly summary?: boolean + readonly cost: number + readonly tokens: TokenUsage & { readonly total?: number } + readonly structured?: unknown + readonly variant?: string + readonly finish?: string + readonly error?: AppMessageError +} + +export type AppMessageError = + | { readonly name: "ProviderAuthError"; readonly data: { readonly providerID: string; readonly message: string } } + | { readonly name: "UnknownError"; readonly data: { readonly message: string; readonly ref?: string } } + | { readonly name: "MessageOutputLengthError"; readonly data: Readonly> } + | { readonly name: "MessageAbortedError"; readonly data: { readonly message: string } } + | { readonly name: "StructuredOutputError"; readonly data: { readonly message: string; readonly retries: number } } + | { readonly name: "ContextOverflowError"; readonly data: { readonly message: string; readonly responseBody?: string } } + | { readonly name: "ContentFilterError"; readonly data: { readonly message: string } } + | { readonly name: "APIError"; readonly data: { readonly message: string; readonly statusCode?: number; readonly isRetryable: boolean; readonly responseHeaders?: Readonly>; readonly responseBody?: string; readonly metadata?: Readonly> } } + +export type AppRetryError = Extract + +export type AppSessionError = AppMessageError | { readonly type: "unknown"; readonly message: string } + +type AppPartBase = { + readonly id: string + readonly sessionID: string + readonly messageID: string +} + +export type AppFilePart = AppPartBase & { + readonly type: "file" + readonly mime: string + readonly filename?: string + readonly url: string + readonly source?: AppFilePartSource +} + +export type AppFilePartSource = + | { readonly type: "file"; readonly path: string; readonly text: { readonly value: string; readonly start: number; readonly end: number } } + | { readonly type: "symbol"; readonly path: string; readonly name: string; readonly kind: number; readonly range: { readonly start: { readonly line: number; readonly character: number }; readonly end: { readonly line: number; readonly character: number } }; readonly text: { readonly value: string; readonly start: number; readonly end: number } } + | { readonly type: "resource"; readonly clientName: string; readonly uri: string; readonly text: { readonly value: string; readonly start: number; readonly end: number } } + +export type AppPart = + | (AppPartBase & { readonly type: "text"; readonly text: string; readonly synthetic?: boolean; readonly ignored?: boolean; readonly time?: { readonly start: number; readonly end?: number }; readonly metadata?: Readonly> }) + | (AppPartBase & { readonly type: "reasoning"; readonly text: string; readonly metadata?: Readonly>; readonly time: { readonly start: number; readonly end?: number } }) + | AppFilePart + | (AppPartBase & { readonly type: "agent"; readonly name: string; readonly source?: { readonly value: string; readonly start: number; readonly end: number } }) + | (AppPartBase & { readonly type: "tool"; readonly callID: string; readonly tool: string; readonly state: ToolState; readonly metadata?: Readonly> }) + | (AppPartBase & { readonly type: "subtask"; readonly prompt: string; readonly description: string; readonly agent: string; readonly model?: { readonly providerID: string; readonly modelID: string }; readonly command?: string }) + | (AppPartBase & { readonly type: "step-start"; readonly snapshot?: string }) + | (AppPartBase & { readonly type: "step-finish"; readonly reason: string; readonly snapshot?: string; readonly cost: number; readonly tokens: TokenUsage & { readonly total?: number } }) + | (AppPartBase & { readonly type: "snapshot"; readonly snapshot: string }) + | (AppPartBase & { readonly type: "patch"; readonly hash: string; readonly files: string[] }) + | (AppPartBase & { readonly type: "retry"; readonly attempt: number; readonly error: AppRetryError; readonly time: { readonly created: number } }) + | (AppPartBase & { readonly type: "compaction"; readonly auto: boolean }) + export type TimelineContent = | { readonly type: "text" @@ -235,11 +362,14 @@ export type TimelineContent = readonly synthetic?: boolean readonly ignored?: boolean readonly metadata?: Readonly> + readonly time?: { readonly start: number; readonly end?: number } } | { readonly type: "reasoning" readonly id: string readonly text: string + readonly metadata?: Readonly> + readonly time?: { readonly start: number; readonly end?: number } } | { readonly type: "file" @@ -261,7 +391,15 @@ export type TimelineContent = readonly callID?: string readonly tool: string readonly state: ToolState + readonly metadata?: Readonly> } + | { readonly type: "subtask"; readonly id: string; readonly prompt: string; readonly description: string; readonly agent: string; readonly model?: ModelRef; readonly command?: string } + | { readonly type: "step-start"; readonly id: string; readonly snapshot?: string } + | { readonly type: "step-finish"; readonly id: string; readonly reason: string; readonly snapshot?: string; readonly cost: number; readonly tokens: TokenUsage & { readonly total?: number } } + | { readonly type: "snapshot"; readonly id: string; readonly snapshot: string } + | { readonly type: "patch"; readonly id: string; readonly hash: string; readonly files: string[] } + | { readonly type: "retry"; readonly id: string; readonly attempt: number; readonly error: AppRetryError; readonly time: { readonly created: number } } + | { readonly type: "compaction"; readonly id: string; readonly auto: boolean } export type TimelineItem = | { @@ -272,7 +410,10 @@ export type TimelineItem = readonly content: readonly TimelineContent[] readonly agent?: string readonly model?: ModelRef - readonly raw?: unknown + readonly format?: AppUserMessage["format"] + readonly summary?: AppUserMessage["summary"] + readonly system?: string + readonly tools?: Readonly> } | { readonly type: "assistant" @@ -285,17 +426,158 @@ export type TimelineItem = readonly agent?: string readonly model?: ModelRef readonly tokens?: TokenUsage - readonly error?: unknown - readonly raw?: unknown + readonly error?: AppMessageError + readonly mode?: string + readonly path?: AppAssistantMessage["path"] + readonly cost?: number + readonly structured?: unknown + readonly finish?: string + readonly summary?: boolean + readonly snapshot?: { readonly start?: string; readonly end?: string; readonly files?: readonly string[] } } | { readonly type: "agent-switch" | "model-switch" | "synthetic" | "system" | "skill" | "shell" | "compaction" readonly id: string readonly sessionID: string readonly created: number - readonly raw?: unknown + readonly metadata?: Readonly> + readonly text?: string + readonly reason?: "auto" | "manual" + readonly summary?: string + readonly recent?: string + readonly callID?: string + readonly command?: string + readonly output?: string } +export function timelineMessage(item: TimelineItem): AppMessage | undefined { + if (item.type === "user") + return { + id: item.id, + sessionID: item.sessionID, + role: "user", + time: { created: item.created }, + format: item.format, + summary: item.summary, + agent: item.agent ?? "", + model: { + providerID: item.model?.providerID ?? "", + modelID: item.model?.id ?? "", + variant: item.model?.variant, + }, + system: item.system, + tools: item.tools, + } + if (item.type !== "assistant") return + return { + id: item.id, + sessionID: item.sessionID, + role: "assistant", + time: { created: item.created, completed: item.completed }, + parentID: item.parentID ?? "", + modelID: item.model?.id ?? "", + providerID: item.model?.providerID ?? "", + variant: item.model?.variant, + mode: item.mode ?? item.agent ?? "", + agent: item.agent ?? "", + path: item.path ?? { cwd: "", root: "" }, + summary: item.summary, + cost: item.cost ?? 0, + tokens: item.tokens ?? { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + structured: item.structured, + finish: item.finish, + error: item.error, + } +} + +export function timelineParts(item: TimelineItem): AppPart[] { + if (item.type !== "user" && item.type !== "assistant") return [] + return item.content.map((content): AppPart => { + const base = { id: content.id, sessionID: item.sessionID, messageID: item.id } + if (content.type === "file") + return { + ...base, + type: content.type, + mime: content.mime ?? "application/octet-stream", + filename: content.name, + url: content.uri, + source: + content.source?.type === "resource" + ? { + type: content.source.type, + clientName: content.source.clientName, + uri: content.source.uri, + text: { + value: content.source.text.text, + start: content.source.text.start, + end: content.source.text.end, + }, + } + : content.source?.type === "symbol" + ? { + type: content.source.type, + path: content.source.path, + name: content.source.name ?? "", + kind: content.source.kind ?? 0, + range: { + start: { line: 0, character: content.source.text.start }, + end: { line: 0, character: content.source.text.end }, + }, + text: { + value: content.source.text.text, + start: content.source.text.start, + end: content.source.text.end, + }, + } + : content.source && { + type: "file", + path: content.source.path, + text: { + value: content.source.text.text, + start: content.source.text.start, + end: content.source.text.end, + }, + }, + } + if (content.type === "agent") + return { + ...base, + type: content.type, + name: content.name, + source: content.source && { + value: content.source.text, + start: content.source.start, + end: content.source.end, + }, + } + if (content.type === "tool") + return { + ...base, + type: content.type, + callID: content.callID ?? content.id, + tool: content.tool, + state: content.state, + metadata: content.metadata, + } + if (content.type === "subtask") + return { + ...base, + ...content, + model: content.model && { providerID: content.model.providerID, modelID: content.model.id }, + } + if (content.type === "reasoning") + return { + ...base, + ...content, + time: content.time ?? { + start: item.created, + end: item.type === "assistant" ? item.completed : undefined, + }, + } + return { ...base, ...content } + }) +} + export type PromptFile = { readonly uri: string readonly name?: string @@ -312,12 +594,60 @@ export type PromptAgentMention = { readonly text?: string } -export type PromptInput = { +export type PromptPart = + | { + readonly id: string + readonly type: "text" + readonly text: string + readonly synthetic?: boolean + readonly ignored?: boolean + readonly time?: { readonly start: number; readonly end?: number } + readonly metadata?: Readonly> + } + | { + readonly id: string + readonly type: "file" + readonly mime: string + readonly url: string + readonly filename?: string + readonly source?: + | { + readonly type: "file" + readonly path: string + readonly text: { readonly value: string; readonly start: number; readonly end: number } + } + | { + readonly type: "symbol" + readonly path: string + readonly name: string + readonly kind: number + readonly range: { + readonly start: { readonly line: number; readonly character: number } + readonly end: { readonly line: number; readonly character: number } + } + readonly text: { readonly value: string; readonly start: number; readonly end: number } + } + | { + readonly type: "resource" + readonly clientName: string + readonly uri: string + readonly text: { readonly value: string; readonly start: number; readonly end: number } + } + } + | { + readonly id: string + readonly type: "agent" + readonly name: string + readonly source?: { readonly value: string; readonly start: number; readonly end: number } + } + +export type PromptInput = LocationInput & { readonly sessionID: string readonly id: string readonly text: string readonly files?: readonly PromptFile[] readonly agents?: readonly PromptAgentMention[] + readonly parts?: readonly PromptPart[] readonly selection?: { readonly agent?: string readonly model?: ModelRef @@ -325,7 +655,7 @@ export type PromptInput = { readonly delivery?: "steer" | "queue" } -export type CommandInput = { +export type CommandInput = LocationInput & { readonly sessionID: string readonly id?: string readonly command: string @@ -338,9 +668,19 @@ export type CommandInput = { export type FileEntry = { readonly path: string + readonly name?: string + readonly absolute?: string readonly type: "file" | "directory" } +export type AppFileNode = { + readonly name: string + readonly path: string + readonly absolute: string + readonly type: "file" | "directory" + readonly ignored: boolean +} + export type FileContent = { readonly bytes: Uint8Array readonly kind?: "text" | "binary" @@ -355,20 +695,26 @@ export type AppFileDiff = { readonly status?: "added" | "deleted" | "modified" } +export type AppSnapshotFileDiff = Omit & { readonly file?: string } +export type AppVcsFileDiff = AppFileDiff + export type AppPermissionRequest = { readonly id: string readonly sessionID: string readonly action: string readonly resources: readonly string[] - readonly metadata?: Readonly> + readonly permission: string + readonly patterns: string[] + readonly always: string[] + readonly metadata: Record } export type AppQuestion = { readonly question: string - readonly header?: string - readonly options: readonly { + readonly header: string + readonly options: { readonly label: string - readonly description?: string + readonly description: string }[] readonly multiple?: boolean readonly custom?: boolean @@ -377,7 +723,15 @@ export type AppQuestion = { export type AppQuestionRequest = { readonly id: string readonly sessionID: string - readonly questions: readonly AppQuestion[] + readonly questions: AppQuestion[] +} + +export type AppQuestionAnswer = string[] + +export type AppSessionNotFoundError = { + readonly _tag: "SessionNotFoundError" + readonly sessionID: string + readonly message: string } export type AppMcpStatus = @@ -423,20 +777,56 @@ export type AppEventEnvelope = { export type AppEvent = | { readonly type: "server.connected" } | { readonly type: "server.disposed"; readonly location?: LocationRef } + | { readonly type: "instance.disposed"; readonly location: LocationRef } | { readonly type: "project.updated"; readonly project: AppProject } | { readonly type: "session.created"; readonly session: AppSession } | { readonly type: "session.updated"; readonly session: AppSession } | { readonly type: "session.deleted"; readonly sessionID: string } - | { readonly type: "session.activity"; readonly sessionID: string; readonly activity: SessionActivity } - | { readonly type: "session.error"; readonly sessionID?: string; readonly error?: unknown } + | { readonly type: "session.moved"; readonly sessionID: string; readonly location: LocationRef } + | { readonly type: "session.revert"; readonly sessionID: string; readonly revert?: { readonly messageID: string } } + | { + readonly type: "session.activity" + readonly sessionID: string + readonly activity: SessionActivity + readonly item?: TimelineItem + } + | { readonly type: "session.diff"; readonly sessionID: string; readonly diff: readonly AppFileDiff[] } + | { readonly type: "todo.updated"; readonly sessionID: string; readonly todos: readonly AppTodo[] } + | { readonly type: "session.error"; readonly sessionID?: string; readonly error?: AppSessionError } | { readonly type: "timeline.updated"; readonly item: TimelineItem } + | { + readonly type: "timeline.content.updated" + readonly sessionID: string + readonly itemID: string + readonly content: TimelineContent + } | { readonly type: "timeline.removed"; readonly sessionID: string; readonly itemID: string } + | { + readonly type: "timeline.delta" + readonly sessionID: string + readonly itemID: string + readonly contentID: string + readonly field: string + readonly delta: string + } + | { + readonly type: "timeline.part.removed" + readonly sessionID: string + readonly itemID: string + readonly contentID: string + } | { readonly type: "permission.requested"; readonly request: AppPermissionRequest } | { readonly type: "permission.replied"; readonly sessionID: string; readonly requestID: string } | { readonly type: "question.requested"; readonly request: AppQuestionRequest } | { readonly type: "question.replied" | "question.rejected"; readonly sessionID: string; readonly requestID: string } | { readonly type: "file.changed"; readonly path: string; readonly change: "add" | "change" | "unlink" } | { readonly type: "vcs.branch.updated"; readonly branch?: string } + | { readonly type: "worktree.ready"; readonly name: string; readonly branch?: string } + | { readonly type: "worktree.failed"; readonly message: string } + | { readonly type: "lsp.updated" } + | { readonly type: "reference.updated" } + | { readonly type: "mcp.updated"; readonly server?: string } + | { readonly type: "provider.updated" } | { readonly type: "pty.exited"; readonly ptyID: string } | { readonly type: "unknown"; readonly raw: unknown } @@ -445,10 +835,13 @@ export interface HealthApi { } export interface ProjectApi { - list(options?: RequestOptions): Promise current(input?: LocationInput, options?: RequestOptions): Promise } +export interface ProjectListCapability { + list(options?: RequestOptions): Promise +} + export interface CatalogApi { providers(input?: LocationInput, options?: RequestOptions): Promise agents(input?: LocationInput, options?: RequestOptions): Promise @@ -469,7 +862,21 @@ export interface SessionApi { options?: RequestOptions, ): Promise get(input: LocationInput & { readonly sessionID: string }, options?: RequestOptions): Promise - remove(input: LocationInput & { readonly sessionID: string }, options?: RequestOptions): Promise + interrupt(input: LocationInput & { readonly sessionID: string }, options?: RequestOptions): Promise + activity(input?: LocationInput, options?: RequestOptions): Promise>> + history( + input: LocationInput & { readonly sessionID: string; readonly limit?: number; readonly cursor?: string }, + options?: RequestOptions, + ): Promise> + message( + input: LocationInput & { readonly sessionID: string; readonly messageID: string }, + options?: RequestOptions, + ): Promise + prompt(input: PromptInput, options?: RequestOptions): Promise +} + +export interface SessionActionsV1Capability { + remove(input: LocationInput & { readonly sessionID: string }, options?: RequestOptions): Promise fork( input: LocationInput & { readonly sessionID: string; readonly messageID?: string }, options?: RequestOptions, @@ -478,22 +885,11 @@ export interface SessionApi { input: LocationInput & { readonly sessionID: string; readonly title: string }, options?: RequestOptions, ): Promise - interrupt(input: LocationInput & { readonly sessionID: string }, options?: RequestOptions): Promise - activity(input?: LocationInput, options?: RequestOptions): Promise>> - history( - input: { readonly sessionID: string; readonly limit?: number; readonly cursor?: string }, - options?: RequestOptions, - ): Promise> - message( - input: { readonly sessionID: string; readonly messageID: string }, - options?: RequestOptions, - ): Promise - prompt(input: PromptInput, options?: RequestOptions): Promise command(input: CommandInput, options?: RequestOptions): Promise } export interface FileApi { - list(input: LocationInput & { readonly path?: string }, options?: RequestOptions): Promise + list(input: LocationInput & { readonly path?: string }, options?: RequestOptions): Promise find( input: LocationInput & { readonly query: string @@ -508,7 +904,7 @@ export interface FileApi { export interface PermissionApi { pending(input?: LocationInput, options?: RequestOptions): Promise reply( - input: { + input: LocationInput & { readonly sessionID: string readonly requestID: string readonly reply: "once" | "always" | "reject" @@ -521,14 +917,17 @@ export interface PermissionApi { export interface QuestionApi { pending(input?: LocationInput, options?: RequestOptions): Promise reply( - input: { + input: LocationInput & { readonly sessionID: string readonly requestID: string readonly answers: readonly (readonly string[])[] }, options?: RequestOptions, ): Promise - reject(input: { readonly sessionID: string; readonly requestID: string }, options?: RequestOptions): Promise + reject( + input: LocationInput & { readonly sessionID: string; readonly requestID: string }, + options?: RequestOptions, + ): Promise } export interface VcsApi { @@ -598,11 +997,8 @@ export interface CommonClient { readonly files: FileApi readonly permissions: PermissionApi readonly questions: QuestionApi - readonly vcs: VcsApi - readonly mcp: McpApi readonly pty: PtyApi readonly events: EventApi - disposeLocation(input: LocationInput, options?: RequestOptions): Promise } export type AppProviderConfig = { @@ -655,6 +1051,8 @@ export type ProviderAuthMethod = { readonly prompts?: readonly ProviderAuthPrompt[] } +export type AppProviderAuthResponse = Readonly> + export type ProviderAuthorization = { readonly url: string readonly method: "auto" | "code" @@ -710,14 +1108,15 @@ export type AppWorktree = { export interface WorktreesV1Capability { list(input: LocationInput, options?: RequestOptions): Promise create(input: LocationInput, options?: RequestOptions): Promise - remove(input: LocationInput & { readonly directory: string }, options?: RequestOptions): Promise - reset(input: LocationInput & { readonly directory: string }, options?: RequestOptions): Promise + remove(input: LocationInput & { readonly directory: string }, options?: RequestOptions): Promise + reset(input: LocationInput & { readonly directory: string }, options?: RequestOptions): Promise } export type AppTodo = { readonly id?: string readonly content: string readonly status: "pending" | "in_progress" | "completed" | "cancelled" | (string & {}) + readonly priority: "high" | "medium" | "low" | (string & {}) } export type LegacySessionShellInput = LocationInput & { @@ -729,14 +1128,23 @@ export type LegacySessionShellInput = LocationInput & { } export interface SessionExtrasV1Capability { - archive(sessionID: string, archivedAt: number, options?: RequestOptions): Promise - share(sessionID: string, options?: RequestOptions): Promise - unshare(sessionID: string, options?: RequestOptions): Promise - diff(sessionID: string, options?: RequestOptions): Promise - todos(sessionID: string, options?: RequestOptions): Promise - summarize(sessionID: string, model: ModelRef, options?: RequestOptions): Promise - revert(sessionID: string, messageID: string, options?: RequestOptions): Promise - clearRevert(sessionID: string, options?: RequestOptions): Promise + archive( + input: LocationInput & { readonly sessionID: string; readonly archivedAt: number }, + options?: RequestOptions, + ): Promise + share(input: LocationInput & { readonly sessionID: string }, options?: RequestOptions): Promise + unshare(input: LocationInput & { readonly sessionID: string }, options?: RequestOptions): Promise + diff(input: LocationInput & { readonly sessionID: string }, options?: RequestOptions): Promise + todos(input: LocationInput & { readonly sessionID: string }, options?: RequestOptions): Promise + summarize( + input: LocationInput & { readonly sessionID: string; readonly model: ModelRef }, + options?: RequestOptions, + ): Promise + revert( + input: LocationInput & { readonly sessionID: string; readonly messageID: string }, + options?: RequestOptions, + ): Promise + clearRevert(input: LocationInput & { readonly sessionID: string }, options?: RequestOptions): Promise shell(input: LegacySessionShellInput, options?: RequestOptions): Promise } @@ -784,7 +1192,18 @@ export type DecoratedFileContent = { readonly encoding?: "base64" readonly mimeType?: string readonly patch?: { - readonly hunks: readonly { readonly lines: readonly string[] }[] + readonly oldFileName: string + readonly newFileName: string + readonly oldHeader?: string + readonly newHeader?: string + readonly hunks: readonly { + readonly oldStart: number + readonly oldLines: number + readonly newStart: number + readonly newLines: number + readonly lines: readonly string[] + }[] + readonly index?: string } } @@ -793,11 +1212,28 @@ export interface DecoratedFileCapability { } export type PtyTicket = { - readonly ticket: string + readonly status: number + readonly ticket?: string +} + +export type PtyTransportConfig = { + readonly baseUrl: string + readonly fetch: typeof globalThis.fetch + readonly username?: string + readonly password?: string + readonly sameOrigin: boolean + readonly authToken: boolean } export interface PtyTransportCapability { connectToken(input: LocationInput & { readonly ptyID: string }, options?: RequestOptions): Promise + exists(input: LocationInput & { readonly ptyID: string }, options?: RequestOptions): Promise + connectURL(input: { + readonly ptyID: string + readonly location: LocationRef + readonly cursor: number + readonly ticket?: string + }): URL } export type ShellOption = { @@ -811,6 +1247,7 @@ export interface ShellDiscoveryCapability { } export interface RuntimeV1Capability { + disposeLocation(input: LocationInput, options?: RequestOptions): Promise disposeAll(options?: RequestOptions): Promise } @@ -833,6 +1270,11 @@ export type IntegrationMethod = export type IntegrationConnection = { readonly id: string readonly label: string + readonly kind: "credential" | "environment" +} + +export function credentialConnectionIDs(connections: readonly IntegrationConnection[]) { + return connections.filter((connection) => connection.kind === "credential").map((connection) => connection.id) } export type IntegrationInfo = { @@ -914,18 +1356,37 @@ export type InstructionEntry = { } export interface SessionExtrasV2Capability { - switchAgent(input: { readonly sessionID: string; readonly agent: string }, options?: RequestOptions): Promise - switchModel(input: { readonly sessionID: string; readonly model: ModelRef }, options?: RequestOptions): Promise - move( - input: { readonly sessionID: string; readonly directory: string; readonly moveChanges?: boolean }, + diff?( + input: LocationInput & { readonly sessionID: string }, + options?: RequestOptions, + ): Promise + todos?( + input: LocationInput & { readonly sessionID: string }, + options?: RequestOptions, + ): Promise + switchAgent( + input: LocationInput & { readonly sessionID: string; readonly agent: string }, options?: RequestOptions, ): Promise - skill( - input: { readonly sessionID: string; readonly id?: string; readonly skill: string; readonly resume?: boolean }, + switchModel( + input: LocationInput & { readonly sessionID: string; readonly model: ModelRef }, options?: RequestOptions, ): Promise - synthetic( - input: { + move?( + input: LocationInput & { readonly sessionID: string; readonly directory: string; readonly moveChanges?: boolean }, + options?: RequestOptions, + ): Promise + skill?( + input: LocationInput & { + readonly sessionID: string + readonly id?: string + readonly skill: string + readonly resume?: boolean + }, + options?: RequestOptions, + ): Promise + synthetic?( + input: LocationInput & { readonly sessionID: string readonly id?: string readonly text: string @@ -936,40 +1397,50 @@ export interface SessionExtrasV2Capability { }, options?: RequestOptions, ): Promise - shell( - input: { readonly sessionID: string; readonly id?: string; readonly command: string }, + shell?( + input: LocationInput & { readonly sessionID: string; readonly id?: string; readonly command: string }, options?: RequestOptions, ): Promise - compact( - input: { readonly sessionID: string; readonly id?: string }, + compact?( + input: LocationInput & { readonly sessionID: string; readonly id?: string }, options?: RequestOptions, ): Promise - wait(input: { readonly sessionID: string }, options?: RequestOptions): Promise - context(input: { readonly sessionID: string }, options?: RequestOptions): Promise - pending(input: { readonly sessionID: string }, options?: RequestOptions): Promise - instructionEntries( - input: { readonly sessionID: string }, + wait(input: LocationInput & { readonly sessionID: string }, options?: RequestOptions): Promise + context( + input: LocationInput & { readonly sessionID: string }, + options?: RequestOptions, + ): Promise + pending?( + input: LocationInput & { readonly sessionID: string }, + options?: RequestOptions, + ): Promise + instructionEntries?( + input: LocationInput & { readonly sessionID: string }, options?: RequestOptions, ): Promise - putInstructionEntry( - input: { readonly sessionID: string; readonly key: string; readonly value: JsonValue }, + putInstructionEntry?( + input: LocationInput & { readonly sessionID: string; readonly key: string; readonly value: JsonValue }, options?: RequestOptions, ): Promise - removeInstructionEntry( - input: { readonly sessionID: string; readonly key: string }, + removeInstructionEntry?( + input: LocationInput & { readonly sessionID: string; readonly key: string }, options?: RequestOptions, ): Promise log( - input: { readonly sessionID: string; readonly after?: number; readonly follow?: boolean }, + input: LocationInput & { readonly sessionID: string; readonly after?: number; readonly follow?: boolean }, options?: RequestOptions, ): AsyncIterable - background(input: { readonly sessionID: string }, options?: RequestOptions): Promise + background?(input: LocationInput & { readonly sessionID: string }, options?: RequestOptions): Promise stageRevert( - input: { readonly sessionID: string; readonly messageID: string; readonly files?: readonly string[] }, + input: LocationInput & { + readonly sessionID: string + readonly messageID: string + readonly files?: readonly string[] + }, options?: RequestOptions, ): Promise<{ readonly messageID: string }> - clearRevert(input: { readonly sessionID: string }, options?: RequestOptions): Promise - commitRevert(input: { readonly sessionID: string }, options?: RequestOptions): Promise + clearRevert(input: LocationInput & { readonly sessionID: string }, options?: RequestOptions): Promise + commitRevert(input: LocationInput & { readonly sessionID: string }, options?: RequestOptions): Promise } export type ProjectDirectory = { @@ -978,7 +1449,7 @@ export type ProjectDirectory = { } export interface ProjectCopiesV2Capability { - directories( + directories?( input: LocationInput & { readonly projectID: string }, options?: RequestOptions, ): Promise @@ -1161,6 +1632,9 @@ export interface DiscoveryV2Capability { } export interface Capabilities { + readonly projectList?: ProjectListCapability + readonly vcs?: VcsApi + readonly mcp?: McpApi readonly configuration?: ConfigurationCapability readonly providerAuthV1?: ProviderAuthV1Capability readonly integrationsV2?: IntegrationsV2Capability @@ -1168,6 +1642,7 @@ export interface Capabilities { readonly worktreesV1?: WorktreesV1Capability readonly projectCopiesV2?: ProjectCopiesV2Capability readonly sessionExtrasV1?: SessionExtrasV1Capability + readonly sessionActionsV1?: SessionActionsV1Capability readonly sessionExtrasV2?: SessionExtrasV2Capability readonly lsp?: LspCapability readonly mcpControl?: McpControlCapability diff --git a/packages/app/src/context/directory-sync.ts b/packages/app/src/context/directory-sync.ts index 7ccd6e975e..1823e963ab 100644 --- a/packages/app/src/context/directory-sync.ts +++ b/packages/app/src/context/directory-sync.ts @@ -1,7 +1,7 @@ import { Binary } from "@opencode-ai/core/util/binary" -import type { Message, Part, Session } from "@opencode-ai/sdk/v2/client" +import type { AppMessage, AppPart, AppSession } from "./backend" import { createMemo } from "solid-js" -import { produce, reconcile, type SetStoreFunction } from "solid-js/store" +import { createStore, produce, reconcile, type SetStoreFunction } from "solid-js/store" import type { createServerSdkContext } from "./server-sdk" import type { createServerSyncContextInner } from "./server-sync" import type { State } from "./global-sync/types" @@ -23,8 +23,8 @@ export const createDirSyncContext = ( serverSync: ReturnType, serverSDK: ReturnType, ) => { - const client = serverSDK.createClient({ directory, throwOnError: true }) const current = createMemo(() => serverSync.child(directory, { mcp: true })) + const [sessionPage, setSessionPage] = createStore({ cursor: undefined as string | undefined, complete: false }) const absolute = (path: string) => (current()[0].path.directory + "/" + path).replace("//", "/") const data = new Proxy({} as State, { get(_, property: keyof State) { @@ -72,7 +72,7 @@ export const createDirSyncContext = ( if (match.found) return serverSync.data.project[match.index] }, session: { - remember(session: Session) { + remember(session: AppSession) { serverSync.session.remember(session) index(session.id) }, @@ -81,7 +81,7 @@ export const createDirSyncContext = ( if (session?.directory === directory) return session }, optimistic: { - add(input: { directory?: string; sessionID: string; message: Message; parts: Part[] }) { + add(input: { directory?: string; sessionID: string; message: AppMessage; parts: AppPart[] }) { serverSync.session.optimistic.add(input) }, remove(input: { directory?: string; sessionID: string; messageID: string }) { @@ -91,7 +91,7 @@ export const createDirSyncContext = ( addOptimisticMessage(input: { sessionID: string messageID: string - parts: Part[] + parts: AppPart[] agent: string model: { providerID: string; modelID: string } variant?: string @@ -110,7 +110,7 @@ export const createDirSyncContext = ( }) }, async sync(sessionID: string, options?: { force?: boolean }) { - await serverSync.session.sync(sessionID, options) + await serverSync.session.sync(sessionID, { ...options, location: { directory } }) index(sessionID) }, diff: serverSync.session.diff, @@ -121,17 +121,25 @@ export const createDirSyncContext = ( fetch: async (count = 10) => { const [store, setStore] = current() setStore("limit", (value) => value + count) - const response = await client.session.list() - const sessions = (response.data ?? []) - .filter((session) => !!session?.id) + const backend = await serverSDK.backend + const response = await backend.common.sessions.list({ + location: { directory }, + roots: true, + limit: count, + cursor: sessionPage.cursor, + }) + const sessions = [...new Map([...store.session, ...response.items].map((session) => [session.id, session])).values()] .sort((a, b) => cmp(a.id, b.id)) - .slice(0, store.limit) sessions.forEach(serverSync.session.remember) setStore("session", reconcile(sessions, { key: "id" })) + setSessionPage({ cursor: response.older, complete: !response.older }) }, - more: createMemo(() => current()[0].session.length >= current()[0].limit), + more: createMemo(() => !sessionPage.complete), archive: async (sessionID: string) => { - await serverSDK.client.session.update({ sessionID, time: { archived: Date.now() } }) + const backend = await serverSDK.backend + const capability = backend.capabilities.sessionExtrasV1 + if (!capability) throw new Error("Server does not support session archiving") + await capability.archive({ sessionID, archivedAt: Date.now(), location: { directory } }) current()[1]( "session", produce((draft) => { diff --git a/packages/app/src/context/file.tsx b/packages/app/src/context/file.tsx index 6032b81dde..7717fdb4a0 100644 --- a/packages/app/src/context/file.tsx +++ b/packages/app/src/context/file.tsx @@ -79,10 +79,16 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({ const tree = createFileTreeStore({ scope, normalizeDir: path.normalizeDir, - list: (dir) => - sdk() - .client.file.list({ path: dir }) - .then((x) => x.data ?? []), + list: async (dir) => + [...(await (await sdk().backend).common.files.list({ location: { directory: scope() }, path: dir }))].map( + (node) => ({ + name: node.name ?? getFilename(node.path), + path: node.path, + absolute: node.absolute ?? node.path, + type: node.type, + ignored: node.ignored, + }), + ), onError: (message) => { showToast({ variant: "error", @@ -181,10 +187,18 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({ setLoading(file) const promise = sdk() - .client.file.read({ path: file }) - .then((x) => { + .backend.then(async (client) => { if (scope() !== directory) return - const content = x.data + const content: FileState["content"] = client.capabilities.decoratedFiles + ? await client.capabilities.decoratedFiles.read({ location: { directory }, path: file }) + : await client.common.files.read({ location: { directory }, path: file }).then((value) => { + if (value.kind !== "text") return + return { + type: "text" as const, + content: new TextDecoder().decode(value.bytes), + mimeType: value.mimeType, + } + }) setLoaded(file, content) if (!content) return @@ -205,9 +219,19 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({ const search = (query: string, dirs: "true" | "false", options?: { limit?: number; signal?: AbortSignal }) => sdk() - .client.find.files({ query, dirs, limit: options?.limit }, { signal: options?.signal }) + .backend.then((client) => + client.common.files.find( + { + location: { directory: scope() }, + query, + type: dirs === "true" ? undefined : "file", + limit: options?.limit, + }, + { signal: options?.signal }, + ), + ) .then( - (x) => (x.data ?? []).map(path.normalize), + (items) => items.map((item) => path.normalize(item.path)), (error) => { if (options?.signal?.aborted) throw error return [] diff --git a/packages/app/src/context/file/content-cache.ts b/packages/app/src/context/file/content-cache.ts index 4b72406883..a7352f3c70 100644 --- a/packages/app/src/context/file/content-cache.ts +++ b/packages/app/src/context/file/content-cache.ts @@ -1,4 +1,4 @@ -import type { FileContent } from "@opencode-ai/sdk/v2" +import type { DecoratedFileContent as FileContent } from "../backend" const MAX_FILE_CONTENT_ENTRIES = 40 const MAX_FILE_CONTENT_BYTES = 20 * 1024 * 1024 diff --git a/packages/app/src/context/file/tree-store.ts b/packages/app/src/context/file/tree-store.ts index 5769c318f0..b7428e34b0 100644 --- a/packages/app/src/context/file/tree-store.ts +++ b/packages/app/src/context/file/tree-store.ts @@ -1,5 +1,5 @@ import { createStore, produce, reconcile } from "solid-js/store" -import type { FileNode } from "@opencode-ai/sdk/v2" +import type { AppFileNode as FileNode } from "../backend" type DirectoryState = { expanded: boolean diff --git a/packages/app/src/context/file/types.ts b/packages/app/src/context/file/types.ts index 7ce8a37c25..c627792d07 100644 --- a/packages/app/src/context/file/types.ts +++ b/packages/app/src/context/file/types.ts @@ -1,4 +1,4 @@ -import type { FileContent } from "@opencode-ai/sdk/v2" +import type { DecoratedFileContent as FileContent } from "../backend" export type FileSelection = { startLine: number diff --git a/packages/app/src/context/file/watcher.ts b/packages/app/src/context/file/watcher.ts index 1dcaeffd25..b4ac0aef39 100644 --- a/packages/app/src/context/file/watcher.ts +++ b/packages/app/src/context/file/watcher.ts @@ -1,8 +1,10 @@ -import type { FileNode } from "@opencode-ai/sdk/v2" +import type { AppFileNode as FileNode } from "../backend" type WatcherEvent = { type: string - properties: unknown + path?: string + change?: string + properties?: unknown } type WatcherOps = { @@ -16,11 +18,11 @@ type WatcherOps = { } export function invalidateFromWatcher(event: WatcherEvent, ops: WatcherOps) { - if (event.type !== "filesystem.changed") return + if (event.type !== "filesystem.changed" && event.type !== "file.changed" && event.type !== "file.watcher.updated") return const props = typeof event.properties === "object" && event.properties ? (event.properties as Record) : undefined - const rawPath = typeof props?.file === "string" ? props.file : undefined - const kind = typeof props?.event === "string" ? props.event : undefined + const rawPath = event.path ?? (typeof props?.file === "string" ? props.file : undefined) + const kind = event.change ?? (typeof props?.event === "string" ? props.event : undefined) if (!rawPath) return if (!kind) return diff --git a/packages/app/src/context/global-sync/bootstrap.test.ts b/packages/app/src/context/global-sync/bootstrap.test.ts index 872f0929c8..0599d224e3 100644 --- a/packages/app/src/context/global-sync/bootstrap.test.ts +++ b/packages/app/src/context/global-sync/bootstrap.test.ts @@ -1,14 +1,15 @@ import { describe, expect, test } from "bun:test" import { createStore } from "solid-js/store" import { QueryClient } from "@tanstack/solid-query" -import type { Config, OpencodeClient, Project, Session } from "@opencode-ai/sdk/v2/client" -import type { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context" +import type { AppClient, AppProject as Project, AppSession as Session } from "../backend" +import { createAppClient } from "../backend.test-fixture" +import type { ProviderStore } from "./types" import { bootstrapDirectory, loadPathQuery, loadProvidersQuery } from "./bootstrap" import type { State, VcsCache } from "./types" import { createServerSession } from "../server-session" import { ServerScope } from "@/utils/server-scope" -const provider = { all: new Map(), connected: [], default: {} } satisfies NormalizedProviderListResponse +const provider = { all: new Map(), connected: [], default: {} } satisfies ProviderStore function directoryState() { return createStore({ @@ -30,6 +31,7 @@ function directoryState() { return this.session_status[id]?.type !== "idle" }, session_diff: {}, + todo: {}, permission: {}, question: {}, mcp_ready: true, @@ -55,33 +57,35 @@ describe("bootstrapDirectory", () => { scope: ServerScope.local, mcp: false, global: { - config: {} satisfies Config, + config: {}, path: { state: "", config: "", worktree: "/project", directory: "/project", home: "/home" }, project: [{ id: "project", worktree: "/project" } as Project], provider, }, - sdk: { - app: { agents: async () => ({ data: [{ name: "build", mode: "primary" }] }) }, - config: { get: async () => ({ data: {} }) }, - session: { status: async () => ({ data: {} }) }, - vcs: { get: async () => ({ data: undefined }) }, - command: { - list: async () => { - mcpReads.push("command") - return { data: [] } - }, + backend: { + version: "v1", + capabilities: { + configuration: { get: async () => ({}), getGlobal: async () => ({}), updateGlobal: async () => {} }, + vcsInfo: { get: async () => ({}) }, }, - permission: { list: async () => ({ data: [] }) }, - question: { list: async () => ({ data: [] }) }, - v2: { reference: { list: async () => ({ data: { data: [] } }) } }, - mcp: { - status: async () => { - mcpReads.push("status") - return { data: {} } + common: { + catalog: { + agents: async () => [{ id: "build", name: "build", mode: "primary", hidden: false }], + providers: async () => ({ providers: new Map(), connected: [], defaults: {} }), }, + sessions: { activity: async () => ({}) }, + projects: { current: async () => ({ id: "project", directory: "/project" }) }, + commands: { + list: async () => { + mcpReads.push("command") + return [] + }, + }, + permissions: { pending: async () => [] }, + questions: { pending: async () => [] }, + references: { list: async () => [] }, }, - provider: { list: async () => ({ data: { all: [], connected: [], default: {} } }) }, - } as unknown as OpencodeClient, + } as unknown as AppClient, store, setStore, vcsCache: { setStore() {} } as unknown as VcsCache, @@ -101,22 +105,26 @@ describe("bootstrapDirectory", () => { test("seeds session status even while warming session info stalls", async () => { const [store, setStore] = directoryState() const stalled = Promise.withResolvers() - const client = { - app: { agents: async () => ({ data: [{ name: "build", mode: "primary" }] }) }, - config: { get: async () => ({ data: {} }) }, - session: { - status: async () => ({ data: { ses_busy: { type: "busy" } } }), - get: () => stalled.promise, + const backend = createAppClient({ + version: "v1", + capabilities: { + configuration: { get: async () => ({}), getGlobal: async () => ({}), updateGlobal: async () => {} }, + vcsInfo: { get: async () => ({}) }, }, - vcs: { get: async () => ({ data: undefined }) }, - command: { list: async () => ({ data: [] }) }, - permission: { list: async () => ({ data: [] }) }, - question: { list: async () => ({ data: [] }) }, - v2: { reference: { list: async () => ({ data: { data: [] } }) } }, - mcp: { status: async () => ({ data: {} }) }, - provider: { list: async () => ({ data: { all: [], connected: [], default: {} } }) }, - } as unknown as OpencodeClient - const session = createServerSession(client) + common: { + catalog: { + agents: async () => [{ id: "build", name: "build", mode: "primary", hidden: false }], + providers: async () => ({ providers: new Map(), connected: [], defaults: {} }), + }, + sessions: { activity: async () => ({ ses_busy: { type: "busy" } }), get: () => stalled.promise }, + projects: { current: async () => ({ id: "project", directory: "/project" }) }, + commands: { list: async () => [] }, + permissions: { pending: async () => [] }, + questions: { pending: async () => [] }, + references: { list: async () => [] }, + }, + }) + const session = createServerSession(backend) const stale: Session = { id: "ses_stale", slug: "ses_stale", @@ -134,12 +142,12 @@ describe("bootstrapDirectory", () => { scope: ServerScope.local, mcp: false, global: { - config: {} satisfies Config, + config: {}, path: { state: "", config: "", worktree: "/project", directory: "/project", home: "/home" }, project: [{ id: "project", worktree: "/project" } as Project], provider, }, - sdk: client, + backend, store, setStore, vcsCache: { setStore() {} } as unknown as VcsCache, @@ -161,12 +169,12 @@ describe("bootstrapDirectory", () => { describe("query keys", () => { test("partitions identical directories by server scope", () => { - const client = {} as OpencodeClient + const backend = Promise.resolve({} as AppClient) const remote = "https://debian.example" as typeof ServerScope.local - expect([...loadPathQuery(ServerScope.local, "/repo", client).queryKey]).toEqual(["local", "/repo", "path"]) - expect([...loadPathQuery(remote, "/repo", client).queryKey]).toEqual(["https://debian.example", "/repo", "path"]) - expect([...loadProvidersQuery(remote, null, client).queryKey]).toEqual([ + expect([...loadPathQuery(ServerScope.local, "/repo", backend).queryKey]).toEqual(["local", "/repo", "path"]) + expect([...loadPathQuery(remote, "/repo", backend).queryKey]).toEqual(["https://debian.example", "/repo", "path"]) + expect([...loadProvidersQuery(remote, null, backend).queryKey]).toEqual([ "https://debian.example", null, "providers", diff --git a/packages/app/src/context/global-sync/bootstrap.ts b/packages/app/src/context/global-sync/bootstrap.ts index c63b702522..9e06683e16 100644 --- a/packages/app/src/context/global-sync/bootstrap.ts +++ b/packages/app/src/context/global-sync/bootstrap.ts @@ -1,35 +1,35 @@ import type { - Config, - OpencodeClient, - Path, - PermissionRequest, - Project, - ProviderAuthResponse, - QuestionRequest, - ReferenceInfo, - Session, -} from "@opencode-ai/sdk/v2/client" + AppClient, + AppConfig, + AppPathInfo, + AppPermissionRequest, + AppProject, + AppProviderAuthResponse, + AppQuestionRequest, + AppReference, + AppSession, + ProviderCatalog, +} from "../backend" import { showToast } from "@/utils/toast" import { getFilename } from "@opencode-ai/core/util/path" import { retry } from "@opencode-ai/core/util/retry" import { batch } from "solid-js" import { produce, reconcile, type SetStoreFunction, type Store } from "solid-js/store" -import type { State, VcsCache } from "./types" +import type { ProviderStore, State, StoreConfig, VcsCache } from "./types" import type { ServerSession } from "../server-session" -import { cmp, normalizeAgentList, normalizeProviderList } from "./utils" +import { cmp } from "./utils" import { formatServerError } from "@/utils/server-errors" import { QueryClient, queryOptions } from "@tanstack/solid-query" import { loadMcpQuery, loadMcpResourcesQuery } from "../server-sync" -import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context" import { ScopedKey, type ServerScope } from "@/utils/server-scope" type GlobalStore = { ready: boolean - path: Path - project: Project[] - provider: NormalizedProviderListResponse - provider_auth: ProviderAuthResponse - config: Config + path: AppPathInfo + project: AppProject[] + provider: ProviderStore + provider_auth: AppProviderAuthResponse + config: StoreConfig reload: undefined | "pending" | "complete" } @@ -82,29 +82,31 @@ function showErrors(input: { }) } -export const loadGlobalConfigQuery = (scope: ServerScope, sdk: OpencodeClient) => +export const loadGlobalConfigQuery = (scope: ServerScope, backend: Promise) => queryOptions({ queryKey: [scope, "config"], - queryFn: () => retry(() => sdk.global.config.get().then((x) => x.data!)), + queryFn: () => retry(async () => (await backend).capabilities.configuration?.getGlobal() ?? {}), }) -export const loadProjectsQuery = (scope: ServerScope, sdk: OpencodeClient) => +export const loadProjectsQuery = (scope: ServerScope, backend: Promise) => queryOptions({ queryKey: [scope, "project"], queryFn: () => retry(() => - sdk.project.list().then((x) => { - return (x.data ?? []) - .filter((p) => !!p?.id) - .filter((p) => !!p.worktree && !p.worktree.includes("opencode-test")) - .slice() - .sort((a, b) => cmp(a.id, b.id)) - }), + backend + .then((client) => client.capabilities.projectList?.list() ?? []) + .then((projects) => { + return projects + .filter((p) => !!p?.id) + .filter((p) => !!p.worktree && !p.worktree.includes("opencode-test")) + .slice() + .sort((a, b) => cmp(a.id, b.id)) + }), ), }) export async function bootstrapGlobal(input: { - serverSDK: OpencodeClient + backend: Promise scope: ServerScope requestFailedTitle: string translate: (key: string, vars?: Record) => string @@ -113,12 +115,12 @@ export async function bootstrapGlobal(input: { queryClient: QueryClient }) { const slow = [ - () => input.queryClient.fetchQuery(loadGlobalConfigQuery(input.scope, input.serverSDK)), - () => input.queryClient.fetchQuery(loadProvidersQuery(input.scope, null, input.serverSDK)), - () => input.queryClient.fetchQuery(loadPathQuery(input.scope, null, input.serverSDK)), + () => input.queryClient.fetchQuery(loadGlobalConfigQuery(input.scope, input.backend)), + () => input.queryClient.fetchQuery(loadProvidersQuery(input.scope, null, input.backend)), + () => input.queryClient.fetchQuery(loadPathQuery(input.scope, null, input.backend)), () => input.queryClient - .fetchQuery(loadProjectsQuery(input.scope, input.serverSDK)) + .fetchQuery(loadProjectsQuery(input.scope, input.backend)) .then((data) => input.setGlobalStore("project", data)), ] await runAll(slow) @@ -140,11 +142,11 @@ function groupBySession(input: T[]) }, {}) } -function projectID(directory: string, projects: Project[]) { +function projectID(directory: string, projects: readonly AppProject[]) { return projects.find((project) => project.worktree === directory || project.sandboxes?.includes(directory))?.id } -function mergeSession(setStore: SetStoreFunction, session: Session) { +function mergeSession(setStore: SetStoreFunction, session: AppSession) { setStore("session", (list) => { const next = list.slice() const idx = next.findIndex((item) => item.id >= session.id) @@ -162,44 +164,54 @@ function warmSessions(input: { ids: string[] store: Store setStore: SetStoreFunction - sdk: OpencodeClient + backend: AppClient + location: { directory: string } }) { const known = new Set(input.store.session.map((item) => item.id)) const ids = [...new Set(input.ids)].filter((id) => !!id && !known.has(id)) if (ids.length === 0) return Promise.resolve() return Promise.all( ids.map((sessionID) => - retry(() => input.sdk.session.get({ sessionID })).then((x) => { - const session = x.data - if (!session?.id) return - mergeSession(input.setStore, session) + retry(() => input.backend.common.sessions.get({ sessionID, location: input.location })).then((x) => { + if (!x?.id) return + mergeSession(input.setStore, x) }), ), ).then(() => undefined) } -export const loadProvidersQuery = (scope: ServerScope, directory: string | null, sdk: OpencodeClient) => +export const loadProvidersQuery = (scope: ServerScope, directory: string | null, backend: Promise) => queryOptions({ queryKey: [scope, directory, "providers"], - queryFn: () => retry(() => sdk.provider.list().then((x) => normalizeProviderList(x.data!))), + queryFn: () => + retry(() => backend.then((client) => client.common.catalog.providers(location(directory)).then(toProviderStore))), }) -export const loadAgentsQuery = (scope: ServerScope, directory: string | null, sdk: OpencodeClient) => +export const loadAgentsQuery = (scope: ServerScope, directory: string, backend: Promise) => queryOptions({ queryKey: [scope, directory, "agents"], - queryFn: () => retry(() => sdk.app.agents().then((x) => normalizeAgentList(x.data))), + queryFn: () => + retry(() => + backend.then((client) => client.common.catalog.agents(location(directory)).then((agents) => [...agents])), + ), }) -export const loadPathQuery = (scope: ServerScope, directory: string | null, sdk: OpencodeClient) => - queryOptions({ +export const loadPathQuery = (scope: ServerScope, directory: string | null, backend: Promise) => + queryOptions({ queryKey: [scope, directory, "path"], - queryFn: () => retry(() => sdk.path.get().then((x) => x.data!)), + queryFn: async () => { + const client = await backend + return retry( + () => client.capabilities.pathInfo?.get(location(directory)) ?? Promise.resolve(emptyPath(directory)), + ) + }, }) -export const loadReferencesQuery = (scope: ServerScope, directory: string, sdk: OpencodeClient) => - queryOptions({ +export const loadReferencesQuery = (scope: ServerScope, directory: string, backend: Promise) => + queryOptions({ queryKey: [scope, directory, "references"] as const, - queryFn: () => retry(() => sdk.v2.reference.list().then((x) => x.data?.data ?? [])).catch(() => []), + queryFn: () => + retry(() => backend.then((client) => client.common.references.list(location(directory)))).catch(() => []), placeholderData: [], }) @@ -207,17 +219,17 @@ export async function bootstrapDirectory(input: { directory: string scope: ServerScope mcp: boolean - sdk: OpencodeClient + backend: AppClient store: Store setStore: SetStoreFunction vcsCache: VcsCache loadSessions: (directory: string) => Promise | void translate: (key: string, vars?: Record) => string global: { - config: Config - path: Path - project: Project[] - provider: NormalizedProviderListResponse + config: StoreConfig + path: AppPathInfo + project: readonly AppProject[] + provider: ProviderStore } queryClient: QueryClient session?: ServerSession @@ -240,66 +252,90 @@ export async function bootstrapDirectory(input: { () => Promise.resolve(input.loadSessions(input.directory)), () => input.queryClient - .ensureQueryData(loadAgentsQuery(input.scope, input.directory, input.sdk)) + .ensureQueryData(loadAgentsQuery(input.scope, input.directory, Promise.resolve(input.backend))) .then((data) => input.setStore("agent", data)), () => - retry(() => input.sdk.config.get().then((x) => input.setStore("config", reconcile(x.data!, { merge: false })))), + retry( + () => + input.backend.capabilities.configuration + ?.get(location(input.directory)) + .then((config) => input.setStore("config", reconcile(config, { merge: false }))) ?? Promise.resolve(), + ), () => retry(() => - input.sdk.session.status().then(async (x) => { + input.backend.common.sessions.activity(location(input.directory)).then(async (statuses) => { if (!input.session) { - input.setStore("session_status", x.data!) + input.setStore("session_status", mapActivity(statuses)) return } - const statuses = x.data ?? {} + const mapped = mapActivity(statuses) input.session.set( "session_status", produce((draft) => { for (const sessionID of Object.keys(draft)) { - if (statuses[sessionID]) continue + if (mapped[sessionID]) continue if (input.session?.get(sessionID)?.directory === input.directory) delete draft[sessionID] } }), ) - for (const [sessionID, status] of Object.entries(statuses)) { + for (const [sessionID, status] of Object.entries(mapped)) { input.session.set("session_status", sessionID, reconcile(status)) } // Warm session info only after seeding statuses so a stalled session // fetch cannot park busy indicators behind it, mirroring how live // session.status events apply first and resolve info in the background. await Promise.all( - Object.keys(statuses).map((sessionID) => input.session!.resolve(sessionID).catch(() => undefined)), + Object.keys(mapped).map((sessionID) => input.session!.resolve(sessionID).catch(() => undefined)), ) }), ), !seededProject && - (() => retry(() => input.sdk.project.current()).then((x) => input.setStore("project", x.data!.id))), + (() => + retry(() => input.backend.common.projects.current(location(input.directory))).then((project) => + input.setStore("project", project.id), + )), !seededPath && (() => - input.queryClient.ensureQueryData(loadPathQuery(input.scope, input.directory, input.sdk)).then((data) => { - const next = projectID(data.directory ?? input.directory, input.global.project) - if (next) input.setStore("project", next) - })), + input.queryClient + .ensureQueryData(loadPathQuery(input.scope, input.directory, Promise.resolve(input.backend))) + .then((data) => { + const next = projectID(data.directory ?? input.directory, input.global.project) + if (next) input.setStore("project", next) + })), () => retry(() => - input.sdk.vcs.get().then((x) => { - const next = x.data ?? input.store.vcs - input.setStore("vcs", next) - if (next) input.vcsCache.setStore("value", next) - }), + (input.backend.capabilities.vcsInfo?.get(location(input.directory)) ?? Promise.resolve(undefined)).then( + (data) => { + const next = data ?? input.store.vcs + input.setStore("vcs", next) + if (next) input.vcsCache.setStore("value", next) + }, + ), ), - input.mcp && (() => retry(() => input.sdk.command.list().then((x) => input.setStore("command", x.data ?? [])))), - () => input.queryClient.fetchQuery(loadReferencesQuery(input.scope, input.directory, input.sdk)), + input.mcp && + (() => + retry(() => input.backend.common.commands.list(location(input.directory))).then((commands) => + input.setStore("command", reconcile(commands)), + )), + () => + input.queryClient.fetchQuery(loadReferencesQuery(input.scope, input.directory, Promise.resolve(input.backend))), () => retry(() => - input.sdk.permission.list().then((x) => { - const ids = (x.data ?? []).map((perm) => perm?.sessionID).filter((id): id is string => !!id) - const grouped = groupBySession( - (x.data ?? []).filter((perm): perm is PermissionRequest => !!perm?.id && !!perm.sessionID), + input.backend.common.permissions.pending(location(input.directory)).then((data) => { + const permissions = data.map( + (perm): AppPermissionRequest => perm, ) + const ids = permissions.map((perm) => perm.sessionID) + const grouped = groupBySession(permissions) const warm = input.session ? Promise.all(ids.map((sessionID) => input.session!.resolve(sessionID))).then(() => undefined) - : warmSessions({ ids, store: input.store, setStore: input.setStore, sdk: input.sdk }) + : warmSessions({ + ids, + store: input.store, + setStore: input.setStore, + backend: input.backend, + location: { directory: input.directory }, + }) return warm.then(() => batch(() => { const current = input.session?.data.permission ?? input.store.permission @@ -323,12 +359,21 @@ export async function bootstrapDirectory(input: { ), () => retry(() => - input.sdk.question.list().then((x) => { - const ids = (x.data ?? []).map((question) => question?.sessionID).filter((id): id is string => !!id) - const grouped = groupBySession((x.data ?? []).filter((q): q is QuestionRequest => !!q?.id && !!q.sessionID)) + input.backend.common.questions.pending(location(input.directory)).then((data) => { + const questions = data.map( + (question): AppQuestionRequest => question, + ) + const ids = questions.map((question) => question.sessionID) + const grouped = groupBySession(questions) const warm = input.session ? Promise.all(ids.map((sessionID) => input.session!.resolve(sessionID))).then(() => undefined) - : warmSessions({ ids, store: input.store, setStore: input.setStore, sdk: input.sdk }) + : warmSessions({ + ids, + store: input.store, + setStore: input.setStore, + backend: input.backend, + location: { directory: input.directory }, + }) return warm.then(() => batch(() => { const current = input.session?.data.question ?? input.store.question @@ -351,17 +396,25 @@ export async function bootstrapDirectory(input: { }), ), () => Promise.resolve(input.loadSessions(input.directory)), - input.mcp && (() => input.queryClient.fetchQuery(loadMcpQuery(input.scope, input.directory, input.sdk))), - input.mcp && (() => input.queryClient.fetchQuery(loadMcpResourcesQuery(input.scope, input.directory, input.sdk))), + input.mcp && + (() => + input.queryClient.fetchQuery(loadMcpQuery(input.scope, input.directory, Promise.resolve(input.backend)))), + input.mcp && + (() => + input.queryClient.fetchQuery( + loadMcpResourcesQuery(input.scope, input.directory, Promise.resolve(input.backend)), + )), () => - input.queryClient.fetchQuery(loadProvidersQuery(input.scope, input.directory, input.sdk)).catch((err) => { - const project = getFilename(input.directory) - showToast({ - variant: "error", - title: input.translate("toast.project.reloadFailed.title", { project }), - description: formatServerError(err, input.translate), - }) - }), + input.queryClient + .fetchQuery(loadProvidersQuery(input.scope, input.directory, Promise.resolve(input.backend))) + .catch((err) => { + const project = getFilename(input.directory) + showToast({ + variant: "error", + title: input.translate("toast.project.reloadFailed.title", { project }), + description: formatServerError(err, input.translate), + }) + }), ].filter(Boolean) as (() => Promise)[] await waitForPaint() @@ -379,3 +432,23 @@ export async function bootstrapDirectory(input: { if (loading && slowErrs.length === 0) input.setStore("status", "complete") })() } + +function location(directory: string | null) { + return directory === null ? undefined : { location: { directory } } +} + +function emptyPath(directory: string | null): AppPathInfo { + return { home: "", directory: directory ?? "", state: "", config: "", worktree: "" } +} + +function toProviderStore(input: ProviderCatalog): ProviderStore { + return { + all: input.providers, + connected: [...input.connected], + default: { ...input.defaults }, + } +} + +function mapActivity(input: Awaited>) { + return input +} diff --git a/packages/app/src/context/global-sync/child-store.test.ts b/packages/app/src/context/global-sync/child-store.test.ts index ddbfffe1ad..7a1ba1d0cb 100644 --- a/packages/app/src/context/global-sync/child-store.test.ts +++ b/packages/app/src/context/global-sync/child-store.test.ts @@ -1,8 +1,7 @@ import { beforeAll, describe, expect, mock, test } from "bun:test" import { createRoot, getOwner, type Owner } from "solid-js" import { createStore } from "solid-js/store" -import type { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context" -import type { State } from "./types" +import type { ProviderStore, State } from "./types" import type { QueryOptionsApi } from "../server-sync" import { ServerScope } from "@/utils/server-scope" @@ -16,7 +15,7 @@ const persist: typeof import("@/utils/persist").persisted = (_target, store) => ] const child = () => createStore({} as State) -const provider = { all: new Map(), connected: [], default: {} } satisfies NormalizedProviderListResponse +const provider = { all: new Map(), connected: [], default: {} } satisfies ProviderStore const queryOptionsApi = { globalConfig: () => ({ queryKey: ["globalConfig"], queryFn: async () => ({}) }), diff --git a/packages/app/src/context/global-sync/child-store.ts b/packages/app/src/context/global-sync/child-store.ts index 66e40f66ec..6e7f762acf 100644 --- a/packages/app/src/context/global-sync/child-store.ts +++ b/packages/app/src/context/global-sync/child-store.ts @@ -1,7 +1,7 @@ import { createRoot, createSignal, getOwner, onCleanup, runWithOwner, type Owner } from "solid-js" import { createStore, type SetStoreFunction, type Store } from "solid-js/store" import { Persist, persisted } from "@/utils/persist" -import type { VcsInfo } from "@opencode-ai/sdk/v2/client" +import type { AppVcsInfo } from "../backend" import { DIR_IDLE_TTL_MS, MAX_DIR_STORES, @@ -10,6 +10,7 @@ import { type IconCache, type MetaCache, type ProjectMeta, + type ProviderStore, type State, type VcsCache, } from "./types" @@ -17,7 +18,6 @@ import { canDisposeDirectory, pickDirectoriesToEvict } from "./eviction" import { useQuery } from "@tanstack/solid-query" import { QueryOptionsApi } from "../server-sync" import { directoryKey, type DirectoryKey } from "./utils" -import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context" import type { ServerScope } from "@/utils/server-scope" export function createChildStoreManager(input: { @@ -32,7 +32,7 @@ export function createChildStoreManager(input: { translate: (key: string, vars?: Record) => string queryOptions: QueryOptionsApi global: { - provider: NormalizedProviderListResponse + provider: ProviderStore } }) { const children: Record, SetStoreFunction]> = {} @@ -152,7 +152,7 @@ export function createChildStoreManager(input: { const vcs = runWithOwner(input.owner, () => input.persist( Persist.serverWorkspace(input.scope, directory, "vcs", ["vcs.v1"]), - createStore({ value: undefined as VcsInfo | undefined }), + createStore({ value: undefined as AppVcsInfo | undefined }), ), ) if (!vcs) throw new Error(input.translate("error.childStore.persistedCacheCreateFailed")) @@ -223,6 +223,7 @@ export function createChildStoreManager(input: { return (type ?? "idle") !== "idle" }, session_diff: {}, + todo: {}, permission: {}, question: {}, get mcp_ready() { diff --git a/packages/app/src/context/global-sync/event-reducer.test.ts b/packages/app/src/context/global-sync/event-reducer.test.ts index 8e2113709b..36f7fa586a 100644 --- a/packages/app/src/context/global-sync/event-reducer.test.ts +++ b/packages/app/src/context/global-sync/event-reducer.test.ts @@ -1,5 +1,12 @@ import { describe, expect, test } from "bun:test" -import type { Message, Part, PermissionRequest, Project, QuestionRequest, Session } from "@opencode-ai/sdk/v2/client" +import type { + AppMessage as Message, + AppPart as Part, + AppPermissionRequest as PermissionRequest, + AppProject as Project, + AppQuestionRequest as QuestionRequest, + AppSession as Session, +} from "../backend" import { createStore } from "solid-js/store" import type { State } from "./types" import { applyDirectoryEvent, applyGlobalEvent, cleanupDroppedSessionCaches } from "./event-reducer" @@ -39,7 +46,9 @@ const permissionRequest = (id: string, sessionID: string, title = id) => id, sessionID, permission: title, + action: title, patterns: ["*"], + resources: ["*"], metadata: {}, always: [], }) as PermissionRequest @@ -523,9 +532,9 @@ describe("applyDirectoryEvent", () => { }) test("updates vcs branch in store and cache", () => { - const [store, setStore] = createStore(baseState({ vcs: { branch: "main", default_branch: "main" } })) + const [store, setStore] = createStore(baseState({ vcs: { branch: "main", defaultBranch: "main" } })) const [cacheStore, setCacheStore] = createStore({ - value: { branch: "main", default_branch: "main" } as State["vcs"], + value: { branch: "main", defaultBranch: "main" } as State["vcs"], }) applyDirectoryEvent({ @@ -542,8 +551,8 @@ describe("applyDirectoryEvent", () => { }, }) - expect(store.vcs).toEqual({ branch: "feature/test", default_branch: "main" }) - expect(cacheStore.value).toEqual({ branch: "feature/test", default_branch: "main" }) + expect(store.vcs).toEqual({ branch: "feature/test", defaultBranch: "main" }) + expect(cacheStore.value).toEqual({ branch: "feature/test", defaultBranch: "main" }) }) test("routes disposal and lsp events to side-effect handlers", () => { diff --git a/packages/app/src/context/global-sync/event-reducer.ts b/packages/app/src/context/global-sync/event-reducer.ts index 935fc14e33..daccf7dcdb 100644 --- a/packages/app/src/context/global-sync/event-reducer.ts +++ b/packages/app/src/context/global-sync/event-reducer.ts @@ -1,15 +1,18 @@ import { Binary } from "@opencode-ai/core/util/binary" import { produce, reconcile, type SetStoreFunction, type Store } from "solid-js/store" import type { - Message, - Part, - PermissionRequest, - Project, - QuestionRequest, - Session, - SessionStatus, - FileDiffInfo, -} from "@opencode-ai/sdk/v2/client" + AppEvent, + AppFileDiff, + AppMessage, + AppPart, + AppPermissionRequest, + AppProject, + AppQuestionRequest, + AppSession, + AppTodo, + SessionActivity, +} from "../backend" +import { timelineMessage } from "../backend" import type { State, VcsCache } from "./types" import { trimSessions } from "./session-trim" import { dropSessionCaches } from "./session-cache" @@ -31,19 +34,76 @@ const SESSION_CONTENT_EVENTS = new Set([ "question.rejected", ]) +type LegacyEvent = { type: string; properties?: unknown } +type DirectoryLegacyEvent = + | { type: string; properties: unknown } + | { type: "server.instance.disposed"; properties?: undefined } + +function legacyEvent(event: AppEvent): LegacyEvent | undefined { + if (event.type === "instance.disposed") return { type: "server.instance.disposed", properties: event } + if (event.type === "session.created" || event.type === "session.updated") + return { type: event.type, properties: { info: event.session } } + if (event.type === "session.deleted") return { type: event.type, properties: { sessionID: event.sessionID } } + if (event.type === "session.activity") + return { type: "session.status", properties: { sessionID: event.sessionID, status: event.activity } } + if (event.type === "session.diff" || event.type === "todo.updated") return { type: event.type, properties: event } + if (event.type === "timeline.updated") { + const message = timelineMessage(event.item) + return message ? { type: "message.updated", properties: { info: message } } : undefined + } + if (event.type === "timeline.content.updated") return undefined + if (event.type === "timeline.removed") + return { + type: "message.removed", + properties: { sessionID: event.sessionID, messageID: event.itemID }, + } + if (event.type === "timeline.part.removed") + return { + type: "message.part.removed", + properties: { sessionID: event.sessionID, messageID: event.itemID, partID: event.contentID }, + } + if (event.type === "timeline.delta") + return { + type: "message.part.delta", + properties: { + sessionID: event.sessionID, + messageID: event.itemID, + partID: event.contentID, + field: event.field, + delta: event.delta, + }, + } + if (event.type === "permission.requested") return { type: "permission.asked", properties: event.request } + if (event.type === "permission.replied" || event.type === "question.replied" || event.type === "question.rejected") + return { type: event.type, properties: event } + if (event.type === "question.requested") return { type: "question.asked", properties: event.request } + return { type: event.type, properties: event } +} + +function normalizeDirectoryEvent(event: AppEvent | DirectoryLegacyEvent) { + if ("properties" in event) return event + if (event.type === "server.instance.disposed") return event + return legacyEvent(event) +} + export function applyGlobalEvent(input: { - event: { type: string; properties?: unknown } - project: Project[] - setGlobalProject: (next: Project[] | ((draft: Project[]) => Project[])) => void + event: AppEvent | LegacyEvent + project: AppProject[] + setGlobalProject: (next: AppProject[] | ((draft: AppProject[]) => AppProject[])) => void refresh: () => void }) { - if (input.event.type === "global.disposed" || input.event.type === "server.connected") { + if ( + input.event.type === "server.disposed" || + input.event.type === "global.disposed" || + input.event.type === "server.connected" || + input.event.type === "provider.updated" + ) { input.refresh() return } if (input.event.type !== "project.updated") return - const properties = input.event.properties as Project + const properties = "project" in input.event ? input.event.project : (input.event.properties as AppProject) const result = Binary.search(input.project, properties.id, (s) => s.id) if (result.found) { input.setGlobalProject( @@ -60,7 +120,11 @@ export function applyGlobalEvent(input: { ) } -function cleanupSessionCaches(setStore: SetStoreFunction, sessionID: string) { +function cleanupSessionCaches( + setStore: SetStoreFunction, + sessionID: string, + setSessionTodo?: (sessionID: string, todos: AppTodo[] | undefined) => void, +) { if (!sessionID) return setStore( produce((draft) => { @@ -69,7 +133,12 @@ function cleanupSessionCaches(setStore: SetStoreFunction, sessionID: stri ) } -export function cleanupDroppedSessionCaches(store: Store, setStore: SetStoreFunction, next: Session[]) { +export function cleanupDroppedSessionCaches( + store: Store, + setStore: SetStoreFunction, + next: AppSession[], + setSessionTodo?: (sessionID: string, todos: AppTodo[] | undefined) => void, +) { const keep = new Set(next.map((item) => item.id)) const stale = [ ...Object.keys(store.message), @@ -90,7 +159,7 @@ export function cleanupDroppedSessionCaches(store: Store, setStore: SetSt } export function applyDirectoryEvent(input: { - event: { type: string; properties?: unknown } + event: AppEvent | DirectoryLegacyEvent store: Store setStore: SetStoreFunction push: (directory: string) => void @@ -98,11 +167,17 @@ export function applyDirectoryEvent(input: { loadLsp: () => void loadReferences?: () => void vcsCache?: VcsCache + setSessionTodo?: (sessionID: string, todos: AppTodo[] | undefined) => void retainedLimit?: number sessionContent?: boolean permission?: State["permission"] }) { - const event = input.event + if (input.event.type === "server.instance.disposed") { + input.push(input.directory) + return + } + const event = normalizeDirectoryEvent(input.event) + if (!event) return if (input.sessionContent === false && SESSION_CONTENT_EVENTS.has(event.type)) return const limit = Math.max(input.store.limit, input.retainedLimit ?? 0) switch (event.type) { @@ -111,7 +186,7 @@ export function applyDirectoryEvent(input: { return } case "session.created": { - const info = (event.properties as { info: Session }).info + const info = (event.properties as { info: AppSession }).info const result = Binary.search(input.store.session, info.id, (s) => s.id) if (result.found) { input.setStore("session", result.index, reconcile(info)) @@ -126,7 +201,7 @@ export function applyDirectoryEvent(input: { break } case "session.updated": { - const info = (event.properties as { info: Session }).info + const info = (event.properties as { info: AppSession }).info const result = Binary.search(input.store.session, info.id, (s) => s.id) if (info.time.archived) { if (input.store.session[result.index]!.time.archived === info.time.archived) break @@ -155,8 +230,10 @@ export function applyDirectoryEvent(input: { break } case "session.deleted": { - const info = (event.properties as { info: Session }).info - const result = Binary.search(input.store.session, info.id, (s) => s.id) + const properties = event.properties as { sessionID?: string; info?: { id: string } } + const sessionID = properties.sessionID ?? properties.info?.id ?? "" + const result = Binary.search(input.store.session, sessionID, (s) => s.id) + const info = result.found ? input.store.session[result.index] : undefined if (result.found) { input.setStore( "session", @@ -165,23 +242,29 @@ export function applyDirectoryEvent(input: { }), ) } - cleanupSessionCaches(input.setStore, info.id) - if (info.parentID) break + cleanupSessionCaches(input.setStore, sessionID, input.setSessionTodo) + if (info?.parentID) break input.setStore("sessionTotal", (value) => Math.max(0, value - 1)) break } case "session.diff": { - const props = event.properties as { sessionID: string; diff: FileDiffInfo[] } + const props = event.properties as { sessionID: string; diff: AppFileDiff[] } input.setStore("session_diff", props.sessionID, reconcile(list(props.diff), { key: "file" })) break } + case "todo.updated": { + const props = event.properties as { sessionID: string; todos: AppTodo[] } + input.setStore("todo", props.sessionID, reconcile(props.todos, { key: "id" })) + input.setSessionTodo?.(props.sessionID, props.todos) + break + } case "session.status": { - const props = event.properties as { sessionID: string; status: SessionStatus } + const props = event.properties as { sessionID: string; status: SessionActivity } input.setStore("session_status", props.sessionID, reconcile(props.status)) break } case "message.updated": { - const info = clean((event.properties as { info: Message }).info) + const info = clean((event.properties as { info: AppMessage }).info) const messages = input.store.message[info.sessionID] if (!messages) { input.setStore("message", info.sessionID, [info]) @@ -222,7 +305,7 @@ export function applyDirectoryEvent(input: { break } case "message.part.updated": { - const part = (event.properties as { part: Part }).part + const part = (event.properties as { part: AppPart }).part if (SKIP_PARTS.has(part.type)) break input.setStore( produce((draft) => { @@ -306,7 +389,7 @@ export function applyDirectoryEvent(input: { break } case "permission.asked": { - const permission = event.properties as PermissionRequest + const permission = event.properties as AppPermissionRequest const permissions = input.store.permission[permission.sessionID] if (!permissions) { input.setStore("permission", permission.sessionID, [permission]) @@ -342,7 +425,7 @@ export function applyDirectoryEvent(input: { break } case "question.asked": { - const question = event.properties as QuestionRequest + const question = event.properties as AppQuestionRequest const questions = input.store.question[question.sessionID] if (!questions) { input.setStore("question", question.sessionID, [question]) diff --git a/packages/app/src/context/global-sync/mcp.ts b/packages/app/src/context/global-sync/mcp.ts index 2eeb297b95..6956d5b856 100644 --- a/packages/app/src/context/global-sync/mcp.ts +++ b/packages/app/src/context/global-sync/mcp.ts @@ -1,7 +1,7 @@ -import type { McpStatus } from "@opencode-ai/sdk/v2/client" +import type { AppMcpStatus } from "../backend" export async function toggleMcp(input: { - status: McpStatus["status"] + status: AppMcpStatus["status"] connect: () => Promise disconnect: () => Promise authenticate: () => Promise @@ -9,6 +9,7 @@ export async function toggleMcp(input: { }) { await { connected: input.disconnect, + pending: async () => {}, needs_auth: input.authenticate, disabled: input.connect, failed: input.connect, diff --git a/packages/app/src/context/global-sync/session-cache.test.ts b/packages/app/src/context/global-sync/session-cache.test.ts index 41034186e6..b0c62302f1 100644 --- a/packages/app/src/context/global-sync/session-cache.test.ts +++ b/packages/app/src/context/global-sync/session-cache.test.ts @@ -1,12 +1,12 @@ import { describe, expect, test } from "bun:test" import type { - Message, - Part, - PermissionRequest, - QuestionRequest, - SessionStatus, - FileDiffInfo, -} from "@opencode-ai/sdk/v2/client" + AppMessage as Message, + AppPart as Part, + AppPermissionRequest as PermissionRequest, + AppQuestionRequest as QuestionRequest, + AppFileDiff as SnapshotFileDiff, + SessionActivity as SessionStatus, +} from "../backend" import { dropSessionCaches, pickSessionCacheEvictions } from "./session-cache" const msg = (id: string, sessionID: string) => @@ -32,7 +32,8 @@ describe("app session cache", () => { test("dropSessionCaches clears orphaned parts without message rows", () => { const store: { session_status: Record - session_diff: Record + session_diff: Record + todo: Record message: Record part: Record permission: Record @@ -41,6 +42,7 @@ describe("app session cache", () => { } = { session_status: { ses_1: { type: "busy" } as SessionStatus }, session_diff: { ses_1: [] }, + todo: {}, message: {}, part: { msg_1: [part("prt_1", "ses_1", "msg_1")] }, permission: { ses_1: [] as PermissionRequest[] }, @@ -63,7 +65,8 @@ describe("app session cache", () => { const m = msg("msg_1", "ses_1") const store: { session_status: Record - session_diff: Record + session_diff: Record + todo: Record message: Record part: Record permission: Record @@ -72,6 +75,7 @@ describe("app session cache", () => { } = { session_status: {}, session_diff: {}, + todo: {}, message: { ses_1: [m] }, part: { [m.id]: [part("prt_1", "ses_1", m.id)] }, permission: {}, @@ -85,6 +89,21 @@ describe("app session cache", () => { expect(store.part[m.id]).toBeUndefined() }) + test("dropSessionCaches accepts V2 stores without todo caches", () => { + const store = { + session_status: { ses_1: { type: "running" } }, + session_diff: { ses_1: [] }, + message: { ses_1: [] }, + part: {}, + permission: { ses_1: [] }, + question: { ses_1: [] }, + part_text_accum_delta: {}, + } + + expect(() => dropSessionCaches(store, ["ses_1"])).not.toThrow() + expect(store.session_status.ses_1).toBeUndefined() + }) + test("pickSessionCacheEvictions preserves requested sessions", () => { const seen = new Set(["ses_1", "ses_2", "ses_3"]) diff --git a/packages/app/src/context/global-sync/session-cache.ts b/packages/app/src/context/global-sync/session-cache.ts index 6f2c3dc540..6cf70eba0c 100644 --- a/packages/app/src/context/global-sync/session-cache.ts +++ b/packages/app/src/context/global-sync/session-cache.ts @@ -1,21 +1,13 @@ -import type { - Message, - Part, - PermissionRequest, - QuestionRequest, - SessionStatus, - FileDiffInfo, -} from "@opencode-ai/sdk/v2/client" - export const SESSION_CACHE_LIMIT = 40 type SessionCache = { - session_status: Record - session_diff: Record - message: Record - part: Record - permission: Record - question: Record + session_status: Record + session_diff: Record + todo?: Record + message: Record + part: Record + permission: Record + question: Record part_text_accum_delta: Record } @@ -35,6 +27,7 @@ export function dropSessionCaches(store: SessionCache, sessionIDs: Iterable @@ -43,7 +43,7 @@ describe("trimSessions", () => { const result = trimSessions(list, { limit: 2, permission: { - "child-kept-by-permission": [{ id: "perm-1" } as PermissionRequest], + "child-kept-by-permission": [{ id: "perm-1" }], }, now, }) diff --git a/packages/app/src/context/global-sync/session-trim.ts b/packages/app/src/context/global-sync/session-trim.ts index ba13cb5ec0..f25cfc70ac 100644 --- a/packages/app/src/context/global-sync/session-trim.ts +++ b/packages/app/src/context/global-sync/session-trim.ts @@ -1,21 +1,21 @@ -import type { PermissionRequest, Session } from "@opencode-ai/sdk/v2/client" +import type { AppSession } from "../backend" import { cmp } from "./utils" import { SESSION_RECENT_LIMIT, SESSION_RECENT_WINDOW } from "./types" -export function sessionUpdatedAt(session: Session) { +export function sessionUpdatedAt(session: AppSession) { return session.time.updated ?? session.time.created } -export function compareSessionRecent(a: Session, b: Session) { +export function compareSessionRecent(a: AppSession, b: AppSession) { const aUpdated = sessionUpdatedAt(a) const bUpdated = sessionUpdatedAt(b) if (aUpdated !== bUpdated) return bUpdated - aUpdated return cmp(a.id, b.id) } -export function takeRecentSessions(sessions: Session[], limit: number, cutoff: number) { - if (limit <= 0) return [] as Session[] - const selected: Session[] = [] +export function takeRecentSessions(sessions: AppSession[], limit: number, cutoff: number) { + if (limit <= 0) return [] as AppSession[] + const selected: AppSession[] = [] const seen = new Set() for (const session of sessions) { if (!session?.id) continue @@ -31,8 +31,8 @@ export function takeRecentSessions(sessions: Session[], limit: number, cutoff: n } export function trimSessions( - input: Session[], - options: { limit: number; permission: Record; now?: number }, + input: AppSession[], + options: { limit: number; permission: Record; now?: number }, ) { const limit = Math.max(0, options.limit) const cutoff = (options.now ?? Date.now()) - SESSION_RECENT_WINDOW diff --git a/packages/app/src/context/global-sync/types.ts b/packages/app/src/context/global-sync/types.ts index 53191b756d..be884d5507 100644 --- a/packages/app/src/context/global-sync/types.ts +++ b/packages/app/src/context/global-sync/types.ts @@ -1,22 +1,23 @@ import type { - Agent, - Command, - Config, - LspStatus, - McpResource, - McpStatus, - Message, - Part, - Path, - PermissionRequest, - QuestionRequest, - ReferenceInfo, - Session, - SessionStatus, - FileDiffInfo, - VcsInfo, -} from "@opencode-ai/sdk/v2/client" -import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context" + AppMessage, + AppPart, + AppPermissionRequest, + AppQuestionRequest, + AppSession, + AppFileDiff, + AppTodo, + AppCommand, + AppConfig, + AppAgent, + AppLspStatus, + AppMcpResource, + AppMcpStatus, + AppPathInfo, + AppProvider, + AppReference, + AppVcsInfo, + SessionActivity, +} from "../backend" import type { Accessor } from "solid-js" import type { SetStoreFunction, Store } from "solid-js/store" @@ -31,49 +32,62 @@ export type ProjectMeta = { } } +export type StoreConfig = { + -readonly [Key in keyof AppConfig]: AppConfig[Key] +} + +export type ProviderStore = { + all: ReadonlyMap + connected: readonly string[] + default: Readonly> +} + export type State = { status: "loading" | "partial" | "complete" - agent: Agent[] - command: Command[] - reference: ReferenceInfo[] + agent: AppAgent[] + command: readonly AppCommand[] + reference: readonly AppReference[] project: string projectMeta: ProjectMeta | undefined icon: string | undefined provider_ready: boolean - provider: NormalizedProviderListResponse - config: Config - path: Path - session: Session[] + provider: ProviderStore + config: StoreConfig + path: AppPathInfo + session: AppSession[] sessionTotal: number session_status: { - [sessionID: string]: SessionStatus + [sessionID: string]: SessionActivity } session_working(id: string): boolean session_diff: { - [sessionID: string]: FileDiffInfo[] + [sessionID: string]: AppFileDiff[] + } + todo: { + [sessionID: string]: AppTodo[] } permission: { - [sessionID: string]: PermissionRequest[] + [sessionID: string]: AppPermissionRequest[] } question: { - [sessionID: string]: QuestionRequest[] + [sessionID: string]: AppQuestionRequest[] } mcp_ready: boolean mcp: { - [name: string]: McpStatus + [name: string]: AppMcpStatus } mcp_resource: { - [key: string]: McpResource + [key: string]: AppMcpResource } lsp_ready: boolean - lsp: LspStatus[] - vcs: VcsInfo | undefined + lsp: readonly AppLspStatus[] + vcs: AppVcsInfo | undefined limit: number message: { - [sessionID: string]: Message[] + [sessionID: string]: AppMessage[] } part: { - [messageID: string]: Part[] + [messageID: string]: AppPart[] } part_text_accum_delta: { [partID: string]: string @@ -81,8 +95,8 @@ export type State = { } export type VcsCache = { - store: Store<{ value: VcsInfo | undefined }> - setStore: SetStoreFunction<{ value: VcsInfo | undefined }> + store: Store<{ value: AppVcsInfo | undefined }> + setStore: SetStoreFunction<{ value: AppVcsInfo | undefined }> ready: Accessor } @@ -127,11 +141,11 @@ export type DisposeCheck = { export type RootLoadArgs = { directory: string limit: number - list: (query: { directory: string; roots: true; limit?: number }) => Promise<{ data?: Session[] }> + list: (query: { directory: string; roots: true; limit?: number }) => Promise<{ data?: AppSession[] }> } export type RootLoadResult = { - data?: Session[] + data?: AppSession[] limit: number limited: boolean } diff --git a/packages/app/src/context/global-sync/utils.test.ts b/packages/app/src/context/global-sync/utils.test.ts index 406c0f124e..9d92d75408 100644 --- a/packages/app/src/context/global-sync/utils.test.ts +++ b/packages/app/src/context/global-sync/utils.test.ts @@ -1,38 +1,5 @@ import { describe, expect, test } from "bun:test" -import type { Agent } from "@opencode-ai/sdk/v2/client" -import { directoryKey, normalizeAgentList } from "./utils" - -const agent = (name = "build") => - ({ - name, - mode: "primary", - permission: {}, - options: {}, - }) as Agent - -describe("normalizeAgentList", () => { - test("keeps array payloads", () => { - expect(normalizeAgentList([agent("build"), agent("docs")])).toEqual([agent("build"), agent("docs")]) - }) - - test("wraps a single agent payload", () => { - expect(normalizeAgentList(agent("docs"))).toEqual([agent("docs")]) - }) - - test("extracts agents from keyed objects", () => { - expect( - normalizeAgentList({ - build: agent("build"), - docs: agent("docs"), - }), - ).toEqual([agent("build"), agent("docs")]) - }) - - test("drops invalid payloads", () => { - expect(normalizeAgentList({ name: "AbortError" })).toEqual([]) - expect(normalizeAgentList([{ name: "build" }, agent("docs")])).toEqual([agent("docs")]) - }) -}) +import { directoryKey } from "./utils" describe("directoryKey", () => { test("normalizes slashes", () => { diff --git a/packages/app/src/context/global-sync/utils.ts b/packages/app/src/context/global-sync/utils.ts index e54bc88d4d..b0bbef3f36 100644 --- a/packages/app/src/context/global-sync/utils.ts +++ b/packages/app/src/context/global-sync/utils.ts @@ -1,43 +1,8 @@ -import type { Agent, Project, ProviderListResponse } from "@opencode-ai/sdk/v2/client" -import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context" +import type { AppProject as Project } from "../backend" export { pathKey as directoryKey, type PathKey as DirectoryKey } from "@/utils/path-key" export const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0) -function isAgent(input: unknown): input is Agent { - if (!input || typeof input !== "object") return false - const item = input as { name?: unknown; mode?: unknown } - if (typeof item.name !== "string") return false - return item.mode === "subagent" || item.mode === "primary" || item.mode === "all" -} - -export function normalizeAgentList(input: unknown): Agent[] { - if (Array.isArray(input)) return input.filter(isAgent) - if (isAgent(input)) return [input] - if (!input || typeof input !== "object") return [] - return Object.values(input).filter(isAgent) -} - -export function normalizeProviderList(input: ProviderListResponse): NormalizedProviderListResponse { - return { - ...input, - all: new Map( - input.all.map( - (provider) => - [ - provider.id, - { - ...provider, - models: Object.fromEntries( - Object.entries(provider.models).filter(([, info]) => info.status !== "deprecated"), - ), - }, - ] as const, - ), - ), - } -} - export function sanitizeProject(project: Project) { if (!project.icon?.url && !project.icon?.override) return project return { diff --git a/packages/app/src/context/global.tsx b/packages/app/src/context/global.tsx index 83438898b5..659e9b379c 100644 --- a/packages/app/src/context/global.tsx +++ b/packages/app/src/context/global.tsx @@ -3,20 +3,25 @@ import { createEffect, createMemo, createRoot } from "solid-js" import { createStore } from "solid-js/store" import { createServerProjects, RECENTLY_CLOSED_DISPLAY_LIMIT, ServerConnection, useServer } from "./server" import { pathKey } from "@/utils/path-key" -import { useServerHealth } from "@/utils/server-health" +import { useCheckServerHealth, useServerHealth } from "@/utils/server-health" import { createServerSdkContext } from "./server-sdk" import { createServerSyncContext } from "./server-sync" import { getOwner } from "solid-js/web" import { QueryClient } from "@tanstack/solid-query" import type { ServerScope } from "@/utils/server-scope" +import { usePlatform } from "./platform" +import { backendIdentity, createBackendForServer } from "./backend-client" export const { use: useGlobal, provider: GlobalProvider } = createSimpleContext({ name: "Global", init: () => { const server = useServer() + const platform = usePlatform() + const checkServerHealth = useCheckServerHealth() const serverHealth = useServerHealth( () => server.list, () => true, + checkServerHealth, ) const [store, setStore] = createStore({ settings: { @@ -37,7 +42,7 @@ export const { use: useGlobal, provider: GlobalProvider } = createSimpleContext( const serverCtxs = new Map< ServerConnection.Key, - { dispose: () => void; serverCtx: ReturnType } + { dispose: () => void; identity: string; serverCtx: ReturnType } >() const owner = getOwner() @@ -45,10 +50,26 @@ export const { use: useGlobal, provider: GlobalProvider } = createSimpleContext( const ensureServerCtx = (conn: ServerConnection.Any) => { const key = ServerConnection.key(conn) const existing = serverCtxs.get(key) - if (existing) return existing.serverCtx + const identity = backendIdentity(conn) + if (existing?.identity === identity) return existing.serverCtx + if (existing) { + existing.dispose() + serverCtxs.delete(key) + } const root = createRoot((dispose) => { - const serverCtx = createServerCtx(conn, server.scope(key), server.projects.forServer(key)) - return { dispose, serverCtx } + const serverCtx = createServerCtx( + conn, + server.scope(key), + server.projects.forServer(key), + createBackendForServer({ + server: conn, + browserUrl: location.href, + fetch: platform.fetch ?? globalThis.fetch, + eventFetch: eventStreamFetch(conn.http.url, platform.fetch), + health: checkServerHealth(conn.http), + }), + ) + return { dispose, identity, serverCtx } }, owner as any) serverCtxs.set(key, root) return root.serverCtx @@ -97,6 +118,7 @@ function createServerCtx( conn: ServerConnection.Any, scope: ServerScope, projects: ReturnType, + backend: ReturnType, ) { const queryClient = new QueryClient({ defaultOptions: { @@ -107,7 +129,7 @@ function createServerCtx( }, }, }) - const sdk = createServerSdkContext(conn, scope) + const sdk = createServerSdkContext(conn, scope, backend) const sync = createServerSyncContext(sdk) function enrich(project: { worktree: string; expanded: boolean }) { @@ -141,6 +163,7 @@ function createServerCtx( (conn?.type === "sidecar" && conn.variant === "base") || (conn?.type === "http" && isLocalHost(conn.http.url)) return { + backend, queryClient, sdk, sync, @@ -153,6 +176,18 @@ function createServerCtx( } } +function eventStreamFetch(url: string, fetch?: typeof globalThis.fetch) { + if (!fetch) return globalThis.fetch + try { + const parsed = new URL(url) + const loopback = parsed.hostname === "localhost" || parsed.hostname === "127.0.0.1" || parsed.hostname === "::1" + if (parsed.protocol === "http:" && !loopback) return fetch + } catch { + return globalThis.fetch + } + return globalThis.fetch +} + export type ServerCtx = ReturnType function isLocalHost(url: string) { diff --git a/packages/app/src/context/layout.tsx b/packages/app/src/context/layout.tsx index 8a26d91c46..84c6fed9e9 100644 --- a/packages/app/src/context/layout.tsx +++ b/packages/app/src/context/layout.tsx @@ -7,7 +7,7 @@ import { useServerSync } from "./server-sync" import { useServerSDK } from "./server-sdk" import { RECENTLY_CLOSED_DISPLAY_LIMIT, ServerConnection, useServer } from "./server" import { usePlatform } from "./platform" -import { Project } from "@opencode-ai/sdk/v2" +import type { AppProject } from "./backend" import { Persist, persisted, removePersisted } from "@/utils/persist" import { pathKey } from "@/utils/path-key" import { decode64 } from "@/utils/base64" @@ -72,7 +72,7 @@ type TabHandoff = { at: number } -export type LocalProject = Partial & { worktree: string; expanded: boolean } +export type LocalProject = Partial & { worktree: string; expanded: boolean } export type HomeProjectSelection = { server: ServerConnection.Key; directory?: string } export type ReviewDiffStyle = "unified" | "split" @@ -550,6 +550,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( setColors(worktree, color) } if (!project.id) continue + const projectID = project.id const requested = colorRequested.get(worktree) if (requested === color) continue @@ -561,7 +562,11 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( } void serverSdk() - .client.project.update({ projectID: project.id, directory: worktree, icon: { color } }) + .backend.then((client) => { + const editing = client.capabilities.projectEditing + if (!editing) throw new Error("Project editing is not supported by this server") + return editing.update({ projectID, location: { directory: worktree }, icon: { color } }) + }) .catch(() => { if (colorRequested.get(worktree) === color) colorRequested.delete(worktree) }) diff --git a/packages/app/src/context/local.tsx b/packages/app/src/context/local.tsx index a12fd832fd..29259c34ff 100644 --- a/packages/app/src/context/local.tsx +++ b/packages/app/src/context/local.tsx @@ -64,7 +64,15 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ const models = useModels() const id = createMemo(() => params.id || undefined) - const list = createMemo(() => sync().data.agent.filter((item) => item.mode !== "subagent" && !item.hidden)) + const list = createMemo(() => + sync() + .data.agent.filter((item) => item.mode !== "subagent" && !item.hidden) + .map((item) => ({ + ...item, + model: item.model && { providerID: item.model.providerID, modelID: item.model.id }, + variant: item.model?.variant, + })), + ) const connected = createMemo(() => new Set(providers.connected().map((item) => item.id))) const [saved, setSaved, , savedReady] = persisted( diff --git a/packages/app/src/context/models.tsx b/packages/app/src/context/models.tsx index 736dc3a7c5..c634c9e787 100644 --- a/packages/app/src/context/models.tsx +++ b/packages/app/src/context/models.tsx @@ -50,7 +50,7 @@ export const { use: useModels, provider: ModelsProvider } = createSimpleContext( () => new Map( available().map((model) => { - const parsed = DateTime.fromISO(model.release_date) + const parsed = DateTime.fromISO(model.releaseDate ?? "") return [modelKey({ providerID: model.provider.id, modelID: model.id }), parsed] as const }), ), @@ -75,7 +75,7 @@ export const { use: useModels, provider: ModelsProvider } = createSimpleContext( values(), (groups) => groups.flatMap((g) => { - const first = firstBy(g, [(x) => x.release_date, "desc"]) + const first = firstBy(g, [(x) => x.releaseDate ?? "", "desc"]) return first ? [{ modelID: first.id, providerID: first.provider.id }] : [] }), ), diff --git a/packages/app/src/context/notification.tsx b/packages/app/src/context/notification.tsx index e12f8f1250..4cac333f98 100644 --- a/packages/app/src/context/notification.tsx +++ b/packages/app/src/context/notification.tsx @@ -9,7 +9,6 @@ import { useLanguage } from "@/context/language" import { useSettings } from "@/context/settings" import { base64Encode } from "@opencode-ai/core/util/encode" 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" @@ -32,7 +31,7 @@ type TurnCompleteNotification = NotificationBase & { type ErrorNotification = NotificationBase & { type: "error" - error: EventSessionError["properties"]["error"] + error: unknown } export type Notification = TurnCompleteNotification | ErrorNotification @@ -325,8 +324,7 @@ function createServerNotificationState(input: { return sessionID === activeSession } - const handleSessionIdle = (directory: string, event: { properties: { sessionID?: string } }, time: number) => { - const sessionID = event.properties.sessionID + const handleSessionIdle = (directory: string, sessionID: string, time: number) => { void lookup(directory, sessionID).then((session) => { if (meta.disposed) return if (!session) return @@ -353,10 +351,10 @@ function createServerNotificationState(input: { const handleSessionError = ( directory: string, - event: { properties: { sessionID?: string; error?: EventSessionError["properties"]["error"] } }, + event: { sessionID?: string; error?: unknown }, time: number, ) => { - const sessionID = event.properties.sessionID + const sessionID = event.sessionID void lookup(directory, sessionID).then((session) => { if (meta.disposed) return if (session?.parentID) return @@ -365,7 +363,7 @@ function createServerNotificationState(input: { void playSoundById(settings.sounds.errors()) } - const error = "error" in event.properties ? event.properties.error : undefined + const error = event.error append({ directory, time, @@ -386,12 +384,13 @@ function createServerNotificationState(input: { const unsub = serverSDK().event.listen((e) => { const event = e.details - if (event.type !== "session.idle" && event.type !== "session.error") return + if (event.type !== "session.activity" && event.type !== "session.error") return const directory = e.name const time = Date.now() - if (event.type === "session.idle") { - handleSessionIdle(directory, event, time) + if (event.type === "session.activity") { + if (event.activity.type !== "idle") return + handleSessionIdle(directory, event.sessionID, time) return } handleSessionError(directory, event, time) diff --git a/packages/app/src/context/permission-auto-respond.test.ts b/packages/app/src/context/permission-auto-respond.test.ts index 002ae94e5b..370e8578a1 100644 --- a/packages/app/src/context/permission-auto-respond.test.ts +++ b/packages/app/src/context/permission-auto-respond.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test" -import type { PermissionRequest, Session } from "@opencode-ai/sdk/v2/client" +import type { AppPermissionRequest as PermissionRequest, AppSession as Session } from "./backend" import { base64Encode } from "@opencode-ai/core/util/encode" import { autoRespondsPermission, isDirectoryAutoAccepting } from "./permission-auto-respond" diff --git a/packages/app/src/context/permission.tsx b/packages/app/src/context/permission.tsx index ff43638bbc..699fd4890b 100644 --- a/packages/app/src/context/permission.tsx +++ b/packages/app/src/context/permission.tsx @@ -1,7 +1,6 @@ import { type Accessor, createEffect, createMemo, onCleanup } from "solid-js" import { createStore, produce } from "solid-js/store" import { createSimpleContext } from "@opencode-ai/ui/context" -import type { PermissionRequest } from "@opencode-ai/sdk/v2/client" import { Persist, persisted } from "@/utils/persist" import { useServerSDK } from "@/context/server-sdk" import { useServerSync } from "./server-sync" @@ -21,6 +20,8 @@ type PermissionRespondFn = (input: { directory?: string }) => void +type PermissionRequest = { id: string; sessionID: string } + function isNonAllowRule(rule: unknown) { if (!rule) return false if (typeof rule === "string") return rule !== "allow" @@ -119,8 +120,17 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple } const respond: PermissionRespondFn = (input) => { + const directory = input.directory ?? props.directory?.() ?? decode64(params.dir) + if (!directory) return serverSDK() - .client.permission.respond(input) + .backend.then((client) => + client.common.permissions.reply({ + sessionID: input.sessionID, + requestID: input.permissionID, + reply: input.response, + location: { directory }, + }), + ) .catch(() => { responded.delete(input.permissionID) }) @@ -164,9 +174,9 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple const unsubscribe = serverSDK().event.listen((e) => { const event = e.details - if (event?.type !== "permission.asked") return + if (event?.type !== "permission.requested") return - const perm = event.properties + const perm = event.request if (!shouldAutoRespond(perm, e.name)) return respondOnce(perm, e.name) @@ -182,10 +192,10 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple ) serverSDK() - .client.permission.list({ directory }) - .then((x) => { + .backend.then((client) => client.common.permissions.pending({ location: { directory } })) + .then((items) => { if (!isAutoAcceptingDirectory(directory)) return - for (const perm of x.data ?? []) { + for (const perm of items) { if (!perm?.id) continue if (!shouldAutoRespond(perm, directory)) continue respondOnce(perm, directory) @@ -214,11 +224,11 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple ) serverSDK() - .client.permission.list({ directory }) - .then((x) => { + .backend.then((client) => client.common.permissions.pending({ location: { directory } })) + .then((items) => { if (enableVersion.get(key) !== version) return if (!isAutoAccepting(sessionID, directory)) return - for (const perm of x.data ?? []) { + for (const perm of items) { if (!perm?.id) continue if (!shouldAutoRespond(perm, directory)) continue respondOnce(perm, directory) diff --git a/packages/app/src/context/prompt-state.ts b/packages/app/src/context/prompt-state.ts index b6a68be4e7..a3b7fe324a 100644 --- a/packages/app/src/context/prompt-state.ts +++ b/packages/app/src/context/prompt-state.ts @@ -1,5 +1,5 @@ import { checksum } from "@opencode-ai/core/util/encode" -import type { FilePartSource } from "@opencode-ai/sdk/v2/client" +import type { AppFilePartSource as FilePartSource } from "./backend" import { batch, createMemo, type Accessor } from "solid-js" import { createStore, type SetStoreFunction } from "solid-js/store" import type { FileSelection } from "@/context/file" diff --git a/packages/app/src/context/server-sdk.test.ts b/packages/app/src/context/server-sdk.test.ts index 052faf496a..93b677d0b6 100644 --- a/packages/app/src/context/server-sdk.test.ts +++ b/packages/app/src/context/server-sdk.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test" import { coalesceServerEvents, enqueueServerEvent, resumeStreamAfterPageShow } from "./server-sdk" -import type { Event } from "@opencode-ai/sdk/v2/client" +import type { AppEvent } from "./backend" describe("resumeStreamAfterPageShow", () => { test("restarts a stream only after a back-forward cache restore", () => { @@ -18,34 +18,36 @@ describe("coalesceServerEvents", () => { const delta = (value: string, field = "text", partID = "part") => ({ directory: "/repo", payload: { - type: "message.part.delta", - properties: { messageID: "msg", partID, field, delta: value }, - } as Event, + type: "timeline.delta", + sessionID: "ses", + itemID: "msg", + contentID: partID, + field, + delta: value, + } as AppEvent, }) test("merges adjacent deltas for the same field", () => { const first = delta("hello ") const second = delta("world") - first.payload.id = "first" - second.payload.id = "second" const result = coalesceServerEvents([first, second]) expect(result).toHaveLength(1) - expect(result[0]?.payload).toMatchObject({ id: "second", properties: { delta: "hello world" } }) + expect(result[0]?.payload).toMatchObject({ delta: "hello world" }) }) test("preserves event boundaries and distinct fields", () => { const status = { directory: "/repo", - payload: { type: "session.status", properties: { sessionID: "ses", status: { type: "idle" } } } as Event, + payload: { type: "session.activity", sessionID: "ses", activity: { type: "idle" } } as AppEvent, } const result = coalesceServerEvents([delta("a"), delta("b", "metadata"), status, delta("c")]) expect(result.map((event) => event.payload.type)).toEqual([ - "message.part.delta", - "message.part.delta", - "session.status", - "message.part.delta", + "timeline.delta", + "timeline.delta", + "session.activity", + "timeline.delta", ]) }) @@ -53,104 +55,99 @@ describe("coalesceServerEvents", () => { const first = delta("a") const other = delta("b", "text", "other") const last = delta("c") - first.payload.id = "1" - other.payload.id = "2" - last.payload.id = "3" - const result = coalesceServerEvents([first, other, last]) - expect(result.map((event) => event.payload.id)).toEqual(["1", "2", "3"]) + expect(result.map((event) => event.payload.type)).toEqual(["timeline.delta", "timeline.delta", "timeline.delta"]) }) }) describe("enqueueServerEvent", () => { const partUpdated = (text: string) => ({ - type: "message.part.updated", - properties: { + type: "timeline.updated", + item: { + type: "user", + id: "message", sessionID: "session", - part: { id: "part", sessionID: "session", messageID: "message", type: "text", text }, + created: 1, + content: [{ id: "part", type: "text", text }], }, - }) as Event + }) as AppEvent test("preserves part updates across message remove and re-add barriers", () => { - const events: Array<{ directory: string; payload: Event }> = [] - const enqueue = (payload: Event) => enqueueServerEvent(events, { directory: "/repo", payload }) + const events: Array<{ directory: string; payload: AppEvent }> = [] + const enqueue = (payload: AppEvent) => enqueueServerEvent(events, { directory: "/repo", payload }) enqueue(partUpdated("old")) - enqueue({ type: "message.removed", properties: { sessionID: "session", messageID: "message" } } as Event) + enqueue({ type: "timeline.removed", sessionID: "session", itemID: "message" }) enqueue({ - type: "message.updated", - properties: { + type: "timeline.updated", + item: { + type: "user", + created: 1, + content: [], sessionID: "session", - info: { - id: "message", - sessionID: "session", - role: "user", - time: { created: 1 }, - agent: "build", - model: { providerID: "provider", modelID: "model" }, - }, + id: "message", }, - } as Event) + }) enqueue(partUpdated("new")) expect(events.map((event) => event.payload.type)).toEqual([ - "message.part.updated", - "message.removed", - "message.updated", - "message.part.updated", + "timeline.updated", + "timeline.removed", + "timeline.updated", ]) }) test("preserves deltas after a replacement snapshot", () => { - const events: Array<{ directory: string; payload: Event }> = [] - const enqueue = (payload: Event) => enqueueServerEvent(events, { directory: "/repo", payload }) + const events: Array<{ directory: string; payload: AppEvent }> = [] + const enqueue = (payload: AppEvent) => enqueueServerEvent(events, { directory: "/repo", payload }) enqueue(partUpdated("a")) enqueue(partUpdated("ab")) enqueue({ - type: "message.part.delta", - properties: { sessionID: "session", messageID: "message", partID: "part", field: "text", delta: "c" }, - } as Event) + type: "timeline.delta", + sessionID: "session", + itemID: "message", + contentID: "part", + field: "text", + delta: "c", + }) const result = coalesceServerEvents(events) - expect(result.map((event) => event.payload.type)).toEqual(["message.part.updated", "message.part.delta"]) - expect(result[0]?.payload).toMatchObject({ properties: { part: { text: "ab" } } }) - expect(result[1]?.payload).toMatchObject({ properties: { delta: "c" } }) + expect(result.map((event) => event.payload.type)).toEqual(["timeline.updated", "timeline.delta"]) + expect(result[0]?.payload).toMatchObject({ item: { content: [{ text: "ab" }] } }) + expect(result[1]?.payload).toMatchObject({ delta: "c" }) }) test("preserves updates after session deletion", () => { - const events: Array<{ directory: string; payload: Event }> = [] - const enqueue = (payload: Event) => enqueueServerEvent(events, { directory: "/repo", payload }) + const events: Array<{ directory: string; payload: AppEvent }> = [] + const enqueue = (payload: AppEvent) => enqueueServerEvent(events, { directory: "/repo", payload }) enqueue(partUpdated("old")) enqueue({ - id: "event-delete", type: "session.deleted", - properties: { sessionID: "session" }, - } as Event) + sessionID: "session", + }) enqueue(partUpdated("new")) expect(events.map((event) => event.payload.type)).toEqual([ - "message.part.updated", + "timeline.updated", "session.deleted", - "message.part.updated", + "timeline.updated", ]) }) test("does not coalesce edge-triggered session statuses", () => { - const events: Array<{ directory: string; payload: Event }> = [] + const events: Array<{ directory: string; payload: AppEvent }> = [] const enqueue = (status: "retry" | "busy") => enqueueServerEvent(events, { directory: "/repo", payload: { - type: "session.status", - properties: { - sessionID: "session", - status: status === "retry" ? { type: "retry", attempt: 1, message: "retry", next: 1 } : { type: "busy" }, - }, - } as Event, + type: "session.activity", + sessionID: "session", + activity: status === "retry" ? { type: "retry", attempt: 1, message: "retry", next: 1 } : { type: "running" }, + }, }) enqueue("retry") diff --git a/packages/app/src/context/server-sdk.tsx b/packages/app/src/context/server-sdk.tsx index 06597e56e7..dd1a87fb0d 100644 --- a/packages/app/src/context/server-sdk.tsx +++ b/packages/app/src/context/server-sdk.tsx @@ -1,9 +1,8 @@ -import type { Event } from "@opencode-ai/sdk/v2/client" +import type { AppClient, AppEvent } from "./backend" import { createSimpleContext } from "@opencode-ai/ui/context" import { createGlobalEmitter } from "@solid-primitives/event-bus" import { makeEventListener } from "@solid-primitives/event-listener" import { type Accessor, batch, createMemo, onCleanup, onMount } from "solid-js" -import { createSdkForServer } from "@/utils/server" import { useLanguage } from "./language" import { usePlatform } from "./platform" import { ServerConnection, useServer } from "./server" @@ -15,14 +14,15 @@ const isAbortError = (error: unknown) => error !== null && typeof error === "object" && "name" in error && error.name === "AbortError" const isStreamClosed = (error: unknown, signal?: AbortSignal) => isAbortError(error) || signal?.aborted === true -type QueuedServerEvent = { directory: string; payload: Event } +type QueuedServerEvent = { directory: string; payload: AppEvent } const coalescedKey = (event: QueuedServerEvent) => { if (event.payload.type === "lsp.updated") return `lsp.updated:${event.directory}` - if (event.payload.type === "message.part.updated") { - const part = event.payload.properties.part - return `message.part.updated:${event.directory}:${part.messageID}:${part.id}` + if (event.payload.type === "timeline.updated") { + return `timeline.updated:${event.directory}:${event.payload.item.id}` } + if (event.payload.type === "timeline.content.updated") + return `timeline.content.updated:${event.directory}:${event.payload.itemID}:${event.payload.content.id}` return undefined } @@ -40,23 +40,22 @@ export function enqueueServerEvent(queue: QueuedServerEvent[], event: QueuedServ export function coalesceServerEvents(events: QueuedServerEvent[]) { const output: QueuedServerEvent[] = [] events.forEach((event) => { - if (event.payload.type !== "message.part.delta") { + if (event.payload.type !== "timeline.delta") { output.push(event) return } - const props = event.payload.properties const previous = output[output.length - 1] if ( !previous || - previous.payload.type !== "message.part.delta" || + previous.payload.type !== "timeline.delta" || previous.directory !== event.directory || - previous.payload.properties.messageID !== props.messageID || - previous.payload.properties.partID !== props.partID || - previous.payload.properties.field !== props.field + previous.payload.itemID !== event.payload.itemID || + previous.payload.contentID !== event.payload.contentID || + previous.payload.field !== event.payload.field ) { output.push({ directory: event.directory, - payload: { ...event.payload, properties: { ...props } }, + payload: { ...event.payload }, }) return } @@ -64,7 +63,7 @@ export function coalesceServerEvents(events: QueuedServerEvent[]) { directory: event.directory, payload: { ...event.payload, - properties: { ...props, delta: previous.payload.properties.delta + props.delta }, + delta: previous.payload.delta + event.payload.delta, }, } }) @@ -76,7 +75,7 @@ export function resumeStreamAfterPageShow(event: PageTransitionEvent, start: () start() } -function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerScope) { +function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerScope, backend: Promise) { const platform = usePlatform() const abort = new AbortController() @@ -91,13 +90,8 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS } })() - const eventSdk = createSdkForServer({ - signal: abort.signal, - fetch: eventFetch, - server: server.http, - }) const emitter = createGlobalEmitter<{ - [key: string]: Event + [key: string]: AppEvent }>() type Queued = QueuedServerEvent @@ -174,29 +168,13 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS } abort.signal.addEventListener("abort", onAbort) try { - const events = await eventSdk.global.event({ - signal: attempt.signal, - onSseError: (error) => { - if (isStreamClosed(error, attempt?.signal)) return - if (streamErrorLogged) return - streamErrorLogged = true - console.error("[global-sdk] event stream error", { - url: server.http.url, - fetch: eventFetch ? "platform" : "webview", - error, - }) - }, - }) let yielded = Date.now() resetHeartbeat() - for await (const event of events.stream) { + for await (const envelope of (await backend).common.events.subscribe({ signal: attempt.signal })) { resetHeartbeat() streamErrorLogged = false - if (event.payload.type !== "sync") { - const directory = event.directory ?? "global" - const payload = event.payload as Event - if (enqueueServerEvent(queue, { directory, payload })) schedule() - } + const directory = envelope.location?.directory ?? "global" + if (enqueueServerEvent(queue, { directory, payload: envelope.event })) schedule() if (Date.now() - yielded < STREAM_YIELD_MS) continue yielded = Date.now() @@ -253,39 +231,30 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS flush() }) - const sdk = createSdkForServer({ - server: server.http, - fetch: platform.fetch, - throwOnError: true, - }) - return { server, scope, url: server.http.url, - client: sdk, event: { on: emitter.on.bind(emitter), listen: emitter.listen.bind(emitter), start, }, - createClient(opts: Omit[0], "server" | "fetch">) { - return createSdkForServer({ - server: server.http, - fetch: platform.fetch, - ...opts, - }) - }, } } type ServerSDKBase = ReturnType -export type ServerSDK = ServerSDKBase & { +type ServerSDKWithBackend = ServerSDKBase & { backend: Promise } +export type ServerSDK = ServerSDKWithBackend & { ensureDirSdkContext: (directory: string) => ReturnType } -export function createServerSdkContext(server: ServerConnection.Any, scope: ServerScope): ServerSDK { - const sdk = createServerSdkContextBase(server, scope) +export function createServerSdkContext( + server: ServerConnection.Any, + scope: ServerScope, + backend: Promise, +): ServerSDK { + const sdk = Object.assign(createServerSdkContextBase(server, scope, backend), { backend }) return Object.assign(sdk, { ensureDirSdkContext: createRefCountMap((dir) => createDirSdkContext(dir, sdk)), }) @@ -309,15 +278,10 @@ export const { use: useServerSDK, provider: ServerSDKProvider } = createSimpleCo }) type SDKEventMap = { - [key in Event["type"]]: Extract + [key in AppEvent["type"]]: AppEvent } -function createDirSdkContext(directory: string, serverSDK: ServerSDKBase) { - const client = serverSDK.createClient({ - directory, - throwOnError: true, - }) - +function createDirSdkContext(directory: string, serverSDK: ServerSDKWithBackend) { const emitter = createGlobalEmitter() const unsub = serverSDK.event.on(directory, (event) => { @@ -328,13 +292,10 @@ function createDirSdkContext(directory: string, serverSDK: ServerSDKBase) { return { scope: serverSDK.scope, directory, - client, + backend: serverSDK.backend, event: emitter, get url() { return serverSDK.url }, - createClient(opts: Parameters[0]) { - return serverSDK.createClient(opts) - }, } } diff --git a/packages/app/src/context/server-session.test.ts b/packages/app/src/context/server-session.test.ts index fb4443a3ef..0f37bd17da 100644 --- a/packages/app/src/context/server-session.test.ts +++ b/packages/app/src/context/server-session.test.ts @@ -1,7 +1,51 @@ import { describe, expect, test } from "bun:test" import type { retry } from "@opencode-ai/core/util/retry" -import type { Message, OpencodeClient, Part, Session } from "@opencode-ai/sdk/v2/client" -import { createServerSession } from "./server-session" +import type { + AppFileDiff, + AppMessage as Message, + AppPart as Part, + AppSession as Session, + AppTodo, + TimelineContent, + TimelineItem, +} from "./backend" +import { createAppClient } from "./backend.test-fixture" +import { createServerSession as createAppServerSession } from "./server-session" + +type FixtureClient = { + session: { + get(input: unknown): Promise<{ data: Session }> + messages(input: unknown): MessageResponse | Promise + message?(input: unknown): SingleMessageResponse | Promise + diff?(input: unknown): Promise<{ data: AppFileDiff[] }> + todo?(input: unknown): Promise<{ data: AppTodo[] }> + } +} + +const createServerSession = (client: FixtureClient, options?: { retry?: typeof retry }) => + createAppServerSession( + createAppClient({ + version: "v1", + common: { + sessions: { + get: async (input) => (await client.session.get(input)).data, + history: async (input) => { + const result = await client.session.messages(input) + return { + items: result.data.map((item) => timelineItem(item.info, item.parts)), + older: result.response.headers.get("x-next-cursor") ?? undefined, + } + }, + message: async (input) => { + if (!client.session.message) throw new Error("Message fixture is not configured") + const result = await client.session.message(input) + return timelineItem(result.data.info, result.data.parts) + }, + }, + }, + }), + options, + ) const session = (id: string, parentID?: string): Session => ({ id, @@ -67,6 +111,81 @@ const singleResponse = (info: Message, parts: Part[] = []): SingleMessageRespons const deferredResponse = () => Promise.withResolvers() +function timelineItem(info: Message, parts: Part[]): TimelineItem { + const content = parts.map(toTimelineContent) + if (info.role === "user") + return { + type: "user", + id: info.id, + sessionID: info.sessionID, + created: info.time.created, + content, + agent: info.agent, + model: { id: info.model.modelID, providerID: info.model.providerID, variant: info.model.variant }, + format: info.format, + summary: info.summary, + system: info.system, + tools: info.tools, + } + return { + type: "assistant", + id: info.id, + sessionID: info.sessionID, + parentID: info.parentID, + created: info.time.created, + completed: info.time.completed, + content, + agent: info.agent, + model: { id: info.modelID, providerID: info.providerID, variant: info.variant }, + tokens: info.tokens, + error: info.error, + mode: info.mode, + path: info.path, + cost: info.cost, + structured: info.structured, + finish: info.finish, + summary: info.summary, + } +} + +function toTimelineContent(part: Part): TimelineContent { + if (part.type === "agent") + return { + type: part.type, + id: part.id, + name: part.name, + source: part.source && { text: part.source.value, start: part.source.start, end: part.source.end }, + } + if (part.type === "subtask") + return { + ...part, + model: part.model && { id: part.model.modelID, providerID: part.model.providerID }, + } + if (part.type !== "file") return { ...part } + return { + type: "file", + id: part.id, + uri: part.url, + name: part.filename, + mime: part.mime, + source: + part.source?.type === "resource" + ? { + type: part.source.type, + clientName: part.source.clientName, + uri: part.source.uri, + text: { text: part.source.text.value, start: part.source.text.start, end: part.source.text.end }, + } + : part.source && { + type: part.source.type, + path: part.source.path, + name: part.source.type === "symbol" ? part.source.name : undefined, + kind: part.source.type === "symbol" ? part.source.kind : undefined, + text: { text: part.source.text.value, start: part.source.text.start, end: part.source.text.end }, + }, + } +} + function messageClient(...responses: Array>) { let index = 0 const requests: unknown[] = [] @@ -81,7 +200,7 @@ function messageClient(...responses: Array) { }, diff: async () => ({ data: [] }), }, - } as unknown as OpencodeClient + } as FixtureClient return { get, messages, store: createServerSession(client) } } @@ -163,7 +282,10 @@ describe("server session", () => { const result = await ctx.store.lineage.resolve("child") expect(result.root.id).toBe("root") - expect(ctx.get).toEqual([{ sessionID: "child" }, { sessionID: "root" }]) + expect(ctx.get).toEqual([ + { sessionID: "child", location: undefined }, + { sessionID: "root", location: undefined }, + ]) expect(ctx.store.lineage.peek("child")).toEqual(result) }) @@ -194,7 +316,9 @@ describe("server session", () => { await store.sync("child") expect(client.requests).toEqual([{ sessionID: "child", limit: 2, before: undefined }]) - expect(client.rootRequests).toEqual([{ sessionID: "child", messageID: user.id }]) + expect(client.rootRequests).toEqual([ + { sessionID: "child", messageID: user.id, location: { directory: "/repo" } }, + ]) expect(store.data.message.child).toEqual([user, ...assistants]) expect(store.history.more("child")).toBe(true) }) @@ -278,7 +402,9 @@ describe("server session", () => { await store.sync("child", { force: true }) - expect(client.rootRequests).toEqual([{ sessionID: "child", messageID: stale.id }]) + expect(client.rootRequests).toEqual([ + { sessionID: "child", messageID: stale.id, location: { directory: "/repo" } }, + ]) expect(store.data.message.child).toEqual([fresh, assistant]) expect(store.data.part[stale.id]).toEqual([freshPart]) }) @@ -300,7 +426,9 @@ describe("server session", () => { await store.sync("child", { force: true }) - expect(client.rootRequests).toEqual([{ sessionID: "child", messageID: stale.id }]) + expect(client.rootRequests).toEqual([ + { sessionID: "child", messageID: stale.id, location: { directory: "/repo" } }, + ]) expect(store.data.message.child).toEqual([fresh, assistant]) expect(store.data.part[stale.id]).toEqual([refreshed, pending]) }) @@ -636,6 +764,21 @@ describe("server session", () => { expect(store.data.part[message.id]).toEqual([fetched]) }) + test("reconciles semantically identical native parts to optimistic IDs without duplicates", async () => { + const pending = deferredResponse() + const message = userMessage("message") + const optimistic = textPart(message.id, { id: "optimistic", text: "hello" }) + const fetched = textPart(message.id, { id: "message:text", text: "hello" }) + const store = createServerSession(messageClient(pending.promise)) + const loading = store.sync("child") + + store.optimistic.add({ sessionID: "child", message, parts: [optimistic] }) + pending.resolve(response([{ info: message, parts: [fetched] }])) + await loading + + expect(store.data.part[message.id]).toEqual([optimistic]) + }) + test("rolls back only unconfirmed optimistic parts", async () => { const pending = deferredResponse() const message = userMessage("message") @@ -1207,6 +1350,7 @@ describe("server session", () => { await store.history.loadMore("child") + guard.active = false expect(store.data.message.child).toEqual([older, latest]) }) @@ -1429,4 +1573,28 @@ describe("server session", () => { expect(ctx.store.data.message.active?.map((message) => message.id)).toEqual(["message"]) expect(ctx.store.data.session_status["session-0"]).toBeUndefined() }) + + test("force-resyncs pinned sessions after a stream reconnect", async () => { + const first = userMessage("first") + const second = userMessage("second", { time: { created: 2 } }) + let messages = response([{ info: first, parts: [] }]) + let requests = 0 + const store = createServerSession({ + session: { + get: async () => ({ data: session("child") }), + messages: async () => { + requests++ + return messages + }, + }, + }) + store.pin("child") + await store.sync("child") + messages = response([{ info: first, parts: [] }, { info: second, parts: [] }]) + + await store.resync() + + expect(requests).toBe(2) + expect(store.data.message.child.map((message) => message.id)).toEqual(["first", "second"]) + }) }) diff --git a/packages/app/src/context/server-session.ts b/packages/app/src/context/server-session.ts index 12c3d8abb8..d11cd23170 100644 --- a/packages/app/src/context/server-session.ts +++ b/packages/app/src/context/server-session.ts @@ -1,15 +1,19 @@ import { Binary } from "@opencode-ai/core/util/binary" import { retry } from "@opencode-ai/core/util/retry" import type { - Message, - OpencodeClient, - Part, - PermissionRequest, - QuestionRequest, - Session, - SessionStatus, - FileDiffInfo, -} from "@opencode-ai/sdk/v2/client" + AppClient, + AppEvent, + AppFileDiff, + AppMessage, + AppPart, + AppPermissionRequest, + AppQuestionRequest, + AppSession, + AppTodo, + LocationRef, + SessionActivity, +} from "./backend" +import { timelineMessage, timelineParts } from "./backend" import { batch } from "solid-js" import { createStore, produce, reconcile } from "solid-js/store" import { diffs as cleanDiffs, message as cleanMessage } from "@/utils/diffs" @@ -18,7 +22,7 @@ import { rootSession } from "@/utils/session-route" import { dropSessionCaches, pickSessionCacheEvictions, SESSION_CACHE_LIMIT } from "./global-sync/session-cache" const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0) -const cmpMessage = (a: Message, b: Message) => a.time.created - b.time.created || cmp(a.id, b.id) +const cmpMessage = (a: AppMessage, b: AppMessage) => a.time.created - b.time.created || cmp(a.id, b.id) const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"]) const initialMessagePageSize = 2 const historyMessagePageSize = 200 @@ -26,15 +30,15 @@ const sessionInfoLimit = 2_048 const emptyIDs: ReadonlySet = new Set() type OptimisticItem = { - message: Message - parts: Part[] - confirmedParts?: Part[] + message: AppMessage + parts: AppPart[] + confirmedParts?: AppPart[] confirmedMessage?: boolean } type MessagePage = { - session: Message[] - part: { id: string; part: Part[] }[] + session: AppMessage[] + part: { id: string; part: AppPart[] }[] cursor?: string complete: boolean } @@ -59,22 +63,31 @@ type MessageLoadBaseline = Pick< > function mergeOptimisticPage(page: MessagePage, items: OptimisticItem[]) { - if (items.length === 0) return { ...page, observed: [] as { messageID: string; parts: Part[] }[] } + if (items.length === 0) return { ...page, observed: [] as { messageID: string; parts: AppPart[] }[] } const session = [...page.session] const part = new Map(page.part.map((item) => [item.id, item.part])) - const observed: { messageID: string; parts: Part[] }[] = [] + const observed: { messageID: string; parts: AppPart[] }[] = [] for (const item of items) { const result = Binary.search(session, item.message.id, (message) => message.id) if (!result.found) session.splice(result.index, 0, item.message) - const current = part.get(item.message.id) - const confirmed = result.found - ? item.parts.filter((part) => Binary.search(current ?? [], part.id, (value) => value.id).found) + const current = part.get(item.message.id) ?? [] + const matched = result.found + ? item.parts.flatMap((optimistic) => { + const fetched = current.find((value) => value.id === optimistic.id || samePromptPart(value, optimistic)) + return fetched ? [{ optimistic, fetched }] : [] + }) : [] + const confirmed = matched.map((value) => value.optimistic) if (result.found) observed.push({ messageID: item.message.id, parts: confirmed }) part.set( item.message.id, merge( - result.found ? (current ?? []) : merge(item.confirmedParts ?? [], current ?? []), + result.found + ? current.map((value) => { + const match = matched.find((item) => item.fetched.id === value.id) + return match ? { ...value, id: match.optimistic.id } : value + }) + : merge(item.confirmedParts ?? [], current), item.parts.filter((part) => !confirmed.includes(part)), ), ) @@ -87,6 +100,14 @@ function mergeOptimisticPage(page: MessagePage, items: OptimisticItem[]) { } } +function samePromptPart(a: AppPart, b: AppPart) { + if (a.type !== b.type) return false + if (a.type === "text" && b.type === "text") return a.text === b.text + if (a.type === "file" && b.type === "file") return a.url === b.url && a.filename === b.filename && a.mime === b.mime + if (a.type === "agent" && b.type === "agent") return a.name === b.name + return false +} + function runInflight(map: Map>, key: string, task: () => Promise) { const pending = map.get(key) if (pending) return pending @@ -134,21 +155,22 @@ function reconcileFetched( return [...result.values()].sort((a, b) => cmp(a.id, b.id)) } -export function createServerSession(client: OpencodeClient, options?: { retry?: typeof retry }) { +export function createServerSession(backend: Promise | AppClient, options?: { retry?: typeof retry }) { const [data, setData] = createStore({ - info: {} as Record, - session_status: {} as Record, - session_diff: {} as Record, - permission: {} as Record, - question: {} as Record, - message: {} as Record, - part: {} as Record, + info: {} as Record, + session_status: {} as Record, + session_diff: {} as Record, + todo: {} as Record, + permission: {} as Record, + question: {} as Record, + message: {} as Record, + part: {} as Record, part_text_accum_delta: {} as Record, session_working(id: string) { return (this.session_status[id]?.type ?? "idle") !== "idle" }, }) - const requests = new Map>() + const requests = new Map>() const inflight = new Map>() const inflightDiff = new Map>() const optimistic = new Map>() @@ -158,7 +180,7 @@ export function createServerSession(client: OpencodeClient, options?: { retry?: const removedMessages = new Map>() const deltaBases = new Map() const deleteMessageParts = ( - cache: { part: Record; part_text_accum_delta: Record }, + cache: { part: Record; part_text_accum_delta: Record }, messageID: string, ) => { for (const part of cache.part[messageID] ?? []) { @@ -186,7 +208,15 @@ export function createServerSession(client: OpencodeClient, options?: { retry?: at: {} as Record, }) - const remember = (session: Session) => { + const locations = new Map() + const location = (sessionID: string) => { + const session = data.info[sessionID] + return session?.location ?? locations.get(sessionID) ?? (session?.directory ? { directory: session.directory } : undefined) + } + + const remember = (session: AppSession) => { + if (session.location) locations.set(session.id, session.location) + if (session.parentID && session.location) locations.set(session.parentID, session.location) setData("info", session.id, reconcile(session)) infoSeen.delete(session.id) infoSeen.add(session.id) @@ -236,11 +266,13 @@ export function createServerSession(client: OpencodeClient, options?: { retry?: const pending = requests.get(sessionID) if (pending) return pending const active = generation(sessionID) - const request = client.session.get({ sessionID }).then((result) => { - if (!result.data) throw sessionNotFoundError(sessionID) - if (generations.get(sessionID) !== active) return result.data - return remember(result.data) - }) + const request = Promise.resolve(backend) + .then((client) => client.common.sessions.get({ sessionID, location: location(sessionID) })) + .then((result) => { + if (!result) throw sessionNotFoundError(sessionID) + if (generations.get(sessionID) !== active) return result + return remember(result) + }) requests.set(sessionID, request) const cleanup = () => { if (requests.get(sessionID) === request) requests.delete(sessionID) @@ -297,7 +329,7 @@ export function createServerSession(client: OpencodeClient, options?: { retry?: items.set(messageID, { ...item, parts, confirmedParts, confirmedMessage: true }) } - const confirmOptimisticPart = (sessionID: string, messageID: string, part: Part) => { + const confirmOptimisticPart = (sessionID: string, messageID: string, part: AppPart) => { const items = optimistic.get(sessionID) const item = items?.get(messageID) if (!items || !item) return @@ -314,7 +346,7 @@ export function createServerSession(client: OpencodeClient, options?: { retry?: }) } - const confirmOptimistic = (sessionID: string, messageID: string, confirmedParts: Part[]) => { + const confirmOptimistic = (sessionID: string, messageID: string, confirmedParts: AppPart[]) => { const items = optimistic.get(sessionID) const item = items?.get(messageID) if (!items || !item) return @@ -461,35 +493,38 @@ export function createServerSession(client: OpencodeClient, options?: { retry?: ) const fetchMessages = async (sessionID: string, limit: number, before?: string, onAttempt?: () => void) => { - const response = await (options?.retry ?? retry)(() => { + const response = await (options?.retry ?? retry)(async () => { onAttempt?.() - return client.session.messages({ sessionID, limit, before }) + return (await backend).common.sessions.history({ sessionID, limit, cursor: before, location: location(sessionID) }) }) - const items = (response.data ?? []).filter((item) => !!item?.info?.id) + const items = response.items + .map((item) => ({ message: timelineMessage(item), parts: timelineParts(item) })) + .filter((item): item is { message: AppMessage; parts: AppPart[] } => !!item.message) return { - session: items.map((item) => cleanMessage(item.info)).sort((a, b) => cmp(a.id, b.id)), + session: items.map((item) => cleanMessage(item.message)).sort((a, b) => cmp(a.id, b.id)), part: items.map((item) => ({ - id: item.info.id, - part: item.parts.filter((part) => !!part?.id).sort((a, b) => cmp(a.id, b.id)), + id: item.message.id, + part: item.parts.sort((a, b) => cmp(a.id, b.id)), })), - cursor: response.response.headers.get("x-next-cursor") ?? undefined, - complete: !response.response.headers.get("x-next-cursor"), + cursor: response.older, + complete: !response.older, } } const fetchMessage = async (sessionID: string, messageID: string, onAttempt?: () => void) => { - const response = await (options?.retry ?? retry)(() => { + const response = await (options?.retry ?? retry)(async () => { onAttempt?.() - return client.session.message({ sessionID, messageID }) + return (await backend).common.sessions.message({ sessionID, messageID, location: location(sessionID) }) }) - if (!response.data?.info?.id) throw new Error(`Message not found: ${messageID}`) + const message = timelineMessage(response) + if (!message) throw new Error(`Message not found: ${messageID}`) return { - message: cleanMessage(response.data.info), - parts: response.data.parts.filter((part) => !!part?.id).sort((a, b) => cmp(a.id, b.id)), + message: cleanMessage(message), + parts: timelineParts(response).sort((a, b) => cmp(a.id, b.id)), } } - const replaceMessages = (sessionID: string, messages: Message[]) => { + const replaceMessages = (sessionID: string, messages: AppMessage[]) => { const messageIDs = new Set(messages.map((message) => message.id)) const dropped = (data.message[sessionID] ?? []).filter((message) => !messageIDs.has(message.id)) setData("message", sessionID, reconcile(messages, { key: "id" })) @@ -559,7 +594,7 @@ export function createServerSession(client: OpencodeClient, options?: { retry?: sessionID: string, page: MessagePage, load: MessageLoadState | undefined, - preserveUnfetched: boolean | ((message: Message) => boolean), + preserveUnfetched: boolean | ((message: AppMessage) => boolean), cleanupOrphans: boolean, ) => { const merged = mergeOptimisticPage(page, [...(optimistic.get(sessionID)?.values() ?? [])]) @@ -609,7 +644,7 @@ export function createServerSession(client: OpencodeClient, options?: { retry?: let applied = false try { const page = await fetchMessages(sessionID, limit, before, () => resetMessageLoad(sessionID, load)) - const first = page.session.reduce( + const first = page.session.reduce( (oldest, message) => (!oldest || cmpMessage(message, oldest) < 0 ? message : oldest), undefined, ) @@ -630,7 +665,7 @@ export function createServerSession(client: OpencodeClient, options?: { retry?: const parentIDs = [ ...new Set( page.session.flatMap((message) => - message.role === "assistant" && !users.has(message.parentID) ? [message.parentID] : [], + message.role === "assistant" && message.parentID && !users.has(message.parentID) ? [message.parentID] : [], ), ), ] @@ -659,7 +694,7 @@ export function createServerSession(client: OpencodeClient, options?: { retry?: ), } const preserveUnfetched = - mode === "prepend" || (!result.complete && (!first || ((message: Message) => cmpMessage(message, first) < 0))) + mode === "prepend" || (!result.complete && (!first || ((message: AppMessage) => cmpMessage(message, first) < 0))) applyMessagePage( sessionID, result, @@ -682,7 +717,11 @@ export function createServerSession(client: OpencodeClient, options?: { retry?: } } - const sync = (sessionID: string, options?: { force?: boolean; messageLimit?: number }) => { + const sync = ( + sessionID: string, + options?: { force?: boolean; messageLimit?: number; location?: LocationRef }, + ) => { + if (options?.location) locations.set(sessionID, options.location) touch(sessionID) return runInflight(inflight, sessionID, async () => { const cached = data.message[sessionID] !== undefined && meta.limit[sessionID] !== undefined @@ -729,7 +768,7 @@ export function createServerSession(client: OpencodeClient, options?: { retry?: return properties.part.sessionID } - const apply = (event: { type: string; properties?: unknown }) => { + const applyLegacy = (event: { type: string; properties?: unknown }) => { const eventID = eventSessionID(event) if (eventID) { touch(eventID) @@ -743,16 +782,16 @@ export function createServerSession(client: OpencodeClient, options?: { retry?: } switch (event.type) { case "session.created": - remember((event.properties as { info: Session }).info) + remember((event.properties as { info: AppSession }).info) return case "session.updated": { - const info = (event.properties as { info: Session }).info + const info = (event.properties as { info: AppSession }).info remember(info) if (info.time.archived) evict([info.id]) return } case "session.deleted": { - const sessionID = (event.properties as { info: Session }).info.id + const sessionID = (event.properties as { sessionID: string }).sessionID infoSeen.delete(sessionID) setData( "info", @@ -762,17 +801,22 @@ export function createServerSession(client: OpencodeClient, options?: { retry?: return } case "session.diff": { - const props = event.properties as { sessionID: string; diff: FileDiffInfo[] } + const props = event.properties as { sessionID: string; diff: AppFileDiff[] } setData("session_diff", props.sessionID, reconcile(cleanDiffs(props.diff), { key: "file" })) return } + case "todo.updated": { + const props = event.properties as { sessionID: string; todos: AppTodo[] } + setData("todo", props.sessionID, reconcile(props.todos, { key: "id" })) + return + } case "session.status": { - const props = event.properties as { sessionID: string; status: SessionStatus } + const props = event.properties as { sessionID: string; status: SessionActivity } setData("session_status", props.sessionID, reconcile(props.status)) return } case "message.updated": { - const info = cleanMessage((event.properties as { info: Message }).info) + const info = cleanMessage((event.properties as { info: AppMessage }).info) const load = messageLoads.get(info.sessionID) load?.touchedMessages.add(info.id) load?.removedMessages.delete(info.id) @@ -832,7 +876,7 @@ export function createServerSession(client: OpencodeClient, options?: { retry?: return } case "message.part.updated": { - const part = (event.properties as { part: Part }).part + const part = (event.properties as { part: AppPart }).part if (SKIP_PARTS.has(part.type)) return const messages = data.message[part.sessionID] const load = messageLoads.get(part.sessionID) @@ -971,7 +1015,7 @@ export function createServerSession(client: OpencodeClient, options?: { retry?: return } case "permission.asked": { - const permission = event.properties as PermissionRequest + const permission = event.properties as AppPermissionRequest const permissions = data.permission[permission.sessionID] if (!permissions) { setData("permission", permission.sessionID, [permission]) @@ -1001,7 +1045,7 @@ export function createServerSession(client: OpencodeClient, options?: { retry?: return } case "question.asked": { - const question = event.properties as QuestionRequest + const question = event.properties as AppQuestionRequest const questions = data.question[question.sessionID] if (!questions) { setData("question", question.sessionID, [question]) @@ -1033,6 +1077,118 @@ export function createServerSession(client: OpencodeClient, options?: { retry?: } } + const apply = (event: AppEvent | { type: string; properties: unknown }, eventLocation?: LocationRef) => { + if ("properties" in event) { + applyLegacy(event) + return + } + const sessionID = + event.type === "session.created" || event.type === "session.updated" + ? event.session.id + : "sessionID" in event && typeof event.sessionID === "string" + ? event.sessionID + : event.type === "timeline.updated" + ? event.item.sessionID + : undefined + if (sessionID && eventLocation) locations.set(sessionID, eventLocation) + if (event.type === "session.created" || event.type === "session.updated") { + applyLegacy({ type: event.type, properties: { info: event.session } }) + return + } + if (event.type === "session.deleted") { + applyLegacy({ type: event.type, properties: { sessionID: event.sessionID } }) + return + } + if (event.type === "session.moved") { + const current = data.info[event.sessionID] + if (!current) return + remember({ + ...current, + location: event.location, + directory: event.location.directory, + workspaceID: event.location.workspaceID, + }) + return + } + if (event.type === "session.revert") { + const current = data.info[event.sessionID] + if (current) remember({ ...current, revert: event.revert }) + return + } + if (event.type === "session.activity") { + applyLegacy({ type: "session.status", properties: { sessionID: event.sessionID, status: event.activity } }) + if (event.item) apply({ type: "timeline.updated", item: event.item }, eventLocation) + return + } + if (event.type === "session.diff" || event.type === "todo.updated") { + applyLegacy({ type: event.type, properties: event }) + return + } + if (event.type === "timeline.updated") { + const message = timelineMessage(event.item) + if (!message) return + applyLegacy({ type: "message.updated", properties: { info: message } }) + timelineParts(event.item).forEach((part) => + applyLegacy({ type: "message.part.updated", properties: { part } }), + ) + return + } + if (event.type === "timeline.content.updated") { + const message = data.message[event.sessionID]?.find((item) => item.id === event.itemID) + if (!message) return + const part = timelineParts({ + type: message.role, + id: message.id, + sessionID: message.sessionID, + created: message.time.created, + completed: message.role === "assistant" ? message.time.completed : undefined, + content: [event.content], + })[0] + if (part) applyLegacy({ type: "message.part.updated", properties: { part } }) + return + } + if (event.type === "timeline.removed") { + applyLegacy({ + type: "message.removed", + properties: { sessionID: event.sessionID, messageID: event.itemID }, + }) + return + } + if (event.type === "timeline.part.removed") { + applyLegacy({ + type: "message.part.removed", + properties: { + sessionID: event.sessionID, + messageID: event.itemID, + partID: event.contentID, + }, + }) + return + } + if (event.type === "timeline.delta") { + applyLegacy({ + type: "message.part.delta", + properties: { + sessionID: event.sessionID, + messageID: event.itemID, + partID: event.contentID, + field: event.field, + delta: event.delta, + }, + }) + return + } + if (event.type === "permission.requested") { + applyLegacy({ type: "permission.asked", properties: event.request }) + return + } + if (event.type === "permission.replied" || event.type === "question.replied" || event.type === "question.rejected") { + applyLegacy({ type: event.type, properties: event }) + return + } + if (event.type === "question.requested") applyLegacy({ type: "question.asked", properties: event.request }) + } + return { data, set: setData, @@ -1059,7 +1215,7 @@ export function createServerSession(client: OpencodeClient, options?: { retry?: return Date.now() - (meta.at[sessionID] ?? 0) <= ttl }, optimistic: { - add(input: { sessionID: string; message: Message; parts: Part[] }) { + add(input: { sessionID: string; message: AppMessage; parts: AppPart[] }) { const parts = input.parts .filter((part) => !!part?.id && !SKIP_PARTS.has(part.type)) .sort((a, b) => cmp(a.id, b.id)) @@ -1122,9 +1278,14 @@ export function createServerSession(client: OpencodeClient, options?: { retry?: if (data.session_diff[sessionID] !== undefined && !options?.force) return Promise.resolve() return runInflight(inflightDiff, sessionID, () => { const active = generation(sessionID) - return retry(() => client.session.diff({ sessionID })).then((result) => { + return retry(async () => { + const client = await backend + const capability = client.capabilities.sessionExtrasV1 ?? client.capabilities.sessionExtrasV2 + if (!capability?.diff) return [] + return capability.diff({ sessionID, location: location(sessionID) }) + }).then((result) => { if (generations.get(sessionID) !== active) return - setData("session_diff", sessionID, reconcile(cleanDiffs(result.data), { key: "file" })) + setData("session_diff", sessionID, reconcile(cleanDiffs(result), { key: "file" })) }) }) }, @@ -1155,6 +1316,14 @@ export function createServerSession(client: OpencodeClient, options?: { retry?: if (!count || count === 1) pinned.delete(sessionID) if (count && count > 1) pinned.set(sessionID, count - 1) }, + async resync() { + const sessionIDs = new Set([ + ...pinned.keys(), + ...optimistic.keys(), + ...Object.entries(data.session_status).flatMap(([sessionID, status]) => status.type === "idle" ? [] : [sessionID]), + ]) + await Promise.all([...sessionIDs].map((sessionID) => sync(sessionID, { force: true }))) + }, apply, } } diff --git a/packages/app/src/context/server-sync.tsx b/packages/app/src/context/server-sync.tsx index 9b337aa076..9625a24104 100644 --- a/packages/app/src/context/server-sync.tsx +++ b/packages/app/src/context/server-sync.tsx @@ -1,11 +1,11 @@ import type { - Config, - McpResource, - OpencodeClient, - Path, - Project, - ProviderAuthResponse, -} from "@opencode-ai/sdk/v2/client" + AppClient, + AppConfig, + AppMcpResource, + AppPathInfo, + AppProject, + AppProviderAuthResponse, +} from "./backend" import { showToast } from "@/utils/toast" import { getFilename } from "@opencode-ai/core/util/path" import { type Accessor, batch, createMemo, getOwner, onCleanup, onMount, untrack } from "solid-js" @@ -28,7 +28,7 @@ import { createChildStoreManager } from "./global-sync/child-store" import { applyDirectoryEvent, applyGlobalEvent } from "./global-sync/event-reducer" import { estimateRootSessionTotal, loadRootSessionsWithFallback } from "./global-sync/session-load" import { trimSessions } from "./global-sync/session-trim" -import type { ProjectMeta } from "./global-sync/types" +import type { ProjectMeta, ProviderStore, StoreConfig } from "./global-sync/types" import { SESSION_RECENT_LIMIT } from "./global-sync/types" import { formatServerError } from "@/utils/server-errors" import { queryOptions, useMutation, useQueries, useQuery, useQueryClient } from "@tanstack/solid-query" @@ -37,7 +37,6 @@ import { directoryKey } from "./global-sync/utils" import { PathKey } from "@/utils/path-key" import { createDirSyncContext } from "./directory-sync" import { createSimpleContext } from "@opencode-ai/ui/context" -import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context" import { createRefCountMap } from "@/utils/refcount" import { useGlobal } from "./global" import { ServerConnection, useServer } from "./server" @@ -47,53 +46,63 @@ import { persisted } from "@/utils/persist" import { toggleMcp } from "./global-sync/mcp" import { createServerSession } from "./server-session" +type GlobalConfigUpdate = Pick + type GlobalStore = { ready: boolean error?: InitError - path: Path - project: Project[] - provider: NormalizedProviderListResponse - provider_auth: ProviderAuthResponse - config: Config + path: AppPathInfo + project: AppProject[] + provider: ProviderStore + provider_auth: AppProviderAuthResponse + config: StoreConfig reload: undefined | "pending" | "complete" } -export const loadMcpQuery = (scope: ServerScope, directory: string, sdk: OpencodeClient) => +export const loadMcpQuery = (scope: ServerScope, directory: string, backend: Promise) => queryOptions({ queryKey: [scope, directory, "mcp"] as const, - queryFn: () => sdk.mcp.status().then((r) => r.data ?? {}), + queryFn: () => + backend + .then((client) => client.capabilities.mcp?.list({ location: { directory } }) ?? []) + .then((servers) => Object.fromEntries(servers.map((server) => [server.name, server.status]))), }) -export const loadMcpResourcesQuery = (scope: ServerScope, directory: string, sdk: OpencodeClient) => - queryOptions>({ +export const loadMcpResourcesQuery = (scope: ServerScope, directory: string, backend: Promise) => + queryOptions>({ queryKey: [scope, directory, "mcpResources"] as const, - queryFn: () => sdk.experimental.resource.list().then((r) => r.data ?? {}), + queryFn: () => + backend + .then( + (client) => + client.capabilities.mcp?.resources({ location: { directory } }) ?? { resources: [], templates: [] }, + ) + .then((result) => + Object.fromEntries(result.resources.map((resource) => [`${resource.server}:${resource.uri}`, resource])), + ), placeholderData: {}, }) -export const loadLspQuery = (scope: ServerScope, directory: string, sdk: OpencodeClient) => +export const loadLspQuery = (scope: ServerScope, directory: string, backend: Promise) => queryOptions({ queryKey: [scope, directory, "lsp"] as const, - queryFn: () => sdk.lsp.status().then((r) => r.data ?? []), + queryFn: async () => { + const client = await backend + return client.capabilities.lsp?.status({ location: { directory } }) ?? [] + }, }) -function makeQueryOptionsApi( - scope: ServerScope, - serverSDK: () => OpencodeClient, - sdkFor: (dir: PathKey) => OpencodeClient, -) { +function makeQueryOptionsApi(scope: ServerScope, backend: Promise) { return { - globalConfig: () => loadGlobalConfigQuery(scope, serverSDK()), - projects: () => loadProjectsQuery(scope, serverSDK()), - providers: (directory: PathKey | null) => - loadProvidersQuery(scope, directory, directory === null ? serverSDK() : sdkFor(directory)), - path: (directory: PathKey | null) => - loadPathQuery(scope, directory, directory === null ? serverSDK() : sdkFor(directory)), - agents: (directory: PathKey) => loadAgentsQuery(scope, directory, sdkFor(directory)), - references: (directory: PathKey) => loadReferencesQuery(scope, directory, sdkFor(directory)), - mcp: (directory: PathKey) => loadMcpQuery(scope, directory, sdkFor(directory)), - mcpResources: (directory: PathKey) => loadMcpResourcesQuery(scope, directory, sdkFor(directory)), - lsp: (directory: PathKey) => loadLspQuery(scope, directory, sdkFor(directory)), + globalConfig: () => loadGlobalConfigQuery(scope, backend), + projects: () => loadProjectsQuery(scope, backend), + providers: (directory: PathKey | null) => loadProvidersQuery(scope, directory, backend), + path: (directory: PathKey | null) => loadPathQuery(scope, directory, backend), + agents: (directory: PathKey) => loadAgentsQuery(scope, directory, backend), + references: (directory: PathKey) => loadReferencesQuery(scope, directory, backend), + mcp: (directory: PathKey) => loadMcpQuery(scope, directory, backend), + mcpResources: (directory: PathKey) => loadMcpResourcesQuery(scope, directory, backend), + lsp: (directory: PathKey) => loadLspQuery(scope, directory, backend), sessions: (directory: PathKey) => ({ queryKey: [scope, directory, "loadSessions"] as const }), } } @@ -104,24 +113,11 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) { const owner = getOwner() if (!owner) throw new Error("ServerSync must be created within owner") - const sdkCache = new Map() const booting = new Map>() const sessionLoads = new Map>() const sessionMeta = new Map() - const sdkFor = (directory: string) => { - const key = directoryKey(directory) - const cached = sdkCache.get(key) - if (cached) return cached - const sdk = serverSDK.createClient({ - directory, - throwOnError: true, - }) - sdkCache.set(key, sdk) - return sdk - } - - const queryOptionsApi = makeQueryOptionsApi(serverSDK.scope, () => serverSDK.client, sdkFor) + const queryOptionsApi = makeQueryOptionsApi(serverSDK.scope, serverSDK.backend) const [configQuery, providerQuery, pathQuery] = useQueries(() => ({ queries: [queryOptionsApi.globalConfig(), queryOptionsApi.providers(null), queryOptionsApi.path(null)], @@ -164,13 +160,13 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) { if (eventTimer !== undefined) clearTimeout(eventTimer) }) - const setProjects = (next: Project[] | ((draft: Project[]) => Project[])) => { + const setProjects = (next: AppProject[] | ((draft: AppProject[]) => AppProject[])) => { setGlobalStore("project", next) } const setBootStore = ((...input: unknown[]) => { if (input[0] === "project" && Array.isArray(input[1])) { - setProjects(input[1] as Project[]) + setProjects(input[1] as AppProject[]) return input[1] } return (setGlobalStore as (...args: unknown[]) => unknown)(...input) @@ -180,7 +176,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) { queryKey: [serverSDK.scope, "bootstrap"], queryFn: async () => { await bootstrapGlobal({ - serverSDK: serverSDK.client, + backend: serverSDK.backend, scope: serverSDK.scope, requestFailedTitle: language.t("common.requestFailed"), translate: language.t, @@ -195,7 +191,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) { const set = ((...input: unknown[]) => { if (input[0] === "project" && (Array.isArray(input[1]) || typeof input[1] === "function")) { - setProjects(input[1] as Project[] | ((draft: Project[]) => Project[])) + setProjects(input[1] as AppProject[] | ((draft: AppProject[]) => AppProject[])) return input[1] } return (setGlobalStore as (...args: unknown[]) => unknown)(...input) @@ -210,7 +206,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) { bootstrapInstance, }) - const session = createServerSession(serverSDK.client) + const session = createServerSession(serverSDK.backend) const children = createChildStoreManager({ owner, @@ -223,9 +219,9 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) { }, onMcp: (directory, setStore) => { void retry(() => - sdkFor(directory) - .command.list() - .then((x) => setStore("command", x.data ?? [])), + serverSDK.backend + .then((client) => client.common.commands.list({ location: { directory } })) + .then((commands) => setStore("command", reconcile(commands))), ).catch((err) => { showToast({ variant: "error", @@ -238,7 +234,6 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) { const key = directoryKey(directory) queue.clear(key) sessionMeta.delete(key) - sdkCache.delete(key) clearProviderRev(serverSDK.scope, key) }, translate: language.t, @@ -280,7 +275,12 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) { loadRootSessionsWithFallback({ directory, limit, - list: (query) => serverSDK.client.session.list(query), + list: (query) => + serverSDK.backend.then((client) => + client.common.sessions + .list({ location: { directory: query.directory }, roots: query.roots, limit: query.limit }) + .then((page) => ({ data: [...page.items] })), + ), }) .then((x) => { const nonArchived = (x.data ?? []) @@ -339,7 +339,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) { const child = children.ensureChild(directory) const cache = children.vcsCache.get(key) if (!cache) return - const sdk = sdkFor(directory) + const backend = await serverSDK.backend await bootstrapDirectory({ directory, scope: serverSDK.scope, @@ -350,7 +350,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) { project: globalStore.project, provider: globalStore.provider, }, - sdk, + backend, store: child[0], setStore: child[1], vcsCache: cache, @@ -375,7 +375,14 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) { const event = e.details const recent = bootingRoot || Date.now() - bootedAt < 1500 - session.apply(event) + session.apply(event, directory === "global" ? undefined : { directory }) + + if (event.type === "server.connected") void session.resync() + if (event.type === "provider.updated") { + if (!recent) bootstrap.refetch() + if (directory !== "global") queue.push(directory) + return + } if (directory === "global") { applyGlobalEvent({ @@ -387,7 +394,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) { }, setGlobalProject: setProjects, }) - if (event.type === "server.connected" || event.type === "global.disposed") { + if (event.type === "server.connected" || event.type === "server.disposed") { if (recent) return for (const directory of Object.keys(children.children)) { queue.push(directory) @@ -457,7 +464,16 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) { } const updateConfigMutation = useMutation(() => ({ - mutationFn: (config: Config) => serverSDK.client.global.config.update({ config }), + mutationFn: async (config: GlobalConfigUpdate) => { + const backend = await serverSDK.backend + const capability = backend.capabilities.configuration + if (!capability) throw new Error("Server does not support configuration updates") + await capability.updateGlobal({ + shell: config.shell, + provider: config.provider, + disabledProviders: config.disabledProviders, + }) + }, onSuccess: () => { bootstrap.refetch() // Invalidate all provider queries so newly configured custom providers @@ -484,23 +500,32 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) { queryOptions: queryOptionsApi, // bootstrap, updateConfig: updateConfigMutation.mutateAsync, + refreshProviders: async () => { + await queryClient.invalidateQueries({ + predicate: (query) => query.queryKey[0] === serverSDK.scope && query.queryKey[2] === "providers", + }) + await bootstrap.refetch() + }, project: projectApi, session, mcp: { toggle: async (directory: string, name: string) => { const key = directoryKey(directory) - const sdk = sdkFor(key) + const backend = await serverSDK.backend + const capability = backend.capabilities.mcpControl + if (!capability) throw new Error("Server does not support MCP controls") + const location = { location: { directory: key } } const status = children.child(key, { bootstrap: false })[0].mcp[name].status await toggleMcp({ status, connect: async () => { - await sdk.mcp.connect({ name }) + await capability.connect({ ...location, name }) }, disconnect: async () => { - await sdk.mcp.disconnect({ name }) + await capability.disconnect({ ...location, name }) }, authenticate: async () => { - await sdk.mcp.auth.authenticate({ name }) + await capability.authenticate({ ...location, name }) }, refresh: async () => { await queryClient.refetchQueries(queryOptionsApi.mcp(key)) diff --git a/packages/app/src/context/sync-optimistic.test.ts b/packages/app/src/context/sync-optimistic.test.ts index 94324f8a06..b4bfcfa2cb 100644 --- a/packages/app/src/context/sync-optimistic.test.ts +++ b/packages/app/src/context/sync-optimistic.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test" -import type { Message, Part } from "@opencode-ai/sdk/v2/client" +import type { AppMessage as Message, AppPart as Part } from "./backend" import { applyOptimisticAdd, applyOptimisticRemove, mergeOptimisticPage } from "./sync" type Text = Extract diff --git a/packages/app/src/context/sync.tsx b/packages/app/src/context/sync.tsx index b90f49ffd4..3b0acdafdd 100644 --- a/packages/app/src/context/sync.tsx +++ b/packages/app/src/context/sync.tsx @@ -2,25 +2,25 @@ import { Binary } from "@opencode-ai/core/util/binary" import { createMemo } from "solid-js" import { useServerSync } from "./server-sync" import { useSDK } from "./sdk" -import type { Message, Part } from "@opencode-ai/sdk/v2/client" +import type { AppMessage, AppPart } from "./backend" const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"]) -function sortParts(parts: Part[]) { +function sortParts(parts: AppPart[]) { return parts.filter((part) => !!part?.id).sort((a, b) => cmp(a.id, b.id)) } const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0) type OptimisticStore = { - message: Record - part: Record + message: Record + part: Record } type OptimisticAddInput = { sessionID: string - message: Message - parts: Part[] + message: AppMessage + parts: AppPart[] } type OptimisticRemoveInput = { @@ -29,23 +29,23 @@ type OptimisticRemoveInput = { } type OptimisticItem = { - message: Message - parts: Part[] + message: AppMessage + parts: AppPart[] } type MessagePage = { - session: Message[] - part: { id: string; part: Part[] }[] + session: AppMessage[] + part: { id: string; part: AppPart[] }[] cursor?: string complete: boolean } -const hasParts = (parts: Part[] | undefined, want: Part[]) => { +const hasParts = (parts: AppPart[] | undefined, want: AppPart[]) => { if (!parts) return want.length === 0 return want.every((part) => Binary.search(parts, part.id, (item) => item.id).found) } -const mergeParts = (parts: Part[] | undefined, want: Part[]) => { +const mergeParts = (parts: AppPart[] | undefined, want: AppPart[]) => { if (!parts) return sortParts(want) const next = [...parts] let changed = false diff --git a/packages/app/src/context/tabs.tsx b/packages/app/src/context/tabs.tsx index cac6500350..b600489263 100644 --- a/packages/app/src/context/tabs.tsx +++ b/packages/app/src/context/tabs.tsx @@ -1,4 +1,4 @@ -import type { Session } from "@opencode-ai/sdk/v2/client" +import type { AppSession } from "./backend" import { createSimpleContext } from "@opencode-ai/ui/context" import { createStore, produce } from "solid-js/store" import { Persist, persisted, removePersisted, draftPersistedKeys } from "@/utils/persist" @@ -45,7 +45,7 @@ export const tabHref = (tab: Tab) => export const tabKey = (tab: Tab) => (tab.type === "draft" ? `draft:${tab.draftID}` : `${tab.server}\n${tabHref(tab)}`) -export function sessionHasOpenTab(tabs: Tab[], server: ServerConnection.Key, session: Session) { +export function sessionHasOpenTab(tabs: Tab[], server: ServerConnection.Key, session: AppSession) { return tabs.some((tab) => tab.type === "session" && tab.server === server && tab.sessionId === session.id) } @@ -348,7 +348,7 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({ for (const key of removed) memory.remove(key) for (const key of removed) removeInfo(key) }, - rememberSessionInfo(tab: SessionTab, session: Session) { + rememberSessionInfo(tab: SessionTab, session: AppSession) { const key = tabKey(tab) const next = { title: session.title, directory: session.directory } const current = info[key] diff --git a/packages/app/src/context/terminal.tsx b/packages/app/src/context/terminal.tsx index a9c66c4833..97065cd98e 100644 --- a/packages/app/src/context/terminal.tsx +++ b/packages/app/src/context/terminal.tsx @@ -149,6 +149,7 @@ function createWorkspaceTerminalSession( scope: ServerScopeValue, legacySessionID?: string, ) { + const location = { directory: sdk.directory } const legacy = scope === ServerScope.local ? getLegacyTerminalStorageKeys(dir, legacySessionID) : [] const [store, setStore, _, ready] = persisted( @@ -198,23 +199,27 @@ function createWorkspaceTerminalSession( }) } - const unsub = sdk.event.on("pty.exited", (event: { properties: { id: string } }) => { - removeExited(event.properties.id) + const unsub = sdk.event.on("pty.exited", (event) => { + if (event.type !== "pty.exited") return + removeExited(event.ptyID) }) onCleanup(unsub) - const update = (client: DirectorySDK["client"], pty: Partial & { id: string }) => { + const update = (pty: Partial & { id: string }) => { const index = store.all.findIndex((x) => x.id === pty.id) const previous = index >= 0 ? store.all[index] : undefined if (index >= 0) { setStore("all", index, (item) => ({ ...item, ...pty })) } - client.pty - .update({ - ptyID: pty.id, - title: pty.title, - size: pty.cols && pty.rows ? { rows: pty.rows, cols: pty.cols } : undefined, - }) + sdk.backend + .then((client) => + client.common.pty.update({ + ptyID: pty.id, + title: pty.title, + size: pty.cols && pty.rows ? { rows: pty.rows, cols: pty.cols } : undefined, + location, + }), + ) .catch((error: unknown) => { if (previous) { const currentIndex = store.all.findIndex((item) => item.id === pty.id) @@ -224,26 +229,24 @@ function createWorkspaceTerminalSession( }) } - const clone = async (client: DirectorySDK["client"], id: string) => { + const clone = async (id: string) => { const index = store.all.findIndex((x) => x.id === id) const pty = store.all[index] if (!pty) return - const next = await client.pty - .create({ - title: pty.title, - }) + const next = await sdk.backend + .then((client) => client.common.pty.create({ title: pty.title, location })) .catch((error: unknown) => { console.error("Failed to clone terminal", error) return undefined }) - if (!next?.data) return + if (!next) return const active = store.active === pty.id batch(() => { setStore("all", index, { - id: next.data.id, - title: next.data.title ?? pty.title, + id: next.id, + title: next.title ?? pty.title, titleNumber: pty.titleNumber, buffer: undefined, cursor: undefined, @@ -252,7 +255,7 @@ function createWorkspaceTerminalSession( cols: undefined, }) if (active) { - setStore("active", next.data.id) + setStore("active", next.id) } }) } @@ -270,14 +273,14 @@ function createWorkspaceTerminalSession( new() { const nextNumber = pickNextTerminalNumber() - sdk.client.pty - .create({ title: defaultTitle(nextNumber) }) - .then((pty: { data?: { id?: string; title?: string } }) => { - const id = pty.data?.id + sdk.backend + .then((client) => client.common.pty.create({ title: defaultTitle(nextNumber), location })) + .then((pty) => { + const id = pty.id if (!id) return const newTerminal = { id, - title: pty.data?.title ?? defaultTitle(nextNumber), + title: pty.title ?? defaultTitle(nextNumber), titleNumber: nextNumber, } setStore("all", store.all.length, newTerminal) @@ -288,7 +291,7 @@ function createWorkspaceTerminalSession( }) }, update(pty: Partial & { id: string }) { - update(sdk.client, pty) + update(pty) }, trim(id: string) { const index = store.all.findIndex((x) => x.id === id) @@ -303,10 +306,9 @@ function createWorkspaceTerminalSession( }) }, async clone(id: string) { - await clone(sdk.client, id) + await clone(id) }, bind() { - const client = sdk.client return { trim(id: string) { const index = store.all.findIndex((x) => x.id === id) @@ -314,10 +316,10 @@ function createWorkspaceTerminalSession( setStore("all", index, (pty) => trimTerminal(pty)) }, update(pty: Partial & { id: string }) { - update(client, pty) + update(pty) }, async clone(id: string) { - await clone(client, id) + await clone(id) }, } }, @@ -353,9 +355,11 @@ function createWorkspaceTerminalSession( }) } - await sdk.client.pty.remove({ ptyID: id }).catch((error: unknown) => { - console.error("Failed to close terminal", error) - }) + await sdk.backend + .then((client) => client.common.pty.remove({ ptyID: id, location })) + .catch((error: unknown) => { + console.error("Failed to close terminal", error) + }) }, move(id: string, to: number) { const index = store.all.findIndex((f) => f.id === id) diff --git a/packages/app/src/hooks/provider-catalog.test.ts b/packages/app/src/hooks/provider-catalog.test.ts index 04fbe5ab66..a8ff418400 100644 --- a/packages/app/src/hooks/provider-catalog.test.ts +++ b/packages/app/src/hooks/provider-catalog.test.ts @@ -1,9 +1,9 @@ import { expect, test } from "bun:test" -import type { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context" +import type { ProviderStore } from "@/context/global-sync/types" import { selectProviderCatalog } from "./provider-catalog" -const catalog = (id: string): NormalizedProviderListResponse => ({ - all: new Map([[id, { id, name: id, source: "api", env: [], options: {}, models: {} }]]), +const catalog = (id: string): ProviderStore => ({ + all: new Map([[id, { id, name: id, source: "api", models: {} }]]), connected: [id], default: { [id]: `${id}-model` }, }) diff --git a/packages/app/src/hooks/provider-catalog.ts b/packages/app/src/hooks/provider-catalog.ts index 01b966eda6..dc5d760b81 100644 --- a/packages/app/src/hooks/provider-catalog.ts +++ b/packages/app/src/hooks/provider-catalog.ts @@ -1,10 +1,10 @@ -import type { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context" +import type { ProviderStore } from "@/context/global-sync/types" -const emptyProviderCatalog: NormalizedProviderListResponse = { all: new Map(), connected: [], default: {} } +const emptyProviderCatalog: ProviderStore = { all: new Map(), connected: [], default: {} } type DirectoryCatalog = { ready: boolean - providers: NormalizedProviderListResponse + providers: ProviderStore } type ProviderCatalogInput = @@ -17,7 +17,7 @@ type ProviderCatalogInput = explicit: false directory?: string catalog?: DirectoryCatalog - global: NormalizedProviderListResponse + global: ProviderStore } export function selectProviderCatalog(input: ProviderCatalogInput) { diff --git a/packages/app/src/pages/home.tsx b/packages/app/src/pages/home.tsx index c8a89672df..ab437096ad 100644 --- a/packages/app/src/pages/home.tsx +++ b/packages/app/src/pages/home.tsx @@ -1,4 +1,4 @@ -import type { Session } from "@opencode-ai/sdk/v2/client" +import type { AppSession as Session } from "@/context/backend" import { type ComponentProps, createEffect, @@ -508,7 +508,15 @@ export function NewHome() { await archiveHomeSession({ server: ServerConnection.key(conn), session, - update: (value) => ctx.sdk.client.session.update(value), + update: async (value) => { + const capability = (await ctx.sdk.backend).capabilities.sessionExtrasV1 + if (!capability) throw new Error("Session archiving is not supported by this server") + await capability.archive({ + location: { directory: value.directory }, + sessionID: value.sessionID, + archivedAt: value.time.archived, + }) + }, remove: () => setStore( produce((draft) => { diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index 8762d2f884..e600e68afc 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -25,8 +25,8 @@ import { IconButton } from "@opencode-ai/ui/icon-button" import { Tooltip } from "@opencode-ai/ui/tooltip" import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu" import { Dialog } from "@opencode-ai/ui/dialog" -import { getFilename } from "@opencode-ai/core/util/path" -import { Session } from "@opencode-ai/sdk/v2/client" +import { getDirectory, getFilename } from "@opencode-ai/core/util/path" +import type { AppSession as Session } from "@/context/backend" import { usePlatform } from "@/context/platform" import { useSettings } from "@/context/settings" import { createStore, produce, reconcile } from "solid-js/store" @@ -66,6 +66,7 @@ import { errorMessage, latestRootSession, sortedRootSessions, + workspaceCopyCreateInput, } from "./layout/helpers" import { collectNewSessionDeepLinks, @@ -85,6 +86,11 @@ import { SidebarContent } from "./layout/sidebar-shell" export default function LegacyLayout(props: ParentProps) { const serverSDK = useServerSDK() + const [backend] = createResource( + () => serverSDK().backend, + (value) => value, + ) + const canArchive = () => !!backend()?.capabilities.sessionExtrasV1 const [store, setStore, , ready] = persisted( Persist.serverGlobal(serverSDK().scope, "layout.page", ["layout.page.v1"]), createStore({ @@ -398,7 +404,7 @@ export default function LegacyLayout(props: ParentProps) { WorktreeState.failed( serverSDK().scope, e.name, - e.details.properties?.message ?? language.t("common.requestFailed"), + e.details.message ?? language.t("common.requestFailed"), ) return } @@ -408,21 +414,21 @@ export default function LegacyLayout(props: ParentProps) { e.details?.type === "question.rejected" || e.details?.type === "permission.replied" ) { - const props = e.details.properties as { sessionID: string } + const props = e.details const sessionKey = `${e.name}:${props.sessionID}` dismissSessionAlert(sessionKey) return } - if (e.details?.type !== "permission.asked" && e.details?.type !== "question.asked") return + if (e.details?.type !== "permission.requested" && e.details?.type !== "question.requested") return const title = - e.details.type === "permission.asked" + e.details.type === "permission.requested" ? language.t("notification.permission.title") : language.t("notification.question.title") - const icon = e.details.type === "permission.asked" ? ("checklist" as const) : ("bubble-5" as const) + const icon = e.details.type === "permission.requested" ? ("checklist" as const) : ("bubble-5" as const) const directory = e.name - const props = e.details.properties - if (e.details.type === "permission.asked" && permission.autoResponds(e.details.properties, directory)) return + const props = e.details.request + if (e.details.type === "permission.requested" && permission.autoResponds(e.details.request, directory)) return const [store] = serverSync().child(directory, { bootstrap: false }) const session = store.session.find((s) => s.id === props.sessionID) @@ -431,7 +437,7 @@ export default function LegacyLayout(props: ParentProps) { const sessionTitle = session?.title ?? language.t("command.session.new") const projectName = getFilename(directory) const description = - e.details.type === "permission.asked" + e.details.type === "permission.requested" ? language.t("notification.permission.description", { sessionTitle, projectName }) : language.t("notification.question.description", { sessionTitle, projectName }) const href = `/${base64Encode(directory)}/session/${props.sessionID}` @@ -441,7 +447,7 @@ export default function LegacyLayout(props: ParentProps) { if (now - lastAlerted < cooldownMs) return alertedAtBySession.set(sessionKey, now) - if (e.details.type === "permission.asked") { + if (e.details.type === "permission.requested") { if (settings.sounds.permissionsEnabled()) { void playSoundById(settings.sounds.permissions()) } @@ -450,7 +456,7 @@ export default function LegacyLayout(props: ParentProps) { } } - if (e.details.type === "question.asked") { + if (e.details.type === "question.requested") { if (settings.notifications.agent()) { void platform.notify(title, description, href) } @@ -874,10 +880,12 @@ export default function LegacyLayout(props: ParentProps) { const index = sessions.findIndex((s) => s.id === session.id) const nextSession = sessions[index + 1] ?? sessions[index - 1] - await serverSDK().client.session.update({ - directory: session.directory, + const capability = (await serverSDK().backend).capabilities.sessionExtrasV1 + if (!capability) return + await capability.archive({ + location: { directory: session.directory }, sessionID: session.id, - time: { archived: Date.now() }, + archivedAt: Date.now(), }) setStore( produce((draft) => { @@ -976,7 +984,7 @@ export default function LegacyLayout(props: ParentProps) { title: language.t("command.session.archive"), category: language.t("command.category.session"), keybind: "mod+shift+backspace", - disabled: !params.dir || !params.id, + disabled: !params.dir || !params.id || !canArchive(), onSelect: () => { const session = currentSessions().find((s) => s.id === params.id) if (session) void archiveSession(session) @@ -1185,8 +1193,15 @@ export default function LegacyLayout(props: ParentProps) { const refreshDirs = async (target?: string) => { if (!target || target === root || canOpen(target)) return canOpen(target) const listed = await serverSDK() - .client.worktree.list({ directory: root }) - .then((x) => x.data ?? []) + .backend.then((client) => { + const location = { directory: root } + if (client.capabilities.worktreesV1) return client.capabilities.worktreesV1.list({ location }) + if (project?.id && client.capabilities.projectCopiesV2?.directories) + return client.capabilities.projectCopiesV2 + .directories({ projectID: project.id, location }) + .then((items) => items.map((item) => item.directory)) + return [] + }) .catch(() => [] as string[]) dirs = effectiveWorkspaceOrder(root, [root, ...listed], store.workspaceOrder[root]) return canOpen(target) @@ -1231,8 +1246,11 @@ export default function LegacyLayout(props: ParentProps) { dirs.map(async (item) => ({ path: { directory: item }, session: await serverSDK() - .client.session.list({ directory: item }) - .then((x) => x.data ?? []) + .backend.then((client) => + client.common.sessions + .list({ location: { directory: item } }) + .then((page) => [...page.items]), + ) .catch(() => []), })), ), @@ -1293,7 +1311,9 @@ export default function LegacyLayout(props: ParentProps) { const name = next === getFilename(project.worktree) ? "" : next if (project.id && project.id !== "global") { - await serverSDK().client.project.update({ projectID: project.id, directory: project.worktree, name }) + const editing = (await serverSDK().backend).capabilities.projectEditing + if (!editing) throw new Error("Project editing is not supported by this server") + await editing.update({ projectID: project.id, location: { directory: project.worktree }, name }) return } @@ -1387,8 +1407,15 @@ export default function LegacyLayout(props: ParentProps) { setBusy(directory, true) const result = await serverSDK() - .client.worktree.remove({ directory: root, worktreeRemoveInput: { directory } }) - .then((x) => x.data) + .backend.then(async (client) => { + const location = { directory: root } + if (client.capabilities.worktreesV1) return client.capabilities.worktreesV1.remove({ location, directory }) + const copies = client.capabilities.projectCopiesV2 + const project = layout.projects.list().find((item) => item.worktree === root) + if (!copies || !project?.id) throw new Error("Project copy removal is not supported by this server") + await copies.remove({ projectID: project.id, location, directory, force: true }) + return true + }) .catch((err) => { showToast({ title: language.t("workspace.delete.failed.title"), @@ -1444,9 +1471,12 @@ export default function LegacyLayout(props: ParentProps) { }) const dismiss = () => toaster.dismiss(progress) - const sessions: Session[] = await serverSDK() - .client.session.list({ directory }) - .then((x) => x.data ?? []) + const sessions = await serverSDK() + .backend.then((client) => + client.common.sessions + .list({ location: { directory } }) + .then((page) => [...page.items]), + ) .catch(() => []) clearWorkspaceTerminals( @@ -1456,12 +1486,26 @@ export default function LegacyLayout(props: ParentProps) { serverSDK().scope, ) await serverSDK() - .client.instance.dispose({ directory }) + .backend.then((client) => client.capabilities.runtimeV1?.disposeLocation({ location: { directory } })) .catch(() => undefined) const result = await serverSDK() - .client.worktree.reset({ directory: root, worktreeResetInput: { directory } }) - .then((x) => x.data) + .backend.then(async (client) => { + const location = { directory: root } + if (client.capabilities.worktreesV1) return client.capabilities.worktreesV1.reset({ location, directory }) + const copies = client.capabilities.projectCopiesV2 + const project = layout.projects.list().find((item) => item.worktree === root) + if (!copies || !project?.id) throw new Error("Project copy reset is not supported by this server") + await copies.remove({ projectID: project.id, location, directory, force: true }) + await copies.create({ + projectID: project.id, + location, + strategy: "git_worktree", + directory: getDirectory(directory), + name: getFilename(directory), + }) + return true + }) .catch((err) => { showToast({ title: language.t("workspace.reset.failed.title"), @@ -1482,10 +1526,14 @@ export default function LegacyLayout(props: ParentProps) { .filter((session) => session.time.archived === undefined) .map((session) => serverSDK() - .client.session.update({ - sessionID: session.id, - directory: session.directory, - time: { archived: archivedAt }, + .backend.then(async (client) => { + const capability = client.capabilities.sessionExtrasV1 + if (!capability) return + await capability.archive({ + location: { directory: session.directory }, + sessionID: session.id, + archivedAt, + }) }) .catch(() => undefined), ), @@ -1523,9 +1571,8 @@ export default function LegacyLayout(props: ParentProps) { onMount(() => { serverSDK() - .client.vcs.status({ directory: props.directory }) - .then((x) => { - const files = x.data ?? [] + .backend.then((client) => client.capabilities.vcs?.status({ location: { directory: props.directory } }) ?? []) + .then((files) => { const dirty = files.length > 0 setData({ status: "ready", dirty }) }) @@ -1582,8 +1629,11 @@ export default function LegacyLayout(props: ParentProps) { const refresh = async () => { const sessions = await serverSDK() - .client.session.list({ directory: props.directory }) - .then((x) => x.data ?? []) + .backend.then((client) => + client.common.sessions + .list({ location: { directory: props.directory } }) + .then((page) => [...page.items]), + ) .catch(() => []) const active = sessions.filter((session) => session.time.archived === undefined) setState({ sessions: active }) @@ -1591,9 +1641,8 @@ export default function LegacyLayout(props: ParentProps) { onMount(() => { serverSDK() - .client.vcs.status({ directory: props.directory }) - .then((x) => { - const files = x.data ?? [] + .backend.then((client) => client.capabilities.vcs?.status({ location: { directory: props.directory } }) ?? []) + .then((files) => { const dirty = files.length > 0 setState({ status: "ready", dirty }) void refresh() @@ -1822,8 +1871,17 @@ export default function LegacyLayout(props: ParentProps) { const createWorkspace = async (project: LocalProject) => { clearSidebarHoverState() const created = await serverSDK() - .client.worktree.create({ directory: project.worktree }) - .then((x) => x.data) + .backend.then((client) => { + const location = { directory: project.worktree } + if (client.capabilities.worktreesV1) return client.capabilities.worktreesV1.create({ location }) + const copies = client.capabilities.projectCopiesV2 + const input = workspaceCopyCreateInput(project) + if (!copies || !input) throw new Error("Project copy creation is not supported by this server") + return copies.create({ + ...input, + location, + }) + }) .catch((err) => { showToast({ title: language.t("workspace.create.failed.title"), @@ -1834,7 +1892,8 @@ export default function LegacyLayout(props: ParentProps) { if (!created?.directory) return - setWorkspaceName(created.directory, created.branch ?? getFilename(created.directory), project.id, created.branch) + const branch = "branch" in created && typeof created.branch === "string" ? created.branch : undefined + setWorkspaceName(created.directory, branch ?? getFilename(created.directory), project.id, branch) const local = project.worktree const key = pathKey(created.directory) @@ -1867,6 +1926,7 @@ export default function LegacyLayout(props: ParentProps) { clearHoverProjectSoon, prefetchSession, archiveSession, + canArchive, workspaceName, renameWorkspace, editorOpen, @@ -1913,6 +1973,7 @@ export default function LegacyLayout(props: ParentProps) { clearHoverProjectSoon, prefetchSession, archiveSession, + canArchive, }, } diff --git a/packages/app/src/pages/layout/helpers.test.ts b/packages/app/src/pages/layout/helpers.test.ts index df87ddfecd..749b2c5d1d 100644 --- a/packages/app/src/pages/layout/helpers.test.ts +++ b/packages/app/src/pages/layout/helpers.test.ts @@ -6,7 +6,7 @@ import { parseDeepLink, parseNewSessionDeepLink, } from "./deep-links" -import { type Session } from "@opencode-ai/sdk/v2/client" +import type { AppSession as Session } from "@/context/backend" import { childSessionOnPath, closeHomeProject, @@ -19,12 +19,22 @@ import { homeSessionServerStatus, latestRootSession, toggleHomeProjectSelection, + workspaceCopyCreateInput, } from "./helpers" import { pathKey } from "@/utils/path-key" import { ServerConnection } from "@/context/server" const serverKey = ServerConnection.Key.make +test("builds a v2 git worktree copy request from the project root", () => { + expect(workspaceCopyCreateInput({ id: "project", worktree: "/repos/opencode" })).toEqual({ + projectID: "project", + strategy: "git_worktree", + directory: "/repos/", + }) + expect(workspaceCopyCreateInput({ worktree: "/repos/opencode" })).toBeUndefined() +}) + const session = (input: Partial & Pick) => ({ title: "", diff --git a/packages/app/src/pages/layout/helpers.ts b/packages/app/src/pages/layout/helpers.ts index ce793e282b..177fa8dfe4 100644 --- a/packages/app/src/pages/layout/helpers.ts +++ b/packages/app/src/pages/layout/helpers.ts @@ -1,5 +1,5 @@ -import { getFilename } from "@opencode-ai/core/util/path" -import { type Session } from "@opencode-ai/sdk/v2/client" +import { getDirectory, getFilename } from "@opencode-ai/core/util/path" +import type { AppSession as Session } from "@/context/backend" import { pathKey } from "@/utils/path-key" import type { ServerConnection } from "@/context/server" import type { HomeProjectSelection } from "@/context/layout" @@ -9,6 +9,15 @@ type SessionStore = { path: { directory: string } } +export function workspaceCopyCreateInput(project: { id?: string; worktree: string }) { + if (!project.id) return + return { + projectID: project.id, + strategy: "git_worktree", + directory: getDirectory(project.worktree), + } +} + function sortSessions(now: number) { const oneMinuteAgo = now - 60 * 1000 return (a: Session, b: Session) => { diff --git a/packages/app/src/pages/layout/sidebar-items.tsx b/packages/app/src/pages/layout/sidebar-items.tsx index 776b81ae5a..0dcada784d 100644 --- a/packages/app/src/pages/layout/sidebar-items.tsx +++ b/packages/app/src/pages/layout/sidebar-items.tsx @@ -1,4 +1,4 @@ -import type { Session } from "@opencode-ai/sdk/v2/client" +import type { AppSession as Session } from "@/context/backend" import { Avatar } from "@opencode-ai/ui/avatar" import { Icon } from "@opencode-ai/ui/icon" import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon" @@ -87,6 +87,7 @@ export type SessionItemProps = { clearHoverProjectSoon: () => void prefetchSession: (session: Session, priority?: "high" | "low") => void archiveSession: (session: Session) => Promise + canArchive: Accessor } const SessionRow = (props: { @@ -241,7 +242,7 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => {
- +
void prefetchSession: (session: Session, priority?: "high" | "low") => void archiveSession: (session: Session) => Promise + canArchive: Accessor workspaceName: (directory: string, projectId?: string, branch?: string) => string | undefined renameWorkspace: (directory: string, next: string, projectId?: string, branch?: string) => void editorOpen: (id: string) => boolean @@ -272,6 +273,7 @@ const WorkspaceSessionList = (props: { clearHoverProjectSoon={props.ctx.clearHoverProjectSoon} prefetchSession={props.ctx.prefetchSession} archiveSession={props.ctx.archiveSession} + canArchive={props.ctx.canArchive} /> )} diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 9a0518fb91..11f92d01d2 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -1,4 +1,10 @@ -import type { Project, UserMessage, VcsFileDiff } from "@opencode-ai/sdk/v2" +import type { + AppFilePart as FilePart, + AppProject as Project, + AppUserMessage as UserMessage, + AppVcsFileDiff as VcsFileDiff, +} from "@/context/backend" +import { getFilename } from "@opencode-ai/core/util/path" import { useDialog } from "@opencode-ai/ui/context/dialog" import { createQuery, skipToken, useMutation, useQueryClient } from "@tanstack/solid-query" import { @@ -640,7 +646,7 @@ export default function Page() { const project = sync().project const vcs = sync().data.vcs if (project?.vcs === "git") list.push("git") - if (project?.vcs === "git" && vcs?.branch && vcs?.default_branch && vcs.branch !== vcs.default_branch) { + if (project?.vcs === "git" && vcs?.branch && vcs?.defaultBranch && vcs.branch !== vcs.defaultBranch) { list.push("branch") } list.push("turn") @@ -658,7 +664,7 @@ export default function Page() { }) const vcsKey = createMemo( () => - ["session-vcs", sdk().directory, sync().data.vcs?.branch ?? "", sync().data.vcs?.default_branch ?? ""] as const, + ["session-vcs", sdk().directory, sync().data.vcs?.branch ?? "", sync().data.vcs?.defaultBranch ?? ""] as const, ) const vcsQuery = createQuery(() => { const mode = vcsMode() @@ -670,8 +676,13 @@ export default function Page() { queryFn: mode ? () => sdk() - .client.vcs.diff({ mode }) - .then((result) => list(result.data)) + .backend.then((client) => + client.capabilities.vcs?.diff({ + location: { directory: sdk().directory }, + mode: mode === "git" ? "working" : mode, + }) ?? [], + ) + .then(list) .catch((error) => { console.debug("[session-review] failed to load vcs diff", { mode, error }) return [] @@ -717,9 +728,13 @@ export default function Page() { staleTime: Number.POSITIVE_INFINITY, retry: 2, queryFn: () => - sdk() - .client.vcs.diff({ mode, directory: scope, context }) - .then((result) => result.data ?? []), + sdk().backend.then((client) => + client.capabilities.vcs?.diff({ + location: { directory: scope }, + mode: mode === "git" ? "working" : mode, + context, + }) ?? [], + ), }) .then((diffs) => diffs.find((diff) => diff.file === file)) @@ -823,10 +838,13 @@ export default function Page() { } const gitMutation = useMutation(() => ({ - mutationFn: () => sdk().client.project.initGit(), + mutationFn: async () => { + const editing = (await sdk().backend).capabilities.projectEditing + if (!editing) throw new Error("Git initialization is not supported by this server") + return editing.initGit({ location: { directory: sdk().directory } }) + }, onSuccess: (x) => { - if (!x.data) return - upsert(x.data) + upsert(x as Project) }, onError: (err) => { showToast({ @@ -891,13 +909,7 @@ export default function Page() { ) const stopVcs = sdk().event.listen((evt) => { - if (evt.details.type !== "filesystem.changed") return - const props = - typeof evt.details.properties === "object" && evt.details.properties - ? (evt.details.properties as Record) - : undefined - const file = typeof props?.file === "string" ? props.file : undefined - if (!file || file.startsWith(".git/")) return + if (evt.details.type !== "file.changed" || evt.details.path.startsWith(".git/")) return refreshVcs() }) onCleanup(stopVcs) @@ -1659,8 +1671,6 @@ export default function Page() { }) } - const merge = (next: NonNullable>, target = sync()) => target.session.remember(next) - const roll = (sessionID: string, next: NonNullable>["revert"], target = sync()) => { const session = target.session.get(sessionID) if (!session) return @@ -1691,11 +1701,12 @@ export default function Page() { setFollowup("failed", input.sessionID, undefined) const ok = await sendFollowupDraft({ - client: sdk().client, + backend: sdk().backend, sync: sync(), serverSync: serverSync(), draft: item, optimisticBusy: item.sessionDirectory === sdk().directory, + commitRevert: !!sync().session.get(input.sessionID)?.revert, }).catch((err) => { setFollowup("failed", input.sessionID, input.id) fail(err) @@ -1787,13 +1798,14 @@ export default function Page() { const halt = (sessionID: string) => busy(sessionID) ? sdk() - .client.session.abort({ sessionID }) + .backend.then((client) => + client.common.sessions.interrupt({ location: { directory: sdk().directory }, sessionID }), + ) .catch(() => {}) : Promise.resolve() const revertMutation = useMutation(() => ({ mutationFn: async (input: { sessionID: string; messageID: string }) => { - const client = sdk().client const target = sync() const last = target.session.get(input.sessionID)?.revert const value = draft(input.messageID) @@ -1803,10 +1815,18 @@ export default function Page() { roll(input.sessionID, { messageID: input.messageID }, target) prompt.set(value) }, - request: () => halt(input.sessionID).then(() => client.session.revert(input)), - complete: (result) => { - if (result.data) merge(result.data, target) - }, + request: () => + halt(input.sessionID).then(() => + sdk().backend.then((client) => { + const value = { ...input, location: { directory: sdk().directory } } + if (client.capabilities.sessionExtrasV1) + return client.capabilities.sessionExtrasV1.revert(value).then(() => undefined) + if (client.capabilities.sessionExtrasV2) + return client.capabilities.sessionExtrasV2.stageRevert(value).then(() => undefined) + throw new Error("Session revert is not supported by this server") + }), + ), + complete: () => undefined, rollback: () => roll(input.sessionID, last, target), fail, }) @@ -1818,7 +1838,6 @@ export default function Page() { const sessionID = params.id if (!sessionID) return - const client = sdk().client const target = sync() const next = userMessages().find((item) => item.id > id) const last = target.session.get(sessionID)?.revert @@ -1835,11 +1854,26 @@ export default function Page() { }, request: () => !next - ? halt(sessionID).then(() => client.session.unrevert({ sessionID })) - : halt(sessionID).then(() => client.session.revert({ sessionID, messageID: next.id })), - complete: (result) => { - if (result.data) merge(result.data, target) - }, + ? halt(sessionID).then(() => + sdk().backend.then((client) => { + const value = { location: { directory: sdk().directory }, sessionID } + if (client.capabilities.sessionExtrasV1) + return client.capabilities.sessionExtrasV1.clearRevert(value).then(() => undefined) + if (client.capabilities.sessionExtrasV2) return client.capabilities.sessionExtrasV2.clearRevert(value) + throw new Error("Session revert is not supported by this server") + }), + ) + : halt(sessionID).then(() => + sdk().backend.then((client) => { + const value = { location: { directory: sdk().directory }, sessionID, messageID: next.id } + if (client.capabilities.sessionExtrasV1) + return client.capabilities.sessionExtrasV1.revert(value).then(() => undefined) + if (client.capabilities.sessionExtrasV2) + return client.capabilities.sessionExtrasV2.stageRevert(value).then(() => undefined) + throw new Error("Session revert is not supported by this server") + }), + ), + complete: () => undefined, rollback: () => roll(sessionID, last, target), fail, }) diff --git a/packages/app/src/pages/session/composer/session-composer-state.test.ts b/packages/app/src/pages/session/composer/session-composer-state.test.ts index 934d3152a9..d8be786109 100644 --- a/packages/app/src/pages/session/composer/session-composer-state.test.ts +++ b/packages/app/src/pages/session/composer/session-composer-state.test.ts @@ -1,5 +1,9 @@ import { describe, expect, test } from "bun:test" -import type { PermissionRequest, QuestionRequest, Session } from "@opencode-ai/sdk/v2/client" +import type { + AppPermissionRequest as PermissionRequest, + AppQuestionRequest as QuestionRequest, + AppSession as Session, +} from "@/context/backend" import { sessionPermissionRequest, sessionQuestionRequest } from "./session-request-tree" const session = (input: { id: string; parentID?: string }) => @@ -12,6 +16,12 @@ const permission = (id: string, sessionID: string) => ({ id, sessionID, + action: "read", + resources: ["*"], + permission: "read", + patterns: ["*"], + always: [], + metadata: {}, }) as PermissionRequest const question = (id: string, sessionID: string) => diff --git a/packages/app/src/pages/session/composer/session-composer-state.ts b/packages/app/src/pages/session/composer/session-composer-state.ts index eb282c15fc..a8f538456f 100644 --- a/packages/app/src/pages/session/composer/session-composer-state.ts +++ b/packages/app/src/pages/session/composer/session-composer-state.ts @@ -1,6 +1,9 @@ import { createMemo } from "solid-js" import { createStore } from "solid-js/store" -import type { PermissionRequest, QuestionRequest } from "@opencode-ai/sdk/v2" +import type { + AppPermissionRequest as PermissionRequest, + AppQuestionRequest as QuestionRequest, +} from "@/context/backend" import { useParams } from "@solidjs/router" import { showToast } from "@/utils/toast" import { useLanguage } from "@/context/language" @@ -51,7 +54,14 @@ export function createSessionComposerController() { setStore("responding", perm.id) sdk() - .client.permission.respond({ sessionID: perm.sessionID, permissionID: perm.id, response }) + .backend.then((client) => + client.common.permissions.reply({ + sessionID: perm.sessionID, + requestID: perm.id, + reply: response, + location: { directory: sdk().directory }, + }), + ) .catch((err: unknown) => { const description = err instanceof Error ? err.message : String(err) showToast({ title: language.t("common.requestFailed"), description }) diff --git a/packages/app/src/pages/session/composer/session-permission-dock.tsx b/packages/app/src/pages/session/composer/session-permission-dock.tsx index 35a62f75cf..24b6b1b76f 100644 --- a/packages/app/src/pages/session/composer/session-permission-dock.tsx +++ b/packages/app/src/pages/session/composer/session-permission-dock.tsx @@ -1,5 +1,5 @@ import { For, Show } from "solid-js" -import type { PermissionRequest } from "@opencode-ai/sdk/v2" +import type { AppPermissionRequest as PermissionRequest } from "@/context/backend" import { Button } from "@opencode-ai/ui/button" import { DockPrompt } from "@opencode-ai/session-ui/dock-prompt" import { Icon } from "@opencode-ai/ui/icon" diff --git a/packages/app/src/pages/session/composer/session-question-dock.tsx b/packages/app/src/pages/session/composer/session-question-dock.tsx index 445a9f47a0..e8d5b6a1da 100644 --- a/packages/app/src/pages/session/composer/session-question-dock.tsx +++ b/packages/app/src/pages/session/composer/session-question-dock.tsx @@ -6,7 +6,7 @@ import { DockPrompt } from "@opencode-ai/session-ui/dock-prompt" import { Icon } from "@opencode-ai/ui/icon" import { useSpring } from "@opencode-ai/ui/motion-spring" import { showToast } from "@/utils/toast" -import type { QuestionAnswer, QuestionRequest } from "@opencode-ai/sdk/v2" +import type { AppQuestionAnswer as QuestionAnswer, AppQuestionRequest as QuestionRequest } from "@/context/backend" import { useLanguage } from "@/context/language" import { useSDK } from "@/context/sdk" import { makeEventListener } from "@solid-primitives/event-listener" @@ -223,7 +223,15 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit } const replyMutation = useMutation(() => ({ - mutationFn: (answers: QuestionAnswer[]) => sdk().client.question.reply({ requestID: props.request.id, answers }), + mutationFn: (answers: QuestionAnswer[]) => + sdk().backend.then((client) => + client.common.questions.reply({ + sessionID: props.request.sessionID, + requestID: props.request.id, + answers, + location: { directory: sdk().directory }, + }), + ), onMutate: () => { props.onSubmit() }, @@ -235,7 +243,14 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit })) const rejectMutation = useMutation(() => ({ - mutationFn: () => sdk().client.question.reject({ requestID: props.request.id }), + mutationFn: () => + sdk().backend.then((client) => + client.common.questions.reject({ + sessionID: props.request.sessionID, + requestID: props.request.id, + location: { directory: sdk().directory }, + }), + ), onMutate: () => { props.onSubmit() }, diff --git a/packages/app/src/pages/session/composer/session-request-tree.ts b/packages/app/src/pages/session/composer/session-request-tree.ts index 03872c091c..afa222475c 100644 --- a/packages/app/src/pages/session/composer/session-request-tree.ts +++ b/packages/app/src/pages/session/composer/session-request-tree.ts @@ -1,4 +1,8 @@ -import type { PermissionRequest, QuestionRequest, Session } from "@opencode-ai/sdk/v2/client" +import type { + AppPermissionRequest as PermissionRequest, + AppQuestionRequest as QuestionRequest, + AppSession as Session, +} from "@/context/backend" function sessionTreeRequest( session: Session[], diff --git a/packages/app/src/pages/session/review-tab.tsx b/packages/app/src/pages/session/review-tab.tsx index 586942399d..af51c2685d 100644 --- a/packages/app/src/pages/session/review-tab.tsx +++ b/packages/app/src/pages/session/review-tab.tsx @@ -1,6 +1,9 @@ import { createEffect, onCleanup, type JSX } from "solid-js" import { makeEventListener } from "@solid-primitives/event-listener" -import type { FileDiffInfo, VcsFileDiff } from "@opencode-ai/sdk/v2" +import type { + AppFileDiff as FileDiffInfo, + AppVcsFileDiff as VcsFileDiff, +} from "@/context/backend" import { SessionReview } from "@opencode-ai/session-ui/session-review" import type { SessionReviewCommentActions, @@ -54,8 +57,13 @@ export function SessionReviewTab(props: SessionReviewTabProps) { const readFile = async (path: string) => { return sdk() - .client.file.read({ path }) - .then((x) => x.data) + .backend.then(async (client) => { + const input = { location: { directory: sdk().directory }, path } + if (client.capabilities.decoratedFiles) return client.capabilities.decoratedFiles.read(input) + const content = await client.common.files.read(input) + if (content.kind !== "text") return + return { type: "text" as const, content: new TextDecoder().decode(content.bytes), mimeType: content.mimeType } + }) .catch((error) => { console.debug("[session-review] failed to read file", { path, error }) return undefined diff --git a/packages/app/src/pages/session/session-model-helpers.test.ts b/packages/app/src/pages/session/session-model-helpers.test.ts index 201627b369..3ec28e53ae 100644 --- a/packages/app/src/pages/session/session-model-helpers.test.ts +++ b/packages/app/src/pages/session/session-model-helpers.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test" -import type { UserMessage } from "@opencode-ai/sdk/v2" +import type { AppUserMessage as UserMessage } from "@/context/backend" import { resetSessionModel, restorePromptModel, syncPromptModel, syncSessionModel } from "./session-model-helpers" const message = (input?: { agent?: string; model?: UserMessage["model"] }) => diff --git a/packages/app/src/pages/session/session-model-helpers.ts b/packages/app/src/pages/session/session-model-helpers.ts index f2aadff27d..5389511885 100644 --- a/packages/app/src/pages/session/session-model-helpers.ts +++ b/packages/app/src/pages/session/session-model-helpers.ts @@ -1,4 +1,4 @@ -import type { UserMessage } from "@opencode-ai/sdk/v2" +import type { AppUserMessage as UserMessage } from "@/context/backend" type Local = { session: { diff --git a/packages/app/src/pages/session/session-side-panel.tsx b/packages/app/src/pages/session/session-side-panel.tsx index b85e7ab13d..4cf8e04bda 100644 --- a/packages/app/src/pages/session/session-side-panel.tsx +++ b/packages/app/src/pages/session/session-side-panel.tsx @@ -9,7 +9,7 @@ import { ResizeHandle } from "@opencode-ai/ui/resize-handle" import { Mark } from "@opencode-ai/ui/logo" import { DragDropProvider, DragDropSensors, DragOverlay, SortableProvider, closestCenter } from "@thisbeyond/solid-dnd" import type { DragEvent } from "@thisbeyond/solid-dnd" -import type { FileDiffInfo, VcsFileDiff } from "@opencode-ai/sdk/v2" +import type { AppFileDiff as FileDiffInfo, AppVcsFileDiff as VcsFileDiff } from "@/context/backend" import { ConstrainDragYAxis, getDraggableId } from "@/utils/solid-dnd" import { useDialog } from "@opencode-ai/ui/context/dialog" diff --git a/packages/app/src/pages/session/timeline/message-timeline.tsx b/packages/app/src/pages/session/timeline/message-timeline.tsx index 168bdf2f69..fc2dc0008a 100644 --- a/packages/app/src/pages/session/timeline/message-timeline.tsx +++ b/packages/app/src/pages/session/timeline/message-timeline.tsx @@ -1,6 +1,7 @@ import { createEffect, createMemo, + createResource, createSignal, For, Index, @@ -46,12 +47,12 @@ import { TextField } from "@opencode-ai/ui/text-field" import { TextReveal } from "@opencode-ai/ui/text-reveal" import { TextShimmer } from "@opencode-ai/ui/text-shimmer" import type { - AssistantMessage, - Message as MessageType, - Part as PartType, - ToolPart, - UserMessage, -} from "@opencode-ai/sdk/v2" + AppAssistantMessage as AssistantMessage, + AppMessage as MessageType, + AppPart as PartType, + AppUserMessage as UserMessage, +} from "@/context/backend" +type ToolPart = Extract import { showToast } from "@/utils/toast" import { getDirectory, getFilename } from "@opencode-ai/core/util/path" import { Popover as KobaltePopover } from "@kobalte/core/popover" @@ -290,7 +291,14 @@ export function MessageTimeline(props: { const titleValue = createMemo(() => info()?.title) const titleLabel = createMemo(() => sessionTitle(titleValue())) const shareUrl = createMemo(() => info()?.share?.url) - const shareEnabled = createMemo(() => sync().data.config.share !== "disabled") + const [backend] = createResource( + () => sdk().backend, + (value) => value, + ) + const shareEnabled = createMemo( + () => sync().data.config.share !== "disabled" && !!backend()?.capabilities.sessionExtrasV1, + ) + const canArchive = createMemo(() => !!backend()?.capabilities.sessionExtrasV1) const parentID = createMemo(() => info()?.parentID) const parent = createMemo(() => { const id = parentID() @@ -647,14 +655,22 @@ export function MessageTimeline(props: { } const shareMutation = useMutation(() => ({ - mutationFn: (id: string) => serverSDK().client.session.share({ sessionID: id }), + mutationFn: async (id: string) => { + const capability = (await serverSDK().backend).capabilities.sessionExtrasV1 + if (!capability) throw new Error("Session sharing is not supported by this server") + return capability.share({ location: { directory: sdk().directory }, sessionID: id }) + }, onError: (err) => { console.error("Failed to share session", err) }, })) const unshareMutation = useMutation(() => ({ - mutationFn: (id: string) => serverSDK().client.session.unshare({ sessionID: id }), + mutationFn: async (id: string) => { + const capability = (await serverSDK().backend).capabilities.sessionExtrasV1 + if (!capability) throw new Error("Session sharing is not supported by this server") + return capability.unshare({ location: { directory: sdk().directory }, sessionID: id }) + }, onError: (err) => { console.error("Failed to unshare session", err) }, @@ -662,7 +678,15 @@ export function MessageTimeline(props: { const titleMutation = useMutation(() => ({ mutationFn: (input: { id: string; title: string }) => - sdk().client.session.update({ sessionID: input.id, title: input.title }), + sdk().backend.then((client) => { + const capability = client.capabilities.sessionActionsV1 + if (!capability) throw new Error("Session renaming is not supported by this server") + return capability.rename({ + location: { directory: sdk().directory }, + sessionID: input.id, + title: input.title, + }) + }), onSuccess: (_, input) => { sync().set( produce((draft) => { @@ -806,7 +830,11 @@ export function MessageTimeline(props: { const nextSession = index === -1 ? undefined : (sessions[index + 1] ?? sessions[index - 1]) await sdk() - .client.session.update({ sessionID, time: { archived: Date.now() } }) + .backend.then(async (client) => { + const capability = client.capabilities.sessionExtrasV1 + if (!capability) throw new Error("Session archiving is not supported by this server") + await capability.archive({ location: { directory: sdk().directory }, sessionID, archivedAt: Date.now() }) + }) .then(() => { sync().set( produce((draft) => { @@ -835,8 +863,11 @@ export function MessageTimeline(props: { const nextSession = index === -1 ? undefined : (sessions[index + 1] ?? sessions[index - 1]) const result = await sdk() - .client.session.delete({ sessionID }) - .then((x) => x.data) + .backend.then((client) => { + const capability = client.capabilities.sessionActionsV1 + if (!capability) throw new Error("Session deletion is not supported by this server") + return capability.remove({ location: { directory: sdk().directory }, sessionID }) + }) .catch((err) => { showToast({ title: language.t("session.delete.failed.title"), @@ -1203,7 +1234,10 @@ export function MessageTimeline(props: { return (
- + (status.type === "running" ? { type: "busy" as const } : status))(sessionStatus())} + show={activeMessageID() === retryRow().userMessageID} + />
) @@ -1549,9 +1583,11 @@ export function MessageTimeline(props: { - void archiveSession(id)}> - {language.t("common.archive")} - + + void archiveSession(id)}> + {language.t("common.archive")} + + dialog.show(() => )} @@ -1620,9 +1656,11 @@ export function MessageTimeline(props: { {language.t("session.share.action.share")}... - void archiveSession(id)}> - {language.t("common.archive")} - + + void archiveSession(id)}> + {language.t("common.archive")} + + dialog.show(() => )}> {language.t("common.delete")}... diff --git a/packages/app/src/pages/session/timeline/model.test.ts b/packages/app/src/pages/session/timeline/model.test.ts index 24612072c3..2147a50057 100644 --- a/packages/app/src/pages/session/timeline/model.test.ts +++ b/packages/app/src/pages/session/timeline/model.test.ts @@ -1,5 +1,9 @@ import { describe, expect, test } from "bun:test" -import type { AssistantMessage, Message, UserMessage } from "@opencode-ai/sdk/v2" +import type { + AppAssistantMessage as AssistantMessage, + AppMessage as Message, + AppUserMessage as UserMessage, +} from "@/context/backend" import { isTimelineReady, loadOlderTimeline, selectUserMessages, selectVisibleUserMessages } from "./model" const user = (id: string) => ({ id, role: "user" }) as UserMessage diff --git a/packages/app/src/pages/session/timeline/model.ts b/packages/app/src/pages/session/timeline/model.ts index 7eebee6080..760e54ed9f 100644 --- a/packages/app/src/pages/session/timeline/model.ts +++ b/packages/app/src/pages/session/timeline/model.ts @@ -1,4 +1,4 @@ -import type { Message, UserMessage } from "@opencode-ai/sdk/v2" +import type { AppMessage as Message, AppUserMessage as UserMessage } from "@/context/backend" import { createMemo, createResource, onCleanup, untrack, type Accessor } from "solid-js" import { useServerSync } from "@/context/server-sync" import { useSync } from "@/context/sync" diff --git a/packages/app/src/pages/session/timeline/projection.ts b/packages/app/src/pages/session/timeline/projection.ts index ec2a3190ba..455e8911be 100644 --- a/packages/app/src/pages/session/timeline/projection.ts +++ b/packages/app/src/pages/session/timeline/projection.ts @@ -1,5 +1,11 @@ import { Binary } from "@opencode-ai/core/util/binary" -import type { AssistantMessage, Message, Part, SessionStatus, UserMessage } from "@opencode-ai/sdk/v2" +import type { + AppAssistantMessage as AssistantMessage, + AppMessage as Message, + AppPart as Part, + AppUserMessage as UserMessage, + SessionActivity as SessionStatus, +} from "@/context/backend" import { createMemo, mapArray, type Accessor } from "solid-js" import { reuseTimelineRows } from "./row-reconciliation" import { Timeline, TimelineRow } from "./rows" diff --git a/packages/app/src/pages/session/timeline/rows.ts b/packages/app/src/pages/session/timeline/rows.ts index 6a9cb669d0..d663eb9c41 100644 --- a/packages/app/src/pages/session/timeline/rows.ts +++ b/packages/app/src/pages/session/timeline/rows.ts @@ -1,5 +1,10 @@ import { parseCommentNote, readCommentMetadata } from "@/utils/comment-note" -import { AssistantMessage, Part, SessionStatus, SnapshotFileDiff, UserMessage } from "@opencode-ai/sdk/v2" +import type { + AppAssistantMessage as AssistantMessage, + AppPart as Part, + AppUserMessage as UserMessage, + SessionActivity as SessionStatus, +} from "@/context/backend" import { groupParts, renderable, type PartGroup } from "@opencode-ai/session-ui/message-part" import { TimelineRow, type SummaryDiff } from "./timeline-row" @@ -119,7 +124,7 @@ export namespace Timeline { assistantGroupIndex += 1 }) - if (isActive && status === "busy" && !error && (showReasoning ? assistantPartRefs.length === 0 : true)) { + if (isActive && (status === "busy" || status === "running") && !error && (showReasoning ? assistantPartRefs.length === 0 : true)) { const heading = assistantMessages .flatMap((message) => getMessageParts(message.id)) .map((part) => (part.type === "reasoning" && part.text ? reasoningHeading(part.text) : undefined)) @@ -167,7 +172,9 @@ export namespace Timeline { return rows } - function isSummaryDiff(value: SnapshotFileDiff): value is SummaryDiff { + function isSummaryDiff( + value: NonNullable["diffs"][number], + ): value is SummaryDiff { return typeof value.file === "string" } diff --git a/packages/app/src/pages/session/timeline/timeline-row.ts b/packages/app/src/pages/session/timeline/timeline-row.ts index 3905254b29..9741fe6cb2 100644 --- a/packages/app/src/pages/session/timeline/timeline-row.ts +++ b/packages/app/src/pages/session/timeline/timeline-row.ts @@ -1,4 +1,4 @@ -import type { SnapshotFileDiff } from "@opencode-ai/sdk/v2" +import type { AppFileDiff as SnapshotFileDiff } from "@/context/backend" import type { PartGroup } from "@opencode-ai/session-ui/message-part" import { Data, Equal } from "effect" diff --git a/packages/app/src/pages/session/usage-exceeded-dialogs.tsx b/packages/app/src/pages/session/usage-exceeded-dialogs.tsx index d56fa3d1f4..7c057a984e 100644 --- a/packages/app/src/pages/session/usage-exceeded-dialogs.tsx +++ b/packages/app/src/pages/session/usage-exceeded-dialogs.tsx @@ -1,6 +1,6 @@ import { useSDK } from "@/context/sdk" import { Persist, persisted } from "@/utils/persist" -import { SessionStatus } from "@opencode-ai/sdk/v2" +import type { SessionActivity } from "@/context/backend" import { onCleanup } from "solid-js" import { createStore } from "solid-js/store" import { useSessionLayout } from "./session-layout" @@ -15,7 +15,7 @@ const GO_UPSELL_ACCOUNT_RATE_LIMIT_DONT_SHOW = "go_upsell_account_rate_limit_don const GO_UPSELL_WINDOW = 86_400_000 // 24 hrs const GO_UPSELL_PROVIDERS = new Set(["opencode", "opencode-go"]) -function goUpsellKeys(status: SessionStatus) { +function goUpsellKeys(status: SessionActivity) { if (status.type !== "retry" || !status.action) return const { action } = status if (!GO_UPSELL_PROVIDERS.has(action.provider)) return @@ -51,14 +51,14 @@ export function useUsageExceededDialogs() { ) onCleanup( - sdk().event.on("session.status", (evt) => { - if (evt.properties.sessionID !== params.id) return - if (evt.properties.status.type !== "retry") return - const { action } = evt.properties.status + sdk().event.on("session.activity", (evt) => { + if (evt.type !== "session.activity" || evt.sessionID !== params.id) return + if (evt.activity.type !== "retry") return + const { action } = evt.activity if (!action) return if (dialog.active) return - const keys = goUpsellKeys(evt.properties.status) + const keys = goUpsellKeys(evt.activity) if (!keys) return const seen = goUpsellState[keys.lastSeenAt] diff --git a/packages/app/src/pages/session/use-session-commands.tsx b/packages/app/src/pages/session/use-session-commands.tsx index e23268c64b..bec45c6370 100644 --- a/packages/app/src/pages/session/use-session-commands.tsx +++ b/packages/app/src/pages/session/use-session-commands.tsx @@ -16,9 +16,10 @@ import { showToast } from "@/utils/toast" import { findLast } from "@opencode-ai/core/util/array" import { createSessionTabs } from "@/pages/session/helpers" import { extractPromptFromParts } from "@/utils/prompt" -import { UserMessage } from "@opencode-ai/sdk/v2" +import type { AppUserMessage as UserMessage } from "@/context/backend" import { useSessionLayout } from "@/pages/session/session-layout" import { createSessionOwnership } from "./session-ownership" +import { createResource } from "solid-js" export type SessionCommandContext = { navigateMessageByOffset: (offset: number) => void @@ -51,6 +52,10 @@ export const useSessionCommands = (actions: SessionCommandContext) => { const navigate = useNavigate() const { params, sessionKey, tabs, view } = useSessionLayout() const sessionOwnership = createSessionOwnership(sessionKey) + const [backend] = createResource( + () => sdk().backend, + (value) => value, + ) const openDialog = async (load: () => Promise, show: (value: T) => void) => { const owner = sessionOwnership.capture() const value = await load() @@ -194,8 +199,9 @@ export const useSessionCommands = (actions: SessionCommandContext) => { } const url = await sdk() - .client.session.share({ sessionID }) - .then((res) => res.data?.share?.url) + .backend.then((client) => + client.capabilities.sessionExtrasV1?.share({ location: { directory: sdk().directory }, sessionID }), + ) .catch(() => undefined) if (!url) { showToast({ @@ -214,7 +220,11 @@ export const useSessionCommands = (actions: SessionCommandContext) => { if (!sessionID) return await sdk() - .client.session.unshare({ sessionID }) + .backend.then((client) => { + const capability = client.capabilities.sessionExtrasV1 + if (!capability) throw new Error("Session sharing is not supported by this server") + return capability.unshare({ location: { directory: sdk().directory }, sessionID }) + }) .then(() => showToast({ title: language.t("toast.session.unshare.success.title"), @@ -305,7 +315,6 @@ export const useSessionCommands = (actions: SessionCommandContext) => { const sessionID = params.id if (!sessionID) return const owner = sessionOwnership.capture() - const client = sdk().client const directory = sdk().directory const promptSession = prompt.capture() const revert = info()?.revert?.messageID @@ -315,13 +324,23 @@ export const useSessionCommands = (actions: SessionCommandContext) => { const parts = sync().data.part[message.id] if (sync().data.session_working(sessionID)) { - await client.session.abort({ sessionID }).catch(() => {}) + await sdk() + .backend.then((client) => client.common.sessions.interrupt({ location: { directory }, sessionID })) + .catch(() => {}) } await runCommand({ owner, prompt: promptSession, - request: () => client.session.revert({ sessionID, messageID: message.id }), + request: () => + sdk().backend.then((client) => { + const input = { location: { directory }, sessionID, messageID: message.id } + if (client.capabilities.sessionExtrasV1) + return client.capabilities.sessionExtrasV1.revert(input).then(() => undefined) + if (client.capabilities.sessionExtrasV2) + return client.capabilities.sessionExtrasV2.stageRevert(input).then(() => undefined) + throw new Error("Session revert is not supported by this server") + }), updatePrompt: (promptSession) => { if (parts) promptSession.set(extractPromptFromParts(parts, { directory })) }, @@ -333,7 +352,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => { const sessionID = params.id if (!sessionID) return const owner = sessionOwnership.capture() - const client = sdk().client + const directory = sdk().directory const messages = userMessages() const promptSession = prompt.capture() @@ -345,7 +364,14 @@ export const useSessionCommands = (actions: SessionCommandContext) => { await runCommand({ owner, prompt: promptSession, - request: () => client.session.unrevert({ sessionID }), + request: () => + sdk().backend.then((client) => { + const input = { location: { directory }, sessionID } + if (client.capabilities.sessionExtrasV1) + return client.capabilities.sessionExtrasV1.clearRevert(input).then(() => undefined) + if (client.capabilities.sessionExtrasV2) return client.capabilities.sessionExtrasV2.clearRevert(input) + throw new Error("Session revert is not supported by this server") + }), updatePrompt: (promptSession) => promptSession.reset(), updateViewport: () => setActiveMessage(findLast(messages, (x) => x.id >= revertMessageID)), }) @@ -355,7 +381,15 @@ export const useSessionCommands = (actions: SessionCommandContext) => { await runCommand({ owner, prompt: promptSession, - request: () => client.session.revert({ sessionID, messageID: next.id }), + request: () => + sdk().backend.then((client) => { + const input = { location: { directory }, sessionID, messageID: next.id } + if (client.capabilities.sessionExtrasV1) + return client.capabilities.sessionExtrasV1.revert(input).then(() => undefined) + if (client.capabilities.sessionExtrasV2) + return client.capabilities.sessionExtrasV2.stageRevert(input).then(() => undefined) + throw new Error("Session revert is not supported by this server") + }), updatePrompt: () => undefined, updateViewport: () => setActiveMessage(findLast(messages, (x) => x.id < next.id)), }) @@ -374,11 +408,17 @@ export const useSessionCommands = (actions: SessionCommandContext) => { return } - await sdk().client.session.summarize({ - sessionID, - modelID: model.id, - providerID: model.provider.id, - }) + const client = await sdk().backend + const location = { directory: sdk().directory } + if (client.capabilities.sessionExtrasV1) { + await client.capabilities.sessionExtrasV1.summarize({ + location, + sessionID, + model: { id: model.id, providerID: model.provider.id }, + }) + return + } + await client.capabilities.sessionExtrasV2?.compact?.({ location, sessionID }) } const fork = () => { @@ -389,7 +429,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => { } const shareCmds = () => { - if (sync().data.config.share === "disabled") return [] + if (sync().data.config.share === "disabled" || !backend()?.capabilities.sessionExtrasV1) return [] return [ sessionCommand({ id: "session.share", @@ -431,7 +471,10 @@ export const useSessionCommands = (actions: SessionCommandContext) => { title: language.t("command.session.undo"), description: language.t("command.session.undo.description"), slash: "undo", - disabled: !params.id || visibleUserMessages().length === 0, + disabled: + !params.id || + visibleUserMessages().length === 0 || + (!backend()?.capabilities.sessionExtrasV1 && !backend()?.capabilities.sessionExtrasV2), onSelect: undo, }), sessionCommand({ @@ -439,7 +482,10 @@ export const useSessionCommands = (actions: SessionCommandContext) => { title: language.t("command.session.redo"), description: language.t("command.session.redo.description"), slash: "redo", - disabled: !params.id || !info()?.revert?.messageID, + disabled: + !params.id || + !info()?.revert?.messageID || + (!backend()?.capabilities.sessionExtrasV1 && !backend()?.capabilities.sessionExtrasV2), onSelect: redo, }), sessionCommand({ @@ -447,7 +493,10 @@ export const useSessionCommands = (actions: SessionCommandContext) => { title: language.t("command.session.compact"), description: language.t("command.session.compact.description"), slash: "compact", - disabled: !params.id || visibleUserMessages().length === 0, + disabled: + !params.id || + visibleUserMessages().length === 0 || + (!backend()?.capabilities.sessionExtrasV1 && !backend()?.capabilities.sessionExtrasV2?.compact), onSelect: compact, }), sessionCommand({ diff --git a/packages/app/src/pages/session/use-session-hash-scroll.ts b/packages/app/src/pages/session/use-session-hash-scroll.ts index 65577abae2..6c93bafc35 100644 --- a/packages/app/src/pages/session/use-session-hash-scroll.ts +++ b/packages/app/src/pages/session/use-session-hash-scroll.ts @@ -1,4 +1,4 @@ -import type { UserMessage } from "@opencode-ai/sdk/v2" +import type { AppUserMessage as UserMessage } from "@/context/backend" import { useLocation, useNavigate } from "@solidjs/router" import { createEffect, createMemo, onCleanup, onMount } from "solid-js" import { messageIdFromHash } from "./message-id-from-hash" diff --git a/packages/app/src/pages/session/v2/review-diff-kinds.ts b/packages/app/src/pages/session/v2/review-diff-kinds.ts index 8b252b0258..41a0c9068d 100644 --- a/packages/app/src/pages/session/v2/review-diff-kinds.ts +++ b/packages/app/src/pages/session/v2/review-diff-kinds.ts @@ -1,4 +1,4 @@ -import type { FileDiffInfo, VcsFileDiff } from "@opencode-ai/sdk/v2" +import type { AppFileDiff as FileDiffInfo, AppVcsFileDiff as VcsFileDiff } from "@/context/backend" import type { Kind } from "@/components/file-tree-v2" import { normalizeFileTreeV2Path } from "@/components/file-tree-v2-model" diff --git a/packages/app/src/pages/session/v2/review-panel-v2.tsx b/packages/app/src/pages/session/v2/review-panel-v2.tsx index cf9367d063..bde5e5790b 100644 --- a/packages/app/src/pages/session/v2/review-panel-v2.tsx +++ b/packages/app/src/pages/session/v2/review-panel-v2.tsx @@ -1,5 +1,8 @@ import { createMemo, createResource, createSignal, Show, type JSX } from "solid-js" -import type { FileDiffInfo, VcsFileDiff } from "@opencode-ai/sdk/v2" +import type { + AppFileDiff as FileDiffInfo, + AppVcsFileDiff as VcsFileDiff, +} from "@/context/backend" import { SESSION_REVIEW_V2_SIDEBAR_WIDTH_MAX, SESSION_REVIEW_V2_SIDEBAR_WIDTH_MIN, @@ -99,8 +102,13 @@ export function ReviewPanelV2(props: ReviewPanelV2Props) { const readFile = async (path: string) => sdk() - .client.file.read({ path }) - .then((x) => x.data) + .backend.then(async (client) => { + const input = { location: { directory: sdk().directory }, path } + if (client.capabilities.decoratedFiles) return client.capabilities.decoratedFiles.read(input) + const content = await client.common.files.read(input) + if (content.kind !== "text") return + return { type: "text" as const, content: new TextDecoder().decode(content.bytes), mimeType: content.mimeType } + }) .catch((error) => { console.debug("[session-review-v2] failed to read file", { path, error }) return undefined diff --git a/packages/app/src/utils/diffs.test.ts b/packages/app/src/utils/diffs.test.ts index f6d768e1de..c2df408bdf 100644 --- a/packages/app/src/utils/diffs.test.ts +++ b/packages/app/src/utils/diffs.test.ts @@ -1,6 +1,5 @@ import { describe, expect, test } from "bun:test" -import type { FileDiffInfo } from "@opencode-ai/sdk/v2" -import type { Message } from "@opencode-ai/sdk/v2/client" +import type { AppFileDiff as FileDiffInfo, AppMessage as Message } from "@/context/backend" import { diffs, message } from "./diffs" const item = { diff --git a/packages/app/src/utils/diffs.ts b/packages/app/src/utils/diffs.ts index 60df039410..b28e37e4a8 100644 --- a/packages/app/src/utils/diffs.ts +++ b/packages/app/src/utils/diffs.ts @@ -1,7 +1,6 @@ -import type { FileDiffInfo } from "@opencode-ai/sdk/v2" -import type { Message } from "@opencode-ai/sdk/v2/client" +import type { AppFileDiff, AppMessage } from "@/context/backend" -type Diff = FileDiffInfo +type Diff = AppFileDiff function diff(value: unknown): value is Diff { if (!value || typeof value !== "object" || Array.isArray(value)) return false @@ -25,7 +24,7 @@ export function diffs(value: unknown): Diff[] { return Object.values(value).filter(diff) } -export function message(value: Message): Message { +export function message(value: AppMessage): AppMessage { if (value.role !== "user") return value const raw = value.summary as unknown diff --git a/packages/app/src/utils/prompt.test.ts b/packages/app/src/utils/prompt.test.ts index 1ecaf02c97..3a78cdf3f9 100644 --- a/packages/app/src/utils/prompt.test.ts +++ b/packages/app/src/utils/prompt.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test" -import type { Part } from "@opencode-ai/sdk/v2" +import type { AppPart as Part } from "@/context/backend" import { extractPromptFromParts } from "./prompt" describe("extractPromptFromParts", () => { diff --git a/packages/app/src/utils/prompt.ts b/packages/app/src/utils/prompt.ts index 35aec0071a..53ec71c535 100644 --- a/packages/app/src/utils/prompt.ts +++ b/packages/app/src/utils/prompt.ts @@ -1,4 +1,7 @@ -import type { AgentPart as MessageAgentPart, FilePart, Part, TextPart } from "@opencode-ai/sdk/v2" +import type { AppFilePart as FilePart, AppPart } from "@/context/backend" +type Part = AppPart +type TextPart = Extract +type MessageAgentPart = Extract import type { AgentPart, FileAttachmentPart, ImageAttachmentPart, Prompt } from "@/context/prompt" type Inline = diff --git a/packages/app/src/utils/server-errors.test.ts b/packages/app/src/utils/server-errors.test.ts index 9c735fe689..80c46df038 100644 --- a/packages/app/src/utils/server-errors.test.ts +++ b/packages/app/src/utils/server-errors.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test" -import type { SessionNotFoundError } from "@opencode-ai/sdk/v2/client" +import type { AppSessionNotFoundError as SessionNotFoundError } from "@/context/backend" import type { ConfigInvalidError, ProviderModelNotFoundError } from "./server-errors" import { formatServerError, isSessionNotFoundError, parseReadableConfigInvalidError } from "./server-errors" diff --git a/packages/app/src/utils/server-health.test.ts b/packages/app/src/utils/server-health.test.ts index b1c8f2c7e2..d58810c383 100644 --- a/packages/app/src/utils/server-health.test.ts +++ b/packages/app/src/utils/server-health.test.ts @@ -13,16 +13,40 @@ function abortFromInput(input: RequestInfo | URL, init?: RequestInit) { } describe("checkServerHealth", () => { - test("returns healthy response with version", async () => { + test("prefers native v2 health", async () => { + const paths: string[] = [] const fetch = (async () => - new Response(JSON.stringify({ healthy: true, version: "1.2.3" }), { + new Response(JSON.stringify({ healthy: true }), { status: 200, headers: { "content-type": "application/json" }, })) as unknown as typeof globalThis.fetch + const trackingFetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + paths.push(new URL(input instanceof Request ? input.url : input).pathname) + return fetch(input, init) + }) as typeof globalThis.fetch + + const result = await checkServerHealth(server, trackingFetch) + + expect(result).toEqual({ healthy: true, version: "v2" }) + expect(paths).toEqual(["/api/health"]) + }) + + test("falls back to v1 and preserves installation version", async () => { + const paths: string[] = [] + const fetch = (async (input: RequestInfo | URL) => { + const path = new URL(input instanceof Request ? input.url : input).pathname + paths.push(path) + if (path === "/api/health") return new Response(null, { status: 404 }) + return new Response(JSON.stringify({ healthy: true, version: "1.2.3" }), { + headers: { "content-type": "application/json" }, + }) + }) as typeof globalThis.fetch + const result = await checkServerHealth(server, fetch) - expect(result).toEqual({ healthy: true, version: "1.2.3" }) + expect(result).toEqual({ healthy: true, version: "v1", installationVersion: "1.2.3" }) + expect(paths).toEqual(["/api/health", "/global/health"]) }) test("allows slow servers thirty seconds by default", async () => { @@ -57,7 +81,7 @@ describe("checkServerHealth", () => { const result = await checkServerHealth(server, fetch) - expect(result).toEqual({ healthy: false }) + expect(result).toEqual({ healthy: false, version: "v2" }) }) test("uses timeout fallback when AbortSignal.timeout is unavailable", async () => { @@ -89,7 +113,7 @@ describe("checkServerHealth", () => { }) expect(aborted).toBe(true) - expect(result).toEqual({ healthy: false }) + expect(result).toEqual({ healthy: false, version: "v2" }) }) test("uses provided abort signal", async () => { @@ -127,7 +151,7 @@ describe("checkServerHealth", () => { }) expect(count).toBe(3) - expect(result).toEqual({ healthy: true, version: "1.2.3" }) + expect(result).toEqual({ healthy: true, version: "v2" }) }) test("returns unhealthy when retries are exhausted", async () => { @@ -143,6 +167,56 @@ describe("checkServerHealth", () => { }) expect(count).toBe(3) - expect(result).toEqual({ healthy: false }) + expect(result).toEqual({ healthy: false, version: "v2" }) + }) + + test("does not fall back to v1 for a transient v2 server response", async () => { + const paths: string[] = [] + const fetch = (async (input: RequestInfo | URL) => { + paths.push(new URL(input instanceof Request ? input.url : input).pathname) + return new Response(null, { status: 503 }) + }) as typeof globalThis.fetch + + const result = await checkServerHealth(server, fetch, { retryCount: 0 }) + + expect(result).toEqual({ healthy: false, version: "v2" }) + expect(paths).toEqual(["/api/health"]) + }) + + test("retries transient v2 responses before succeeding", async () => { + const paths: string[] = [] + const fetch = (async (input: RequestInfo | URL) => { + const path = new URL(input instanceof Request ? input.url : input).pathname + paths.push(path) + if (paths.length === 1) return new Response(null, { status: 503 }) + return new Response(JSON.stringify({ healthy: true }), { + headers: { "content-type": "application/json" }, + }) + }) as typeof globalThis.fetch + + expect(await checkServerHealth(server, fetch, { retryCount: 1, retryDelayMs: 1 })).toEqual({ + healthy: true, + version: "v2", + }) + expect(paths).toEqual(["/api/health", "/api/health"]) + }) + + test("falls back to v1 for method-not-allowed v2 health", async () => { + const paths: string[] = [] + const fetch = (async (input: RequestInfo | URL) => { + const path = new URL(input instanceof Request ? input.url : input).pathname + paths.push(path) + if (path === "/api/health") return new Response(null, { status: 405 }) + return new Response(JSON.stringify({ healthy: true, version: "1.2.3" }), { + headers: { "content-type": "application/json" }, + }) + }) as typeof globalThis.fetch + + expect(await checkServerHealth(server, fetch)).toEqual({ + healthy: true, + version: "v1", + installationVersion: "1.2.3", + }) + expect(paths).toEqual(["/api/health", "/global/health"]) }) }) diff --git a/packages/app/src/utils/server-health.ts b/packages/app/src/utils/server-health.ts index 1b684d9af7..05d35efa44 100644 --- a/packages/app/src/utils/server-health.ts +++ b/packages/app/src/utils/server-health.ts @@ -1,10 +1,14 @@ import { usePlatform } from "@/context/platform" import { ServerConnection } from "@/context/server" -import { createSdkForServer } from "./server" +import { createV1RawClient, createV2RawClient } from "@/context/backend-client" import { Accessor, createEffect, onCleanup } from "solid-js" import { createStore, reconcile } from "solid-js/store" -export type ServerHealth = { healthy: boolean; version?: string } +export type ServerHealth = { + healthy: boolean + version: "v1" | "v2" + installationVersion?: string +} interface CheckServerHealthOptions { timeoutMs?: number @@ -64,9 +68,40 @@ function retryable(error: unknown, signal?: AbortSignal) { if (!(error instanceof Error)) return false if (error.name === "AbortError" || error.name === "TimeoutError") return false if (error instanceof TypeError) return true + if (error !== null && typeof error === "object" && "reason" in error && error.reason === "Transport") return true + if (error.cause !== null && typeof error.cause === "object" && "status" in error.cause) { + const status = error.cause.status + if (status === 408 || status === 429 || (typeof status === "number" && status >= 500)) return true + } return /network|fetch|econnreset|econnrefused|enotfound|timedout/i.test(error.message) } +function unsupported(error: unknown) { + if (error === null || typeof error !== "object" || !("cause" in error)) return false + const cause = error.cause + if (cause === null || typeof cause !== "object" || !("status" in cause)) return false + return cause.status === 404 || cause.status === 405 +} + +function abortable(promise: Promise, signal?: AbortSignal) { + if (!signal) return promise + if (signal.aborted) return Promise.reject(new DOMException("Aborted", "AbortError")) + return new Promise((resolve, reject) => { + const abort = () => reject(new DOMException("Aborted", "AbortError")) + signal.addEventListener("abort", abort, { once: true }) + promise.then( + (value) => { + signal.removeEventListener("abort", abort) + resolve(value) + }, + (error) => { + signal.removeEventListener("abort", abort) + reject(error) + }, + ) + }) +} + export async function checkServerHealth( server: ServerConnection.HttpBase, fetch: typeof globalThis.fetch, @@ -77,20 +112,33 @@ export async function checkServerHealth( const retryCount = opts?.retryCount ?? defaultRetryCount const retryDelayMs = opts?.retryDelayMs ?? defaultRetryDelayMs const next = (count: number, error: unknown) => { - if (count >= retryCount || !retryable(error, signal)) return Promise.resolve({ healthy: false } as const) + if (count >= retryCount || !retryable(error, signal)) + return Promise.resolve({ healthy: false, version: "v2" } as const) return wait(retryDelayMs * (count + 1), signal) .then(() => attempt(count + 1)) - .catch(() => ({ healthy: false })) + .catch(() => ({ healthy: false, version: "v2" as const })) + } + const attempt = async (count: number): Promise => { + try { + const result = await abortable(createV2RawClient(server, fetch).health.get({ signal }), signal) + return { healthy: result.healthy === true, version: "v2" } + } catch (error) { + if (!unsupported(error)) return next(count, error) + } + + try { + const result = await abortable(createV1RawClient(server, fetch).global.health({ signal }), signal) + if (result.error) return { healthy: false, version: "v1" } + return { + healthy: result.data?.healthy === true, + version: "v1", + installationVersion: result.data?.version, + } + } catch (error) { + if (signal?.aborted) return { healthy: false, version: "v1" } + return { healthy: false, version: "v1" } + } } - const attempt = (count: number): Promise => - createSdkForServer({ - server, - fetch, - signal, - }) - .global.health() - .then((x) => (x.error ? next(count, x.error) : { healthy: x.data?.healthy === true, version: x.data?.version })) - .catch((error) => next(count, error)) return attempt(0).finally(() => timeout?.clear?.()) } @@ -116,8 +164,11 @@ export function useCheckServerHealth() { } } -export const useServerHealth = (servers: Accessor, enabled: Accessor) => { - const checkServerHealth = useCheckServerHealth() +export const useServerHealth = ( + servers: Accessor, + enabled: Accessor, + checkServerHealth = useCheckServerHealth(), +) => { const [status, setStatus] = createStore({} as Record) createEffect(() => { diff --git a/packages/app/src/utils/server.ts b/packages/app/src/utils/server.ts index 603784e4d4..2c5ce5a99d 100644 --- a/packages/app/src/utils/server.ts +++ b/packages/app/src/utils/server.ts @@ -1,5 +1,3 @@ -import { createOpencodeClient } from "@opencode-ai/sdk/v2/client" -import type { ServerConnection } from "@/context/server" import { decode64 } from "@/utils/base64" export function authTokenFromCredentials(input: { username?: string; password: string }) { @@ -16,26 +14,3 @@ export function authFromToken(token: string | null) { password: decoded.slice(separator + 1), } } - -export function createSdkForServer({ - server, - ...config -}: Omit[0]>, "baseUrl"> & { - server: ServerConnection.HttpBase -}) { - const auth = (() => { - if (!server.password) return - return { - Authorization: `Basic ${authTokenFromCredentials({ username: server.username, password: server.password })}`, - } - })() - - return createOpencodeClient({ - ...config, - headers: { - ...(config.headers instanceof Headers ? Object.fromEntries(config.headers.entries()) : config.headers), - ...auth, - }, - baseUrl: server.url, - }) -} diff --git a/packages/app/src/utils/terminal-websocket-url.test.ts b/packages/app/src/utils/terminal-websocket-url.test.ts deleted file mode 100644 index 5fa1506b1e..0000000000 --- a/packages/app/src/utils/terminal-websocket-url.test.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { describe, expect, test } from "bun:test" -import { terminalWebSocketURL } from "./terminal-websocket-url" - -describe("terminalWebSocketURL", () => { - test("uses query auth without embedding credentials in websocket URL", () => { - const url = terminalWebSocketURL({ - url: "http://127.0.0.1:49365", - id: "pty_test", - directory: "/tmp/project", - cursor: 0, - sameOrigin: false, - username: "opencode", - password: "secret", - }) - - expect(url.protocol).toBe("ws:") - expect(url.username).toBe("") - expect(url.password).toBe("") - expect(url.searchParams.get("auth_token")).toBe(btoa("opencode:secret")) - }) - - test("omits query auth for same-origin saved credentials", () => { - const url = terminalWebSocketURL({ - url: "https://app.example.test", - id: "pty_test", - directory: "/tmp/project", - cursor: 10, - sameOrigin: true, - username: "opencode", - password: "secret", - }) - - expect(url.protocol).toBe("wss:") - expect(url.searchParams.has("auth_token")).toBe(false) - }) - - test("uses query auth for same-origin credentials from auth_token", () => { - const url = terminalWebSocketURL({ - url: "https://app.example.test", - id: "pty_test", - directory: "/tmp/project", - cursor: 10, - sameOrigin: true, - username: "opencode", - password: "secret", - authToken: true, - }) - - expect(url.protocol).toBe("wss:") - expect(url.searchParams.get("auth_token")).toBe(btoa("opencode:secret")) - }) -}) diff --git a/packages/app/src/utils/terminal-websocket-url.ts b/packages/app/src/utils/terminal-websocket-url.ts deleted file mode 100644 index 06facdc7d2..0000000000 --- a/packages/app/src/utils/terminal-websocket-url.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { authTokenFromCredentials } from "@/utils/server" - -export function terminalWebSocketURL(input: { - url: string - id: string - directory: string - cursor: number - ticket?: string - sameOrigin?: boolean - username?: string - password?: string - authToken?: boolean -}) { - const next = new URL(`${input.url}/pty/${input.id}/connect`) - next.searchParams.set("directory", input.directory) - next.searchParams.set("cursor", String(input.cursor)) - next.protocol = next.protocol === "https:" ? "wss:" : "ws:" - if (input.ticket) { - next.searchParams.set("ticket", input.ticket) - return next - } - if (input.password && (!input.sameOrigin || input.authToken)) - next.searchParams.set( - "auth_token", - authTokenFromCredentials({ username: input.username, password: input.password }), - ) - return next -} diff --git a/packages/session-ui/package.json b/packages/session-ui/package.json index cf3be61f36..d7da65bb3c 100644 --- a/packages/session-ui/package.json +++ b/packages/session-ui/package.json @@ -36,7 +36,6 @@ "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/core": "workspace:*", - "@opencode-ai/sdk": "workspace:*", "@opencode-ai/ui": "workspace:*", "@pierre/diffs": "catalog:", "@shikijs/stream": "catalog:", diff --git a/packages/session-ui/src/components/file-media.tsx b/packages/session-ui/src/components/file-media.tsx index 4e89970ac5..5d582bd986 100644 --- a/packages/session-ui/src/components/file-media.tsx +++ b/packages/session-ui/src/components/file-media.tsx @@ -1,4 +1,4 @@ -import type { FileContent } from "@opencode-ai/sdk/v2" +import type { FileContent } from "../context/data-types" import { createEffect, createMemo, Match, on, onCleanup, Show, Switch, untrack, type JSX } from "solid-js" import { createStore } from "solid-js/store" import { useI18n } from "@opencode-ai/ui/context/i18n" diff --git a/packages/session-ui/src/components/message-file.test.ts b/packages/session-ui/src/components/message-file.test.ts index 7bdf007631..cea4a7f630 100644 --- a/packages/session-ui/src/components/message-file.test.ts +++ b/packages/session-ui/src/components/message-file.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test" -import type { FilePart } from "@opencode-ai/sdk/v2" +import type { FilePart } from "../context/data-types" import { attached, inline, kind } from "./message-file" function file(part: Partial = {}): FilePart { diff --git a/packages/session-ui/src/components/message-file.ts b/packages/session-ui/src/components/message-file.ts index ecc7456902..b5363cff99 100644 --- a/packages/session-ui/src/components/message-file.ts +++ b/packages/session-ui/src/components/message-file.ts @@ -1,4 +1,4 @@ -import type { FilePart } from "@opencode-ai/sdk/v2" +import type { FilePart } from "../context/data-types" export function attached(part: FilePart) { return part.url.startsWith("data:") diff --git a/packages/session-ui/src/components/message-nav.tsx b/packages/session-ui/src/components/message-nav.tsx index 85db1e1bcd..f01098c4c2 100644 --- a/packages/session-ui/src/components/message-nav.tsx +++ b/packages/session-ui/src/components/message-nav.tsx @@ -1,4 +1,4 @@ -import { UserMessage } from "@opencode-ai/sdk/v2" +import type { UserMessage } from "../context/data-types" import { HoverCard } from "@kobalte/core/hover-card" import { ComponentProps, For, Match, Show, createSignal, splitProps, Switch } from "solid-js" import { DiffChanges } from "@opencode-ai/ui/diff-changes" diff --git a/packages/session-ui/src/components/message-part.tsx b/packages/session-ui/src/components/message-part.tsx index b16b0344f3..8285340fcd 100644 --- a/packages/session-ui/src/components/message-part.tsx +++ b/packages/session-ui/src/components/message-part.tsx @@ -29,7 +29,7 @@ import { UserMessage, QuestionAnswer, QuestionInfo, -} from "@opencode-ai/sdk/v2" +} from "../context/data-types" import { useData } from "../context" import { useFileComponent } from "@opencode-ai/ui/context/file" import { useDialog } from "@opencode-ai/ui/context/dialog" diff --git a/packages/session-ui/src/components/session-diff.ts b/packages/session-ui/src/components/session-diff.ts index 6fccfc305d..9fa24046a1 100644 --- a/packages/session-ui/src/components/session-diff.ts +++ b/packages/session-ui/src/components/session-diff.ts @@ -1,6 +1,6 @@ import { parseDiffFromFile, parsePatchFiles, type FileDiffMetadata } from "@pierre/diffs" import { parsePatch } from "diff" -import type { FileDiffInfo, VcsFileDiff } from "@opencode-ai/sdk/v2" +import type { SnapshotFileDiff as FileDiffInfo, VcsFileDiff } from "../context/data-types" type LegacyDiff = { file: string diff --git a/packages/session-ui/src/components/session-retry.tsx b/packages/session-ui/src/components/session-retry.tsx index cf2d6c9459..89a75e1a28 100644 --- a/packages/session-ui/src/components/session-retry.tsx +++ b/packages/session-ui/src/components/session-retry.tsx @@ -1,5 +1,5 @@ import { createEffect, createMemo, createSignal, on, onCleanup, Show } from "solid-js" -import type { SessionStatus } from "@opencode-ai/sdk/v2/client" +import type { SessionStatus } from "../context/data-types" import { useI18n } from "@opencode-ai/ui/context/i18n" import { Card } from "@opencode-ai/ui/card" import { Tooltip } from "@opencode-ai/ui/tooltip" diff --git a/packages/session-ui/src/components/session-review.tsx b/packages/session-ui/src/components/session-review.tsx index ec94af2f2d..5292acb1ec 100644 --- a/packages/session-ui/src/components/session-review.tsx +++ b/packages/session-ui/src/components/session-review.tsx @@ -15,7 +15,7 @@ import { getDirectory, getFilename } from "@opencode-ai/core/util/path" import { checksum } from "@opencode-ai/core/util/encode" import { createEffect, createMemo, For, Match, onCleanup, Show, Switch, untrack, type JSX } from "solid-js" import { createStore } from "solid-js/store" -import { type FileContent, type FileDiffInfo, type VcsFileDiff } from "@opencode-ai/sdk/v2" +import type { FileContent, SnapshotFileDiff as FileDiffInfo, VcsFileDiff } from "../context/data-types" import { PreloadMultiFileDiffResult } from "@pierre/diffs/ssr" import { type SelectedLineRange } from "@pierre/diffs" import { Dynamic } from "solid-js/web" diff --git a/packages/session-ui/src/components/session-turn.tsx b/packages/session-ui/src/components/session-turn.tsx index a6d0f11efb..80872f52b9 100644 --- a/packages/session-ui/src/components/session-turn.tsx +++ b/packages/session-ui/src/components/session-turn.tsx @@ -1,11 +1,11 @@ -import { +import type { AssistantMessage, - type FileDiffInfo, Message as MessageType, Part as PartType, - type UserMessage, -} from "@opencode-ai/sdk/v2/client" -import type { SessionStatus } from "@opencode-ai/sdk/v2" + SessionStatus, + SnapshotFileDiff as FileDiffInfo, + UserMessage, +} from "../context/data-types" import { useData } from "../context" import { useFileComponent } from "@opencode-ai/ui/context/file" @@ -436,7 +436,7 @@ export function SessionTurn(
- + (value.type === "running" ? { type: "busy" as const } : value))(status())} show={active()} /> 0 && !working()}>
+ multiple?: boolean + custom?: boolean +} + +export type QuestionAnswer = string[] + +export type SnapshotFileDiff = { + file: string + patch?: string + additions: number + deletions: number + status?: "added" | "deleted" | "modified" +} + +export type VcsFileDiff = SnapshotFileDiff & { file: string } +type SummaryFileDiff = Omit & { file?: string } + +export type FileContent = { + readonly type: "text" | "binary" + readonly content: string + readonly diff?: string + readonly patch?: { + readonly oldFileName: string + readonly newFileName: string + readonly oldHeader?: string + readonly newHeader?: string + readonly hunks: readonly { + readonly oldStart: number + readonly oldLines: number + readonly newStart: number + readonly newLines: number + readonly lines: readonly string[] + }[] + readonly index?: string + } + readonly encoding?: "base64" + readonly mimeType?: string +} + +export type MessageError = + | { name: "ProviderAuthError"; data: { providerID: string; message: string } } + | { name: "UnknownError"; data: { message: string; ref?: string } } + | { name: "MessageOutputLengthError"; data: Record } + | { name: "MessageAbortedError"; data: { message: string } } + | { name: "StructuredOutputError"; data: { message: string; retries: number } } + | { name: "ContextOverflowError"; data: { message: string; responseBody?: string } } + | { name: "ContentFilterError"; data: { message: string } } + | { + name: "APIError" + data: { + message: string + statusCode?: number + isRetryable: boolean + responseHeaders?: Record + responseBody?: string + metadata?: Record + } + } + +export type Message = + | { + id: string + sessionID: string + role: "user" + time: { created: number } + agent: string + model: { providerID: string; modelID: string; variant?: string } + system?: string + summary?: { title?: string; body?: string; diffs: SummaryFileDiff[] } + } + | { + id: string + sessionID: string + role: "assistant" + time: { created: number; completed?: number } + parentID: string + modelID: string + providerID: string + mode: string + agent: string + path: { cwd: string; root: string } + cost: number + tokens: { input: number; output: number; reasoning: number; cache: { read: number; write: number } } + error?: MessageError + finish?: string + } + +export type UserMessage = Extract +export type AssistantMessage = Extract + +type PartBase = { id: string; sessionID: string; messageID: string } +export type FilePart = PartBase & { + type: "file" + mime: string + filename?: string + url: string + source?: + | { type: "file"; path: string; text: { value: string; start: number; end: number } } + | { + type: "symbol" + path: string + range: { + start: { line: number; character: number } + end: { line: number; character: number } + } + name: string + kind: number + text: { value: string; start: number; end: number } + } + | { type: "resource"; clientName: string; uri: string; text: { value: string; start: number; end: number } } +} +type ToolState = + | { status: "pending"; input: Record; raw: string } + | { + status: "running" + input: Record + title?: string + metadata?: Record + time: { start: number } + } + | { + status: "completed" + input: Record + output: string + title: string + metadata: Record + time: { start: number; end: number; compacted?: number } + attachments?: FilePart[] + } + | { + status: "error" + input: Record + error: string + metadata?: Record + time: { start: number; end: number } + } + +export type Part = + | (PartBase & { + type: "text" + text: string + synthetic?: boolean + ignored?: boolean + time?: { start: number; end?: number } + metadata?: Record + }) + | (PartBase & { + type: "reasoning" + text: string + metadata?: Record + time: { start: number; end?: number } + }) + | FilePart + | (PartBase & { type: "agent"; name: string; source?: { value: string; start: number; end: number } }) + | (PartBase & { type: "tool"; callID: string; tool: string; state: ToolState; metadata?: Record }) + | (PartBase & { type: "subtask"; prompt: string; description: string; agent: string }) + | (PartBase & { type: "step-start"; snapshot?: string }) + | (PartBase & { + type: "step-finish" + reason: string + snapshot?: string + cost: number + tokens: { input: number; output: number; reasoning: number; cache: { read: number; write: number } } + }) + | (PartBase & { type: "snapshot"; snapshot: string }) + | (PartBase & { type: "patch"; hash: string; files: string[] }) + | (PartBase & { + type: "retry" + attempt: number + error: Extract + time: { created: number } + }) + | (PartBase & { type: "compaction"; auto: boolean }) + +export type TextPart = Extract +export type ReasoningPart = Extract +export type ToolPart = Extract +export type AgentPart = Extract diff --git a/packages/session-ui/src/context/data.tsx b/packages/session-ui/src/context/data.tsx index d505249931..03f5d6bf8f 100644 --- a/packages/session-ui/src/context/data.tsx +++ b/packages/session-ui/src/context/data.tsx @@ -1,13 +1,13 @@ -import type { FileDiffInfo, Message, Part, Provider, Session, SessionStatus } from "@opencode-ai/sdk/v2" +import type { Message, Part, Session, SessionStatus, SnapshotFileDiff as FileDiffInfo } from "./data-types" import { createSimpleContext } from "@opencode-ai/ui/context" import { PreloadMultiFileDiffResult } from "@pierre/diffs/ssr" export type NormalizedProviderListResponse = { - all: Map + all: ReadonlyMap> }> default: { [key: string]: string } - connected: Array + connected: readonly string[] } type Data = { diff --git a/packages/session-ui/src/pierre/media.ts b/packages/session-ui/src/pierre/media.ts index 1ee63c25ba..1c471814c1 100644 --- a/packages/session-ui/src/pierre/media.ts +++ b/packages/session-ui/src/pierre/media.ts @@ -1,4 +1,4 @@ -import type { FileContent } from "@opencode-ai/sdk/v2" +import type { FileContent } from "../context/data-types" export type MediaKind = "image" | "audio" | "svg" diff --git a/packages/session-ui/src/v2/components/session-review-file-preview-v2.tsx b/packages/session-ui/src/v2/components/session-review-file-preview-v2.tsx index c05fe3a745..9f9db2d36b 100644 --- a/packages/session-ui/src/v2/components/session-review-file-preview-v2.tsx +++ b/packages/session-ui/src/v2/components/session-review-file-preview-v2.tsx @@ -6,7 +6,7 @@ import { useFileComponent } from "@opencode-ai/ui/context/file" import { useI18n } from "@opencode-ai/ui/context/i18n" import { mediaKindFromPath } from "../../pierre/media" import { cloneSelectedLineRange, previewSelectedLines } from "../../pierre/selection-bridge" -import type { FileContent, FileDiffInfo, VcsFileDiff } from "@opencode-ai/sdk/v2" +import type { FileContent, SnapshotFileDiff as FileDiffInfo, VcsFileDiff } from "../../context/data-types" import { createEffect, createMemo, onCleanup, Show, untrack } from "solid-js" import { createStore } from "solid-js/store" import { Dynamic } from "solid-js/web"