Compare commits

...

4 Commits

Author SHA1 Message Date
Ryan Vogel 02eae8cde0 fix(cli): sign macOS preview binaries 2026-08-05 14:45:57 +00:00
Shoubhit Dash 143a776373 fix(acp): surface subagent activity (#40438) 2026-08-05 18:57:15 +05:30
Brendan Allan 76b318e990 refactor(app): gate unsupported v2 capabilities (#40381) 2026-08-05 15:15:26 +08:00
opencode-agent[bot] c0ab35c3c2 fix(app): show active search details (#40597)
Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com>
2026-08-05 17:09:42 +10:00
44 changed files with 981 additions and 391 deletions
+56 -1
View File
@@ -124,12 +124,66 @@ jobs:
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: opencode-preview-cli
name: opencode-preview-cli-unsigned
path: packages/cli/dist/cli-*
outputs:
version: ${{ needs.version.outputs.version }}
sign-cli-macos:
needs: build-cli
runs-on: macos-26
if: github.repository == 'anomalyco/opencode'
steps:
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
- uses: apple-actions/import-codesign-certs@8f3fb608891dd2244cdab3d69cd68c0d37a7fe93 # v2.0.0
with:
keychain: build
p12-file-base64: ${{ secrets.APPLE_CERTIFICATE }}
p12-password: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
with:
name: opencode-preview-cli-unsigned
path: packages/cli/dist
- name: Sign macOS CLI binaries
run: |
identity=$(security find-identity -v -p codesigning build.keychain | sed -n 's/.*"\(Developer ID Application:.*\)"/\1/p' | head -n 1)
if [ -z "$identity" ]; then
echo "Developer ID Application identity not found"
exit 1
fi
found=0
for file in packages/cli/dist/cli-darwin-*/bin/opencode2; do
if [ ! -f "$file" ]; then
continue
fi
found=1
codesign \
--force \
--timestamp \
--options runtime \
--entitlements packages/cli/script/entitlements.plist \
--sign "$identity" \
"$file"
codesign --verify --deep --strict --verbose=4 "$file"
codesign --display --requirements - "$file"
done
if [ "$found" -eq 0 ]; then
echo "No macOS CLI binaries found"
exit 1
fi
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: opencode-preview-cli
path: packages/cli/dist/cli-*
if-no-files-found: error
build-node-cli:
needs: version
if: github.repository == 'anomalyco/opencode'
@@ -471,6 +525,7 @@ jobs:
needs:
- version
- build-cli
- sign-cli-macos
- build-node-cli
- sign-cli-windows
- build-electron
@@ -14,8 +14,8 @@ const projectID = "proj_context_resize_regression"
const sessionID = "ses_context_resize_regression"
const title = "Context resize regression"
const model = { providerID: "opencode", modelID: "claude-opus-4-6", variant: "max" }
const contextIDs = ["ctx_0100_read", "ctx_0101_glob", "ctx_0102_grep", "ctx_0103_list"]
const followingTextID = `${id("msg_assistant", 10)}:text:0`
const contextIDs = ["prt_0100_read", "prt_0101_glob", "prt_0102_grep", "prt_0103_list"]
const followingTextID = "prt_0104_text"
type Message = {
info: Record<string, unknown> & { id: string; role: "user" | "assistant" }
@@ -263,7 +263,7 @@ function turn(index: number, target: boolean, status: "running" | "completed" =
),
contextTool(contextIDs[3]!, assistantID, "list", { path: "src" }, status),
{
id: "prt_0104_text",
id: followingTextID,
sessionID,
messageID: assistantID,
type: "text",
@@ -295,7 +295,7 @@ function contextTool(
sessionID,
messageID,
type: "tool",
callID: partID,
callID: `call_${partID}`,
tool,
state: {
status,
@@ -136,7 +136,7 @@ export function CustomProviderForm(props: { autofocus?: boolean } = {}) {
const nextDisabled = disabledProviders.filter((id) => id !== result.providerID)
if (result.key) {
await serverSDK().client.auth.set({
await serverSDK().legacy.auth.set({
providerID: result.providerID,
auth: {
type: "api",
@@ -71,11 +71,8 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) {
() => (missingBase() ? true : undefined),
async (): Promise<Path | undefined> => {
if ((await sdk.protocol) === "v1")
return sdk.client.path
.get()
.then((result) => result.data)
.catch(() => undefined)
return sdk.currentApi.location
return sdk.legacy.path.get().catch(() => undefined)
return sdk.api.location
.get()
.then((location) => ({
state: "",
@@ -62,11 +62,8 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
() => (missingBase() ? true : undefined),
async (): Promise<Path | undefined> => {
if ((await sdk.protocol) === "v1")
return sdk.client.path
.get()
.then((result) => result.data)
.catch(() => undefined)
return sdk.currentApi.location
return sdk.legacy.path.get().catch(() => undefined)
return sdk.api.location
.get()
.then((location) => ({
state: "",
+1 -7
View File
@@ -73,7 +73,7 @@ export function createEditProjectModel(props: { project: LocalProject; server: S
if (props.project.id && props.project.id !== "global") {
if ((await serverCtx().sdk.protocol) !== "v1") return
const project = await serverCtx()
.sdk.client.project.update({
.sdk.legacy.project.update({
projectID: props.project.id,
directory: props.project.worktree,
name,
@@ -82,12 +82,6 @@ export function createEditProjectModel(props: { project: LocalProject; server: S
})
.then((result) => result.data)
if (!project) return
// const project = await serverCtx().sdk.api.project.update({
// projectID: props.project.id,
// name,
// icon: { color: store.color || "", override: store.iconOverride || "" },
// commands: { start },
// })
serverCtx().sync.set("project", (items) =>
items.map((item) => (item.id === project.id ? normalizeProjectInfo(project) : item)),
)
@@ -13,7 +13,7 @@ import { useLanguage } from "@/context/language"
import { usePermission } from "@/context/permission"
import { usePlatform, type DisplayBackend } from "@/context/platform"
import { useServerSync } from "@/context/server-sync"
import { useServerSDK } from "@/context/server-sdk"
import { useServerProtocol, useServerSDK } from "@/context/server-sdk"
import { useUpdaterAction } from "./updater-action"
import {
monoDefault,
@@ -125,16 +125,11 @@ export const SettingsGeneral: Component = () => {
const serverSync = useServerSync()
const serverSdk = useServerSDK()
const protocol = useServerProtocol()
const [shells] = createResource(
async () => {
const sdk = serverSdk()
if ((await sdk.protocol) === "v1") {
return (await sdk.client.pty.shells()).data ?? []
}
// return (await sdk.api.pty.shells()).data
return [] as ShellOption[]
},
() => (protocol() === "v1" ? serverSdk() : undefined),
(sdk) => sdk.legacy.pty.shells().catch(() => [] as ShellOption[]),
{ initialValue: [] as ShellOption[] },
)
@@ -325,10 +320,11 @@ export const SettingsGeneral: Component = () => {
</div>
</SettingsRow>
<SettingsRow
title={language.t("settings.general.row.shell.title")}
description={language.t("settings.general.row.shell.description")}
>
<Show when={protocol() === "v1"}>
<SettingsRow
title={language.t("settings.general.row.shell.title")}
description={language.t("settings.general.row.shell.description")}
>
<Select
data-action="settings-shell"
options={shellOptions()}
@@ -345,7 +341,8 @@ export const SettingsGeneral: Component = () => {
triggerVariant="settings"
triggerStyle={{ "min-width": "180px" }}
/>
</SettingsRow>
</SettingsRow>
</Show>
<SettingsRow
title={language.t("settings.general.row.reasoningSummaries.title")}
@@ -122,9 +122,7 @@ const SettingsProvidersContent: Component<{ onBack?: () => void }> = (props) =>
const disconnect = async (providerID: string, name: string) => {
if (isConfigCustom(providerID)) {
await serverSDK()
.client.auth.remove({ providerID })
.catch(() => undefined)
await serverSDK().legacy.auth.remove({ providerID }).catch(() => undefined)
await disableProvider(providerID, name)
return
}
@@ -10,7 +10,7 @@ import { useLanguage } from "@/context/language"
import { usePermission } from "@/context/permission"
import { usePlatform } from "@/context/platform"
import { useServerSync } from "@/context/server-sync"
import { useServerSDK } from "@/context/server-sdk"
import { useServerProtocol, useServerSDK } from "@/context/server-sdk"
import { useUpdaterAction } from "../updater-action"
import {
monoDefault,
@@ -92,6 +92,7 @@ export const SettingsGeneralV2: Component<{
const settings = useSettings()
const serverSync = useServerSync()
const serverSdk = useServerSDK()
const protocol = useServerProtocol()
const mobile = createMediaQuery("(max-width: 767px)")
const updater = useUpdaterAction()
@@ -122,14 +123,8 @@ export const SettingsGeneralV2: Component<{
const themeOptions = createMemo<ThemeOption[]>(() => theme.ids().map((id) => ({ id, name: theme.name(id) })))
const [shells] = createResource(
async () => {
const sdk = serverSdk()
if ((await sdk.protocol) === "v1") {
return (await sdk.client.pty.shells()).data ?? []
}
// return (await sdk.api.pty.shells()).data
return [] as ShellOption[]
},
() => (protocol() === "v1" ? serverSdk() : undefined),
(sdk) => sdk.legacy.pty.shells().catch(() => [] as ShellOption[]),
{ initialValue: [] as ShellOption[] },
)
@@ -284,10 +279,11 @@ export const SettingsGeneralV2: Component<{
</div>
</SettingsRowV2>
<SettingsRowV2
title={language.t("settings.general.row.shell.title")}
description={language.t("settings.general.row.shell.description")}
>
<Show when={protocol() === "v1"}>
<SettingsRowV2
title={language.t("settings.general.row.shell.title")}
description={language.t("settings.general.row.shell.description")}
>
<SelectV2
appearance="inline"
data-action="settings-shell"
@@ -303,7 +299,8 @@ export const SettingsGeneralV2: Component<{
serverSync().updateConfig({ shell: option.value })
}}
/>
</SettingsRowV2>
</SettingsRowV2>
</Show>
<SettingsRowV2
title={language.t("settings.general.row.reasoningSummaries.title")}
@@ -119,9 +119,7 @@ export const SettingsProvidersV2: Component<{
const disconnect = async (providerID: string, name: string) => {
if (isConfigCustom(providerID)) {
await serverSdk()
.client.auth.remove({ providerID })
.catch(() => undefined)
await serverSdk().legacy.auth.remove({ providerID }).catch(() => undefined)
await disableProvider(providerID, name)
return
}
@@ -318,10 +318,12 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
{mcpConnected() > 0 ? `${mcpConnected()} ` : ""}
{language.t("status.popover.tab.mcp")}
</Tabs.Trigger>
<Tabs.Trigger value="lsp" data-slot="tab" class="text-12-regular">
{lspCount() > 0 ? `${lspCount()} ` : ""}
{language.t("status.popover.tab.lsp")}
</Tabs.Trigger>
<Show when={protocol() === "v1"}>
<Tabs.Trigger value="lsp" data-slot="tab" class="text-12-regular">
{lspCount() > 0 ? `${lspCount()} ` : ""}
{language.t("status.popover.tab.lsp")}
</Tabs.Trigger>
</Show>
<Show when={protocol() === "v1"}>
<Tabs.Trigger value="plugins" data-slot="tab" class="text-12-regular">
{pluginCount() > 0 ? `${pluginCount()} ` : ""}
@@ -459,7 +461,8 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
</div>
</Tabs.Content>
<Tabs.Content value="lsp">
<Show when={protocol() === "v1"}>
<Tabs.Content value="lsp">
<div class="flex flex-col px-2 pb-2">
<div class="flex flex-col p-3 bg-background-base rounded-sm min-h-14">
<Show
@@ -485,7 +488,8 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
</Show>
</div>
</div>
</Tabs.Content>
</Tabs.Content>
</Show>
<Show when={protocol() === "v1"}>
<Tabs.Content value="plugins">
+1 -2
View File
@@ -134,8 +134,7 @@ export const createDirSyncContext = (
},
more: createMemo(() => current()[0].session.length >= current()[0].limit),
archive: async (sessionID: string) => {
if ((await serverSDK.protocol) !== "v1") return
await serverSDK.client.session.update({ sessionID, directory, time: { archived: Date.now() } })
await serverSDK.legacy.session.archive(sessionID, directory)
current()[1](
"session",
produce((draft) => {
@@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test"
import { createStore } from "solid-js/store"
import { QueryClient } from "@tanstack/solid-query"
import type { Config, Project } from "@/types"
import type { OpencodeClient } from "@opencode-ai/sdk/v2/client"
import type { LegacyCapabilities } from "@/utils/server-compat"
import type { AgentApi, CatalogApi, CommandApi, ReferenceApi } from "@opencode-ai/client/promise"
import type { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
import {
@@ -111,36 +111,7 @@ describe("bootstrapDirectory", () => {
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: [] }
},
},
permission: { list: async () => ({ data: [] }) },
question: { list: async () => ({ data: [] }) },
v2: { reference: { list: async () => ({ data: { data: [] } }) } },
mcp: {
status: async () => {
mcpReads.push("status")
return { data: {} }
},
},
experimental: {
resource: {
list: async () => {
mcpReads.push("resource")
return { data: {} }
},
},
},
provider: { list: async () => ({ data: { all: [], connected: [], default: {} } }) },
} as unknown as OpencodeClient,
legacy: { config: { directory: async () => ({}) } } as unknown as LegacyCapabilities,
api: currentApi,
store,
setStore,
@@ -163,16 +134,15 @@ describe("bootstrapDirectory", () => {
describe("query keys", () => {
test("partitions identical directories by server scope", () => {
const location = {} as Parameters<typeof loadPathQuery>[2]
const client = {} as Parameters<typeof loadPathQuery>[3]
const api = {} as CatalogApi
const remote = "https://debian.example" as typeof ServerScope.local
expect([...loadPathQuery(ServerScope.local, "/repo", location, client).queryKey]).toEqual([
expect([...loadPathQuery(ServerScope.local, "/repo", location).queryKey]).toEqual([
"local",
"/repo",
"path",
])
expect([...loadPathQuery(remote, "/repo", location, client).queryKey]).toEqual([
expect([...loadPathQuery(remote, "/repo", location).queryKey]).toEqual([
"https://debian.example",
"/repo",
"path",
@@ -8,7 +8,7 @@ import type {
ReferenceInfo,
Session,
} from "@/types"
import type { OpencodeClient } from "@opencode-ai/sdk/v2/client"
import type { LegacyCapabilities } from "@/utils/server-compat"
import type {
AgentListInput,
AgentListOutput,
@@ -107,10 +107,11 @@ function showErrors(input: {
})
}
export const loadGlobalConfigQuery = (scope: ServerScope, sdk: OpencodeClient) =>
export const loadGlobalConfigQuery = (scope: ServerScope, legacy: LegacyCapabilities, enabled = true) =>
queryOptions({
queryKey: [scope, "config"],
queryFn: () => retry(() => sdk.global.config.get().then((x) => x.data!)),
queryFn: () => retry(() => legacy.config.global()),
enabled,
})
type ProjectApi = {
@@ -141,7 +142,7 @@ export const loadProjectsQuery = (scope: ServerScope, api: ProjectApi) =>
})
export async function bootstrapGlobal(input: {
serverSDK: OpencodeClient
legacy: LegacyCapabilities
serverAPI: CatalogApi & { readonly location: LocationApi; readonly project: ProjectApi }
protocol?: Promise<ServerProtocol>
scope: ServerScope
@@ -151,21 +152,22 @@ export async function bootstrapGlobal(input: {
setGlobalStore: SetStoreFunction<GlobalStore>
queryClient: QueryClient
}) {
const protocol = await input.protocol
const slow = [
() => input.queryClient.fetchQuery(loadGlobalConfigQuery(input.scope, input.serverSDK)),
protocol === "v1" && (() => input.queryClient.fetchQuery(loadGlobalConfigQuery(input.scope, input.legacy))),
() =>
input.queryClient.fetchQuery(
loadProvidersQuery(input.scope, null, input.serverAPI),
),
() =>
input.queryClient.fetchQuery(
loadPathQuery(input.scope, null, input.serverAPI.location, input.serverSDK, input.protocol),
loadPathQuery(input.scope, null, input.serverAPI.location),
),
() =>
input.queryClient
.fetchQuery(loadProjectsQuery(input.scope, input.serverAPI.project))
.then((data) => input.setGlobalStore("project", data)),
]
].filter(Boolean) as Array<() => Promise<unknown>>
await runAll(slow)
// showErrors({
// errors: errors(),
@@ -273,22 +275,17 @@ export const loadPathQuery = (
scope: ServerScope,
directory: string | null,
api: LocationApi,
sdk: OpencodeClient,
protocol?: Promise<ServerProtocol>,
) =>
queryOptions<Path>({
queryKey: [scope, directory, "path"],
queryFn: async () => {
if ((await protocol) === "v1")
return retry(() => sdk.path.get({ directory: directory ?? undefined }).then((result) => result.data!))
return retry(() => api.get(directory ? { location: { directory } } : undefined)).then((location) => ({
queryFn: () =>
retry(() => api.get(directory ? { location: { directory } } : undefined)).then((location) => ({
state: "",
config: "",
worktree: location.project.directory,
directory: location.directory,
home: "",
}))
},
})),
})
export const loadReferencesQuery = (
@@ -307,7 +304,7 @@ export async function bootstrapDirectory(input: {
directory: string
scope: ServerScope
mcp: boolean
sdk: OpencodeClient
legacy: LegacyCapabilities
api: CatalogApi & {
readonly agent: AgentListApi
readonly command: CommandListApi
@@ -355,35 +352,13 @@ export async function bootstrapDirectory(input: {
input.queryClient
.ensureQueryData(loadAgentsQuery(input.scope, input.directory, input.api.agent))
.then((data) => input.setStore("agent", data)),
() =>
retry(() => input.sdk.config.get().then((x) => input.setStore("config", reconcile(x.data!, { merge: false })))),
() =>
retry(() =>
(async () => {
if ((await input.protocol) !== "v1") return
const x = await input.sdk.session.status()
if (!input.session) {
input.setStore("session_status", x.data!)
return
}
const statuses = x.data ?? {}
input.session.set(
"session_status",
produce((draft) => {
for (const sessionID of Object.keys(draft)) {
if (statuses[sessionID]) continue
if (input.session?.get(sessionID)?.directory === input.directory) delete draft[sessionID]
}
}),
)
for (const [sessionID, status] of Object.entries(statuses)) {
input.session.set("session_status", sessionID, reconcile(status))
}
await Promise.all(
Object.keys(statuses).map((sessionID) => input.session!.resolve(sessionID).catch(() => undefined)),
)
})(),
),
(await input.protocol) === "v1" &&
(() =>
retry(() =>
input.legacy.config
.directory(input.directory)
.then((config) => input.setStore("config", reconcile(config, { merge: false }))),
)),
!seededProject &&
(() =>
retry(() => input.api.project.current({ location: { directory: input.directory } })).then((project) =>
@@ -393,21 +368,12 @@ export async function bootstrapDirectory(input: {
(() =>
input.queryClient
.ensureQueryData(
loadPathQuery(input.scope, input.directory, input.api.location, input.sdk, input.protocol),
loadPathQuery(input.scope, input.directory, input.api.location),
)
.then((data) => {
const next = projectID(data.directory ?? input.directory, input.global.project)
if (next) input.setStore("project", next)
})),
() =>
retry(async () => {
if ((await input.protocol) !== "v1") return
return input.sdk.vcs.get().then((result) => {
const next = { branch: result.data?.branch, default_branch: result.data?.default_branch }
input.setStore("vcs", next)
if (next) input.vcsCache.setStore("value", next)
})
}),
input.mcp &&
(() =>
loadCommands(input.directory, input.api.command).then((commands) =>
@@ -419,12 +385,10 @@ export async function bootstrapDirectory(input: {
),
() =>
retry(() =>
(async () => {
if ((await input.protocol) === "v1") return (await input.sdk.permission.list()).data ?? []
return input.api.permission.request
.list({ location: { directory: input.directory } })
.then((result) => result.data.map(normalizePermissionRequest))
})().then((permissions) => {
input.api.permission.request
.list({ location: { directory: input.directory } })
.then((result) => result.data.map(normalizePermissionRequest))
.then((permissions) => {
const ids = permissions.map((permission) => permission.sessionID)
const grouped = groupBySession(
permissions.filter((permission) => !!permission.id && !!permission.sessionID),
@@ -455,12 +419,10 @@ export async function bootstrapDirectory(input: {
),
() =>
retry(() =>
(async () => {
if ((await input.protocol) === "v1") return (await input.sdk.question.list()).data ?? []
return input.api.question.request
.list({ location: { directory: input.directory } })
.then((result) => result.data)
})().then((questions) => {
input.api.question.request
.list({ location: { directory: input.directory } })
.then((result) => result.data)
.then((questions) => {
const ids = questions.map((question) => question.sessionID)
const grouped = groupBySession(
questions.filter((question) => !!question.id && !!question.sessionID) as QuestionRequest[],
@@ -191,7 +191,10 @@ export function createChildStoreManager(input: {
const pathQuery = useQuery(() => ({ ...input.queryOptions.path(key), enabled: instanceQueriesEnabled() }))
const mcpQuery = useQuery(() => ({ ...input.queryOptions.mcp(key), enabled: mcpEnabled() }))
const mcpResourceQuery = useQuery(() => ({ ...input.queryOptions.mcpResources(key), enabled: mcpEnabled() }))
const lspQuery = useQuery(() => ({ ...input.queryOptions.lsp(key), enabled: instanceQueriesEnabled() }))
const lspQuery = useQuery(() => {
const options = input.queryOptions.lsp(key)
return { ...options, enabled: options.enabled !== false && instanceQueriesEnabled() }
})
const providerQuery = useQuery(() => ({
...input.queryOptions.providers(key),
enabled: instanceQueriesEnabled(),
@@ -1,6 +1,5 @@
import type { SessionApi } from "@opencode-ai/client/promise"
import { normalizeSessionInfo } from "@/utils/session"
import type { OpencodeClient } from "@opencode-ai/sdk/v2/client"
export async function loadRootSessions(input: { api: Pick<SessionApi, "list">; directory: string; limit: number }) {
const result = await input.api.list({
@@ -16,16 +15,6 @@ export async function loadRootSessions(input: { api: Pick<SessionApi, "list">; d
} as const
}
export async function loadRootSessionsV1(input: { client: OpencodeClient; directory: string; limit: number }) {
try {
const result = await input.client.session.list({ directory: input.directory, roots: true, limit: input.limit })
return { data: result.data, limit: input.limit, limited: true } as const
} catch {
const result = await input.client.session.list({ directory: input.directory, roots: true })
return { data: result.data, limit: input.limit, limited: false } as const
}
}
export function estimateRootSessionTotal(input: { count: number; limit: number; limited: boolean }) {
if (!input.limited) return input.count
if (input.count < input.limit) return input.count
+1 -1
View File
@@ -574,7 +574,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
void (async () => {
const sdk = serverSdk()
if ((await sdk.protocol) !== "v1") return
return sdk.client.project
return sdk.legacy.project
.update({ projectID, directory: worktree, icon: { color } })
.then((response) => response.data)
.then((result) => {
-3
View File
@@ -258,9 +258,6 @@ function createServerPermissionState(input: { sdk: ServerSDK; sync: ServerSync }
}
const list = async (directory: string) => {
if ((await input.sdk.protocol) === "v1") {
return (await input.sdk.client.permission.list({ directory })).data ?? []
}
return input.sdk.api.permission.request
.list({ location: { directory } })
.then((result) => result.data.map(normalizePermissionRequest))
+16 -1
View File
@@ -12,7 +12,12 @@ import { createRefCountMap } from "@/utils/refcount"
import { useGlobal } from "./global"
import { ServerScope } from "@/utils/server-scope"
import { detectServerProtocol, type ServerProtocol } from "@/utils/server-protocol"
import { createCompatibleApi, type CompatibleApi } from "@/utils/server-compat"
import {
createCompatibleApi,
createLegacyCapabilities,
type CompatibleApi,
type LegacyCapabilities,
} from "@/utils/server-compat"
import type { OpencodeClient } from "@opencode-ai/sdk/v2/client"
const isAbortError = (error: unknown) =>
@@ -166,6 +171,7 @@ type ServerSDKBase = {
url: string
client: ReturnType<typeof createSdkForServer>
api: CompatibleApi
legacy: LegacyCapabilities
currentApi: ServerApi
event: {
on: ServerEventEmitter["on"]
@@ -329,6 +335,7 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
directory,
})
const api = createCompatibleApi({ protocol, current: currentApi, legacy })
const capabilities = createLegacyCapabilities({ protocol, current: currentApi, legacy })
return {
server,
@@ -338,6 +345,7 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
url: server.http.url,
client: sdk,
api,
legacy: capabilities,
currentApi,
event: {
on: emitter.on.bind(emitter),
@@ -365,6 +373,7 @@ export type DirectorySDK = {
client: OpencodeClient
currentApi: ServerApi
api: CompatibleApi
legacy: LegacyCapabilities
event: ReturnType<typeof createGlobalEmitter<SDKEventMap>>
readonly url: string
createClient: ServerSDKBase["createClient"]
@@ -428,6 +437,12 @@ function createDirSdkContext(directory: string, serverSDK: ServerSDKBase): Direc
legacy: (next) => serverSDK.createClient({ directory: next ?? directory, throwOnError: true }),
directory,
}),
legacy: createLegacyCapabilities({
protocol: serverSDK.protocol,
current: serverSDK.currentApi,
legacy: (next) => serverSDK.createClient({ directory: next ?? directory, throwOnError: true }),
directory,
}),
event: emitter,
get url() {
return serverSDK.url
+11 -5
View File
@@ -10,7 +10,7 @@ import type {
SessionStatus,
Todo,
} from "@/types"
import type { OpencodeClient } from "@opencode-ai/sdk/v2/client"
import type { LegacyCapabilities } from "@/utils/server-compat"
import type { FileDiffInfo } from "@opencode-ai/client/promise"
import { batch } from "solid-js"
import { createStore, produce, reconcile } from "solid-js/store"
@@ -183,10 +183,14 @@ function reconcileFetched<T extends { id: string }>(
return [...result.values()].sort((a, b) => cmp(a.id, b.id))
}
type ServerSessionOptions = { retry?: typeof retry; protocol?: Promise<"v1" | "v2"> }
type ServerSessionOptions = {
retry?: typeof retry
protocol?: Promise<"v1" | "v2">
legacy?: LegacyCapabilities
}
export function createServerSession(
client: OpencodeClient,
client: { session: Pick<LegacyCapabilities["session"], "get" | "messages" | "message"> },
sessionApiOrOptions?: SessionApi | ServerSessionOptions,
messageApi?: MessageApi,
currentOptions?: ServerSessionOptions,
@@ -1389,14 +1393,16 @@ export function createServerSession(
touch(sessionID)
if (data.todo[sessionID] !== undefined && !request?.force) return
if ((await options?.protocol) === "v2") {
// TODO: Restore todos when the V2 API exposes a session todo snapshot.
setData("todo", sessionID, [])
return
}
return runInflight(inflightTodo, sessionID, () => {
const active = generation(sessionID)
return (options?.retry ?? retry)(() => client.session.todo({ sessionID })).then((result) => {
if (!options?.legacy) return Promise.resolve()
return (options.retry ?? retry)(() => options.legacy!.session.todo(sessionID)).then((result) => {
if (generations.get(sessionID) !== active) return
setData("todo", sessionID, reconcile(result.data ?? [], { key: "id" }))
setData("todo", sessionID, reconcile(result, { key: "id" }))
})
})
},
+24 -32
View File
@@ -5,7 +5,6 @@ import type {
ProviderAuthResponse,
SessionStatus,
} from "@/types"
import type { OpencodeClient } from "@opencode-ai/sdk/v2/client"
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"
@@ -60,6 +59,7 @@ import type {
import { toggleMcp } from "./global-sync/mcp"
import { createServerSession, type ServerSession } from "./server-session"
import { usePlatform } from "./platform"
import type { LegacyCapabilities } from "@/utils/server-compat"
type GlobalStore = {
ready: boolean
@@ -132,10 +132,11 @@ export const loadMcpResourcesQuery = (
placeholderData: {},
})
export const loadLspQuery = (scope: ServerScope, directory: string, sdk: OpencodeClient) =>
export const loadLspQuery = (scope: ServerScope, directory: string, legacy: LegacyCapabilities, enabled = true) =>
queryOptions({
queryKey: [scope, directory, "lsp"] as const,
queryFn: () => sdk.lsp.status().then((r) => r.data ?? []),
queryFn: () => legacy.lsp.status(directory),
enabled,
})
export const loadActiveSessionsQuery = (
@@ -166,22 +167,20 @@ export function seedActiveSessionStatuses(
function makeQueryOptionsApi(
scope: ServerScope,
serverSDK: () => OpencodeClient,
serverAPI: ServerApi,
sdkFor: (dir: PathKey) => OpencodeClient,
protocol: Promise<"v1" | "v2">,
protocolKind: Accessor<"v1" | "v2" | undefined>,
legacy: LegacyCapabilities,
) {
return {
globalConfig: () => loadGlobalConfigQuery(scope, serverSDK()),
globalConfig: () => loadGlobalConfigQuery(scope, legacy, protocolKind() === "v1"),
projects: () => loadProjectsQuery(scope, serverAPI.project),
providers: (directory: PathKey | null) => loadProvidersQuery(scope, directory, serverAPI),
path: (directory: PathKey | null) =>
loadPathQuery(scope, directory, serverAPI.location, directory ? sdkFor(directory) : serverSDK(), protocol),
path: (directory: PathKey | null) => loadPathQuery(scope, directory, serverAPI.location),
agents: (directory: PathKey) => loadAgentsQuery(scope, directory, serverAPI.agent),
references: (directory: PathKey) => loadReferencesQuery(scope, directory, serverAPI.reference),
mcp: (directory: PathKey) => loadMcpQuery(scope, directory, serverAPI.mcp),
mcpResources: (directory: PathKey) => loadMcpResourcesQuery(scope, directory, serverAPI.mcp),
lsp: (directory: PathKey) => loadLspQuery(scope, directory, sdkFor(directory)),
lsp: (directory: PathKey) => loadLspQuery(scope, directory, legacy, protocolKind() === "v1"),
sessions: (directory: PathKey) => ({ queryKey: [scope, directory, "loadSessions"] as const }),
}
}
@@ -193,30 +192,24 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
const owner = getOwner()
if (!owner) throw new Error("ServerSync must be created within owner")
const sdkCache = new Map<string, OpencodeClient>()
const booting = new Map<string, Promise<void>>()
const sessionLoads = new Map<string, Promise<void>>()
const sessionMeta = new Map<string, { limit: number }>()
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 session = createServerSession(serverSDK.client, serverSDK.currentApi.session, serverSDK.currentApi.message)
const session = createServerSession(
{ session: serverSDK.legacy.session },
serverSDK.currentApi.session,
serverSDK.currentApi.message,
{
protocol: serverSDK.protocol,
legacy: serverSDK.legacy,
},
)
const queryOptionsApi = makeQueryOptionsApi(
serverSDK.scope,
() => serverSDK.client,
serverSDK.currentApi,
sdkFor,
serverSDK.protocol,
serverSDK.protocolKind,
serverSDK.legacy,
)
const [configQuery, providerQuery, pathQuery] = useQueries(() => ({
@@ -293,7 +286,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
queryKey: [serverSDK.scope, "bootstrap"],
queryFn: async () => {
await bootstrapGlobal({
serverSDK: serverSDK.client,
legacy: serverSDK.legacy,
serverAPI: serverSDK.currentApi,
protocol: serverSDK.protocol,
scope: serverSDK.scope,
@@ -349,7 +342,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,
@@ -446,7 +438,6 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
const child = children.ensureChild(directory)
const cache = children.vcsCache.get(key)
if (!cache) return
const sdk = sdkFor(directory)
await bootstrapDirectory({
directory,
scope: serverSDK.scope,
@@ -457,7 +448,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
project: globalStore.project,
provider: globalStore.provider,
},
sdk,
legacy: serverSDK.legacy,
api: serverSDK.currentApi,
store: child[0],
setStore: child[1],
@@ -577,6 +568,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
permission: session.data.permission,
vcsCache: children.vcsCache.get(key),
loadLsp: () => {
if (serverSDK.protocolKind() !== "v1") return
if (!children.active(key)) return
void queryClient.fetchQuery(queryOptionsApi.lsp(key))
},
@@ -625,7 +617,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
}
const updateConfigMutation = useMutation(() => ({
mutationFn: (config: Config) => serverSDK.client.global.config.update({ config }),
mutationFn: (config: Config) => serverSDK.legacy.config.update(config),
onSuccess: () => {
bootstrap.refetch()
// Invalidate all provider queries so newly configured custom providers
@@ -109,6 +109,7 @@ export function createHomeProjectsController(home: HomeController) {
home.server.context(conn).projects.move(worktree, index)
},
canReveal: canRevealProject,
canEdit: (conn: ServerConnection.Any) => home.server.context(conn).sdk.protocolKind() === "v1",
reveal: (conn: ServerConnection.Any, project: LocalProject) => {
if (!platform.openPath || !canRevealProject(conn)) return
platform.openPath(project.worktree).catch((cause: unknown) =>
@@ -40,6 +40,7 @@ export type HomeProjectsViewProps = {
canDefaultServer: Accessor<boolean>
defaultServerKey: Accessor<ServerConnection.Key | null | undefined>
canRevealProject: (server: ServerConnection.Any) => boolean
canEditProject: (server: ServerConnection.Any) => boolean
unseenCount: (server: ServerConnection.Any, project: LocalProject) => number
onWheel: (event: WheelEvent) => void
onChooseProject: (server: ServerConnection.Any) => void
@@ -548,9 +549,11 @@ function HomeProjectRow(
<MenuV2.Item onSelect={() => props.onOpenProjectNewSession(props.server, props.project.worktree)}>
{props.language.t("command.session.new")}
</MenuV2.Item>
<MenuV2.Item onSelect={() => props.onEditProject(props.server, props.project)}>
{props.language.t("dialog.project.edit.title")}
</MenuV2.Item>
<Show when={props.canEditProject(props.server)}>
<MenuV2.Item onSelect={() => props.onEditProject(props.server, props.project)}>
{props.language.t("dialog.project.edit.title")}
</MenuV2.Item>
</Show>
<Show when={props.canRevealProject(props.server)}>
<MenuV2.Item onSelect={() => props.onRevealProject(props.server, props.project)}>
{props.language.t(
@@ -17,6 +17,7 @@ export function HomeProjects(props: { projects: HomeProjectsController; scroll:
canDefaultServer={props.projects.server.canDefault}
defaultServerKey={props.projects.server.defaultKey}
canRevealProject={props.projects.project.canReveal}
canEditProject={props.projects.project.canEdit}
unseenCount={props.projects.project.unseenCount}
onWheel={props.scroll.viewport.containWheel}
onChooseProject={props.projects.project.choose}
@@ -1,4 +1,4 @@
import type { Session } from "@/types"
import type { Session, V2SessionListResponse } from "@/types"
import { preloadMarkdown } from "@opencode-ai/session-ui/markdown-cache"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useMarked } from "@opencode-ai/ui/context/marked"
@@ -69,7 +69,10 @@ export function createHomeSessionsController(home: HomeController) {
const cache = homeSessions()
const eventSequence = cache.eventSequence()
const index = await loadHomeSessionIndex(
(input, options) => ctx.sdk.client.v2.session.list(input, options),
(input, options) =>
ctx.sdk.currentApi.session.list(input, options).then((data) => ({
data: data as unknown as V2SessionListResponse,
})),
eventSequence,
signal,
)
@@ -179,6 +182,7 @@ export function createHomeSessionsController(home: HomeController) {
showProjectName: () => !home.project.selected(),
server: () => home.selection.value().server,
canCreate: () => !!home.project.newSession(),
canArchive: () => home.server.focusedContext()?.sdk.protocolKind() === "v1",
create: home.project.openNewSession,
open: (session: Session, options?: OpenSessionOptions) => {
const directoryKey = pathKey(session.directory)
@@ -211,16 +215,10 @@ export function createHomeSessionsController(home: HomeController) {
const ctx = home.server.focusedContext()
if (!conn || !ctx) return
const [, setStore] = ctx.sync.child(session.directory)
if ((await ctx.sdk.protocol) !== "v1") return
await archiveHomeSession({
server: ServerConnection.key(conn),
session,
archive: (sessionID) =>
ctx.sdk.client.session.update({
sessionID,
directory: session.directory,
time: { archived: Date.now() },
}),
archive: (sessionID) => ctx.sdk.legacy.session.archive(sessionID, session.directory),
remove: () =>
setStore(
produce((draft) => {
@@ -43,6 +43,7 @@ export type HomeSessionsViewProps = {
showProjectName: Accessor<boolean>
server: Accessor<ServerConnection.Key>
canCreateSession: Accessor<boolean>
canArchiveSession: Accessor<boolean>
searchValue: Accessor<string>
searchPlaceholder: Accessor<string>
searchOpen: Accessor<boolean>
@@ -460,7 +461,8 @@ function HomeSessionRow(props: HomeSessionsViewProps & { record: HomeSessionReco
group-hover/session:opacity-100 focus-within:opacity-100
`}
>
<TooltipV2 class="flex shrink-0 items-center" placement="bottom" value={props.language.t("common.archive")}>
<Show when={props.canArchiveSession()}>
<TooltipV2 class="flex shrink-0 items-center" placement="bottom" value={props.language.t("common.archive")}>
<IconButtonV2
data-action="home-session-archive"
variant="ghost-muted"
@@ -473,7 +475,8 @@ function HomeSessionRow(props: HomeSessionsViewProps & { record: HomeSessionReco
void props.onArchiveSession(props.record.session)
}}
/>
</TooltipV2>
</TooltipV2>
</Show>
</div>
</Show>
</div>
@@ -16,6 +16,7 @@ export function HomeSessions(props: {
showProjectName={props.sessions.session.showProjectName}
server={props.sessions.session.server}
canCreateSession={props.sessions.session.canCreate}
canArchiveSession={props.sessions.session.canArchive}
searchValue={props.search.query.value}
searchPlaceholder={props.search.query.placeholder}
searchOpen={props.search.query.open}
+27 -37
View File
@@ -872,17 +872,12 @@ export default function LegacyLayout(props: ParentProps) {
}
async function archiveSession(session: Session) {
if ((await serverSDK().protocol) !== "v1") return
const [store, setStore] = serverSync().child(session.directory)
const sessions = store.session ?? []
const index = sessions.findIndex((s) => s.id === session.id)
const nextSession = sessions[index + 1] ?? sessions[index - 1]
await serverSDK().client.session.update({
sessionID: session.id,
directory: session.directory,
time: { archived: Date.now() },
})
await serverSDK().legacy.session.archive(session.id, session.directory)
setStore(
produce((draft) => {
const match = Binary.search(draft.session, session.id, (s) => s.id)
@@ -980,6 +975,7 @@ export default function LegacyLayout(props: ParentProps) {
title: language.t("command.session.archive"),
category: language.t("command.category.session"),
keybind: "mod+shift+backspace",
hidden: serverSDK().protocolKind() !== "v1",
disabled: !params.dir || !params.id,
onSelect: () => {
const session = currentSessions().find((s) => s.id === params.id)
@@ -1304,13 +1300,10 @@ export default function LegacyLayout(props: ParentProps) {
const name = next === getFilename(project.worktree) ? "" : next
if (project.id && project.id !== "global") {
const sdk = serverSDK()
if ((await sdk.protocol) !== "v1") return
const result = await sdk.client.project
const result = await serverSDK().legacy.project
.update({ projectID: project.id, directory: project.worktree, name })
.then((response) => response.data)
if (!result) return
// const result = await serverSDK().api.project.update({ projectID: project.id, name })
serverSync().set("project", (items) =>
items.map((item) => (item.id === result.id ? normalizeProjectInfo(result) : item)),
)
@@ -1477,12 +1470,8 @@ export default function LegacyLayout(props: ParentProps) {
platform,
serverSDK().scope,
)
await serverSDK()
.client.instance.dispose({ directory })
.catch(() => undefined)
const result = await serverSDK()
.client.worktree.reset({ directory: root, worktreeResetInput: { directory } })
.legacy.workspace.reset(root, directory)
.then((x) => x.data)
.catch((err) => {
showToast({
@@ -1504,11 +1493,7 @@ 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: Date.now() },
})
.legacy.session.archive(session.id, session.directory)
.catch(() => undefined),
),
)
@@ -1895,6 +1880,8 @@ export default function LegacyLayout(props: ParentProps) {
clearHoverProjectSoon,
prefetchSession,
archiveSession,
canArchive: () => serverSDK().protocolKind() === "v1",
canResetWorkspace: () => serverSDK().protocolKind() === "v1",
workspaceName,
renameWorkspace,
editorOpen,
@@ -1931,6 +1918,7 @@ export default function LegacyLayout(props: ParentProps) {
openSidebar: () => layout.sidebar.open(),
closeProject,
showEditProjectDialog: (proj) => showEditProjectDialog(server.current!, proj),
canEditProject: () => serverSDK().protocolKind() === "v1",
toggleProjectWorkspaces,
workspacesEnabled: (project) => project.vcs === "git" && layout.sidebar.workspaces(project.worktree)(),
workspaceIds,
@@ -1941,6 +1929,7 @@ export default function LegacyLayout(props: ParentProps) {
clearHoverProjectSoon,
prefetchSession,
archiveSession,
canArchive: () => serverSDK().protocolKind() === "v1",
},
}
@@ -2031,16 +2020,19 @@ export default function LegacyLayout(props: ParentProps) {
<div class="shrink-0 pl-1 py-1">
<div class="group/project flex items-start justify-between gap-2 py-2 pl-2 pr-0">
<div class="flex flex-col min-w-0">
<InlineEditor
id={`project:${projectId()}`}
value={projectName}
onSave={(next) => {
void renameProject(project, next)
}}
class="text-14-medium text-text-strong truncate"
displayClass="text-14-medium text-text-strong truncate"
stopPropagation
/>
<Show
when={serverSDK().protocolKind() === "v1" || !project.id || project.id === "global"}
fallback={<span class="text-14-medium text-text-strong truncate">{projectName()}</span>}
>
<InlineEditor
id={`project:${projectId()}`}
value={projectName}
onSave={(next) => void renameProject(project, next)}
class="text-14-medium text-text-strong truncate"
displayClass="text-14-medium text-text-strong truncate"
stopPropagation
/>
</Show>
<Tooltip
placement="bottom"
@@ -2075,13 +2067,11 @@ export default function LegacyLayout(props: ParentProps) {
/>
<DropdownMenu.Portal>
<DropdownMenu.Content class="mt-1">
<DropdownMenu.Item
onSelect={() => {
showEditProjectDialog(server.current!, project)
}}
>
<DropdownMenu.ItemLabel>{language.t("common.edit")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
<Show when={serverSDK().protocolKind() === "v1"}>
<DropdownMenu.Item onSelect={() => showEditProjectDialog(server.current!, project)}>
<DropdownMenu.ItemLabel>{language.t("common.edit")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</Show>
<DropdownMenu.Item
data-action="project-workspaces-toggle"
data-project={slug()}
@@ -87,6 +87,7 @@ export type SessionItemProps = {
clearHoverProjectSoon: () => void
prefetchSession: (session: Session, priority?: "high" | "low") => void
archiveSession: (session: Session) => Promise<void>
canArchive: Accessor<boolean>
}
const SessionRow = (props: {
@@ -241,7 +242,7 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => {
</Show>
</div>
<Show when={!props.level}>
<Show when={!props.level && props.canArchive()}>
<div
class="shrink-0 overflow-hidden transition-[width,opacity]"
classList={{
@@ -27,6 +27,7 @@ export type ProjectSidebarContext = {
openSidebar: () => void
closeProject: (directory: string) => void
showEditProjectDialog: (project: LocalProject) => void
canEditProject: Accessor<boolean>
toggleProjectWorkspaces: (project: LocalProject) => void
workspacesEnabled: (project: LocalProject) => boolean
workspaceIds: (project: LocalProject) => string[]
@@ -65,6 +66,7 @@ const ProjectTile = (props: {
onProjectFocus: (worktree: string) => void
navigateToProject: (directory: string) => void
showEditProjectDialog: (project: LocalProject) => void
canEditProject: Accessor<boolean>
toggleProjectWorkspaces: (project: LocalProject) => void
workspacesEnabled: (project: LocalProject) => boolean
closeProject: (directory: string) => void
@@ -148,9 +150,11 @@ const ProjectTile = (props: {
</ContextMenu.Trigger>
<ContextMenu.Portal>
<ContextMenu.Content>
<ContextMenu.Item onSelect={() => props.showEditProjectDialog(props.project)}>
<ContextMenu.ItemLabel>{props.language.t("common.edit")}</ContextMenu.ItemLabel>
</ContextMenu.Item>
<Show when={props.canEditProject()}>
<ContextMenu.Item onSelect={() => props.showEditProjectDialog(props.project)}>
<ContextMenu.ItemLabel>{props.language.t("common.edit")}</ContextMenu.ItemLabel>
</ContextMenu.Item>
</Show>
<ContextMenu.Item
data-action="project-workspaces-toggle"
data-project={base64Encode(props.project.worktree)}
@@ -331,6 +335,7 @@ export const SortableProject = (props: {
onProjectFocus={props.ctx.onProjectFocus}
navigateToProject={props.ctx.navigateToProject}
showEditProjectDialog={props.ctx.showEditProjectDialog}
canEditProject={props.ctx.canEditProject}
toggleProjectWorkspaces={props.ctx.toggleProjectWorkspaces}
workspacesEnabled={props.ctx.workspacesEnabled}
closeProject={props.ctx.closeProject}
@@ -42,6 +42,8 @@ export type WorkspaceSidebarContext = {
clearHoverProjectSoon: () => void
prefetchSession: (session: Session, priority?: "high" | "low") => void
archiveSession: (session: Session) => Promise<void>
canArchive: Accessor<boolean>
canResetWorkspace: Accessor<boolean>
workspaceName: (directory: string, projectId?: string, branch?: string) => string | undefined
renameWorkspace: (directory: string, next: string, projectId?: string, branch?: string) => void
editorOpen: (id: string) => boolean
@@ -151,6 +153,7 @@ const WorkspaceActions = (props: {
workspaceValue: Accessor<string>
openEditor: WorkspaceSidebarContext["openEditor"]
showResetWorkspaceDialog: WorkspaceSidebarContext["showResetWorkspaceDialog"]
canResetWorkspace: WorkspaceSidebarContext["canResetWorkspace"]
showDeleteWorkspaceDialog: WorkspaceSidebarContext["showDeleteWorkspaceDialog"]
root: string
clearHoverProjectSoon: WorkspaceSidebarContext["clearHoverProjectSoon"]
@@ -199,12 +202,14 @@ const WorkspaceActions = (props: {
>
<DropdownMenu.ItemLabel>{props.language.t("common.rename")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
<DropdownMenu.Item
disabled={props.local() || props.busy()}
onSelect={() => props.showResetWorkspaceDialog(props.root, props.directory)}
>
<DropdownMenu.ItemLabel>{props.language.t("common.reset")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
<Show when={props.canResetWorkspace()}>
<DropdownMenu.Item
disabled={props.local() || props.busy()}
onSelect={() => props.showResetWorkspaceDialog(props.root, props.directory)}
>
<DropdownMenu.ItemLabel>{props.language.t("common.reset")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</Show>
<DropdownMenu.Item
disabled={props.local() || props.busy()}
onSelect={() => props.showDeleteWorkspaceDialog(props.root, props.directory)}
@@ -272,6 +277,7 @@ const WorkspaceSessionList = (props: {
clearHoverProjectSoon={props.ctx.clearHoverProjectSoon}
prefetchSession={props.ctx.prefetchSession}
archiveSession={props.ctx.archiveSession}
canArchive={props.ctx.canArchive}
/>
)}
</For>
@@ -416,6 +422,7 @@ export const SortableWorkspace = (props: {
workspaceValue={workspaceValue}
openEditor={props.ctx.openEditor}
showResetWorkspaceDialog={props.ctx.showResetWorkspaceDialog}
canResetWorkspace={props.ctx.canResetWorkspace}
showDeleteWorkspaceDialog={props.ctx.showDeleteWorkspaceDialog}
root={props.project.worktree}
clearHoverProjectSoon={props.ctx.clearHoverProjectSoon}
+15 -9
View File
@@ -52,7 +52,7 @@ import { useNotification } from "@/context/notification"
import { PromptProvider, usePrompt } from "@/context/prompt"
import { usePlatform } from "@/context/platform"
import { SDKProvider, useSDK } from "@/context/sdk"
import { useServerSDK } from "@/context/server-sdk"
import { useServerProtocol, useServerSDK } from "@/context/server-sdk"
import { ServerConnection, serverName, useServer } from "@/context/server"
import { useSettings } from "@/context/settings"
import { useSync } from "@/context/sync"
@@ -361,6 +361,7 @@ export default function Page() {
const language = useLanguage()
const sdk = useSDK()
const serverSDK = useServerSDK()
const protocol = useServerProtocol()
const settings = useSettings()
const platform = usePlatform()
const prompt = usePrompt()
@@ -847,7 +848,7 @@ export default function Page() {
}
const gitMutation = useMutation(() => ({
mutationFn: () => sdk().client.project.initGit(),
mutationFn: () => sdk().legacy.project.initGit(sdk().directory),
onSuccess: (x) => {
if (!x.data) return
upsert(x.data)
@@ -896,17 +897,19 @@ export default function Page() {
() => {
const id = params.id
return [
protocol(),
sdk().directory,
id,
id ? (sync().data.session_status[id]?.type ?? "idle") : "idle",
id ? composer.blocked() : false,
] as const
},
([dir, id, status, blocked]) => {
([serverProtocol, dir, id, status, blocked]) => {
if (todoFrame !== undefined) cancelAnimationFrame(todoFrame)
if (todoTimer !== undefined) window.clearTimeout(todoTimer)
todoFrame = undefined
todoTimer = undefined
if (serverProtocol !== "v1") return
if (!id) return
if (status === "idle" && !blocked) return
const cached = untrack(() => sync().data.todo[id] !== undefined)
@@ -1217,11 +1220,13 @@ export default function Page() {
{language.t("session.review.noVcs.createGit.description")}
</div>
</div>
<Button size="large" disabled={gitMutation.isPending} onClick={initGit}>
{gitMutation.isPending
? language.t("session.review.noVcs.createGit.actionLoading")
: language.t("session.review.noVcs.createGit.action")}
</Button>
<Show when={protocol() === "v1"}>
<Button size="large" disabled={gitMutation.isPending} onClick={initGit}>
{gitMutation.isPending
? language.t("session.review.noVcs.createGit.actionLoading")
: language.t("session.review.noVcs.createGit.action")}
</Button>
</Show>
</div>
)
@@ -1254,7 +1259,8 @@ export default function Page() {
return <div class="px-6 py-4 text-text-weak">{language.t("session.review.loadingChanges")}</div>
}
if (reviewMode() === "turn" && nogit()) {
return <SessionReviewEmptyNoGitV2 pending={gitMutation.isPending} onInitGit={initGit} />
if (protocol() === "v1") return <SessionReviewEmptyNoGitV2 pending={gitMutation.isPending} onInitGit={initGit} />
return empty(language.t("session.review.noVcs.createGit.description"))
}
return <SessionReviewEmptyChangesV2 />
}
@@ -62,7 +62,7 @@ import { SessionContextUsage } from "@/components/session-context-usage"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useLanguage } from "@/context/language"
import { useSessionKey } from "@/pages/session/session-layout"
import { useServerSDK } from "@/context/server-sdk"
import { useServerProtocol, useServerSDK } from "@/context/server-sdk"
import { usePlatform } from "@/context/platform"
import { useSettings } from "@/context/settings"
import { useTabs } from "@/context/tabs"
@@ -302,7 +302,8 @@ export function MessageTimeline(props: {
return displayLabel(session)
})
const shareUrl = createMemo(() => info()?.share?.url)
const shareEnabled = createMemo(() => sync().data.config.share !== "disabled")
const protocol = useServerProtocol()
const shareEnabled = createMemo(() => protocol() === "v1" && sync().data.config.share !== "disabled")
const parentID = createMemo(() => info()?.parentID)
const parent = createMemo(() => {
const id = parentID()
@@ -665,14 +666,14 @@ export function MessageTimeline(props: {
}
const shareMutation = useMutation(() => ({
mutationFn: (id: string) => serverSDK().client.session.share({ sessionID: id }),
mutationFn: (id: string) => serverSDK().legacy.session.share(id),
onError: (err) => {
console.error("Failed to share session", err)
},
}))
const unshareMutation = useMutation(() => ({
mutationFn: (id: string) => serverSDK().client.session.unshare({ sessionID: id }),
mutationFn: (id: string) => serverSDK().legacy.session.unshare(id),
onError: (err) => {
console.error("Failed to unshare session", err)
},
@@ -818,14 +819,12 @@ export function MessageTimeline(props: {
const archiveSession = async (sessionID: string) => {
const session = sync().session.get(sessionID)
if (!session) return
if ((await sdk().protocol) !== "v1") return
const sessions = sync().data.session ?? []
const index = sessions.findIndex((s) => s.id === sessionID)
const nextSession = index === -1 ? undefined : (sessions[index + 1] ?? sessions[index - 1])
await sdk()
.client.session.update({ sessionID, directory: sdk().directory, time: { archived: Date.now() } })
.legacy.session.archive(sessionID, sdk().directory)
.then(() => {
sync().set(
produce((draft) => {
@@ -1574,9 +1573,11 @@ export function MessageTimeline(props: {
</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</Show>
<DropdownMenu.Item onSelect={() => void archiveSession(id)}>
<DropdownMenu.ItemLabel>{language.t("common.archive")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
<Show when={protocol() === "v1"}>
<DropdownMenu.Item onSelect={() => void archiveSession(id)}>
<DropdownMenu.ItemLabel>{language.t("common.archive")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</Show>
<DropdownMenu.Separator />
<DropdownMenu.Item
onSelect={() => dialog.show(() => <DialogDeleteSession sessionID={id} />)}
@@ -1645,9 +1646,11 @@ export function MessageTimeline(props: {
{language.t("session.share.action.share")}...
</MenuV2.Item>
</Show>
<MenuV2.Item onSelect={() => void archiveSession(id)}>
{language.t("common.archive")}
</MenuV2.Item>
<Show when={protocol() === "v1"}>
<MenuV2.Item onSelect={() => void archiveSession(id)}>
{language.t("common.archive")}
</MenuV2.Item>
</Show>
<MenuV2.Separator />
<MenuV2.Item onSelect={() => dialog.show(() => <DialogDeleteSession sessionID={id} />)}>
{language.t("common.delete")}...
@@ -19,6 +19,7 @@ import type { UserMessage } from "@/types"
import { useSessionLayout } from "@/pages/session/session-layout"
import { createSessionOwnership } from "./session-ownership"
import { useLocal } from "@/context/local"
import { useServerProtocol } from "@/context/server-sdk"
export type SessionCommandContext = {
navigateMessageByOffset: (offset: number) => void
@@ -43,6 +44,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
const permission = usePermission()
const prompt = usePrompt()
const sdk = useSDK()
const protocol = useServerProtocol()
const settings = useSettings()
const sync = useSync()
const terminal = useTerminal()
@@ -194,7 +196,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
}
const url = await sdk()
.client.session.share({ sessionID })
.legacy.session.share(sessionID)
.then((res) => res.data?.share?.url)
.catch(() => undefined)
if (!url) {
@@ -214,7 +216,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
if (!sessionID) return
await sdk()
.client.session.unshare({ sessionID })
.legacy.session.unshare(sessionID)
.then(() =>
showToast({
title: language.t("toast.session.unshare.success.title"),
@@ -377,6 +379,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
}
const shareCmds = () => {
if (protocol() !== "v1") return []
if (sync().data.config.share === "disabled") return []
return [
sessionCommand({
+21 -1
View File
@@ -4,7 +4,10 @@ import { createCompatibleApi } from "./server-compat"
function setup(
protocol: "v1" | "v2" | Promise<"v1" | "v2">,
responses?: { vcs?: { branch: string; default_branch: string } },
responses?: {
vcs?: { branch: string; default_branch: string }
question?: { id: string; sessionID: string; questions: never[]; tool?: { messageID: string; callID: string } }[]
},
) {
const requests: Request[] = []
const fetcher = Object.assign(
@@ -36,6 +39,8 @@ function setup(
}
if (request.method === "GET" && new URL(request.url).pathname === "/vcs")
return Response.json(responses?.vcs ?? {})
if (request.method === "GET" && new URL(request.url).pathname === "/question")
return Response.json(responses?.question ?? [])
if (request.method === "GET") return Response.json([])
return new Response(undefined, { status: 204 })
},
@@ -163,6 +168,21 @@ describe("createCompatibleApi", () => {
expect(new URL(requests[0]!.url).pathname).toBe("/experimental/session")
})
test("translates V1 question tool call IDs", async () => {
const { api } = setup("v1", {
question: [
{
id: "que_1",
sessionID: "ses_1",
questions: [],
tool: { messageID: "msg_1", callID: "call_1" },
},
],
})
expect((await api.question.request.list()).data[0]?.tool).toEqual({ messageID: "msg_1", id: "call_1" })
})
/*
test("projects the V1 default branch", async () => {
const { api } = setup("v1", { vcs: { branch: "feature", default_branch: "dev" } })
+140 -34
View File
@@ -3,7 +3,6 @@ import type { ServerProtocol } from "./server-protocol"
import type { AgentPartInput, FilePartInput, Session, TextPartInput } from "@/types"
import type { OpencodeClient } from "@opencode-ai/sdk/v2/client"
import type {
Project,
ProjectCurrent,
SessionApi,
SessionCommandInput,
@@ -28,7 +27,6 @@ type CompatibleSessionApi = Omit<
shell: (input: SessionShellInput & LegacyPrompt) => Promise<SessionShellOutput>
compact: (input: SessionCompactInput & { model?: LegacyPrompt["model"] }) => Promise<SessionCompactOutput>
rename: (input: Parameters<SessionApi["rename"]>[0] & LegacyLocation) => ReturnType<SessionApi["rename"]>
// archive: (input: Parameters<SessionApi["archive"]>[0] & LegacyLocation) => ReturnType<SessionApi["archive"]>
remove: (input: Parameters<SessionApi["remove"]>[0] & LegacyLocation) => ReturnType<SessionApi["remove"]>
}
type CompatiblePermissionApi = Omit<ServerApi["permission"], "reply"> & {
@@ -54,6 +52,99 @@ type CompatibleInput = {
directory?: string
}
export function createLegacyCapabilities(input: CompatibleInput) {
const directory = (value?: string) => value ?? input.directory
const client = (value?: string) => input.legacy(directory(value))
const requireV1 = async () => {
if ((await input.protocol) !== "v1") throw new Error("This capability is unavailable on V2 servers")
}
return {
config: {
global: async () => {
await requireV1()
return (await client().global.config.get()).data ?? {}
},
directory: async (value?: string) => {
await requireV1()
return (await client(value).config.get()).data ?? {}
},
update: async (config: NonNullable<Parameters<LegacyClient["global"]["config"]["update"]>[0]>["config"]) => {
await requireV1()
return client().global.config.update({ config })
},
},
auth: {
set: async (value: Parameters<LegacyClient["auth"]["set"]>[0]) => {
await requireV1()
return client().auth.set(value)
},
remove: async (value: Parameters<LegacyClient["auth"]["remove"]>[0]) => {
await requireV1()
return client().auth.remove(value)
},
},
session: {
get: (value: Parameters<LegacyClient["session"]["get"]>[0]) => client().session.get(value),
messages: (value: Parameters<LegacyClient["session"]["messages"]>[0]) => client().session.messages(value),
message: (value: Parameters<LegacyClient["session"]["message"]>[0]) => client().session.message(value),
share: async (sessionID: string) => {
await requireV1()
return client().session.share({ sessionID })
},
unshare: async (sessionID: string) => {
await requireV1()
return client().session.unshare({ sessionID })
},
archive: async (sessionID: string, value?: string) => {
await requireV1()
return client(value).session.update({ sessionID, time: { archived: Date.now() } })
},
todo: async (sessionID: string, value?: string) => {
await requireV1()
return (await client(value).session.todo({ sessionID })).data ?? []
},
},
project: {
update: async (value: Parameters<LegacyClient["project"]["update"]>[0]) => {
await requireV1()
return client(value.directory).project.update(value)
},
initGit: async (value?: string) => {
await requireV1()
return client(value).project.initGit()
},
},
workspace: {
reset: async (root: string, value: string) => {
await requireV1()
await client(value).instance.dispose().catch(() => undefined)
return client(root).worktree.reset({ worktreeResetInput: { directory: value } })
},
},
pty: {
shells: async () => {
await requireV1()
return (await client().pty.shells()).data ?? []
},
},
path: {
get: async (value?: string) => {
await requireV1()
return (await client(value).path.get()).data
},
},
lsp: {
status: async (value: string) => {
await requireV1()
return (await client(value).lsp.status()).data ?? []
},
},
}
}
export type LegacyCapabilities = ReturnType<typeof createLegacyCapabilities>
function mime(uri: string) {
const match = /^data:([^;,]+)/.exec(uri)
return match?.[1] ?? "application/octet-stream"
@@ -184,9 +275,6 @@ function createV1Api(input: CompatibleInput): CompatibleApi {
async rename(value: Parameters<ServerApi["session"]["rename"]>[0] & LegacyLocation) {
await legacy(value).session.update({ sessionID: value.sessionID, title: value.title })
},
// async archive(value: Parameters<ServerApi["session"]["archive"]>[0] & LegacyLocation) {
// await legacy(value).session.update({ sessionID: value.sessionID, time: { archived: Date.now() } })
// },
async remove(value: Parameters<ServerApi["session"]["remove"]>[0] & LegacyLocation) {
await legacy(value).session.delete(value)
},
@@ -313,34 +401,28 @@ function createV1Api(input: CompatibleInput): CompatibleApi {
canonical: result.data.worktree,
} satisfies ProjectCurrent
},
// async update(value: Parameters<ServerApi["project"]["update"]>[0]) {
// const project = (await legacy().project.list()).data?.find((item) => item.id === value.projectID)
// const result = await legacy({ directory: project?.worktree }).project.update({
// ...value,
// directory: project?.worktree,
// })
// if (!result.data) throw new Error(`Project not found: ${value.projectID}`)
// return result.data as Project
// },
async directories(value: Parameters<ServerApi["project"]["directories"]>[0]) {
const result = await legacy(value.location).worktree.list()
return (result.data ?? []).map((item) => ({ directory: item }))
},
},
// path: {
// ...input.current.path,
// async get(value?: Parameters<ServerApi["path"]["get"]>[0]) {
// const result = await legacy(value?.location).path.get()
// if (!result.data) throw new Error("Path unavailable")
// return result.data
// },
// },
location: {
...input.current.location,
async get(value?: Parameters<ServerApi["location"]["get"]>[0]) {
const result = await legacy(value?.location).path.get()
if (!result.data) throw new Error("Location unavailable")
return {
directory: result.data.directory,
project: {
id: "",
directory: result.data.worktree,
canonical: result.data.worktree,
},
}
},
},
vcs: {
...input.current.vcs,
// async get(value?: Parameters<ServerApi["vcs"]["get"]>[0]) {
// const result = await legacy(value?.location).vcs.get()
// return located({ branch: result.data?.branch, defaultBranch: result.data?.default_branch }, value?.location)
// },
async status(value?: Parameters<ServerApi["vcs"]["status"]>[0]) {
const result = await legacy(value?.location).vcs.status()
return located(result.data ?? [], value?.location)
@@ -456,9 +538,6 @@ function createV1Api(input: CompatibleInput): CompatibleApi {
},
pty: {
...input.current.pty,
// async shells(value?: Parameters<ServerApi["pty"]["shells"]>[0]) {
// return located((await legacy(value?.location).pty.shells()).data ?? [], value?.location)
// },
async list(value?: Parameters<ServerApi["pty"]["list"]>[0]) {
return located((await legacy(value?.location).pty.list()).data ?? [], value?.location)
},
@@ -490,14 +569,29 @@ function createV1Api(input: CompatibleInput): CompatibleApi {
async remove(value: Parameters<ServerApi["pty"]["remove"]>[0]) {
await legacy(value.location).pty.remove({ ptyID: value.ptyID })
},
// async connectToken(value: Parameters<ServerApi["pty"]["connectToken"]>[0]) {
// const result = await legacy(value.location).pty.connectToken({ ptyID: value.ptyID })
// if (!result.data) throw new Error(`Failed to connect terminal: ${value.ptyID}`)
// return located(result.data, value.location)
// },
},
permission: {
...input.current.permission,
request: {
...input.current.permission.request,
async list(value?: Parameters<ServerApi["permission"]["request"]["list"]>[0]) {
const result = await legacy(value?.location).permission.list()
return located(
(result.data ?? []).map((item) => ({
id: item.id,
sessionID: item.sessionID,
action: item.permission,
resources: item.patterns,
metadata: item.metadata,
save: item.always,
source: item.tool
? { type: "tool" as const, messageID: item.tool.messageID, callID: item.tool.callID }
: undefined,
})),
value?.location,
) as Awaited<ReturnType<ServerApi["permission"]["request"]["list"]>>
},
},
async reply(value: Parameters<ServerApi["permission"]["reply"]>[0] & { location?: { directory?: string } }) {
await legacy(value.location).permission.respond({
sessionID: value.sessionID,
@@ -509,6 +603,18 @@ function createV1Api(input: CompatibleInput): CompatibleApi {
},
question: {
...input.current.question,
request: {
...input.current.question.request,
async list(value?: Parameters<ServerApi["question"]["request"]["list"]>[0]) {
return located(
((await legacy(value?.location).question.list()).data ?? []).map((request) => ({
...request,
tool: request.tool && { messageID: request.tool.messageID, id: request.tool.callID },
})),
value?.location,
)
},
},
async reply(value: Parameters<ServerApi["question"]["reply"]>[0]) {
await legacy().question.reply({
requestID: value.requestID,
+16
View File
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.cs.allow-jit</key>
<true/>
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
<true/>
<key>com.apple.security.cs.disable-executable-page-protection</key>
<true/>
<key>com.apple.security.cs.allow-dyld-environment-variables</key>
<true/>
<key>com.apple.security.cs.disable-library-validation</key>
<true/>
</dict>
</plist>
+193 -38
View File
@@ -1,4 +1,4 @@
import type { AgentSideConnection, PromptResponse } from "@agentclientprotocol/sdk"
import type { AgentSideConnection, PromptResponse, SessionUpdate } from "@agentclientprotocol/sdk"
import type {
EventSubscribeOutput,
OpenCodeClient,
@@ -37,6 +37,34 @@ export type TurnStart =
| { readonly type: "skill"; readonly id: string }
| { readonly type: "compaction"; readonly id: string }
export const ChildSessionUpdatesCapability = "opencode/child-session-updates"
export const ChildSessionUpdateMethod = "opencode/session/child_update"
type ChildSessionUpdateBase = {
readonly rootSessionId: string
readonly childSessionId: string
readonly parentSessionId: string
readonly depth: number
readonly title?: string
}
type ChildSessionEvent =
| { readonly type: "update"; readonly update: SessionUpdate }
| {
readonly type: "status"
readonly status: "created" | "running" | "completed" | "failed" | "interrupted"
readonly error?: { readonly type: string; readonly message: string }
}
export type ChildSessionUpdate = ChildSessionUpdateBase & ChildSessionEvent
type ChildSession = {
readonly id: string
readonly parentID: string
readonly depth: number
readonly title?: string
}
function emptyToolState(): ToolState {
return { name: "tool", input: {}, metadata: {}, content: [] }
}
@@ -50,8 +78,13 @@ export async function streamTurn(input: {
readonly writeTextFile: boolean
readonly submit: (signal: AbortSignal) => Promise<unknown>
readonly control: TurnControl
readonly childSessionUpdate?: (update: ChildSessionUpdate) => Promise<void>
readonly connectionSignal?: AbortSignal
readonly sessionSignal?: AbortSignal
}): Promise<PromptResponse> {
const streamController = new AbortController()
const connectionAbort = () => streamController.abort()
input.connectionSignal?.addEventListener("abort", connectionAbort, { once: true })
const stream = input.client.event.subscribe({ signal: streamController.signal })[Symbol.asyncIterator]()
const connected = await stream.next()
if (connected.done) throw new Error("event stream disconnected before prompt admission")
@@ -62,47 +95,101 @@ export async function streamTurn(input: {
let finish: SessionMessageAssistant["finish"]
let executionError: { readonly type: string; readonly message: string } | undefined
const tools = new Map<string, ToolState>()
const children = new Map<string, ChildSession>()
const openChildren = new Set<string>()
let handedOff = false
const update = (value: Parameters<Connection["sessionUpdate"]>[0]["update"]) =>
input.connection.sessionUpdate({ sessionId: input.sessionID, update: value })
const notifyChild = async (child: ChildSession, value: ChildSessionEvent) => {
if (!input.childSessionUpdate) return
await input
.childSessionUpdate({
rootSessionId: input.sessionID,
childSessionId: child.id,
parentSessionId: child.parentID,
depth: child.depth,
...(child.title ? { title: child.title } : {}),
...value,
})
.catch(() => {})
}
const consume = async () => {
const updateSession = async (value: SessionUpdate, child: ChildSession | undefined, mode: "turn" | "background") => {
const projected = child ? projectChildUpdate(value, child) : value
if (mode === "turn" && (!child || !input.childSessionUpdate)) {
await input.connection.sessionUpdate({ sessionId: input.sessionID, update: projected })
}
if (child) await notifyChild(child, { type: "update", update: projected })
}
const consume = async (mode: "turn" | "background") => {
while (!streamController.signal.aborted) {
const next = await stream.next()
if (next.done) throw new Error("event stream disconnected during prompt execution")
const event = next.value
if (event.type === "permission.asked" && event.data.sessionID === input.sessionID) {
const tool = event.data.source?.id ? tools.get(event.data.source.id) : undefined
if (event.type === "session.created") {
const parentID = event.data.info.parentID
if (!parentID) continue
const parent = parentID === input.sessionID ? undefined : children.get(parentID)
if ((mode === "turn" && parentID === input.sessionID) || parent) {
const child = {
id: event.data.sessionID,
parentID,
depth: parent ? parent.depth + 1 : 1,
title: event.data.info.title,
}
children.set(child.id, child)
openChildren.add(child.id)
await notifyChild(child, { type: "status", status: "created" })
}
continue
}
const eventSessionID = sessionIDFromEvent(event)
const child = eventSessionID ? children.get(eventSessionID) : undefined
const send = (update: SessionUpdate) => updateSession(update, child, mode)
if (mode === "background" && !child) continue
if (event.type === "permission.asked" && (event.data.sessionID === input.sessionID || child)) {
const tool = event.data.source?.id ? tools.get(toolKey(event.data.sessionID, event.data.source.id)) : undefined
await replyPermission({
client: input.client,
connection: input.connection,
event,
sessionID: input.sessionID,
sessionID: event.data.sessionID,
clientSessionID: input.sessionID,
cwd: input.cwd,
tool,
...(child ? { toolCallPrefix: child.id, titlePrefix: child.title } : {}),
})
continue
}
if (event.type === "form.created" && event.data.form.sessionID === input.sessionID) {
if (event.type === "form.created" && (event.data.form.sessionID === input.sessionID || child)) {
await input.client.form
.cancel({ sessionID: input.sessionID, formID: event.data.form.id })
.catch(() => input.client.session.interrupt({ sessionID: input.sessionID }).catch(() => {}))
.cancel({ sessionID: event.data.form.sessionID, formID: event.data.form.id })
.catch(() => input.client.session.interrupt({ sessionID: event.data.form.sessionID }).catch(() => {}))
continue
}
if (!("sessionID" in event.data) || event.data.sessionID !== input.sessionID) continue
if (!eventSessionID || (eventSessionID !== input.sessionID && !child)) continue
if (matchesStart(event, input.start)) {
started = true
continue
}
if (!started) continue
if (event.type === "session.execution.started") {
if (child) {
await notifyChild(child, { type: "status", status: "running" })
}
continue
}
if (event.type === "session.step.started") {
assistantMessageID = event.data.assistantMessageID
if (!child) assistantMessageID = event.data.assistantMessageID
continue
}
if (event.type === "session.text.delta") {
assistantMessageID = event.data.assistantMessageID
await update({
if (!child) assistantMessageID = event.data.assistantMessageID
await send({
sessionUpdate: "agent_message_chunk",
messageId: event.data.assistantMessageID,
content: { type: "text", text: event.data.delta },
@@ -110,8 +197,8 @@ export async function streamTurn(input: {
continue
}
if (event.type === "session.reasoning.delta") {
assistantMessageID = event.data.assistantMessageID
await update({
if (!child) assistantMessageID = event.data.assistantMessageID
await send({
sessionUpdate: "agent_thought_chunk",
messageId: event.data.assistantMessageID,
content: { type: "text", text: event.data.delta },
@@ -119,9 +206,14 @@ export async function streamTurn(input: {
continue
}
if (event.type === "session.tool.input.started") {
assistantMessageID = event.data.assistantMessageID
tools.set(event.data.id, { name: event.data.name, input: {}, metadata: {}, content: [] })
await update({
if (!child) assistantMessageID = event.data.assistantMessageID
tools.set(toolKey(event.data.sessionID, event.data.id), {
name: event.data.name,
input: {},
metadata: {},
content: [],
})
await send({
sessionUpdate: "tool_call",
...pendingToolCall({
toolCallId: event.data.id,
@@ -133,11 +225,12 @@ export async function streamTurn(input: {
continue
}
if (event.type === "session.tool.called") {
assistantMessageID = event.data.assistantMessageID
const current = tools.get(event.data.id) ?? emptyToolState()
if (!child) assistantMessageID = event.data.assistantMessageID
const key = toolKey(event.data.sessionID, event.data.id)
const current = tools.get(key) ?? emptyToolState()
current.input = event.data.input
tools.set(event.data.id, current)
await update({
tools.set(key, current)
await send({
sessionUpdate: "tool_call_update",
...runningToolUpdate({
toolCallId: event.data.id,
@@ -149,10 +242,10 @@ export async function streamTurn(input: {
continue
}
if (event.type === "session.tool.progress") {
const current = tools.get(event.data.id)
const current = tools.get(toolKey(event.data.sessionID, event.data.id))
if (!current) continue
current.metadata = event.data.metadata
await update({
await send({
sessionUpdate: "tool_call_update",
...runningToolUpdate({
toolCallId: event.data.id,
@@ -164,8 +257,9 @@ export async function streamTurn(input: {
continue
}
if (event.type === "session.tool.success") {
const current = tools.get(event.data.id) ?? emptyToolState()
tools.delete(event.data.id)
const key = toolKey(event.data.sessionID, event.data.id)
const current = tools.get(key) ?? emptyToolState()
tools.delete(key)
await syncEditedFiles({
connection: input.connection,
writeTextFile: input.writeTextFile,
@@ -175,7 +269,7 @@ export async function streamTurn(input: {
toolInput: current.input,
metadata: event.data.metadata ?? {},
}).catch(() => {})
await update({
await send({
sessionUpdate: "tool_call_update",
...completedToolUpdate({
toolCallId: event.data.id,
@@ -188,9 +282,10 @@ export async function streamTurn(input: {
continue
}
if (event.type === "session.tool.failed") {
const current = tools.get(event.data.id) ?? emptyToolState()
tools.delete(event.data.id)
await update({
const key = toolKey(event.data.sessionID, event.data.id)
const current = tools.get(key) ?? emptyToolState()
tools.delete(key)
await send({
sessionUpdate: "tool_call_update",
...errorToolUpdate({
toolCallId: event.data.id,
@@ -205,13 +300,33 @@ export async function streamTurn(input: {
continue
}
if (event.type === "session.step.ended") {
assistantMessageID = event.data.assistantMessageID
finish = event.data.finish
if (!child) {
assistantMessageID = event.data.assistantMessageID
finish = event.data.finish
}
continue
}
if (event.type === "session.execution.succeeded") {
if (!child) return "succeeded" as const
openChildren.delete(child.id)
await notifyChild(child, { type: "status", status: "completed" })
if (mode === "background" && openChildren.size === 0) return "succeeded" as const
continue
}
if (event.type === "session.execution.interrupted") {
if (!child) return "interrupted" as const
openChildren.delete(child.id)
await notifyChild(child, { type: "status", status: "interrupted" })
if (mode === "background" && openChildren.size === 0) return "interrupted" as const
continue
}
if (event.type === "session.execution.succeeded") return "succeeded" as const
if (event.type === "session.execution.interrupted") return "interrupted" as const
if (event.type === "session.execution.failed") {
if (child) {
openChildren.delete(child.id)
await notifyChild(child, { type: "status", status: "failed", error: event.data.error })
if (mode === "background" && openChildren.size === 0) return "failed" as const
continue
}
executionError = event.data.error
return "failed" as const
}
@@ -219,7 +334,13 @@ export async function streamTurn(input: {
return "interrupted" as const
}
const completed = consume()
const completed = consume("turn")
const closeStream = async () => {
streamController.abort()
input.connectionSignal?.removeEventListener("abort", connectionAbort)
input.sessionSignal?.removeEventListener("abort", connectionAbort)
await stream.return?.(undefined).catch(() => {})
}
try {
await input.submit(control.admission.signal).catch((error) => {
if (!control.cancelled) throw error
@@ -233,6 +354,13 @@ export async function streamTurn(input: {
}
}
const terminal = await completed
if (input.childSessionUpdate && openChildren.size > 0 && !input.sessionSignal?.aborted) {
handedOff = true
input.sessionSignal?.addEventListener("abort", connectionAbort, { once: true })
void consume("background")
.catch(() => {})
.finally(closeStream)
}
const assistant = assistantMessageID
? await input.client.session
.message({ sessionID: input.sessionID, messageID: assistantMessageID })
@@ -250,11 +378,38 @@ export async function streamTurn(input: {
await completed.catch(() => {})
throw error
} finally {
streamController.abort()
await stream.return?.(undefined).catch(() => {})
if (!handedOff) await closeStream()
}
}
function sessionIDFromEvent(event: EventSubscribeOutput) {
if ("sessionID" in event.data && typeof event.data.sessionID === "string") return event.data.sessionID
if (event.type === "form.created") return event.data.form.sessionID
return undefined
}
function toolKey(sessionID: string, id: string) {
return `${sessionID}:${id}`
}
function projectChildUpdate(update: SessionUpdate, child: ChildSession) {
const projected = { ...update }
projected._meta = {
...projected._meta,
"opencode/child-session": {
id: child.id,
parentID: child.parentID,
depth: child.depth,
...(child.title ? { title: child.title } : {}),
},
}
if (projected.sessionUpdate === "tool_call" || projected.sessionUpdate === "tool_call_update") {
projected.toolCallId = `${child.id}:${projected.toolCallId}`
if (projected.title && child.title) projected.title = `${child.title}: ${projected.title}`
}
return projected
}
export async function replayMessages(
connection: Pick<AgentSideConnection, "sessionUpdate">,
sessionID: string,
+17 -3
View File
@@ -20,20 +20,28 @@ export async function replyPermission(input: {
readonly connection: Connection
readonly event: PermissionEvent
readonly sessionID: string
readonly clientSessionID?: string
readonly cwd: string
readonly tool?: Tool
readonly toolCallPrefix?: string
readonly titlePrefix?: string
}) {
const toolName = input.tool?.name ?? input.event.data.action
const toolInput = { ...input.event.data.metadata, ...input.tool?.input }
const previews = await permissionPreviews(toolName, toolInput, input.cwd)
const toolCallID = input.event.data.source?.id ?? input.event.data.id
const title = permissionTitle(toolName, toolInput, previews)
const result = await input.connection
.requestPermission({
sessionId: input.sessionID,
sessionId: input.clientSessionID ?? input.sessionID,
toolCall: {
...pendingToolCall({
toolCallId: input.event.data.source?.id ?? input.event.data.id,
toolCallId: input.toolCallPrefix ? `${input.toolCallPrefix}:${toolCallID}` : toolCallID,
toolName,
state: { input: toolInput, title: permissionTitle(toolName, toolInput, previews) },
state: {
input: toolInput,
title: prefixedTitle(input.titlePrefix, title),
},
cwd: input.cwd,
}),
locations: permissionLocations(toolName, toolInput, input.event.data.resources, input.cwd, previews),
@@ -51,6 +59,12 @@ export async function replyPermission(input: {
})
}
function prefixedTitle(prefix: string | undefined, title: string | undefined) {
if (!prefix) return title
if (!title) return prefix
return `${prefix}: ${title}`
}
export async function syncEditedFiles(input: {
readonly connection: Partial<Pick<AgentSideConnection, "writeTextFile">>
readonly writeTextFile: boolean
+32 -7
View File
@@ -43,13 +43,21 @@ import { OPENCODE_VERSION } from "../version"
import { SessionMessage } from "@opencode-ai/schema/session-message"
import { buildConfigOptions, parseModelSelection, type ConfigOptionProvider } from "./config-option"
import { promptContentToParts } from "./content"
import { replayMessages, streamTurn, type TurnControl, type TurnStart } from "./event"
import {
ChildSessionUpdateMethod,
ChildSessionUpdatesCapability,
replayMessages,
streamTurn,
type ChildSessionUpdate,
type TurnControl,
type TurnStart,
} from "./event"
import { ACPError } from "./error"
export const AuthMethodID = "opencode-login"
type Connection = Pick<AgentSideConnection, "sessionUpdate" | "requestPermission"> &
Partial<Pick<AgentSideConnection, "writeTextFile">>
Partial<Pick<AgentSideConnection, "writeTextFile" | "extNotification" | "signal">>
type Catalog = {
readonly providers: ConfigOptionProvider[]
@@ -64,6 +72,7 @@ type Catalog = {
type Attached = {
readonly id: string
readonly cwd: string
readonly abort: AbortController
catalog: Catalog
model: ModelRef
modeID: string
@@ -100,7 +109,7 @@ export function make(input: { readonly client: OpenCodeClient; readonly connecti
const catalogs = new Map<string, Promise<Catalog>>()
const registeredMcp = new Map<string, Set<string>>()
const active = new Map<string, TurnControl>()
const capabilities = { writeTextFile: false }
const capabilities = { writeTextFile: false, childSessionUpdates: false }
const catalog = (cwd: string) => {
const cached = catalogs.get(cwd)
@@ -119,11 +128,19 @@ export function make(input: { readonly client: OpenCodeClient; readonly connecti
throw new ACPError.SessionNotFoundError({ sessionId: sessionID })
}
const detach = (sessionID: string) => {
sessions.get(sessionID)?.abort.abort()
sessions.delete(sessionID)
registeredMcp.delete(sessionID)
}
const attach = async (session: SessionInfo, cwd: string, mcpServers: readonly McpServer[]) => {
const currentCatalog = await catalog(cwd)
sessions.get(session.id)?.abort.abort()
const state: Attached = {
id: session.id,
cwd,
abort: new AbortController(),
catalog: currentCatalog,
model: session.model ?? currentCatalog.defaultModel,
modeID: session.agent ?? currentCatalog.defaultModeID,
@@ -161,6 +178,7 @@ export function make(input: { readonly client: OpenCodeClient; readonly connecti
return {
initialize: async (params) => {
capabilities.writeTextFile = params.clientCapabilities?.fs?.writeTextFile === true
capabilities.childSessionUpdates = params.clientCapabilities?._meta?.[ChildSessionUpdatesCapability] === true
const authMethod: AuthMethod = {
description: "Run `opencode auth login` in the terminal",
name: "Login with opencode",
@@ -178,6 +196,7 @@ export function make(input: { readonly client: OpenCodeClient; readonly connecti
mcpCapabilities: { http: true, sse: false },
promptCapabilities: { embeddedContext: true, image: true },
sessionCapabilities: { close: {}, delete: {}, fork: {}, list: {}, resume: {} },
_meta: { [ChildSessionUpdatesCapability]: true },
},
authMethods: [authMethod],
agentInfo: { name: "OpenCode", version: OPENCODE_VERSION },
@@ -224,8 +243,7 @@ export function make(input: { readonly client: OpenCodeClient; readonly connecti
await input.client.session.remove({ sessionID: params.sessionId }).catch((error) => {
if (!isSessionNotFoundError(error)) throw error
})
sessions.delete(params.sessionId)
registeredMcp.delete(params.sessionId)
detach(params.sessionId)
return {}
},
resumeSession: async (params) => {
@@ -234,8 +252,7 @@ export function make(input: { readonly client: OpenCodeClient; readonly connecti
return { configOptions: configOptions(state) }
},
closeSession: async (params) => {
sessions.delete(params.sessionId)
registeredMcp.delete(params.sessionId)
detach(params.sessionId)
const turn = active.get(params.sessionId)
if (turn) {
turn.cancelled = true
@@ -296,6 +313,11 @@ export function make(input: { readonly client: OpenCodeClient; readonly connecti
const messageID = SessionMessage.ID.create()
const prepared = preparePrompt(state.catalog, params.prompt, messageID)
const control: TurnControl = { cancelled: false, admission: new AbortController() }
const extNotification = input.connection.extNotification
const childSessionUpdate =
capabilities.childSessionUpdates && extNotification
? (update: ChildSessionUpdate) => extNotification(ChildSessionUpdateMethod, update).then(() => {})
: undefined
active.set(state.id, control)
const response = await streamTurn({
client: input.client,
@@ -305,7 +327,10 @@ export function make(input: { readonly client: OpenCodeClient; readonly connecti
start: prepared.start,
writeTextFile: capabilities.writeTextFile,
control,
connectionSignal: input.connection.signal,
sessionSignal: state.abort.signal,
submit: (signal) => submitPrompt(input.client, state, prepared, signal),
...(childSessionUpdate ? { childSessionUpdate } : {}),
}).finally(() => {
if (active.get(state.id) === control) active.delete(state.id)
})
+191 -1
View File
@@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test"
import type { AgentSideConnection } from "@agentclientprotocol/sdk"
import type { SessionMessageInfo } from "@opencode-ai/client/promise"
import { resolve } from "node:path"
import { replayMessages, streamTurn, type TurnControl } from "../../src/acp/event"
import { replayMessages, streamTurn, type ChildSessionUpdate, type TurnControl } from "../../src/acp/event"
import { createSseFixture, durableEvent, ephemeralEvent, withTimeout } from "./sse-fixture"
type SessionUpdateParams = Parameters<AgentSideConnection["sessionUpdate"]>[0]
@@ -191,6 +191,181 @@ describe("acp event behavior", () => {
}
})
test("projects foreground child session updates onto the parent turn", async () => {
const updates: SessionUpdateParams[] = []
const fixture = createSseFixture({
onPrompt({ id, send }) {
send(durableEvent("session.input.promoted", { sessionID: "ses_parent", inputID: id }))
send(
durableEvent("session.created", {
sessionID: "ses_child",
info: childSession("ses_child", "ses_parent", "Explore code"),
}),
)
send(durableEvent("session.execution.started", { sessionID: "ses_child" }))
send(
durableEvent("session.tool.input.started", {
sessionID: "ses_child",
assistantMessageID: "msg_child",
id: "call_read",
name: "read",
}),
)
send(
durableEvent("session.tool.called", {
sessionID: "ses_child",
assistantMessageID: "msg_child",
id: "call_read",
input: { path: "/workspace/src/index.ts" },
executed: false,
}),
)
send(
durableEvent("session.tool.success", {
sessionID: "ses_child",
assistantMessageID: "msg_child",
id: "call_read",
metadata: {},
content: [{ type: "text", text: "source" }],
executed: true,
}),
)
send(durableEvent("session.execution.succeeded", { sessionID: "ses_child" }))
send(durableEvent("session.execution.succeeded", { sessionID: "ses_parent" }))
},
})
try {
const response = await turn({
fixture,
connection: recordingConnection(updates),
sessionID: "ses_parent",
inputID: "input_parent",
})
expect(updates.map((item) => [item.sessionId, item.update.sessionUpdate])).toEqual([
["ses_parent", "tool_call"],
["ses_parent", "tool_call_update"],
["ses_parent", "tool_call_update"],
])
expect(updates.map((item) => ("toolCallId" in item.update ? item.update.toolCallId : undefined))).toEqual([
"ses_child:call_read",
"ses_child:call_read",
"ses_child:call_read",
])
expect(updates[0]?.update).toMatchObject({
title: "Explore code: read",
_meta: {
"opencode/child-session": {
id: "ses_child",
parentID: "ses_parent",
depth: 1,
title: "Explore code",
},
},
})
expect(response.stopReason).toBe("end_turn")
} finally {
await fixture.stop()
}
})
test("continues child extension updates after the parent turn ends", async () => {
const updates: SessionUpdateParams[] = []
const childUpdates: ChildSessionUpdate[] = []
const completed = Promise.withResolvers<void>()
const fixture = createSseFixture({
onPrompt({ id, send }) {
send(durableEvent("session.input.promoted", { sessionID: "ses_parent", inputID: id }))
send(
durableEvent("session.created", {
sessionID: "ses_background",
info: childSession("ses_background", "ses_parent", "Background research"),
}),
)
send(durableEvent("session.execution.succeeded", { sessionID: "ses_parent" }))
},
})
try {
const response = await turn({
fixture,
connection: recordingConnection(updates),
sessionID: "ses_parent",
inputID: "input_parent",
childSessionUpdate: async (update) => {
childUpdates.push(update)
if (update.type === "status" && update.status === "completed") completed.resolve()
},
})
expect(response.stopReason).toBe("end_turn")
fixture.send(
durableEvent("session.created", {
sessionID: "ses_future",
info: childSession("ses_future", "ses_parent", "Later turn child"),
}),
)
fixture.send(durableEvent("session.execution.started", { sessionID: "ses_future" }))
fixture.send(durableEvent("session.execution.started", { sessionID: "ses_background" }))
fixture.send(
durableEvent("session.tool.input.started", {
sessionID: "ses_background",
assistantMessageID: "msg_background",
id: "call_shell",
name: "shell",
}),
)
fixture.send(
durableEvent("session.tool.called", {
sessionID: "ses_background",
assistantMessageID: "msg_background",
id: "call_shell",
input: { command: "pwd" },
executed: false,
}),
)
fixture.send(
durableEvent("session.tool.success", {
sessionID: "ses_background",
assistantMessageID: "msg_background",
id: "call_shell",
metadata: { exit: 0 },
content: [{ type: "text", text: "/workspace" }],
executed: true,
}),
)
fixture.send(durableEvent("session.execution.succeeded", { sessionID: "ses_background" }))
await withTimeout(completed.promise, "background child completion was not delivered")
expect(updates).toEqual([])
expect(
childUpdates.map((update) =>
update.type === "status" ? [update.type, update.status] : [update.type, update.update.sessionUpdate],
),
).toEqual([
["status", "created"],
["status", "running"],
["update", "tool_call"],
["update", "tool_call_update"],
["update", "tool_call_update"],
["status", "completed"],
])
expect(childUpdates[2]).toMatchObject({
rootSessionId: "ses_parent",
childSessionId: "ses_background",
parentSessionId: "ses_parent",
depth: 1,
title: "Background research",
type: "update",
update: { toolCallId: "ses_background:call_shell" },
})
expect(childUpdates.some((update) => update.childSessionId === "ses_future")).toBe(false)
} finally {
await fixture.stop()
}
})
test("streams tool pending, progress, success, and failure updates", async () => {
const updates: SessionUpdateParams[] = []
const fixture = createSseFixture({
@@ -556,6 +731,7 @@ function turn(input: {
readonly connection: Connection
readonly sessionID: string
readonly inputID: string
readonly childSessionUpdate?: (update: ChildSessionUpdate) => Promise<void>
}) {
return streamTurn({
client: input.fixture.client,
@@ -565,11 +741,25 @@ function turn(input: {
start: { type: "input", id: input.inputID },
writeTextFile: false,
control: { cancelled: false, admission: new AbortController() },
childSessionUpdate: input.childSessionUpdate,
submit: (signal) =>
input.fixture.client.session.prompt({ sessionID: input.sessionID, id: input.inputID, text: "hello" }, { signal }),
})
}
function childSession(id: string, parentID: string, title: string) {
return {
id,
slug: id,
projectID: "project",
directory: "/workspace",
parentID,
title,
version: "test",
time: { created: 1, updated: 1 },
}
}
function tokens() {
return { input: 1, output: 1, reasoning: 0, cache: { read: 0, write: 0 } }
}
@@ -153,6 +153,68 @@ describe("acp permission behavior", () => {
}
})
test("routes foreground child permissions through the parent ACP session", async () => {
const permissionRequests: RequestPermissionRequest[] = []
const fixture = createSseFixture({
onPrompt({ id, send }) {
send(durableEvent("session.input.promoted", { sessionID: "ses_parent", inputID: id }))
send(
durableEvent("session.created", {
sessionID: "ses_child",
info: {
id: "ses_child",
slug: "ses_child",
projectID: "project",
directory: "/workspace",
parentID: "ses_parent",
title: "Review code",
version: "test",
time: { created: 1, updated: 1 },
},
}),
)
send(durableEvent("session.execution.started", { sessionID: "ses_child" }))
send(
permissionAsked("ses_child", "perm_child", {
action: "read",
metadata: { path: "/workspace/child.ts" },
source: { type: "tool", messageID: "msg_child", id: "call_child" },
}),
)
send(durableEvent("session.execution.succeeded", { sessionID: "ses_child" }))
send(durableEvent("session.execution.succeeded", { sessionID: "ses_parent" }))
},
})
const connection = {
sessionUpdate: async () => {},
requestPermission: async (request) => {
permissionRequests.push(request)
return { outcome: { outcome: "selected", optionId: "once" } } as const
},
} satisfies Connection
try {
await startTurn(fixture, connection, "ses_parent", "input_parent")
expect(permissionRequests).toHaveLength(1)
expect(permissionRequests[0]).toMatchObject({
sessionId: "ses_parent",
toolCall: {
toolCallId: "ses_child:call_child",
title: "Review code: /workspace/child.ts",
},
})
expect(fixture.requests).toContainEqual(
expect.objectContaining({
method: "POST",
path: "/api/session/ses_child/permission/perm_child/reply",
}),
)
} finally {
await fixture.stop()
}
})
test("previews edits during approval and syncs the completed file", async () => {
const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-acp-permission-"))
const file = path.join(cwd, "file.ts")
+7
View File
@@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test"
import type { AgentSideConnection } from "@agentclientprotocol/sdk"
import { OpenCode } from "@opencode-ai/client/promise"
import { ACPService } from "../../src/acp/service"
import { ChildSessionUpdatesCapability } from "../../src/acp/event"
describe("acp service", () => {
test("creates a v2 session, registers mcp, and publishes commands", async () => {
@@ -39,11 +40,17 @@ describe("acp service", () => {
})
try {
const initialized = await service.initialize({
protocolVersion: 1,
clientCapabilities: { _meta: { [ChildSessionUpdatesCapability]: true } },
clientInfo: { name: "test", version: "1" },
})
const result = await service.newSession({
cwd: "/workspace",
mcpServers: [{ name: "docs", command: "bun", args: ["docs.ts"], env: [{ name: "TOKEN", value: "x" }] }],
})
expect(result.sessionId).toBe("ses_acp")
expect(initialized.agentCapabilities?._meta).toEqual({ [ChildSessionUpdatesCapability]: true })
expect(result.configOptions?.map((option) => option.id)).toEqual(["model", "effort", "mode"])
expect(requests).toContainEqual({
method: "PUT",
@@ -1142,6 +1142,9 @@ export function ContextToolGroup(props: {
const running = createMemo(
() => partAccessor().state.status === "pending" || partAccessor().state.status === "running",
)
const showDetails = createMemo(
() => !running() || partAccessor().tool === "glob" || partAccessor().tool === "grep",
)
return (
<div data-slot="context-tool-group-item">
<div data-component="tool-trigger">
@@ -1152,10 +1155,10 @@ export function ContextToolGroup(props: {
<span data-slot="basic-tool-tool-title">
<TextShimmer text={trigger().title} active={running()} />
</span>
<Show when={!running() && trigger().subtitle}>
<Show when={showDetails() && trigger().subtitle}>
<span data-slot="basic-tool-tool-subtitle">{trigger().subtitle}</span>
</Show>
<Show when={!running() && trigger().args?.length}>
<Show when={showDetails() && trigger().args?.length}>
<For each={trigger().args}>
{(arg) => <span data-slot="basic-tool-tool-arg">{arg}</span>}
</For>