From cc9bd8dc864b21182af7a9131a5c42b3401fd6e0 Mon Sep 17 00:00:00 2001 From: stocky789 Date: Wed, 12 Aug 2026 11:31:44 +1000 Subject: [PATCH] feat(opencode): add Claude Code ACP runtime --- bun.lock | 30 +- .../session/session-context-metrics.test.ts | 70 + .../session/session-context-metrics.ts | 18 +- packages/llm/src/schema/events.ts | 16 + packages/opencode/package.json | 2 + packages/opencode/src/acp/service.ts | 45 +- packages/opencode/src/acp/usage.ts | 32 +- .../opencode/src/cli/cmd/run/session-data.ts | 40 +- packages/opencode/src/cli/cmd/tui.ts | 11 +- packages/opencode/src/effect/bridge.ts | 6 +- packages/opencode/src/index.ts | 5 + packages/opencode/src/permission/index.ts | 22 +- packages/opencode/src/provider/provider.ts | 78 + packages/opencode/src/session/llm.ts | 142 +- .../opencode/src/session/llm/claude-acp.ts | 1601 +++++++++++++++++ packages/opencode/src/session/processor.ts | 92 +- packages/opencode/src/session/prompt.ts | 6 +- .../opencode/test/acp/service-session.test.ts | 126 +- packages/opencode/test/acp/usage.test.ts | 78 +- .../test/cli/run/session-data.test.ts | 167 ++ packages/opencode/test/cli/tui/thread.test.ts | 8 + .../opencode/test/session/claude-acp.test.ts | 719 ++++++++ packages/opencode/test/session/llm.test.ts | 267 ++- .../test/session/processor-effect.test.ts | 162 ++ .../tui/src/component/dialog-provider.tsx | 10 + .../tui/src/component/prompt/autocomplete.tsx | 49 +- packages/tui/src/component/prompt/index.tsx | 63 +- packages/tui/src/context/sync.tsx | 58 + .../src/feature-plugins/sidebar/context.tsx | 7 +- packages/tui/src/routes/session/footer.tsx | 8 +- packages/tui/src/routes/session/index.tsx | 17 +- .../tui/src/routes/session/permission.tsx | 25 +- packages/tui/src/routes/session/request.ts | 27 + .../src/routes/session/subagent-footer.tsx | 7 +- packages/tui/src/util/claude-acp.ts | 111 ++ packages/tui/src/util/session.ts | 35 + .../test/cli/cmd/tui/session-requests.test.ts | 90 + .../tui/test/cli/cmd/tui/sync-fixture.tsx | 4 +- packages/tui/test/cli/cmd/tui/sync.test.tsx | 198 +- packages/tui/test/fixture/tui-sdk.ts | 1 + packages/tui/test/util/session.test.ts | 134 +- 41 files changed, 4500 insertions(+), 87 deletions(-) create mode 100644 packages/opencode/src/session/llm/claude-acp.ts create mode 100644 packages/opencode/test/session/claude-acp.test.ts create mode 100644 packages/tui/src/routes/session/request.ts create mode 100644 packages/tui/src/util/claude-acp.ts create mode 100644 packages/tui/test/cli/cmd/tui/session-requests.test.ts diff --git a/bun.lock b/bun.lock index 0cf32fd5fc15..6046515a7859 100644 --- a/bun.lock +++ b/bun.lock @@ -566,6 +566,7 @@ "dependencies": { "@actions/core": "1.11.1", "@actions/github": "6.0.1", + "@agentclientprotocol/claude-agent-acp": "0.54.1", "@agentclientprotocol/sdk": "0.21.0", "@ai-sdk/alibaba": "1.0.17", "@ai-sdk/amazon-bedrock": "4.0.112", @@ -586,6 +587,7 @@ "@ai-sdk/togetherai": "2.0.41", "@ai-sdk/vercel": "2.0.39", "@ai-sdk/xai": "3.0.102", + "@anthropic-ai/sdk": "0.93.0", "@aws-sdk/credential-providers": "3.1057.0", "@clack/prompts": "1.0.0-alpha.1", "@effect/opentelemetry": "catalog:", @@ -1168,6 +1170,8 @@ "@adobe/css-tools": ["@adobe/css-tools@4.5.0", "", {}, "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q=="], + "@agentclientprotocol/claude-agent-acp": ["@agentclientprotocol/claude-agent-acp@0.54.1", "", { "dependencies": { "@agentclientprotocol/sdk": "1.1.0", "@anthropic-ai/claude-agent-sdk": "0.3.197", "zod": "^3.25.0 || ^4.0.0" }, "bin": { "claude-agent-acp": "dist/index.js" } }, "sha512-pvCPsjSUWvMah6PdyljHWoE1VhT5rR0VJ3pD8tyobCwJlReYNpgdXnIk5zLQqFRhwifAyRUUSsazsSpbBRFkuA=="], + "@agentclientprotocol/sdk": ["@agentclientprotocol/sdk@0.21.0", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-ONj+Q8qOdNQp5XbH5jnMwzT9IKZJsSN0p0lkceS4GtUtNOPVLpNzSS8gqQdGMKfBvA0ESbkL8BTaSN1Rc9miEw=="], "@ai-sdk/alibaba": ["@ai-sdk/alibaba@1.0.17", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.41", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ZbE+U5bWz2JBc5DERLowx5+TKbjGBE93LqKZAWvuEn7HOSQMraxFMZuc0ST335QZJAyfBOzh7m1mPQ+y7EaaoA=="], @@ -1222,7 +1226,25 @@ "@ampproject/remapping": ["@ampproject/remapping@2.3.0", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw=="], - "@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.71.2", "", { "dependencies": { "json-schema-to-ts": "^3.1.1" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-TGNDEUuEstk/DKu0/TflXAEt+p+p/WhTlFzEnoosvbaDU2LTjm42igSdlL0VijrKpWejtOKxX0b8A7uc+XiSAQ=="], + "@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.3.197", "", { "optionalDependencies": { "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.197", "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.197", "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.197", "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.197", "@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.197", "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.197", "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.197", "@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.197" }, "peerDependencies": { "@anthropic-ai/sdk": ">=0.93.0", "@modelcontextprotocol/sdk": "^1.29.0", "zod": "^4.0.0" } }, "sha512-XNIi8W1tb+QfMkcK+5kepOC6BsxG8wtupd72H+pIPzIJypVQhHy7FoX+KBMtTRYwtl+5dsjKyABhjWXebeUilw=="], + + "@anthropic-ai/claude-agent-sdk-darwin-arm64": ["@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.197", "", { "os": "darwin", "cpu": "arm64" }, "sha512-jC6WvH5Hr6APTfbMjo4nC6LlyMMqbpCMwiHXIw7/AsQXIHQhZ+cRRMesQlV6UFI1l3O53gLZHzsG9cXwfrPHKw=="], + + "@anthropic-ai/claude-agent-sdk-darwin-x64": ["@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.197", "", { "os": "darwin", "cpu": "x64" }, "sha512-ZQNvGkMrTyatBlHTIQ4w2i2aLBuvq355UP/FDLnVXIH8l23RsL1x/0w9P+dqB7EmY9OZi/cPxSrpskpo+dZWLA=="], + + "@anthropic-ai/claude-agent-sdk-linux-arm64": ["@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.197", "", { "os": "linux", "cpu": "arm64" }, "sha512-pWhQgCtAft4EGM4Zn24HRad1a/k2u6oA+2uM/KCdjehfKtooDiHfMNd1yzXY/n9AEBWP0RHB2Vz3mJ30X2pVAg=="], + + "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": ["@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.3.197", "", { "os": "linux", "cpu": "arm64" }, "sha512-VuIGXsLGK/aqSQ0tTBqqPVNzjefWS5SWnK8mlYyQitT4s5UDzHXJm0UZBTGxRtlcS0e2+QAHKwbGBCq1ZKSXjg=="], + + "@anthropic-ai/claude-agent-sdk-linux-x64": ["@anthropic-ai/claude-agent-sdk-linux-x64@0.3.197", "", { "os": "linux", "cpu": "x64" }, "sha512-AUccrbdcv4Hy/GteP/gYLjG/zDP+fe2BFtDMctEfRFVz40DazYDcOyW1+nIgSTQtxf5jSTAVVf3cNuXB2CZwlw=="], + + "@anthropic-ai/claude-agent-sdk-linux-x64-musl": ["@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.197", "", { "os": "linux", "cpu": "x64" }, "sha512-3Tuy7XhD4UIKE4A4RPmKJcbL7Q/3dcB1hEWQt2lKP7c/DlixeEv+tRzvpnFZKhFX2hy0tkBk3QjkozSAacMC/w=="], + + "@anthropic-ai/claude-agent-sdk-win32-arm64": ["@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.197", "", { "os": "win32", "cpu": "arm64" }, "sha512-Wx8uiAKBenDuL8lWQmrqnX5ppljaH5unQ9cKiCz2/9Kgf09dgnrwbX8n/FhndCZR8PmYw539eWwYVrSVc/bl6w=="], + + "@anthropic-ai/claude-agent-sdk-win32-x64": ["@anthropic-ai/claude-agent-sdk-win32-x64@0.3.197", "", { "os": "win32", "cpu": "x64" }, "sha512-ZXJO/VvR3SI4G0gwthWeFXWdHB5RXPu3rtfGRcKZ/YgtDeW17rQ+LZIJTk2ywzbLb8EvlghR5JPgn293hC179Q=="], + + "@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.93.0", "", { "dependencies": { "json-schema-to-ts": "^3.1.1" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-q9vaSZQVFx6B/gPxetGYfLXSJD5v0sOmh0OpZDq7yCrTSA+Rscvrtyol7JJTW40wEpQB4U1B4JXzxQitbQ3CAA=="], "@anycable/core": ["@anycable/core@0.9.2", "", { "dependencies": { "nanoevents": "^7.0.1" } }, "sha512-x5ZXDcW/N4cxWl93CnbHs/u7qq4793jS2kNPWm+duPrXlrva+ml2ZGT7X9tuOBKzyIHf60zWCdIK7TUgMPAwXA=="], @@ -5638,6 +5660,10 @@ "@actions/http-client/undici": ["undici@6.26.0", "", {}, "sha512-4yqz8a3n5HmGTlsbADNtr/dJlhkh/55Rq798G6ibiULcXbDtaLpTl1pvdqcbFfeoj3iSi52lePFM7h9H21cw/A=="], + "@agentclientprotocol/claude-agent-acp/@agentclientprotocol/sdk": ["@agentclientprotocol/sdk@1.1.0", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-NT2KqphUJ3w6EksUL51ZhJgIYgq/ZLGcBPkyMKgRSO5PMVwe9DnKKX+Htnvk6KHh6dUuh34UHK4gKp+4te1Mdg=="], + + "@agentclientprotocol/claude-agent-acp/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], + "@ai-sdk/alibaba/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.41", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kNAGINk71AlOXx10Dq/PXw4t/9XjdK8uxfpVElRwtSFMdeSiLVt58p9TPx4/FJD+hxZuVhvxYj9r42osxWq79g=="], "@ai-sdk/amazon-bedrock/@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.81", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-B1JDd9Ugq9R5AgIaW3674lhGCMMYJcPUxnrZh8fzbGojgg4QvHFRv6eZahGQAUsmGHbcf74G9bdSBDLWQGY2GA=="], @@ -6334,6 +6360,8 @@ "gaxios/node-fetch": ["node-fetch@3.3.2", "", { "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", "formdata-polyfill": "^4.0.10" } }, "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA=="], + "gitlab-ai-provider/@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.71.2", "", { "dependencies": { "json-schema-to-ts": "^3.1.1" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-TGNDEUuEstk/DKu0/TflXAEt+p+p/WhTlFzEnoosvbaDU2LTjm42igSdlL0VijrKpWejtOKxX0b8A7uc+XiSAQ=="], + "gitlab-ai-provider/openai": ["openai@6.39.1", "", { "peerDependencies": { "ws": "^8.18.0", "zod": "^3.25 || ^4.0" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-z3dO9fEWOXBzlXynVb/xZ/tujzUjFWQWn3C0n0mw6Vo0zJTbEkaN4b2cLWjhJ6haJQx8LlREoafHRl+Gu/Hl+A=="], "gitlab-ai-provider/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], diff --git a/packages/app/src/components/session/session-context-metrics.test.ts b/packages/app/src/components/session/session-context-metrics.test.ts index 37cc0d5b5d2e..ba26776fa78c 100644 --- a/packages/app/src/components/session/session-context-metrics.test.ts +++ b/packages/app/src/components/session/session-context-metrics.test.ts @@ -96,4 +96,74 @@ describe("getSessionContext", () => { expect(ctx).toBeUndefined() }) + + test("prefers the provider-reported total over the token sum", () => { + const messages = [ + { + id: "a1", + role: "assistant", + providerID: "openai", + modelID: "gpt-4.1", + cost: 0.5, + tokens: { total: 800, input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { created: 1 }, + } as unknown as Message, + ] + const providers = [{ id: "openai", models: { "gpt-4.1": { limit: { context: 1000 } } } }] + + const ctx = getSessionContext(messages, providers) + + expect(ctx?.message.id).toBe("a1") + expect(ctx?.total).toBe(800) + expect(ctx?.usage).toBe(80) + }) + + test("skips an interrupt estimate when a reported value exists", () => { + const messages = [ + { + id: "reported", + role: "assistant", + providerID: "openai", + modelID: "gpt-4.1", + cost: 0.5, + tokens: { total: 800, input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { created: 1 }, + } as unknown as Message, + { + id: "estimated", + role: "assistant", + providerID: "openai", + modelID: "gpt-4.1", + cost: 0, + error: { name: "MessageAbortedError" }, + tokens: { input: 100, output: 10, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { created: 2 }, + } as unknown as Message, + ] + const providers = [{ id: "openai", models: { "gpt-4.1": { limit: { context: 1000 } } } }] + + const ctx = getSessionContext(messages, providers) + + expect(ctx?.message.id).toBe("reported") + }) + + test("uses an interrupt estimate when no reported usage exists", () => { + const messages = [ + { + id: "estimated", + role: "assistant", + providerID: "openai", + modelID: "gpt-4.1", + cost: 0, + error: { name: "MessageAbortedError" }, + tokens: { input: 100, output: 10, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { created: 1 }, + } as unknown as Message, + ] + const providers = [{ id: "openai", models: { "gpt-4.1": { limit: { context: 1000 } } } }] + + const ctx = getSessionContext(messages, providers) + + expect(ctx?.message.id).toBe("estimated") + }) }) diff --git a/packages/app/src/components/session/session-context-metrics.ts b/packages/app/src/components/session/session-context-metrics.ts index 30dc9e958b9c..11d125a1aa74 100644 --- a/packages/app/src/components/session/session-context-metrics.ts +++ b/packages/app/src/components/session/session-context-metrics.ts @@ -26,16 +26,30 @@ type Context = { } const tokenTotal = (msg: AssistantMessage) => { - return msg.tokens.input + msg.tokens.output + msg.tokens.reasoning + msg.tokens.cache.read + msg.tokens.cache.write + return ( + msg.tokens.total ?? + msg.tokens.input + msg.tokens.output + msg.tokens.reasoning + msg.tokens.cache.read + msg.tokens.cache.write + ) } +// Interrupted turns get locally estimated tokens (no provider-reported +// `total`); an estimate must never displace a provider-reported value. +const estimatedTokens = (msg: AssistantMessage) => + msg.tokens.total === undefined && msg.error?.name === "MessageAbortedError" + +// Latest assistant message with reported usage, last write wins — reported +// values move the meter in both directions (context shrinks when the provider +// compacts its own history); estimates only ever fill a void. const lastAssistantWithTokens = (messages: Message[]) => { + let estimated: AssistantMessage | undefined for (let i = messages.length - 1; i >= 0; i--) { const msg = messages[i] if (msg.role !== "assistant") continue if (tokenTotal(msg) <= 0) continue - return msg + if (!estimatedTokens(msg)) return msg + estimated ??= msg } + return estimated } const build = (messages: Message[] = [], providers: Provider[] = []): Context | undefined => { diff --git a/packages/llm/src/schema/events.ts b/packages/llm/src/schema/events.ts index 98fcc9a24d41..b4e1d9455173 100644 --- a/packages/llm/src/schema/events.ts +++ b/packages/llm/src/schema/events.ts @@ -189,6 +189,19 @@ export const StepFinish = Schema.Struct({ }).annotate({ identifier: "LLM.Event.StepFinish" }) export type StepFinish = Schema.Schema.Type +/** + * Mid-stream usage snapshot (ACP `usage_update` parity). Providers that + * report live context occupancy emit this as it changes; `usage` is a + * last-write-wins snapshot, not a delta — later events supersede earlier + * ones, and values may decrease (e.g. after the provider compacts its own + * context). + */ +export const UsageUpdate = Schema.Struct({ + type: Schema.tag("usage"), + usage: Usage, +}).annotate({ identifier: "LLM.Event.UsageUpdate" }) +export type UsageUpdate = Schema.Schema.Type + export const Finish = Schema.Struct({ type: Schema.tag("finish"), reason: FinishReason, @@ -221,6 +234,7 @@ const llmEventTagged = Schema.Union([ ToolResult, ToolError, StepFinish, + UsageUpdate, Finish, ProviderErrorEvent, ]).pipe(Schema.toTaggedUnion("type")) @@ -267,6 +281,7 @@ export const LLMEvent = Object.assign(llmEventTagged, { ...input, usage: input.usage === undefined ? undefined : Usage.from(input.usage), }), + usage: (input: UsageInput) => UsageUpdate.make({ usage: Usage.from(input) }), finish: (input: WithUsage) => Finish.make({ ...input, @@ -288,6 +303,7 @@ export const LLMEvent = Object.assign(llmEventTagged, { toolResult: llmEventTagged.guards["tool-result"], toolError: llmEventTagged.guards["tool-error"], stepFinish: llmEventTagged.guards["step-finish"], + usage: llmEventTagged.guards.usage, finish: llmEventTagged.guards.finish, providerError: llmEventTagged.guards["provider-error"], }, diff --git a/packages/opencode/package.json b/packages/opencode/package.json index 1719c4e8045e..4dcc3863fa41 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -54,6 +54,7 @@ "dependencies": { "@actions/core": "1.11.1", "@actions/github": "6.0.1", + "@agentclientprotocol/claude-agent-acp": "0.54.1", "@agentclientprotocol/sdk": "0.21.0", "@ai-sdk/alibaba": "1.0.17", "@ai-sdk/amazon-bedrock": "4.0.112", @@ -74,6 +75,7 @@ "@ai-sdk/togetherai": "2.0.41", "@ai-sdk/vercel": "2.0.39", "@ai-sdk/xai": "3.0.102", + "@anthropic-ai/sdk": "0.93.0", "@aws-sdk/credential-providers": "3.1057.0", "@clack/prompts": "1.0.0-alpha.1", "@effect/opentelemetry": "catalog:", diff --git a/packages/opencode/src/acp/service.ts b/packages/opencode/src/acp/service.ts index 55fbc9681df3..704021bc885e 100644 --- a/packages/opencode/src/acp/service.ts +++ b/packages/opencode/src/acp/service.ts @@ -617,7 +617,11 @@ function makeUsageService(sdk: OpencodeClient) { }) .catch(() => undefined) limits.set(key, next) - return yield* Effect.promise(() => next) + const value = yield* Effect.promise(() => next) + // An unknown limit must not stick: evict the failed lookup so the next + // usage update retries instead of never reporting again. + if (value === undefined && limits.get(key) === next) limits.delete(key) + return value }, ) @@ -796,18 +800,25 @@ function defaultModelFromConfig( const opencodeModel = opencodeProvider ? Provider.sort(Object.values(opencodeProvider.models))[0] : undefined if (opencodeProvider && opencodeModel) return { providerID: opencodeProvider.id, modelID: opencodeModel.id } - const best = Provider.sort(Object.values(providers).flatMap((provider) => Object.values(provider.models)))[0] + const best = Provider.sort( + automaticModels(Object.values(providers).flatMap((provider) => Object.values(provider.models))), + )[0] if (best) return { providerID: best.providerID, modelID: best.id } if (configured) return configured } function selectDefaultModel(snapshot: Directory.Snapshot) { if (snapshot.defaultModel) return snapshot.defaultModel - const model = snapshot.modelOptions[0] + const model = automaticModels(snapshot.modelOptions)[0] if (model) return { providerID: model.providerID, modelID: model.modelID } return { providerID: "unknown" as ProviderV2.ID, modelID: "unknown" as ModelV2.ID } } +function automaticModels(models: readonly T[]) { + const withoutClaudeACP = models.filter((model) => model.providerID !== Provider.ClaudeACPProviderID) + return withoutClaudeACP.length > 0 ? withoutClaudeACP : [...models] +} + function detectSlashCommand(parts: ReturnType) { const text = parts .filter((part): part is Extract<(typeof parts)[number], { type: "text" }> => part.type === "text") @@ -877,6 +888,10 @@ function promptErrorMessage(error: AssistantError) { return "OpenCode prompt failed" } +// One usage service per SDK client so the context-limit cache survives across +// updates — rebuilding it per call would refetch providers on every update. +const usageServices = new WeakMap() + function sendUsageUpdate( usage: UsageService.Interface | undefined, sdk: OpencodeClient, @@ -885,7 +900,12 @@ function sendUsageUpdate( directory: string, ) { if (!connection) return Effect.void - return (usage ?? makeUsageService(sdk)).sendUpdate({ + let service = usage ?? usageServices.get(sdk) + if (!service) { + service = makeUsageService(sdk) + usageServices.set(sdk, service) + } + return service.sendUpdate({ connection, sessionID, directory, @@ -1040,7 +1060,10 @@ function restoreFromMessages(messages: readonly MessageInfo[]) { ) if (user?.model?.providerID && user.model.modelID) { return { - model: { providerID: user.model.providerID as ProviderV2.ID, modelID: user.model.modelID as ModelV2.ID }, + model: normalizeRestoredModel({ + providerID: user.model.providerID as ProviderV2.ID, + modelID: user.model.modelID as ModelV2.ID, + }), variant: user.model.variant, modeId: user.agent, } @@ -1049,7 +1072,10 @@ function restoreFromMessages(messages: readonly MessageInfo[]) { const assistant = messages.findLast((message) => message.providerID && message.modelID) if (assistant?.providerID && assistant.modelID) { return { - model: { providerID: assistant.providerID as ProviderV2.ID, modelID: assistant.modelID as ModelV2.ID }, + model: normalizeRestoredModel({ + providerID: assistant.providerID as ProviderV2.ID, + modelID: assistant.modelID as ModelV2.ID, + }), variant: assistant.variant, modeId: assistant.mode ?? assistant.agent, } @@ -1058,6 +1084,13 @@ function restoreFromMessages(messages: readonly MessageInfo[]) { return {} } +function normalizeRestoredModel(model: Directory.DefaultModel) { + if (model.providerID === Provider.ClaudeACPProviderID && model.modelID === ModelV2.ID.make("default")) { + return { providerID: Provider.ClaudeACPProviderID, modelID: Provider.ClaudeACPModelID } + } + return model +} + function isSdkResponse(value: T | SdkResponse): value is SdkResponse { return typeof value === "object" && value !== null && ("data" in value || "error" in value) } diff --git a/packages/opencode/src/acp/usage.ts b/packages/opencode/src/acp/usage.ts index bc17447e6141..d3a5dd7cbee8 100644 --- a/packages/opencode/src/acp/usage.ts +++ b/packages/opencode/src/acp/usage.ts @@ -83,8 +83,19 @@ export function messageLoaderFromSDK(sdk: SDK): MessageLoaderInterface { export const messageLoaderLayer = (sdk: SDK) => Layer.succeed(MessageLoader, messageLoaderFromSDK(sdk)) +// Context occupancy for ACP usage_update: prefer the provider-reported total +// (for Claude-via-ACP this is the agent's own context measurement), otherwise +// sum all token types — the current turn's output becomes the next turn's +// input, matching the reference claude-agent-acp adapter's accounting. export function contextTokens(message: AssistantTokenCost): number { - return message.tokens.input + message.tokens.cache.read + message.tokens.cache.write + return ( + message.tokens.total ?? + message.tokens.input + + message.tokens.output + + message.tokens.reasoning + + message.tokens.cache.read + + message.tokens.cache.write + ) } export function buildUsage(message: AssistantTokenCost): Usage { @@ -151,12 +162,12 @@ const layer = Layer.effect( readonly providerID: ProviderV2.ID readonly modelID: ModelV2.ID }) { - return yield* SynchronizedRef.modifyEffect( + const [key, cached] = yield* SynchronizedRef.modifyEffect( limits, Effect.fnUntraced(function* (items) { const key = `${input.directory}\u0000${input.providerID}\u0000${input.modelID}` const current = items.get(key) - if (current) return [current, items] as const + if (current) return [[key, current] as const, items] as const const next = yield* Effect.cached( contextLimitLoader.providers(input.directory).pipe( Effect.map((providers) => findContextLimit(providers, input.providerID, input.modelID)), @@ -167,9 +178,20 @@ const layer = Layer.effect( ), ), ) - return [next, new Map(items).set(key, next)] as const + return [[key, next] as const, new Map(items).set(key, next)] as const }), ) + const value = yield* cached + if (value !== undefined) return value + // An unknown limit must not stick: evict the failed lookup so the next + // usage update retries instead of never reporting again. + yield* SynchronizedRef.update(limits, (items) => { + if (items.get(key) !== cached) return items + const next = new Map(items) + next.delete(key) + return next + }) + return value }) const contextLimit = Effect.fn("ACPUsage.contextLimit")(function* (input: { @@ -177,7 +199,7 @@ const layer = Layer.effect( readonly providerID: ProviderV2.ID readonly modelID: ModelV2.ID }) { - return yield* yield* cachedLimit(input) + return yield* cachedLimit(input) }) const sendUpdate = Effect.fn("ACPUsage.sendUpdate")(function* (input: { diff --git a/packages/opencode/src/cli/cmd/run/session-data.ts b/packages/opencode/src/cli/cmd/run/session-data.ts index 03951ec4c9e1..ce02a36e1ad1 100644 --- a/packages/opencode/src/cli/cmd/run/session-data.ts +++ b/packages/opencode/src/cli/cmd/run/session-data.ts @@ -35,6 +35,7 @@ const money = new Intl.NumberFormat("en-US", { }) type Tokens = { + total?: number input?: number output?: number reasoning?: number @@ -86,6 +87,11 @@ export type SessionData = { visible: Map end: Set echo: Map> + usage: { + text: string + tokens: number + estimated: boolean + } | undefined } export type SessionDataInput = { @@ -124,6 +130,7 @@ export function createSessionData( visible: new Map(), end: new Set(), echo: new Map(), + usage: undefined, } } @@ -135,17 +142,18 @@ function formatUsage( tokens: Tokens | undefined, limit: number | undefined, cost: number | undefined, -): string | undefined { +): { text: string; tokens: number } | undefined { const total = + tokens?.total ?? (tokens?.input ?? 0) + - (tokens?.output ?? 0) + - (tokens?.reasoning ?? 0) + - (tokens?.cache?.read ?? 0) + - (tokens?.cache?.write ?? 0) + (tokens?.output ?? 0) + + (tokens?.reasoning ?? 0) + + (tokens?.cache?.read ?? 0) + + (tokens?.cache?.write ?? 0) if (total <= 0) { if (typeof cost === "number" && cost > 0) { - return money.format(cost) + return { text: money.format(cost), tokens: 0 } } return undefined } @@ -154,10 +162,10 @@ function formatUsage( limit && limit > 0 ? `${Locale.number(total)} (${Math.round((total / limit) * 100)}%)` : Locale.number(total) if (typeof cost === "number" && cost > 0) { - return `${text} · ${money.format(cost)}` + return { text: `${text} · ${money.format(cost)}`, tokens: total } } - return text + return { text, tokens: total } } export function formatError(error: { @@ -186,6 +194,17 @@ function isAbort(error: { name?: string } | undefined): boolean { return error?.name === "MessageAbortedError" } +// Last write wins: reported usage moves the meter in both directions (context +// shrinks when the provider compacts its own history). Interrupt estimates +// (aborted turns without a provider-reported total) only ever fill a void — +// they never displace a reported value. +function updateUsage(data: SessionData, usage: { text: string; tokens: number } | undefined, estimated: boolean) { + if (!usage) return undefined + if (estimated && data.usage && !data.usage.estimated) return undefined + data.usage = { ...usage, estimated } + return usage.text +} + function msgErr(id: string): string { return `msg:${id}:error` } @@ -848,10 +867,11 @@ export function reduceSessionData(input: SessionDataInput): SessionDataOutput { input.limits[modelKey(info.providerID, info.modelID)], typeof info.cost === "number" ? info.cost : undefined, ) - if (usage) { + const nextUsage = updateUsage(data, usage, isAbort(info.error) && info.tokens?.total === undefined) + if (nextUsage) { next = { ...next, - usage, + usage: nextUsage, } } diff --git a/packages/opencode/src/cli/cmd/tui.ts b/packages/opencode/src/cli/cmd/tui.ts index 95ffac7ea51d..cf58bae7f3e7 100644 --- a/packages/opencode/src/cli/cmd/tui.ts +++ b/packages/opencode/src/cli/cmd/tui.ts @@ -63,10 +63,15 @@ async function input(value?: string) { return piped + "\n" + value } -export function resolveThreadDirectory(project?: string, envPWD = process.env.PWD, cwd = process.cwd()) { - const root = Filesystem.resolve(envPWD ?? cwd) +export function resolveThreadDirectory( + project?: string, + envPWD = process.env.PWD, + cwd = process.cwd(), + launchCwd = process.env.OPENCODE_LAUNCH_CWD, +) { + const root = Filesystem.resolve(envPWD ?? launchCwd ?? cwd) if (project) return Filesystem.resolve(path.isAbsolute(project) ? project : path.join(root, project)) - return Filesystem.resolve(cwd) + return Filesystem.resolve(launchCwd ?? cwd) } export const TuiThreadCommand = cmd({ diff --git a/packages/opencode/src/effect/bridge.ts b/packages/opencode/src/effect/bridge.ts index a51c2938d869..f9c41a05d61a 100644 --- a/packages/opencode/src/effect/bridge.ts +++ b/packages/opencode/src/effect/bridge.ts @@ -5,7 +5,7 @@ import { InstanceRef, WorkspaceRef } from "./instance-ref" import { attachWith } from "./run-service" export interface Shape { - readonly promise: (effect: Effect.Effect) => Promise + readonly promise: (effect: Effect.Effect, options?: Effect.RunOptions) => Promise readonly fork: (effect: Effect.Effect) => Fiber.Fiber readonly run: (effect: Effect.Effect) => Effect.Effect readonly bind: (fn: (...args: Args) => Result) => (...args: Args) => Result @@ -61,8 +61,8 @@ export function make(): Effect.Effect { attachWith(effect.pipe(Effect.provide(ctx)) as Effect.Effect, { instance, workspace }) return { - promise: (effect: Effect.Effect) => - restoreWorkspace(workspace, () => Effect.runPromise(wrap(effect))), + promise: (effect: Effect.Effect, options?: Effect.RunOptions) => + restoreWorkspace(workspace, () => Effect.runPromise(wrap(effect), options)), fork: (effect: Effect.Effect) => restoreWorkspace(workspace, () => Effect.runFork(wrap(effect))), run: (effect: Effect.Effect) => diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index 13540a73a36f..4c0005ccc891 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -30,6 +30,11 @@ import { errorMessage } from "./util/error" import { PluginCommand } from "./cli/cmd/plug" import { Heap } from "./cli/heap" +const launchCwd = process.env.OPENCODE_LAUNCH_CWD +if (launchCwd) { + process.chdir(launchCwd) +} + const args = hideBin(process.argv) function show(out: string) { diff --git a/packages/opencode/src/permission/index.ts b/packages/opencode/src/permission/index.ts index 2e27ff2424db..3f0cfc9a54d1 100644 --- a/packages/opencode/src/permission/index.ts +++ b/packages/opencode/src/permission/index.ts @@ -11,13 +11,14 @@ export const Event = PermissionV1.Event export interface Interface { readonly ask: (input: PermissionV1.AskInput) => Effect.Effect + readonly askWithReply: (input: PermissionV1.AskInput) => Effect.Effect readonly reply: (input: PermissionV1.ReplyInput) => Effect.Effect readonly list: () => Effect.Effect> } interface PendingEntry { info: PermissionV1.Request - deferred: Deferred.Deferred + deferred: Deferred.Deferred } interface State { @@ -64,7 +65,7 @@ const layer = Layer.effect( }), ) - const ask = Effect.fn("Permission.ask")(function* (input: PermissionV1.AskInput) { + const askWithReply = Effect.fn("Permission.askWithReply")(function* (input: PermissionV1.AskInput) { const { approved, pending } = yield* InstanceState.get(state) const { ruleset, ...request } = input let needsAsk = false @@ -81,7 +82,7 @@ const layer = Layer.effect( needsAsk = true } - if (!needsAsk) return + if (!needsAsk) return "once" as const const id = request.id ?? PermissionV1.ID.ascending() const info: PermissionV1.Request = { @@ -95,7 +96,10 @@ const layer = Layer.effect( } yield* Effect.logInfo("asking", { id, permission: info.permission, patterns: info.patterns }) - const deferred = yield* Deferred.make() + const deferred = yield* Deferred.make< + PermissionV1.Reply, + PermissionV1.RejectedError | PermissionV1.CorrectedError + >() pending.set(id, { info, deferred }) yield* events.publish(Event.Asked, info) return yield* Effect.ensuring( @@ -106,6 +110,10 @@ const layer = Layer.effect( ) }) + const ask = Effect.fn("Permission.ask")(function* (input: PermissionV1.AskInput) { + yield* askWithReply(input) + }) + const reply = Effect.fn("Permission.reply")(function* (input: PermissionV1.ReplyInput) { const { approved, pending } = yield* InstanceState.get(state) const existing = pending.get(input.requestID) @@ -139,7 +147,7 @@ const layer = Layer.effect( return } - yield* Deferred.succeed(existing.deferred, undefined) + yield* Deferred.succeed(existing.deferred, input.reply) if (input.reply === "once") return for (const pattern of existing.info.always) { @@ -162,7 +170,7 @@ const layer = Layer.effect( requestID: item.info.id, reply: "always", }) - yield* Deferred.succeed(item.deferred, undefined) + yield* Deferred.succeed(item.deferred, "always") } }) @@ -171,7 +179,7 @@ const layer = Layer.effect( return Array.from(pending.values(), (item) => item.info) }) - return Service.of({ ask, reply, list }) + return Service.of({ ask, askWithReply, reply, list }) }), ) diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 85b7fd3978e8..acc86a6c4a5f 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -1061,6 +1061,82 @@ export const Info = Schema.Struct({ }).annotate({ identifier: "Provider" }) export type Info = Types.DeepMutable> +export const ClaudeACPProviderID = ProviderV2.ID.make("claude-acp") +export const ClaudeACPModelID = ModelV2.ID.make("claude") + +function claudeACPModel(id: string, name: string, context = 200_000): Model { + const modelID = ModelV2.ID.make(id) + return { + id: modelID, + providerID: ClaudeACPProviderID, + api: { + id: modelID, + npm: "@agentclientprotocol/sdk", + url: "acp://claude", + }, + name, + family: "claude", + capabilities: { + temperature: false, + reasoning: true, + attachment: false, + toolcall: true, + input: { + text: true, + audio: false, + image: false, + video: false, + pdf: false, + }, + output: { + text: true, + audio: false, + image: false, + video: false, + pdf: false, + }, + interleaved: false, + }, + cost: { + input: 0, + output: 0, + cache: { + read: 0, + write: 0, + }, + }, + limit: { + context, + output: 32_000, + }, + status: "active", + options: {}, + headers: {}, + release_date: "2026-07-02", + variants: {}, + } +} + +function claudeACPProvider(): Info { + return { + id: ClaudeACPProviderID, + name: "Claude", + source: "custom", + env: [], + options: {}, + models: { + [ClaudeACPModelID]: claudeACPModel(ClaudeACPModelID, "Claude Code"), + [ModelV2.ID.make("opus")]: claudeACPModel("opus", "Opus"), + [ModelV2.ID.make("opus[1m]")]: claudeACPModel("opus[1m]", "Opus (1M context)", 1_000_000), + [ModelV2.ID.make("sonnet")]: claudeACPModel("sonnet", "Sonnet"), + [ModelV2.ID.make("sonnet[1m]")]: claudeACPModel("sonnet[1m]", "Sonnet (1M context)", 1_000_000), + [ModelV2.ID.make("haiku")]: claudeACPModel("haiku", "Haiku"), + [ModelV2.ID.make("fable")]: claudeACPModel("fable", "Fable"), + [ModelV2.ID.make("fable[1m]")]: claudeACPModel("fable[1m]", "Fable (1M context)", 1_000_000), + }, + } +} + const DefaultModelIDs = Schema.Record(Schema.String, Schema.String) export const ListResult = Schema.Struct({ @@ -1594,6 +1670,8 @@ const layer = Layer.effect( mergeProvider(providerID, partial) } + if (!disabled.has(ClaudeACPProviderID)) providers[ClaudeACPProviderID] = claudeACPProvider() + const gitlab = ProviderV2.ID.make("gitlab") if (discoveryLoaders[gitlab] && providers[gitlab] && isProviderAllowed(gitlab)) { yield* Effect.promise(async () => { diff --git a/packages/opencode/src/session/llm.ts b/packages/opencode/src/session/llm.ts index a99f8acff20c..523944a21935 100644 --- a/packages/opencode/src/session/llm.ts +++ b/packages/opencode/src/session/llm.ts @@ -1,7 +1,10 @@ import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { llmClient } from "@opencode-ai/core/effect/app-node-platform" +import type { McpServer } from "@agentclientprotocol/sdk" +import { ConfigMCPV1 } from "@opencode-ai/core/v1/config/mcp" import { PermissionV1 } from "@opencode-ai/core/v1/permission" -import { Provider } from "@/provider/provider" +import { ClaudeACPProviderID, Provider } from "@/provider/provider" +import { ConfigV1 } from "@opencode-ai/core/v1/config/config" import { SessionV1 } from "@opencode-ai/core/v1/session" import { serviceUse } from "@opencode-ai/core/effect/service-use" import { Context, Effect, Layer } from "effect" @@ -17,6 +20,7 @@ import type { Agent } from "@/agent/agent" import type { MessageV2 } from "./message-v2" import { Plugin } from "@/plugin" import { Permission } from "@/permission" +import { Question } from "@/question" import { EventV2Bridge } from "@/event-v2-bridge" import { EventV2 } from "@opencode-ai/core/event" import { Wildcard } from "@/util/wildcard" @@ -27,14 +31,17 @@ import { RuntimeFlags } from "@/effect/runtime-flags" import * as Option from "effect/Option" import * as OtelTracer from "@effect/opentelemetry/Tracer" import { LLMAISDK } from "./llm/ai-sdk" +import { ClaudeACP } from "./llm/claude-acp" import { LLMNativeRuntime } from "./llm/native-runtime" import { LLMRequestPrep } from "./llm/request" +import { Session } from "./session" export const OUTPUT_TOKEN_MAX = ProviderTransform.OUTPUT_TOKEN_MAX export type StreamInput = { + cwd?: string user: SessionV1.User - sessionID: string + sessionID: SessionID parentSessionID?: string model: Provider.Model agent: Agent.Info @@ -59,6 +66,25 @@ export class Service extends Context.Service()("@opencode/LL export const use = serviceUse(Service) +export function claudeACPPromptBridge(input: { + readonly bridge: EffectBridge.Shape + readonly abort: AbortSignal + readonly permission: Permission.Interface + readonly question: Question.Interface +}) { + return { + permission: { + ask: (request: PermissionV1.AskInput) => + input.bridge.promise(input.permission.askWithReply(request), { signal: input.abort }), + reply: (request: PermissionV1.ReplyInput) => input.bridge.promise(input.permission.reply(request)), + }, + question: { + ask: (request: Parameters[0]) => + input.bridge.promise(input.question.ask(request), { signal: input.abort }), + }, + } +} + const live: Layer.Layer< Service, never, @@ -67,9 +93,11 @@ const live: Layer.Layer< | Provider.Service | Plugin.Service | Permission.Service + | Question.Service | EventV2Bridge.Service | LLMClientService | RuntimeFlags.Service + | Session.Service > = Layer.effect( Service, Effect.gen(function* () { @@ -78,9 +106,11 @@ const live: Layer.Layer< const provider = yield* Provider.Service const plugin = yield* Plugin.Service const perm = yield* Permission.Service + const question = yield* Question.Service const events = yield* EventV2Bridge.Service const llmClient = yield* LLMClient.Service const flags = yield* RuntimeFlags.Service + const session = yield* Session.Service const run = Effect.fn("LLM.run")(function* (input: StreamRequest) { yield* Effect.logInfo("stream", { @@ -92,6 +122,60 @@ const live: Layer.Layer< mode: input.agent.mode, }) + if (input.model.providerID === ClaudeACPProviderID) { + const unsupported = claudeACPUnsupported(input) + if (unsupported) { + return { + type: "native" as const, + stream: Stream.fail(new Error(unsupported)), + } + } + if (!input.cwd) { + return { + type: "native" as const, + stream: Stream.fail(new Error("Claude ACP requires a session cwd")), + } + } + const cfg = yield* config.get() + const bridge = yield* EffectBridge.make() + const prompts = claudeACPPromptBridge({ bridge, abort: input.abort, permission: perm, question }) + yield* Effect.logInfo("llm runtime selected", { + "llm.runtime": "claude-acp", + "llm.provider": input.model.providerID, + "llm.model": input.model.id, + }) + return { + type: "native" as const, + stream: ClaudeACP.stream({ + cwd: input.cwd, + sessionID: input.sessionID, + modelID: input.model.id, + agent: input.agent.name, + mcpServers: claudeMcpServers(cfg), + messages: claudeACPMessages(input.system, input.messages), + abort: input.abort, + // Claude ACP owns tool execution through Claude Code plus MCP servers; + // OpenCode AI SDK tools are intentionally not forwarded here. + ruleset: Permission.merge(input.agent.permission, input.permission ?? []), + permission: prompts.permission, + question: prompts.question, + onConfig: (config) => + bridge.promise( + Effect.gen(function* () { + const current = yield* session.get(input.sessionID) + yield* session.setMetadata({ + sessionID: input.sessionID, + metadata: { + ...current.metadata, + claudeAcp: config, + }, + }) + }), + ), + }), + } + } + const [language, cfg, item, info] = yield* Effect.all( [ provider.getLanguage(input.model), @@ -386,6 +470,58 @@ const live: Layer.Layer< export const hasToolCalls = LLMRequestPrep.hasToolCalls +function claudeACPUnsupported(input: StreamRequest) { + if (input.toolChoice && input.toolChoice !== "auto") { + return `Claude ACP does not support toolChoice "${input.toolChoice}"` + } +} + +function claudeACPMessages(system: string[], messages: ModelMessage[]) { + const text = system.map((item) => item.trim()).filter(Boolean).join("\n\n") + if (!text) return messages + return [{ role: "system" as const, content: text }, ...messages] +} + +function claudeMcpServers(config: ConfigV1.Info): McpServer[] { + return Object.entries(config.mcp ?? {}).flatMap(([name, server]) => { + if (!isMcpConfigured(server) || server.enabled === false) return [] + + if (server.type === "local") { + const [command, ...args] = server.command + if (!command) return [] + return [ + { + name, + command, + args, + env: Object.entries(server.environment ?? {}).map(([name, value]) => ({ name, value })), + } satisfies McpServer, + ] + } + + return [ + { + type: remoteMcpType(server.url), + name, + url: server.url, + headers: Object.entries(server.headers ?? {}).map(([name, value]) => ({ name, value })), + } satisfies McpServer, + ] + }) +} + +function isMcpConfigured(server: NonNullable[string]): server is ConfigMCPV1.Info { + return typeof server === "object" && server !== null && "type" in server +} + +function remoteMcpType(url: string): "http" | "sse" { + try { + const parsed = new URL(url) + if (parsed.pathname.toLowerCase().endsWith("/sse")) return "sse" + } catch {} + return "http" +} + export const node = LayerNode.make({ service: Service, layer: live, @@ -395,9 +531,11 @@ export const node = LayerNode.make({ Provider.node, Plugin.node, Permission.node, + Question.node, EventV2Bridge.node, llmClient, RuntimeFlags.node, + Session.node, ], }) diff --git a/packages/opencode/src/session/llm/claude-acp.ts b/packages/opencode/src/session/llm/claude-acp.ts new file mode 100644 index 000000000000..7f541270963a --- /dev/null +++ b/packages/opencode/src/session/llm/claude-acp.ts @@ -0,0 +1,1601 @@ +import { mkdir } from "node:fs/promises" +import path from "node:path" +import { fileURLToPath } from "node:url" +import { ClientSideConnection, PROTOCOL_VERSION, RequestError, ndJsonStream } from "@agentclientprotocol/sdk" +import { PermissionV1 } from "@opencode-ai/core/v1/permission" +import type { + Client, + CreateElicitationRequest, + CreateElicitationResponse, + CreateTerminalRequest, + CreateTerminalResponse, + ElicitationContentValue, + ElicitationPropertySchema, + KillTerminalRequest, + KillTerminalResponse, + McpServer, + ReadTextFileRequest, + ReadTextFileResponse, + ReleaseTerminalRequest, + ReleaseTerminalResponse, + RequestPermissionRequest, + RequestPermissionResponse, + SessionConfigOption, + SessionNotification, + TerminalOutputRequest, + TerminalOutputResponse, + ToolCall, + ToolCallContent, + ToolCallUpdate, + WaitForTerminalExitRequest, + WaitForTerminalExitResponse, + WriteTextFileRequest, + WriteTextFileResponse, +} from "@agentclientprotocol/sdk" +import { LLMEvent, ToolResultValue, Usage, type FinishReason, type LLMEvent as LLMEventType } from "@opencode-ai/llm" +import { Question } from "@/question" +import type { ModelMessage } from "ai" +import { createTwoFilesPatch } from "diff" +import * as Stream from "effect/Stream" + +type PermissionBridge = { + readonly ask: (input: PermissionV1.AskInput) => Promise + readonly reply: (input: PermissionV1.ReplyInput) => Promise +} + +type QuestionBridge = { + readonly ask: (input: Parameters[0]) => Promise> +} + +export type ClaudeACPConfigState = { + readonly effort?: string + readonly fast?: boolean +} + +type StreamInput = { + readonly cwd: string + readonly sessionID: PermissionV1.AskInput["sessionID"] + readonly modelID: string + readonly agent: string + readonly mcpServers: readonly McpServer[] + readonly messages: ModelMessage[] + readonly abort: AbortSignal + readonly ruleset: PermissionV1.Ruleset + readonly permission: PermissionBridge + readonly question: QuestionBridge + readonly onConfig?: (config: ClaudeACPConfigState) => Promise +} + +type Terminal = { + readonly process: Bun.Subprocess<"ignore", "pipe", "pipe"> + readonly output: string[] + readonly limit: number + truncated: boolean + readonly exited: Promise + exitStatus?: WaitForTerminalExitResponse +} + +type ACPToolState = { + name: string + title: string + input: unknown + content?: ToolCallContent[] | null + rawOutput?: unknown + status?: ToolCall["status"] | null + started: boolean +} + +type QueueItem = + | { readonly type: "event"; readonly event: LLMEventType } + | { readonly type: "done" } + | { readonly type: "error"; readonly error: unknown } + +type ACPUsage = { + readonly cachedReadTokens?: number | null + readonly cachedWriteTokens?: number | null + readonly inputTokens: number + readonly outputTokens: number + readonly thoughtTokens?: number | null + readonly totalTokens: number +} + +type ACPContextUsage = { + readonly used: number + readonly size: number +} + +type ACPProviderMetadata = { + readonly anthropic: Record +} + +type ElicitationField = { + readonly key: string + readonly question: Question.Info + readonly value: (answers: ReadonlyArray) => ElicitationContentValue | undefined +} + +type Connection = { + readonly key: string + readonly cwd: string + readonly child: Bun.Subprocess<"pipe", "pipe", "pipe"> + client: ClientSideConnection + readonly stderr: string[] + readonly terminals: Map + sessionID: string + configOptions: SessionConfigOption[] + lock: Promise + used: boolean + disposed: boolean + active?: { + readonly cwd: string + readonly queue: ReturnType + readonly sessionID: PermissionV1.AskInput["sessionID"] + readonly abort: AbortSignal + readonly ruleset: PermissionV1.Ruleset + readonly permission: PermissionBridge + readonly question: QuestionBridge + readonly tools: Map + contextUsage?: ACPContextUsage + providerCompacted?: boolean + } + disposeTimer?: ReturnType +} + +type ActivePermissionRequest = { + readonly sessionID: PermissionV1.AskInput["sessionID"] + readonly abort: AbortSignal + readonly ruleset: PermissionV1.Ruleset + readonly permission: PermissionBridge +} + +type ActiveDirectRequest = ActivePermissionRequest & { + readonly cwd: string +} + +type DirectPermissionCheck = Pick + +type DirectPermissionInput = + | { + readonly kind: "read" | "write" + readonly cwd: string + readonly path: string + } + | { + readonly kind: "terminal" + readonly cwd: string + readonly command: string + readonly args?: readonly string[] | null + readonly terminalCwd?: string | null + } + +const TEXT_ID = "claude-acp-text" +const REASONING_ID = "claude-acp-reasoning" +const IDLE_CLOSE_MS = 10 * 60_000 +const TERMINAL_OUTPUT_DEFAULT = 128_000 +const TERMINAL_OUTPUT_MAX = 1_000_000 +const connections = new Map>() +const activeConnections = new Set() + +process.once("exit", () => { + for (const connection of activeConnections) { + cleanupTerminals(connection) + connection.child.kill() + } +}) + +export function stream(input: StreamInput): Stream.Stream { + return Stream.fromAsyncIterable(run(input), (error) => + error instanceof Error ? error : new Error(String(error)), + ) +} + +async function* run(input: StreamInput) { + const queue = makeQueue() + let connection: Connection | undefined + let finished = false + const onAbort = () => { + if (!connection) return + void connection.client.cancel({ sessionId: connection.sessionID }).catch(() => undefined) + } + input.abort.addEventListener("abort", onAbort, { once: true }) + + void (async () => { + try { + queue.push(LLMEvent.stepStart({ index: 0 })) + const activeConnection = await getConnection(input) + connection = activeConnection + await withConnectionLock(activeConnection, async () => { + if (input.abort.aborted) { + finish(queue, "error") + return + } + clearDisposeTimer(activeConnection) + activeConnection.stderr.length = 0 + activeConnection.active = { + cwd: input.cwd, + queue, + sessionID: input.sessionID, + abort: input.abort, + ruleset: input.ruleset, + permission: input.permission, + question: input.question, + tools: new Map(), + } + try { + await publishConfig(input, activeConnection) + const commandText = currentPromptText(input.messages) + const command = claudeACPConfigCommand(commandText) + if (command) { + const message = await applyConfigCommand(activeConnection, command) + await publishConfig(input, activeConnection) + activeConnection.active?.queue.text(message) + finish(queue, "stop") + return + } + const response = await activeConnection.client.prompt({ + sessionId: activeConnection.sessionID, + prompt: [ + { + type: "text", + text: activeConnection.used ? commandText : promptText(input.messages), + }, + ], + }) + activeConnection.used = true + await new Promise((resolve) => setTimeout(resolve, 50)) + if (input.abort.aborted) { + finish( + queue, + "error", + claudeUsage(response.usage, activeConnection.active?.contextUsage) ?? + claudeContextUsage(activeConnection.active?.contextUsage), + claudeProviderMetadata(activeConnection.active?.providerCompacted), + ) + return + } + finish( + queue, + finishReason(response.stopReason), + claudeUsage(response.usage, activeConnection.active?.contextUsage), + claudeProviderMetadata(activeConnection.active?.providerCompacted), + ) + } finally { + const providerCompacted = activeConnection.active?.providerCompacted === true + activeConnection.active = undefined + cleanupTerminals(activeConnection) + // OpenCode compaction rewrites our stored history, but Claude Code keeps its own ACP session history. + // Start the next turn in a fresh Claude session so it is seeded from the compacted transcript. + if (input.agent === "compaction" || providerCompacted) disposeConnection(activeConnection) + else scheduleDispose(activeConnection) + } + }) + } catch (error) { + if (connection) { + const usage = claudeContextUsage(connection.active?.contextUsage) + const providerMetadata = claudeProviderMetadata(connection.active?.providerCompacted) + const aborted = input.abort.aborted + connection.active = undefined + cleanupTerminals(connection) + if (aborted) { + scheduleDispose(connection) + finish(queue, "error", usage, providerMetadata) + return + } + disposeConnection(connection) + } + if (input.abort.aborted) { + finish(queue, "error") + return + } + const message = connection?.stderr.join("").trim() + queue.fail(message ? new Error(`${errorMessage(error)}\n${message}`) : error) + } + })() + + try { + for await (const event of queue) { + finished = event.type === "finish" + yield event + } + } finally { + input.abort.removeEventListener("abort", onAbort) + if (connection && !finished) { + await connection.client.cancel({ sessionId: connection.sessionID }).catch(() => undefined) + disposeConnection(connection) + } + } +} + +async function getConnection(input: StreamInput) { + const key = claudeACPConnectionKey(input) + const existing = connections.get(key) + if (existing) { + const connection = await existing + if (!connection.disposed) return connection + connections.delete(key) + } + const created = createConnection(input, key).catch((error) => { + connections.delete(key) + throw error + }) + connections.set(key, created) + return created +} + +async function createConnection(input: StreamInput, key: string): Promise { + if (input.abort.aborted) throw abortError() + const stderr: string[] = [] + const terminals = new Map() + const child = Bun.spawn({ + cmd: claudeCommand(), + cwd: input.cwd, + env: claudeEnv(input.modelID), + stdin: "pipe", + stdout: "pipe", + stderr: "pipe", + }) + const connection = { + key, + cwd: input.cwd, + child, + stderr, + terminals, + sessionID: "", + configOptions: [] as SessionConfigOption[], + lock: Promise.resolve(), + used: false, + disposed: false, + } as unknown as Connection + activeConnections.add(connection) + connection.client = new ClientSideConnection( + () => makeClient(connection), + ndJsonStream(writable(child.stdin), child.stdout), + ) + void collect(child.stderr, stderr, 32_000) + const onAbort = () => disposeConnection(connection) + input.abort.addEventListener("abort", onAbort, { once: true }) + void child.exited.finally(() => { + if (connection.disposed) return + connection.disposed = true + activeConnections.delete(connection) + connections.delete(connection.key) + }) + try { + await connection.client.initialize({ + protocolVersion: PROTOCOL_VERSION, + clientInfo: { name: "OpenCode", version: "0.0.0" }, + clientCapabilities: { + auth: { terminal: true }, + elicitation: { form: {} }, + fs: { readTextFile: true, writeTextFile: true }, + terminal: true, + }, + }) + if (input.abort.aborted) throw abortError() + const session = await connection.client.newSession({ cwd: input.cwd, mcpServers: [...input.mcpServers] }) + if (input.abort.aborted) throw abortError() + connection.sessionID = session.sessionId + connection.configOptions = session.configOptions ?? [] + scheduleDispose(connection) + return connection + } catch (error) { + disposeConnection(connection) + throw error + } finally { + input.abort.removeEventListener("abort", onAbort) + } +} + +async function withConnectionLock(connection: Connection, fn: () => Promise) { + const previous = connection.lock.catch(() => undefined) + let release!: () => void + connection.lock = previous.then(() => new Promise((resolve) => (release = resolve))) + await previous + try { + return await fn() + } finally { + release() + } +} + +export function claudeACPConnectionKey( + input: Pick, +) { + return [ + input.sessionID, + input.cwd, + input.modelID, + input.agent, + stableStringify(input.mcpServers), + stableStringify(input.messages.filter((message) => message.role === "system").map((message) => contentText(message.content))), + ].join("\0") +} + +function scheduleDispose(connection: Connection) { + if (connection.disposed) return + clearDisposeTimer(connection) + connection.disposeTimer = setTimeout(() => disposeConnection(connection), IDLE_CLOSE_MS) + connection.disposeTimer.unref?.() +} + +function clearDisposeTimer(connection: Connection) { + if (!connection.disposeTimer) return + clearTimeout(connection.disposeTimer) + connection.disposeTimer = undefined +} + +function disposeConnection(connection: Connection) { + if (connection.disposed) return + connection.disposed = true + clearDisposeTimer(connection) + activeConnections.delete(connection) + connections.delete(connection.key) + cleanupTerminals(connection) + if (connection.sessionID) void connection.client.closeSession({ sessionId: connection.sessionID }).catch(() => undefined) + connection.child.kill() + void Promise.allSettled([connection.client.closed, connection.child.exited]) +} + +function cleanupTerminals(connection: Connection) { + for (const terminal of connection.terminals.values()) terminal.process.kill() + connection.terminals.clear() +} + +function claudeCommand() { + const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../..") + return ["node", path.join(root, "node_modules", "@agentclientprotocol", "claude-agent-acp", "dist", "index.js")] +} + +function errorMessage(error: unknown) { + const message = error instanceof Error ? error.message : String(error) + if (message.trim() === "Authentication required") { + return "Claude Code authentication required. Run `claude auth login` in a normal terminal, then retry in OpenCode." + } + return message +} + +function abortError() { + return new DOMException("Aborted", "AbortError") +} + +function claudeEnv(modelID: string) { + const selected = claudeModelID(modelID) + if (!selected) return process.env + return { + ...process.env, + ANTHROPIC_MODEL: selected, + } +} + +export type ClaudeACPConfigCommand = { + readonly configId: "effort" | "model" | "fast" + readonly value?: string +} + +/** Claude Code disables /effort under ACP/SDK; OpenCode handles these locally via setSessionConfigOption. */ +export function claudeACPConfigCommand(text: string): ClaudeACPConfigCommand | undefined { + const match = text.trim().match(/^\/(effort|model|fast)(?:\s+(\S+))?\s*$/i) + if (!match) return + return { + configId: match[1].toLowerCase() as ClaudeACPConfigCommand["configId"], + value: match[2]?.toLowerCase(), + } +} + +export function claudeACPConfigOptionValues(option: SessionConfigOption | undefined) { + if (!option) return [] + if (option.type === "boolean") return ["on", "off"] + if (option.type !== "select" || !Array.isArray(option.options)) return [] + return option.options.flatMap((entry) => ("options" in entry ? entry.options : [entry])).map((entry) => entry.value) +} + +export function claudeACPConfigOptionCurrent(option: SessionConfigOption | undefined) { + if (!option) return + if (option.type === "boolean") return option.currentValue ? "on" : "off" + if (typeof option.currentValue === "string") return option.currentValue +} + +export function claudeACPConfigState(configOptions: readonly SessionConfigOption[]): ClaudeACPConfigState { + const effort = claudeACPConfigOptionCurrent(configOptions.find((item) => item.id === "effort")) + const fast = claudeACPConfigOptionCurrent(configOptions.find((item) => item.id === "fast")) + return { + ...(effort ? { effort } : {}), + ...(fast !== undefined ? { fast: fast === "on" } : {}), + } +} + +async function publishConfig(input: StreamInput, connection: Connection) { + if (!input.onConfig) return + await input.onConfig(claudeACPConfigState(connection.configOptions)) +} + +async function applyConfigCommand(connection: Connection, command: ClaudeACPConfigCommand) { + if (command.configId === "fast") return applyFastCommand(connection, command.value) + + const option = connection.configOptions.find((item) => item.id === command.configId) + if (!option) { + return `${labelForConfig(command.configId)} isn't available for the current Claude Code session.` + } + + const current = claudeACPConfigOptionCurrent(option) + const allowed = claudeACPConfigOptionValues(option) + if (!command.value) { + const choices = allowed.length > 0 ? allowed.join(", ") : "unknown" + return `${labelForConfig(command.configId)} is currently ${current ?? "unset"}. Available: ${choices}` + } + + const value = resolveConfigValue(command, allowed) + if (!value) { + return `Invalid ${command.configId} value "${command.value}". Available: ${allowed.join(", ") || "none"}` + } + if (value === current) return `${labelForConfig(command.configId)} is already ${value}` + + await setConfigOption(connection, command.configId, value, option) + const next = claudeACPConfigOptionCurrent(connection.configOptions.find((item) => item.id === command.configId)) + return `${labelForConfig(command.configId)} set to ${next ?? value}` +} + +async function applyFastCommand(connection: Connection, raw?: string) { + const current = () => claudeACPConfigOptionCurrent(configOption(connection, "fast")) + const desired = resolveFastDesired(raw, current()) + if (desired === "invalid") { + return `Invalid fast value "${raw}". Use on, off, or omit a value to toggle.` + } + + if (desired === false) { + const option = configOption(connection, "fast") + if (!option || current() !== "on") return "Fast mode OFF" + await setConfigOption(connection, "fast", "off", option) + return "Fast mode OFF" + } + + // Claude Code switches to Opus when enabling fast mode on an unsupported model. + let option = configOption(connection, "fast") + if (!option) { + const model = configOption(connection, "model") + if (!model) { + return "Fast mode isn't available for the current Claude Code session." + } + await setConfigOption(connection, "model", "opus", model) + option = configOption(connection, "fast") + if (!option) { + return "Switched to Opus, but Fast mode still isn't available. It may be disabled for your account or organization." + } + } + + if (current() === "on") return "Fast mode ON" + await setConfigOption(connection, "fast", "on", option) + return "Fast mode ON" +} + +export function resolveFastDesired(raw: string | undefined, current: string | undefined) { + if (!raw) return current !== "on" + if (raw === "on" || raw === "true" || raw === "1") return true + if (raw === "off" || raw === "false" || raw === "0") return false + if (raw === "toggle") return current !== "on" + return "invalid" +} + +function configOption(connection: Connection, id: string) { + return connection.configOptions.find((item) => item.id === id) +} + +function resolveConfigValue(command: ClaudeACPConfigCommand, allowed: string[]) { + if (!command.value) return + if (allowed.includes(command.value)) return command.value + // Model aliases are resolved by Claude ACP when the exact ID is absent. + if (command.configId === "model") return command.value +} + +function labelForConfig(configId: ClaudeACPConfigCommand["configId"]) { + if (configId === "effort") return "Effort" + if (configId === "model") return "Model" + return "Fast mode" +} + +async function setConfigOption( + connection: Connection, + configId: string, + value: string, + option: SessionConfigOption, +) { + if (option.type === "boolean") { + const response = await connection.client.setSessionConfigOption({ + sessionId: connection.sessionID, + configId, + type: "boolean", + value: value === "on", + }) + connection.configOptions = response.configOptions ?? connection.configOptions + return + } + const response = await connection.client.setSessionConfigOption({ + sessionId: connection.sessionID, + configId, + value, + }) + connection.configOptions = response.configOptions ?? connection.configOptions +} + +function makeClient(connection: Connection): Client { + return { + sessionUpdate: async (params: SessionNotification) => sessionUpdate(connection, params), + requestPermission: (params) => requestPermission(connection, params), + unstable_createElicitation: (params) => createElicitation(connection, params), + readTextFile: (params) => readTextFile(connection.active, connection.cwd, params), + writeTextFile: (params) => writeTextFile(connection.active, connection.cwd, params), + createTerminal: (params) => createTerminal(connection, params), + terminalOutput: (params) => terminalOutput(connection.terminals, params), + waitForTerminalExit: (params) => waitForTerminalExit(connection.terminals, params), + killTerminal: (params) => killTerminal(connection.terminals, params), + releaseTerminal: (params) => releaseTerminal(connection.terminals, params), + } +} + +function sessionUpdate(connection: Connection, params: SessionNotification) { + if (params.update.sessionUpdate === "config_option_update") { + connection.configOptions = params.update.configOptions ?? connection.configOptions + return + } + const active = connection.active + if (!active) return + if (params.update.sessionUpdate === "usage_update") { + const used = token(params.update.used) + const size = token(params.update.size) + // The adapter sends used: 0 only as a fallback when its post-compaction + // context probe fails — never as a real measurement (the system prompt + // alone occupies tokens) — so hold the last report instead of wiping it. + if (!used || !size) return + active.contextUsage = { used, size } + // Claude Code reports context occupancy as it changes (including the drop + // after its internal compaction) — stream it so the meter moves live. + const usage = claudeContextUsage(active.contextUsage) + if (usage) active.queue.push(LLMEvent.usage(usage)) + return + } + if (params.update.sessionUpdate === "agent_message_chunk" && params.update.content.type === "text") { + const compaction = claudeACPCompactionStatus(params.update.content.text) + if (compaction) { + if (compaction === "completed") active.providerCompacted = true + return + } + active.queue.text(params.update.content.text) + return + } + if (params.update.sessionUpdate === "agent_thought_chunk" && params.update.content.type === "text") { + active.queue.reasoning(params.update.content.text) + return + } + if (params.update.sessionUpdate === "tool_call" || params.update.sessionUpdate === "tool_call_update") { + const events = claudeACPToolEvents(active.tools, params.update) + if (events.length === 0) return + active.queue.closeBlocks() + for (const event of events) active.queue.push(event) + } +} + +export function claudeACPToolEvents( + state: Map, + update: Extract, +) { + const previous = state.get(update.toolCallId) + const tool = { + name: claudeACPToolName(update, previous?.name), + title: update.title ?? previous?.title ?? update.toolCallId, + input: "rawInput" in update && update.rawInput !== undefined ? update.rawInput : (previous?.input ?? {}), + content: update.content ?? previous?.content, + rawOutput: "rawOutput" in update && update.rawOutput !== undefined ? update.rawOutput : previous?.rawOutput, + status: update.status ?? previous?.status, + started: previous?.started ?? false, + } satisfies ACPToolState + state.set(update.toolCallId, tool) + + const events: LLMEventType[] = [] + if (!tool.started) { + tool.started = true + events.push( + LLMEvent.toolCall({ + id: update.toolCallId, + name: tool.name, + input: tool.input, + providerExecuted: true, + providerMetadata: claudeACPToolMetadata(tool, update), + }), + ) + } + + if (tool.status === "completed") { + events.push( + LLMEvent.toolResult({ + id: update.toolCallId, + name: tool.name, + result: ToolResultValue.make({ + title: tool.title, + output: claudeACPToolOutput(tool), + metadata: claudeACPToolResultMetadata(tool, update), + }), + providerExecuted: true, + providerMetadata: claudeACPToolMetadata(tool, update), + }), + ) + state.delete(update.toolCallId) + } + + if (tool.status === "failed") { + events.push( + LLMEvent.toolError({ + id: update.toolCallId, + name: tool.name, + message: claudeACPToolOutput(tool), + error: tool.rawOutput, + providerMetadata: claudeACPToolMetadata(tool, update), + }), + ) + state.delete(update.toolCallId) + } + + return events +} + +function claudeACPToolName(tool: Partial>, fallback?: string) { + switch (tool.kind) { + case "execute": + return "bash" + case "edit": + case "delete": + case "move": + return "edit" + case "fetch": + return "webfetch" + case "search": + return "grep" + case "read": + return "read" + } + + const input = recordValue(tool.rawInput) + return ( + stringValue(input.tool) ?? + stringValue(input.toolName) ?? + stringValue(input.name) ?? + stringValue(input.command) ?? + fallback ?? + tool.kind ?? + tool.title ?? + "claude_tool" + ) +} + +function claudeACPToolMetadata( + tool: ACPToolState, + update: Extract, +) { + return { + anthropic: { + acpTool: { + id: update.toolCallId, + name: tool.name, + title: tool.title, + status: tool.status, + kind: update.kind, + }, + }, + } +} + +function claudeACPToolResultMetadata( + tool: ACPToolState, + update: Extract, +) { + return { + acp: { + status: tool.status, + kind: update.kind, + rawOutput: tool.rawOutput, + }, + } +} + +function claudeACPToolOutput(tool: ACPToolState) { + if (typeof tool.rawOutput === "string") return tool.rawOutput + const output = recordValue(tool.rawOutput).output + if (typeof output === "string") return output + if (tool.rawOutput !== undefined) return stringifyToolValue(tool.rawOutput) + const content = (tool.content ?? []).map(toolContentText).filter(Boolean).join("\n") + return content || tool.title +} + +function toolContentText(content: ToolCallContent) { + if (content.type === "diff") return `Updated ${content.path}` + if (content.type === "terminal") return `Terminal ${content.terminalId}` + if (content.content.type === "text") return content.content.text + if (content.content.type === "image") return "[image]" + return stringifyToolValue(content.content) +} + +function stringifyToolValue(value: unknown) { + if (typeof value === "string") return value + try { + return JSON.stringify(value) ?? "" + } catch { + return String(value) + } +} + +async function requestPermission( + connection: Connection, + params: RequestPermissionRequest, +): Promise { + return requestPermissionForActive(connection.active, params) +} + +export async function requestPermissionForActive( + active: ActivePermissionRequest | undefined, + params: RequestPermissionRequest, +): Promise { + if (!active || active.abort.aborted) return cancelledPermission() + + const requestID = PermissionV1.ID.ascending() + const permission = permissionName(params) + const metadata = permissionMetadata(params) + const patterns = permissionPatterns(permission, metadata, params) + const onAbort = () => { + void active.permission.reply({ requestID, reply: "reject" }).catch(() => undefined) + } + active.abort.addEventListener("abort", onAbort, { once: true }) + + try { + const reply = await active.permission.ask({ + id: requestID, + sessionID: active.sessionID, + permission, + patterns, + always: permissionAlways(permission, patterns), + metadata, + ruleset: active.ruleset, + }) + return allowPermission(params, reply) + } catch (error) { + if (active.abort.aborted) return cancelledPermission() + if ( + error instanceof PermissionV1.DeniedError || + error instanceof PermissionV1.RejectedError || + error instanceof PermissionV1.CorrectedError + ) { + return rejectPermission(params) + } + return cancelledPermission() + } finally { + active.abort.removeEventListener("abort", onAbort) + } +} + +function permissionName(params: RequestPermissionRequest) { + return claudeACPToolName(params.toolCall) +} + +function permissionMetadata(params: RequestPermissionRequest): Record { + const metadata = { ...recordValue(params.toolCall.rawInput) } + metadata.toolCallId = params.toolCall.toolCallId + metadata.toolName = claudeACPToolName(params.toolCall) + metadata.kind = params.toolCall.kind ?? "other" + metadata.title = params.toolCall.title ?? params.toolCall.toolCallId + + const location = params.toolCall.locations?.find((item) => item.path)?.path + if (location) { + metadata.path ??= location + metadata.filePath ??= location + metadata.filepath ??= location + } + + const diff = params.toolCall.content?.find((item) => item.type === "diff") + if (diff) { + const diffPath = stringValue(diff.path) + if (diffPath) { + metadata.filepath = diffPath + metadata.filePath = diffPath + } + if (diffPath && typeof diff.newText === "string") { + metadata.diff = createTwoFilesPatch( + diffPath, + diffPath, + typeof diff.oldText === "string" ? diff.oldText : "", + diff.newText, + ) + } + } + + if (params.toolCall.kind === "execute") metadata.command ??= params.toolCall.title ?? params.toolCall.toolCallId + if (params.toolCall.kind === "fetch") metadata.url ??= params.toolCall.title + if (params.toolCall.kind === "search") metadata.pattern ??= params.toolCall.title + return metadata +} + +function permissionPatterns( + permission: string, + metadata: Record, + params: RequestPermissionRequest, +) { + const locations = params.toolCall.locations?.map((item) => item.path).filter((item): item is string => !!item) ?? [] + if (permission === "bash") return [stringValue(metadata.command) ?? params.toolCall.title ?? params.toolCall.toolCallId] + if (permission === "webfetch") return [stringValue(metadata.url) ?? params.toolCall.title ?? "*"] + if (permission === "grep") return [stringValue(metadata.pattern) ?? params.toolCall.title ?? "*"] + + const file = firstString(metadata.filePath, metadata.filepath, metadata.path) + if ((permission === "read" || permission === "edit") && file) return [file] + if (locations.length > 0) return locations + return [stringValue(metadata.toolName) ?? params.toolCall.title ?? "*"] +} + +function permissionAlways(permission: string, patterns: string[]) { + if (permission === "bash") return patterns + return ["*"] +} + +function allowPermission(params: RequestPermissionRequest, reply: PermissionV1.Reply): RequestPermissionResponse { + void reply + const option = params.options.find((item) => item.kind === "allow_once") + if (!option) return cancelledPermission() + return { outcome: { outcome: "selected", optionId: option.optionId } } +} + +function rejectPermission(params: RequestPermissionRequest): RequestPermissionResponse { + const option = params.options.find((item) => item.kind === "reject_once" || item.kind === "reject_always") + if (!option) return cancelledPermission() + return { outcome: { outcome: "selected", optionId: option.optionId } } +} + +function cancelledPermission(): RequestPermissionResponse { + return { outcome: { outcome: "cancelled" } } +} + +async function createElicitation( + connection: Connection, + params: CreateElicitationRequest, +): Promise { + const active = connection.active + if (!active || active.abort.aborted) return { action: "cancel" } + if (params.mode !== "form" || !("sessionId" in params)) return { action: "decline" } + + const fields = claudeACPElicitationFields(params) + if (fields.length === 0) return { action: "accept", content: {} } + + try { + const answers = await active.question.ask({ + sessionID: active.sessionID, + questions: fields.map((field) => field.question), + }) + return { action: "accept", content: claudeACPElicitationContent(fields, answers) } + } catch { + if (active.abort.aborted) return { action: "cancel" } + return { action: "decline" } + } +} + +export function claudeACPElicitationFields(params: CreateElicitationRequest): ElicitationField[] { + if (params.mode !== "form") return [] + return Object.entries(params.requestedSchema.properties ?? {}).map(([key, property]) => + elicitationField(key, property, params), + ) +} + +export function claudeACPElicitationContent( + fields: ReadonlyArray, + answers: ReadonlyArray, +) { + return Object.fromEntries( + fields.flatMap((field, index) => { + const value = field.value(answers[index] ?? []) + if (value === undefined) return [] + return [[field.key, value]] + }), + ) +} + +function elicitationField(key: string, property: ElicitationPropertySchema, params: CreateElicitationRequest) { + const title = property.title ?? key + const description = property.description ?? params.message + const base = { + header: shortHeader(title), + question: title, + } + + if (property.type === "string") { + const choices = property.oneOf?.map((item) => ({ label: item.title, description: item.const })) ?? property.enum + const options = choices?.map((item) => + typeof item === "string" ? { label: item, description } : { label: item.label, description: item.description }, + ) + const values = choices?.map( + (item): readonly [string, string] => + typeof item === "string" ? [item, item] : [item.label, item.description], + ) + return { + key, + question: { ...base, options: options ?? [], custom: !options?.length }, + value: (answers: ReadonlyArray) => valueFromLabels(values, answers, property.default), + } satisfies ElicitationField + } + + if (property.type === "array") { + const raw = "anyOf" in property.items ? property.items.anyOf : property.items.enum + const values = raw.map( + (item): readonly [string, string] => (typeof item === "string" ? [item, item] : [item.title, item.const]), + ) + return { + key, + question: { + ...base, + options: values.map(([label, value]) => ({ label, description: value })), + custom: false, + multiple: true, + }, + value: (answers: ReadonlyArray) => valueFromLabels(values, answers, property.default ?? []), + } satisfies ElicitationField + } + + if (property.type === "boolean") { + return { + key, + question: { + ...base, + options: [ + { label: "Yes", description }, + { label: "No", description }, + ], + custom: false, + }, + value: (answers: ReadonlyArray) => { + if (answers[0] === "Yes") return true + if (answers[0] === "No") return false + return property.default ?? undefined + }, + } satisfies ElicitationField + } + + return { + key, + question: { ...base, options: [], custom: true }, + value: (answers: ReadonlyArray) => { + const value = answers[0] + if (value === undefined || value.trim() === "") return property.default ?? undefined + const parsed = property.type === "integer" ? Number.parseInt(value, 10) : Number(value) + if (Number.isNaN(parsed)) return property.default ?? undefined + return parsed + }, + } satisfies ElicitationField +} + +function valueFromLabels( + values: ReadonlyArray | undefined, + answers: ReadonlyArray, + fallback: string | ReadonlyArray | null | undefined, +) { + if (!values) return answers[0] ?? fallback ?? undefined + const selected = answers.flatMap((answer) => values.find(([label]) => label === answer)?.[1] ?? []) + if (Array.isArray(fallback)) return selected.length ? selected : fallback + return selected[0] ?? fallback ?? undefined +} + +function shortHeader(value: string) { + return value.length <= 30 ? value : value.slice(0, 30) +} + +export function claudeACPDirectPermissionChecks(input: DirectPermissionInput): DirectPermissionCheck[] { + if (input.kind === "terminal") { + const cwd = input.terminalCwd ? resolveACPPath(input.cwd, input.terminalCwd) : path.resolve(input.cwd) + const command = [input.command, ...(input.args ?? [])].join(" ") + return uniqueDirectPermissionChecks([ + ...externalDirectoryPermission(input.cwd, cwd, "directory"), + ...terminalArgumentExternalPermissions(input.cwd, cwd, input.args), + { + permission: "bash", + patterns: [command], + always: [`${input.command} *`], + metadata: { + command, + cwd, + }, + }, + ]) + } + + const target = resolveACPPath(input.cwd, input.path) + return [ + ...externalDirectoryPermission(input.cwd, target, "file"), + { + permission: input.kind === "read" ? "read" : "edit", + patterns: [permissionPathPattern(input.cwd, target)], + always: ["*"], + metadata: { + filepath: target, + }, + }, + ] +} + +function uniqueDirectPermissionChecks(checks: DirectPermissionCheck[]) { + const seen = new Set() + return checks.filter((check) => { + const key = `${check.permission}\0${check.patterns.join("\0")}` + if (seen.has(key)) return false + seen.add(key) + return true + }) +} + +function terminalArgumentExternalPermissions( + projectCwd: string, + terminalCwd: string, + args: readonly string[] | null | undefined, +) { + return (args ?? []).flatMap((arg) => { + const target = terminalArgumentPath(terminalCwd, arg) + return target ? externalDirectoryPermission(projectCwd, target, "file") : [] + }) +} + +function terminalArgumentPath(cwd: string, value: string) { + const candidate = terminalArgumentPathCandidate(value) + if (!candidate) return + return resolveACPPath(cwd, candidate) +} + +function terminalArgumentPathCandidate(value: string) { + const text = unquote(value.trim()) + if (!text || text === "." || /^\/[A-Za-z]$/i.test(text)) return + const assigned = text.includes("=") ? text.slice(text.indexOf("=") + 1) : text + const candidate = unquote(assigned) + if ( + path.isAbsolute(candidate) || + candidate.startsWith("../") || + candidate.startsWith("..\\") || + candidate.startsWith("./") || + candidate.startsWith(".\\") || + candidate.includes("/") || + candidate.includes("\\") + ) { + return candidate + } +} + +function unquote(value: string) { + if (value.length < 2) return value + const first = value[0] + const last = value[value.length - 1] + if ((first === `"` || first === "'") && first === last) return value.slice(1, -1) + return value +} + +async function assertACPDirectPermissions( + active: ActivePermissionRequest | undefined, + checks: ReadonlyArray, +) { + if (!active || active.abort.aborted) throw RequestError.invalidParams({}, "permission unavailable") + + for (const check of checks) { + const requestID = PermissionV1.ID.ascending() + const onAbort = () => { + void active.permission.reply({ requestID, reply: "reject" }).catch(() => undefined) + } + active.abort.addEventListener("abort", onAbort, { once: true }) + try { + await active.permission.ask({ + id: requestID, + sessionID: active.sessionID, + ...check, + ruleset: active.ruleset, + }) + } catch (error) { + if (active.abort.aborted) throw RequestError.invalidParams({}, "permission cancelled") + if ( + error instanceof PermissionV1.DeniedError || + error instanceof PermissionV1.RejectedError || + error instanceof PermissionV1.CorrectedError + ) { + throw RequestError.invalidParams( + { permission: check.permission, patterns: check.patterns }, + "permission denied", + ) + } + throw RequestError.internalError({ permission: check.permission }, errorMessage(error)) + } finally { + active.abort.removeEventListener("abort", onAbort) + } + } +} + +function externalDirectoryPermission(cwd: string, target: string, kind: "file" | "directory"): DirectPermissionCheck[] { + if (containsACPPath(cwd, target)) return [] + const dir = kind === "directory" ? target : path.dirname(target) + const pattern = path.join(dir, "*") + return [ + { + permission: "external_directory", + patterns: [pattern], + always: [pattern], + metadata: { + filepath: target, + parentDir: dir, + }, + }, + ] +} + +function permissionPathPattern(cwd: string, target: string) { + if (!containsACPPath(cwd, target)) return target + return path.relative(path.resolve(cwd), target) || "." +} + +function containsACPPath(cwd: string, target: string) { + const relative = path.relative(path.resolve(cwd), target) + return relative === "" || (relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative)) +} + +async function readTextFile( + active: ActiveDirectRequest | undefined, + cwd: string, + params: ReadTextFileRequest, +): Promise { + const base = active?.cwd ?? cwd + const target = resolveACPPath(base, params.path) + await assertACPDirectPermissions( + active, + claudeACPDirectPermissionChecks({ kind: "read", cwd: base, path: params.path }), + ) + const content = await Bun.file(target).text() + if (!params.line && !params.limit) return { content } + const start = (params.line ?? 1) - 1 + return { content: content.split(/\r?\n/).slice(start, params.limit ? start + params.limit : undefined).join("\n") } +} + +async function writeTextFile( + active: ActiveDirectRequest | undefined, + cwd: string, + params: WriteTextFileRequest, +): Promise { + const base = active?.cwd ?? cwd + const target = resolveACPPath(base, params.path) + await assertACPDirectPermissions( + active, + claudeACPDirectPermissionChecks({ kind: "write", cwd: base, path: params.path }), + ) + await mkdir(path.dirname(target), { recursive: true }) + await Bun.write(target, params.content) + return {} +} + +async function createTerminal( + input: Connection, + params: CreateTerminalRequest, +): Promise { + const terminalId = `claude-acp-terminal-${Date.now()}-${Math.random().toString(36).slice(2)}` + const output: string[] = [] + const base = input.active?.cwd ?? input.cwd + const cwd = params.cwd ? resolveACPPath(base, params.cwd) : base + await assertACPDirectPermissions( + input.active, + claudeACPDirectPermissionChecks({ + kind: "terminal", + cwd: base, + command: params.command, + args: params.args, + terminalCwd: params.cwd, + }), + ) + const subprocess = Bun.spawn({ + cmd: [params.command, ...(params.args ?? [])], + cwd, + env: params.env ? { ...process.env, ...Object.fromEntries(params.env.map((entry) => [entry.name, entry.value])) } : process.env, + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + }) + const terminal: Terminal = { + process: subprocess, + output, + limit: claudeACPTerminalOutputLimit(params.outputByteLimit), + truncated: false, + exited: subprocess.exited.then((exitCode) => { + terminal.exitStatus = { exitCode } + return terminal.exitStatus + }), + } + input.terminals.set(terminalId, terminal) + void collect(subprocess.stdout, output, terminal.limit, () => { + terminal.truncated = true + }) + void collect(subprocess.stderr, output, terminal.limit, () => { + terminal.truncated = true + }) + return { terminalId } +} + +async function terminalOutput( + terminals: Map, + params: TerminalOutputRequest, +): Promise { + const terminal = requireTerminal(terminals, params.terminalId) + return { output: terminal.output.join(""), truncated: terminal.truncated, exitStatus: terminal.exitStatus } +} + +async function waitForTerminalExit( + terminals: Map, + params: WaitForTerminalExitRequest, +): Promise { + return requireTerminal(terminals, params.terminalId).exited +} + +async function killTerminal(terminals: Map, params: KillTerminalRequest): Promise { + requireTerminal(terminals, params.terminalId).process.kill() + return {} +} + +async function releaseTerminal( + terminals: Map, + params: ReleaseTerminalRequest, +): Promise { + const terminal = requireTerminal(terminals, params.terminalId) + terminal.process.kill() + terminals.delete(params.terminalId) + return {} +} + +function requireTerminal(terminals: Map, terminalID: string) { + const terminal = terminals.get(terminalID) + if (!terminal) throw RequestError.resourceNotFound(terminalID) + return terminal +} + +export function claudeACPTerminalOutputLimit(value: number | null | undefined) { + if (typeof value !== "number") return TERMINAL_OUTPUT_DEFAULT + if (!Number.isFinite(value)) return TERMINAL_OUTPUT_DEFAULT + return Math.min(TERMINAL_OUTPUT_MAX, Math.max(0, Math.floor(value))) +} + +export function claudeACPAppendOutput(output: string[], text: string, limit: number) { + if (!text) return false + output.push(text) + const trimmed = trimOutput(output.join(""), claudeACPTerminalOutputLimit(limit)) + if (!trimmed.truncated) return false + output.length = 0 + if (trimmed.text) output.push(trimmed.text) + return true +} + +function trimOutput(text: string, limit: number) { + const bytes = Buffer.from(text, "utf8") + if (bytes.length <= limit) return { text, truncated: false } + if (limit <= 0) return { text: "", truncated: true } + + let start = bytes.length - limit + while (start < bytes.length && (bytes[start] & 0xc0) === 0x80) start++ + return { text: bytes.subarray(start).toString("utf8"), truncated: true } +} + +async function collect(stream: ReadableStream, output: string[], limit: number, onTruncated?: () => void) { + const reader = stream.getReader() + const decoder = new TextDecoder() + while (true) { + const read = await reader.read().catch(() => undefined) + if (!read) return + if (read.done) { + appendCollectedOutput(decoder.decode()) + return + } + appendCollectedOutput(decoder.decode(read.value, { stream: true })) + } + + function appendCollectedOutput(text: string) { + if (!text) return + if (claudeACPAppendOutput(output, text, limit)) onTruncated?.() + } +} + +function finish( + queue: ReturnType, + reason: FinishReason, + usage?: Usage, + providerMetadata?: ACPProviderMetadata, +) { + queue.closeBlocks() + queue.push(LLMEvent.stepFinish({ index: 0, reason, usage, providerMetadata })) + queue.push(LLMEvent.finish({ reason, usage, providerMetadata })) + queue.end() +} + +function finishReason(reason: string): FinishReason { + if (reason === "end_turn") return "stop" + if (reason === "max_tokens") return "length" + if (reason === "cancelled") return "error" + if (reason === "refusal") return "content-filter" + return "unknown" +} + +export function claudeUsage(input: ACPUsage | null | undefined, context?: ACPContextUsage) { + if (!input) return + const nonCachedInputTokens = token(input.inputTokens) + const cacheReadInputTokens = token(input.cachedReadTokens) + const cacheWriteInputTokens = token(input.cachedWriteTokens) + const inputTokens = (nonCachedInputTokens ?? 0) + (cacheReadInputTokens ?? 0) + (cacheWriteInputTokens ?? 0) + return new Usage({ + inputTokens, + outputTokens: token(input.outputTokens), + nonCachedInputTokens, + cacheReadInputTokens, + cacheWriteInputTokens, + reasoningTokens: token(input.thoughtTokens), + totalTokens: context?.used ?? token(input.totalTokens), + providerMetadata: { anthropic: context ? { ...input, context } : input }, + }) +} + +export function claudeContextUsage(context: ACPContextUsage | undefined) { + if (!context) return + return new Usage({ + inputTokens: 0, + outputTokens: 0, + totalTokens: context.used, + providerMetadata: { anthropic: { context } }, + }) +} + +export function claudeACPCompactionStatus(text: string) { + const normalized = text.trim() + if (normalized === "Compacting...") return "started" + if (normalized === "Compacting completed.") return "completed" + return undefined +} + +function claudeProviderMetadata(providerCompacted: boolean | undefined): ACPProviderMetadata | undefined { + if (!providerCompacted) return + return { anthropic: { acpCompacted: true } } +} + +function token(value: number | null | undefined) { + if (typeof value !== "number") return + if (!Number.isFinite(value)) return + return Math.max(0, value) +} + +function promptText(messages: ModelMessage[]) { + const last = messages.at(-1) + if (last?.role === "user") { + const text = contentText(last.content).trim() + if (text.match(/^\/[A-Za-z][\w:-]*(?:\s|$)/)) return text + } + + return messages + .map((message) => `${message.role.toUpperCase()}:\n${contentText(message.content)}`) + .filter((message) => message.trim() !== "") + .join("\n\n") +} + +function currentPromptText(messages: ModelMessage[]) { + const last = messages.at(-1) + if (last?.role !== "user") return promptText(messages) + const text = contentText(last.content).trim() + return text || promptText(messages) +} + +function claudeModelID(modelID: string) { + if (modelID === "claude" || modelID === "default") return + if (modelID === "fable") return "claude-fable-5" + if (modelID === "fable[1m]") return "claude-fable-5[1m]" + return modelID +} + +function stableStringify(value: unknown): string { + if (Array.isArray(value)) return `[${value.map(stableStringify).join(",")}]` + if (!value || typeof value !== "object") return JSON.stringify(value) + return `{${Object.entries(value) + .toSorted(([a], [b]) => a.localeCompare(b)) + .map(([key, item]) => `${JSON.stringify(key)}:${stableStringify(item)}`) + .join(",")}}` +} + +function recordValue(value: unknown): Record { + if (!value || typeof value !== "object" || Array.isArray(value)) return {} + return value as Record +} + +function stringValue(value: unknown) { + return typeof value === "string" ? value : undefined +} + +function firstString(...values: unknown[]) { + return values.find((value): value is string => typeof value === "string") +} + +function contentText(content: ModelMessage["content"]): string { + if (typeof content === "string") return content + if (!Array.isArray(content)) return JSON.stringify(content) + return content + .map((part) => { + if (part.type === "text") return part.text + if (part.type === "file") return `[file: ${part.filename ?? part.mediaType}]` + if (part.type === "image") return "[image]" + return JSON.stringify(part) + }) + .join("\n") +} + +export function resolveACPPath(cwd: string, value: string) { + if (path.isAbsolute(value)) return path.resolve(value) + return path.resolve(cwd, value) +} + +function writable(sink: Bun.FileSink): WritableStream { + return new WritableStream({ + write: (chunk) => { + sink.write(chunk) + sink.flush() + }, + close: () => { + sink.end() + }, + }) +} + +function makeQueue() { + const items: QueueItem[] = [] + const waiting: ((item: QueueItem) => void)[] = [] + let textStarted = false + let reasoningStarted = false + return { + push(event: LLMEventType) { + offer({ type: "event", event }) + }, + text(text: string) { + if (!textStarted) { + textStarted = true + offer({ type: "event", event: LLMEvent.textStart({ id: TEXT_ID }) }) + } + offer({ type: "event", event: LLMEvent.textDelta({ id: TEXT_ID, text }) }) + }, + reasoning(text: string) { + if (!reasoningStarted) { + reasoningStarted = true + offer({ type: "event", event: LLMEvent.reasoningStart({ id: REASONING_ID }) }) + } + offer({ type: "event", event: LLMEvent.reasoningDelta({ id: REASONING_ID, text }) }) + }, + closeBlocks() { + if (reasoningStarted) { + reasoningStarted = false + offer({ type: "event", event: LLMEvent.reasoningEnd({ id: REASONING_ID }) }) + } + if (textStarted) { + textStarted = false + offer({ type: "event", event: LLMEvent.textEnd({ id: TEXT_ID }) }) + } + }, + end() { + offer({ type: "done" }) + }, + fail(error: unknown) { + offer({ type: "error", error }) + }, + async *[Symbol.asyncIterator]() { + while (true) { + const item = items.shift() ?? (await new Promise((resolve) => waiting.push(resolve))) + if (item.type === "event") { + yield item.event + continue + } + if (item.type === "error") throw item.error + return + } + }, + } + + function offer(item: QueueItem) { + const resolve = waiting.shift() + if (resolve) { + resolve(item) + return + } + items.push(item) + } +} + +export * as ClaudeACP from "./claude-acp" diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts index 20aa8a8404d8..88b166136239 100644 --- a/packages/opencode/src/session/processor.ts +++ b/packages/opencode/src/session/processor.ts @@ -22,6 +22,7 @@ import type { Provider } from "@/provider/provider" import { Question } from "@/question" import { errorMessage } from "@/util/error" import { isRecord } from "@/util/record" +import { Token } from "@/util/token" import { EventV2Bridge } from "@/event-v2-bridge" import { Database } from "@opencode-ai/core/database/database" import { Usage, type LLMEvent } from "@opencode-ai/llm" @@ -72,6 +73,7 @@ interface ProcessorContext extends Input { needsCompaction: boolean currentText: SessionV1.TextPart | undefined reasoningMap: Record + interruptedInputTokens: number } type StreamEvent = LLMEvent @@ -111,6 +113,7 @@ const layer = Layer.effect( needsCompaction: false, currentText: undefined, reasoningMap: {}, + interruptedInputTokens: 0, } let aborted = false @@ -213,6 +216,65 @@ const layer = Layer.effect( delete ctx.reasoningMap[reasoningID] }) + const recordProviderCompaction = Effect.fn("SessionProcessor.recordProviderCompaction")(function* ( + metadata: Record> | undefined, + ) { + if (!providerCompacted(metadata)) return + const parts = yield* MessageV2.parts(ctx.assistantMessage.parentID).pipe( + Effect.provideService(Database.Service, database), + ) + if (parts.some((part) => part.type === "compaction")) return + yield* session.updatePart({ + id: PartID.ascending(), + messageID: ctx.assistantMessage.parentID, + sessionID: ctx.sessionID, + type: "compaction", + auto: true, + }) + }) + + const recordInterruptedUsage = Effect.fn("SessionProcessor.recordInterruptedUsage")(function* () { + if (!aborted) return + if (tokenTotal(ctx.assistantMessage.tokens) > 0) return + const parts = yield* MessageV2.parts(ctx.assistantMessage.id).pipe( + Effect.provideService(Database.Service, database), + ) + if (parts.some((part) => part.type === "step-finish")) return + const usage = { + cost: 0, + tokens: { + // A provider-reported context total survives the interrupt; the + // local estimate only fills in the missing breakdown. + ...(ctx.assistantMessage.tokens.total !== undefined ? { total: ctx.assistantMessage.tokens.total } : {}), + input: ctx.interruptedInputTokens, + output: Token.estimate( + JSON.stringify( + parts.flatMap((part) => { + if (part.type === "text") return [part.text] + if (part.type === "tool") return [JSON.stringify(part.state)] + return [] + }), + ), + ), + reasoning: Token.estimate( + JSON.stringify(parts.flatMap((part) => (part.type === "reasoning" ? [part.text] : []))), + ), + cache: { read: 0, write: 0 }, + }, + } + if (tokenTotal(usage.tokens) <= 0) return + ctx.assistantMessage.tokens = usage.tokens + yield* session.updatePart({ + id: PartID.ascending(), + reason: "error", + messageID: ctx.assistantMessage.id, + sessionID: ctx.assistantMessage.sessionID, + type: "step-finish", + tokens: usage.tokens, + cost: usage.cost, + }) + }) + const ensureToolCall = Effect.fn("SessionProcessor.ensureToolCall")(function* (input: { id: string name: string @@ -454,6 +516,7 @@ const layer = Layer.effect( cost: usage.cost, }) yield* session.updateMessage(ctx.assistantMessage) + yield* recordProviderCompaction(value.providerMetadata) if (ctx.snapshot) { const patch = yield* snapshot.patch(ctx.snapshot) if (patch.files.length) { @@ -483,6 +546,21 @@ const layer = Layer.effect( return } + case "usage": { + // Live context-occupancy snapshot (ACP usage_update). Last write + // wins, and values may drop after provider-side compaction — only + // step-finish touches cost/finish, so just refresh the tokens. + const usage = Session.getUsage({ + model: ctx.model, + usage: value.usage, + metadata: value.usage.providerMetadata, + }) + if ((usage.tokens.total ?? tokenTotal(usage.tokens)) <= 0) return + ctx.assistantMessage.tokens = usage.tokens + yield* session.updateMessage(ctx.assistantMessage) + return + } + case "text-start": ctx.currentText = { id: PartID.ascending(), @@ -592,6 +670,7 @@ const layer = Layer.effect( }) } ctx.toolcalls = {} + yield* recordInterruptedUsage() ctx.assistantMessage.time.completed = Date.now() yield* session.updateMessage(ctx.assistantMessage) }) @@ -631,13 +710,16 @@ const layer = Layer.effect( }) ctx.needsCompaction = false ctx.shouldBreak = (yield* config.get()).experimental?.continue_loop_on_deny !== true + ctx.interruptedInputTokens = Token.estimate( + JSON.stringify({ system: streamInput.system, messages: streamInput.messages }), + ) return yield* Effect.gen(function* () { yield* Effect.gen(function* () { ctx.currentText = undefined ctx.reasoningMap = {} yield* status.set(ctx.sessionID, { type: "busy" }) - const stream = llm.stream(streamInput) + const stream = llm.stream({ ...streamInput, cwd: ctx.assistantMessage.path.cwd }) yield* stream.pipe( Stream.tap((event) => handleEvent(event)), @@ -715,4 +797,12 @@ export const node = LayerNode.make({ ], }) +function providerCompacted(metadata: Record> | undefined) { + return metadata?.anthropic?.acpCompacted === true +} + +function tokenTotal(tokens: SessionV1.Assistant["tokens"]) { + return tokens.input + tokens.output + tokens.reasoning + tokens.cache.read + tokens.cache.write +} + export * as SessionProcessor from "./processor" diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 22b1d7d99a2a..f149ef4ac52b 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -8,7 +8,7 @@ import { MessageV2 } from "./message-v2" import { SessionRevert } from "./revert" import { Session } from "./session" import { Agent } from "../agent/agent" -import { Provider } from "@/provider/provider" +import { ClaudeACPProviderID, Provider } from "@/provider/provider" import { type Tool as AITool, tool, jsonSchema } from "ai" import type { JSONSchema7 } from "@ai-sdk/provider" @@ -198,6 +198,7 @@ const layer = Layer.effect( }) { if (input.session.parentID) return if (!Session.isDefaultTitle(input.session.title)) return + if (input.providerID === ClaudeACPProviderID) return const real = (m: SessionV1.WithParts) => m.info.role === "user" && !m.parts.every((p) => "synthetic" in p && p.synthetic) @@ -222,8 +223,10 @@ const layer = Layer.effect( const msgs = onlySubtasks ? [{ role: "user" as const, content: subtasks.map((p) => p.prompt).join("\n") }] : yield* MessageV2.toModelMessagesEffect(context, mdl) + const ctx = yield* InstanceState.context const text = yield* llm .stream({ + cwd: ctx.directory, agent: ag, user: firstInfo, system: [], @@ -1270,6 +1273,7 @@ const layer = Layer.effect( const format = lastUser.format ?? { type: "text" as const } if (format.type === "json_schema") system.push(STRUCTURED_OUTPUT_SYSTEM_PROMPT) const result = yield* handle.process({ + cwd: msg.path.cwd, user: lastUser, agent, permission: session.permission, diff --git a/packages/opencode/test/acp/service-session.test.ts b/packages/opencode/test/acp/service-session.test.ts index d784709dbb2a..5956eea7d952 100644 --- a/packages/opencode/test/acp/service-session.test.ts +++ b/packages/opencode/test/acp/service-session.test.ts @@ -17,7 +17,7 @@ import { Effect } from "effect" import * as ACPService from "@/acp/service" import * as ACPError from "@/acp/error" import { UsageService } from "@/acp/usage" -import type { Provider } from "@/provider/provider" +import { Provider } from "@/provider/provider" const providerID = ProviderV2.ID.make("test") const modelID = ModelV2.ID.make("test-model") @@ -189,6 +189,40 @@ const provider: Provider.Info = { }, } +function cloneModel(nextProviderID: ProviderV2.ID, nextModelID: ModelV2.ID, name: string): Provider.Model { + const base = provider.models[modelID]! + return { + ...base, + id: nextModelID, + providerID: nextProviderID, + api: { + ...base.api, + id: nextModelID, + }, + name, + variants: {}, + } +} + +function singleModelProvider(input: { + providerID: ProviderV2.ID + modelID: ModelV2.ID + name: string + modelName: string + source?: Provider.Info["source"] +}): Provider.Info { + return { + id: input.providerID, + name: input.name, + source: input.source ?? "config", + env: [], + options: {}, + models: { + [input.modelID]: cloneModel(input.providerID, input.modelID, input.modelName), + }, + } +} + describe("ACP service sessions", () => { const makeService = ( messages: readonly { info: unknown; parts: readonly unknown[] }[] = [], @@ -771,6 +805,96 @@ describe("ACP service sessions", () => { expect(historyCalls).toEqual([]) }) + it("does not choose Claude ACP as the automatic fresh-session fallback when other models exist", async () => { + const fallbackProviderID = ProviderV2.ID.make("aaa") + const fallbackModelID = ModelV2.ID.make("aaa-model") + const fallbackProvider = singleModelProvider({ + providerID: fallbackProviderID, + modelID: fallbackModelID, + name: "Fallback", + modelName: "Fallback Model", + }) + const claudeProvider = singleModelProvider({ + providerID: Provider.ClaudeACPProviderID, + modelID: Provider.ClaudeACPModelID, + name: "Claude", + modelName: "Claude Code", + source: "custom", + }) + const sdk = { + config: { + providers: () => Promise.resolve({ data: { providers: [claudeProvider, fallbackProvider], default: {} } }), + get: () => Promise.resolve({ data: {} }), + }, + app: { + agents: () => Promise.resolve({ data: [{ name: "build", mode: "primary", permission: [], options: {} }] }), + skills: () => Promise.resolve({ data: [] }), + }, + command: { + list: () => Promise.resolve({ data: [] }), + }, + session: { + create: (input: { model?: { providerID?: string; id?: string } }) => + Promise.resolve({ data: { id: `${input.model?.providerID}/${input.model?.id}` } }), + list: () => Promise.resolve({ data: [] }), + }, + mcp: { + add: () => Promise.resolve({ data: {} }), + }, + } as unknown as OpencodeClient + const service = ACPService.make({ sdk }) + + const result = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] })) + + expect(result.sessionId).toBe("aaa/aaa-model") + expect(result.configOptions?.find((option) => option.id === "model")?.currentValue).toBe("aaa/aaa-model") + }) + + it("ignores the removed Claude ACP default alias from config", async () => { + const fallbackProviderID = ProviderV2.ID.make("aaa") + const fallbackModelID = ModelV2.ID.make("aaa-model") + const fallbackProvider = singleModelProvider({ + providerID: fallbackProviderID, + modelID: fallbackModelID, + name: "Fallback", + modelName: "Fallback Model", + }) + const claudeProvider = singleModelProvider({ + providerID: Provider.ClaudeACPProviderID, + modelID: Provider.ClaudeACPModelID, + name: "Claude", + modelName: "Claude Code", + source: "custom", + }) + const sdk = { + config: { + providers: () => Promise.resolve({ data: { providers: [claudeProvider, fallbackProvider], default: {} } }), + get: () => Promise.resolve({ data: { model: "claude-acp/default" } }), + }, + app: { + agents: () => Promise.resolve({ data: [{ name: "build", mode: "primary", permission: [], options: {} }] }), + skills: () => Promise.resolve({ data: [] }), + }, + command: { + list: () => Promise.resolve({ data: [] }), + }, + session: { + create: (input: { model?: { providerID?: string; id?: string } }) => + Promise.resolve({ data: { id: `${input.model?.providerID}/${input.model?.id}` } }), + list: () => Promise.resolve({ data: [] }), + }, + mcp: { + add: () => Promise.resolve({ data: {} }), + }, + } as unknown as OpencodeClient + const service = ACPService.make({ sdk }) + + const result = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] })) + + expect(result.sessionId).toBe("aaa/aaa-model") + expect(result.configOptions?.find((option) => option.id === "model")?.currentValue).toBe("aaa/aaa-model") + }) + it("switches model and returns updated model and effort options", async () => { const { service } = makeService() const session = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] })) diff --git a/packages/opencode/test/acp/usage.test.ts b/packages/opencode/test/acp/usage.test.ts index e9d3b3c62e32..6f126533b97b 100644 --- a/packages/opencode/test/acp/usage.test.ts +++ b/packages/opencode/test/acp/usage.test.ts @@ -207,7 +207,7 @@ describe("acp usage", () => { ) }) - it.effect("includes cache reads and writes in ACP context usage", () => { + it.effect("includes all token types in ACP context usage", () => { const updates: SessionNotification[] = [] return Effect.gen(function* () { const usage = yield* UsageService.Service @@ -222,7 +222,7 @@ describe("acp usage", () => { sessionId: "ses_1", update: { sessionUpdate: "usage_update", - used: 22, + used: 42, size: 128_000, cost: { amount: 3, currency: "USD" }, }, @@ -248,6 +248,80 @@ describe("acp usage", () => { ) }) + it.effect("prefers the provider-reported total for ACP context usage", () => { + const updates: SessionNotification[] = [] + return Effect.gen(function* () { + const usage = yield* UsageService.Service + yield* usage.sendUpdate({ + connection: connection(updates), + sessionID: "ses_1", + directory: "/workspace", + }) + + expect(updates).toEqual([ + { + sessionId: "ses_1", + update: { + sessionUpdate: "usage_update", + used: 55_000, + size: 128_000, + cost: { amount: 2, currency: "USD" }, + }, + }, + ]) + }).pipe( + Effect.provide( + fakeLayer({ + messages: Effect.succeed([ + assistant({ + cost: 2, + tokens: { + total: 55_000, + input: 10, + output: 20, + reasoning: 0, + cache: { read: 5, write: 7 }, + }, + }), + ]), + }), + ), + ) + }) + + it.effect("retries context limit lookup after a failure instead of caching it", () => { + const calls: string[] = [] + let fail = true + return Effect.gen(function* () { + const usage = yield* UsageService.Service + const input = { + directory: "/workspace", + providerID: ProviderV2.ID.make("anthropic"), + modelID: ModelV2.ID.make("claude-sonnet"), + } + const first = yield* usage.contextLimit(input) + fail = false + const second = yield* usage.contextLimit(input) + const third = yield* usage.contextLimit(input) + + expect(first).toBeUndefined() + expect(second).toBe(200_000) + expect(third).toBe(200_000) + expect(calls).toEqual(["/workspace", "/workspace"]) + }).pipe( + Effect.provide( + fakeLayer({ + providers: (directory) => + Effect.suspend(() => { + calls.push(directory) + if (fail) return Effect.fail(new Error("boom")) + return Effect.succeed(providers(200_000)) + }), + }), + ), + ) + }) + it.effect("skips usage update when messages cannot be fetched", () => { const updates: SessionNotification[] = [] return Effect.gen(function* () { diff --git a/packages/opencode/test/cli/run/session-data.test.ts b/packages/opencode/test/cli/run/session-data.test.ts index b685fb679f30..2f2f5501d3a4 100644 --- a/packages/opencode/test/cli/run/session-data.test.ts +++ b/packages/opencode/test/cli/run/session-data.test.ts @@ -282,6 +282,173 @@ describe("run session data", () => { }) }) + test("uses provider total when formatting footer usage", () => { + const out = reduce( + createSessionData(), + assistant("msg-1", { + tokens: { + total: 40_578, + input: 38, + output: 6_476, + reasoning: 0, + cache: { read: 109_805, write: 15_824 }, + }, + }), + ) + + expect(out.footer?.patch?.usage).toBe("40.6K") + }) + + test("keeps completed footer usage after a later aborted estimate", () => { + let data = createSessionData() + const completed = reduce( + data, + assistant("msg-1", { + tokens: { + total: 40_578, + input: 38, + output: 6_476, + reasoning: 0, + cache: { read: 109_805, write: 15_824 }, + }, + }), + ) + data = completed.data + + const aborted = reduce( + data, + assistant("msg-2", { + error: { name: "MessageAbortedError" }, + tokens: { + input: 15_129, + output: 1, + reasoning: 265, + cache: { read: 0, write: 0 }, + }, + }), + ) + + expect(completed.footer?.patch?.usage).toBe("40.6K") + expect(aborted.footer?.patch?.usage).toBeUndefined() + expect(aborted.data.usage?.text).toBe("40.6K") + }) + + test("ignores an aborted estimate even when it exceeds previous completed usage", () => { + let data = createSessionData() + data = reduce( + data, + assistant("msg-1", { + tokens: { + total: 40_578, + input: 38, + output: 6_476, + reasoning: 0, + cache: { read: 109_805, write: 15_824 }, + }, + }), + ).data + + const aborted = reduce( + data, + assistant("msg-2", { + error: { name: "MessageAbortedError" }, + tokens: { + input: 55_000, + output: 2_000, + reasoning: 500, + cache: { read: 0, write: 0 }, + }, + }), + ) + + expect(aborted.footer?.patch?.usage).toBeUndefined() + expect(aborted.data.usage?.text).toBe("40.6K") + }) + + test("follows an aborted turn with a provider-reported total", () => { + let data = createSessionData() + data = reduce( + data, + assistant("msg-1", { + tokens: { + total: 40_578, + input: 38, + output: 6_476, + reasoning: 0, + cache: { read: 109_805, write: 15_824 }, + }, + }), + ).data + + const aborted = reduce( + data, + assistant("msg-2", { + error: { name: "MessageAbortedError" }, + tokens: { + total: 13_278, + input: 0, + output: 0, + reasoning: 0, + cache: { read: 0, write: 0 }, + }, + }), + ) + + expect(aborted.footer?.patch?.usage).toBe("13.3K") + expect(aborted.data.usage?.text).toBe("13.3K") + }) + + test("lowers footer usage when a later completed turn reports fewer tokens", () => { + let data = createSessionData() + const first = reduce( + data, + assistant("msg-1", { + tokens: { + total: 44_044, + input: 106, + output: 8_740, + reasoning: 0, + cache: { read: 451_001, write: 22_516 }, + }, + }), + ) + data = first.data + + const later = reduce( + data, + assistant("msg-2", { + tokens: { + total: 29_240, + input: 10, + output: 450, + reasoning: 0, + cache: { read: 21_158, write: 7_622 }, + }, + }), + ) + + expect(first.footer?.patch?.usage).toBe("44.0K") + expect(later.footer?.patch?.usage).toBe("29.2K") + expect(later.data.usage?.text).toBe("29.2K") + }) + + test("shows aborted footer usage when no completed usage exists", () => { + const out = reduce( + createSessionData(), + assistant("msg-1", { + error: { name: "MessageAbortedError" }, + tokens: { + input: 15_129, + output: 1, + reasoning: 265, + cache: { read: 0, write: 0 }, + }, + }), + ) + + expect(out.footer?.patch?.usage).toBe("15.4K") + }) + test("strips bash echo only from the first assistant flush", () => { let data = createSessionData() data = reduce(data, assistant("msg-1")).data diff --git a/packages/opencode/test/cli/tui/thread.test.ts b/packages/opencode/test/cli/tui/thread.test.ts index 2b2969808690..aa532c06a633 100644 --- a/packages/opencode/test/cli/tui/thread.test.ts +++ b/packages/opencode/test/cli/tui/thread.test.ts @@ -51,6 +51,14 @@ describe("tui thread", () => { expect(resolveThreadDirectory(undefined, pwd.path, cwd.path)).toBe(cwd.path) }) + test("uses launcher cwd when package cwd differs", async () => { + await using launch = await tmpdir({ git: true }) + await using cwd = await tmpdir({ git: true }) + + expect(resolveThreadDirectory(undefined, undefined, cwd.path, launch.path)).toBe(launch.path) + expect(resolveThreadDirectory("subdir", undefined, cwd.path, launch.path)).toBe(path.join(launch.path, "subdir")) + }) + test("parses supported --no-replay forms", async () => { for (const option of ["--no-replay", "--no-replay=true", "--noReplay"]) { const args = await yargs([]) diff --git a/packages/opencode/test/session/claude-acp.test.ts b/packages/opencode/test/session/claude-acp.test.ts new file mode 100644 index 000000000000..5af153e405d7 --- /dev/null +++ b/packages/opencode/test/session/claude-acp.test.ts @@ -0,0 +1,719 @@ +import { describe, expect, it } from "bun:test" +import { + claudeACPAppendOutput, + claudeACPCompactionStatus, + claudeACPConfigCommand, + claudeACPConfigOptionCurrent, + claudeACPConfigOptionValues, + claudeACPConfigState, + claudeACPConnectionKey, + resolveFastDesired, + claudeACPDirectPermissionChecks, + claudeACPElicitationContent, + claudeACPElicitationFields, + claudeACPTerminalOutputLimit, + claudeACPToolEvents, + requestPermissionForActive, + resolveACPPath, + claudeContextUsage, + claudeUsage, +} from "@/session/llm/claude-acp" +import { PermissionV1 } from "@opencode-ai/core/v1/permission" +import type { RequestPermissionRequest, SessionNotification } from "@agentclientprotocol/sdk" +import { SessionID } from "../../src/session/schema" +import { Permission } from "@/permission" + +describe("Claude ACP compaction status", () => { + it("recognizes Claude ACP compaction control messages", () => { + expect(claudeACPCompactionStatus("Compacting...")).toBe("started") + expect(claudeACPCompactionStatus("\n\nCompacting completed.")).toBe("completed") + expect(claudeACPCompactionStatus("Compacting failed: too much context")).toBeUndefined() + expect(claudeACPCompactionStatus("Compacting the answer now.")).toBeUndefined() + }) +}) + +describe("Claude ACP config slash commands", () => { + it("parses effort, model, and fast commands", () => { + expect(claudeACPConfigCommand("/effort max")).toEqual({ configId: "effort", value: "max" }) + expect(claudeACPConfigCommand("/effort")).toEqual({ configId: "effort", value: undefined }) + expect(claudeACPConfigCommand("/model opus")).toEqual({ configId: "model", value: "opus" }) + expect(claudeACPConfigCommand("/fast on")).toEqual({ configId: "fast", value: "on" }) + expect(claudeACPConfigCommand("not a command")).toBeUndefined() + expect(claudeACPConfigCommand("/compact")).toBeUndefined() + }) + + it("resolves fast mode desired state like Claude Code toggles", () => { + expect(resolveFastDesired(undefined, undefined)).toBe(true) + expect(resolveFastDesired(undefined, "off")).toBe(true) + expect(resolveFastDesired(undefined, "on")).toBe(false) + expect(resolveFastDesired("on", "off")).toBe(true) + expect(resolveFastDesired("off", "on")).toBe(false) + expect(resolveFastDesired("nope", "off")).toBe("invalid") + }) + + it("reads select and boolean config option values", () => { + expect( + claudeACPConfigOptionValues({ + id: "effort", + name: "Effort", + type: "select", + currentValue: "default", + options: [ + { value: "default", name: "Default" }, + { value: "max", name: "Max" }, + ], + }), + ).toEqual(["default", "max"]) + expect(claudeACPConfigOptionCurrent({ id: "fast", name: "Fast", type: "boolean", currentValue: true })).toBe("on") + expect(claudeACPConfigOptionValues({ id: "fast", name: "Fast", type: "boolean", currentValue: false })).toEqual([ + "on", + "off", + ]) + }) + + it("derives footer state from ACP config options", () => { + expect( + claudeACPConfigState([ + { + id: "effort", + name: "Effort", + type: "select", + currentValue: "high", + options: [ + { value: "default", name: "Default" }, + { value: "high", name: "High" }, + ], + }, + { + id: "fast", + name: "Fast", + type: "boolean", + currentValue: true, + }, + ]), + ).toEqual({ effort: "high", fast: true }) + + expect( + claudeACPConfigState([ + { + id: "effort", + name: "Effort", + type: "select", + currentValue: "default", + options: [{ value: "default", name: "Default" }], + }, + { + id: "fast", + name: "Fast", + type: "boolean", + currentValue: false, + }, + ]), + ).toEqual({ effort: "default", fast: false }) + + expect(claudeACPConfigState([])).toEqual({}) + }) +}) + +describe("Claude ACP usage", () => { + it("maps ACP usage into inclusive OpenCode token usage", () => { + const usage = claudeUsage({ + inputTokens: 100, + outputTokens: 25, + cachedReadTokens: 30, + cachedWriteTokens: 10, + thoughtTokens: 5, + totalTokens: 165, + }) + + expect(usage?.inputTokens).toBe(140) + expect(usage?.nonCachedInputTokens).toBe(100) + expect(usage?.cacheReadInputTokens).toBe(30) + expect(usage?.cacheWriteInputTokens).toBe(10) + expect(usage?.outputTokens).toBe(25) + expect(usage?.reasoningTokens).toBe(5) + expect(usage?.totalTokens).toBe(165) + }) + + it("uses ACP context usage as the reported total when available", () => { + const usage = claudeUsage( + { + inputTokens: 100, + outputTokens: 25, + cachedReadTokens: 30, + cachedWriteTokens: 10, + totalTokens: 165, + }, + { used: 32_000, size: 1_000_000 }, + ) + + expect(usage?.inputTokens).toBe(140) + expect(usage?.outputTokens).toBe(25) + expect(usage?.totalTokens).toBe(32_000) + expect(usage?.providerMetadata?.anthropic).toEqual({ + inputTokens: 100, + outputTokens: 25, + cachedReadTokens: 30, + cachedWriteTokens: 10, + totalTokens: 165, + context: { used: 32_000, size: 1_000_000 }, + }) + }) + + it("omits usage when Claude ACP does not report it", () => { + expect(claudeUsage(undefined)).toBeUndefined() + expect(claudeUsage(null)).toBeUndefined() + }) + + it("creates context-only usage from ACP usage updates", () => { + const usage = claudeContextUsage({ used: 48_000, size: 200_000 }) + + expect(usage?.inputTokens).toBe(0) + expect(usage?.outputTokens).toBe(0) + expect(usage?.totalTokens).toBe(48_000) + expect(usage?.providerMetadata?.anthropic).toEqual({ + context: { used: 48_000, size: 200_000 }, + }) + }) +}) + +describe("Claude ACP connection key", () => { + it("changes when the agent or system context changes", () => { + const base = { + sessionID: SessionID.make("ses_test"), + cwd: "C:/Users/matt/Projects/opencode", + modelID: "claude", + agent: "build", + mcpServers: [], + messages: [{ role: "system" as const, content: "Use build instructions" }, { role: "user" as const, content: "hi" }], + } + + expect(claudeACPConnectionKey(base)).not.toBe(claudeACPConnectionKey({ ...base, agent: "review" })) + expect(claudeACPConnectionKey(base)).not.toBe( + claudeACPConnectionKey({ + ...base, + messages: [ + { role: "system" as const, content: "Use review instructions" }, + { role: "user" as const, content: "hi" }, + ], + }), + ) + expect(claudeACPConnectionKey(base)).toBe( + claudeACPConnectionKey({ + ...base, + messages: [{ role: "system" as const, content: "Use build instructions" }, { role: "user" as const, content: "next" }], + }), + ) + }) +}) + +describe("Claude ACP tool updates", () => { + it("maps ACP tool completion to provider-executed LLM tool events", () => { + const state = new Map() + const events = [ + ...claudeACPToolEvents(state, toolUpdate({ sessionUpdate: "tool_call", status: "in_progress" })), + ...claudeACPToolEvents( + state, + toolUpdate({ + sessionUpdate: "tool_call_update", + status: "completed", + rawOutput: { output: "README contents" }, + }), + ), + ] + + expect(events.map((event) => event.type)).toEqual(["tool-call", "tool-result"]) + expect(events[0]).toMatchObject({ + type: "tool-call", + id: "call_read", + name: "read", + input: { filePath: "README.md" }, + providerExecuted: true, + }) + expect(events[1]).toMatchObject({ + type: "tool-result", + id: "call_read", + name: "read", + providerExecuted: true, + result: { + type: "json", + value: { + title: "Read README.md", + output: "README contents", + }, + }, + }) + }) + + it("maps ACP tool failures to LLM tool errors", () => { + const events = [ + ...claudeACPToolEvents(new Map(), toolUpdate({ sessionUpdate: "tool_call_update", status: "failed", rawOutput: "denied" })), + ] + + expect(events).toHaveLength(2) + expect(events[0]).toMatchObject({ type: "tool-call", id: "call_read", name: "read", providerExecuted: true }) + expect(events[1]).toMatchObject({ type: "tool-error", id: "call_read", name: "read", message: "denied" }) + }) +}) + +describe("Claude ACP elicitation", () => { + it("maps ACP form enum choices through OpenCode questions", () => { + const fields = claudeACPElicitationFields({ + mode: "form", + sessionId: "ses_123", + message: "Choose research depth", + requestedSchema: { + properties: { + depth: { + type: "string", + title: "Depth", + oneOf: [ + { title: "Quick", const: "quick" }, + { title: "Deep", const: "deep" }, + ], + }, + includeSources: { + type: "boolean", + title: "Include sources", + }, + }, + }, + }) + + expect(fields.map((field) => field.question)).toEqual([ + { + header: "Depth", + question: "Depth", + options: [ + { label: "Quick", description: "quick" }, + { label: "Deep", description: "deep" }, + ], + custom: false, + }, + { + header: "Include sources", + question: "Include sources", + options: [ + { label: "Yes", description: "Choose research depth" }, + { label: "No", description: "Choose research depth" }, + ], + custom: false, + }, + ]) + expect(claudeACPElicitationContent(fields, [["Deep"], ["Yes"]])).toEqual({ + depth: "deep", + includeSources: true, + }) + }) + + it("maps ACP multi-select and numeric answers back to content values", () => { + const fields = claudeACPElicitationFields({ + mode: "form", + sessionId: "ses_123", + message: "Configure report", + requestedSchema: { + properties: { + sections: { + type: "array", + title: "Sections", + items: { + anyOf: [ + { title: "Economy", const: "economy" }, + { title: "Politics", const: "politics" }, + ], + }, + }, + limit: { + type: "integer", + title: "Limit", + }, + }, + }, + }) + + expect(fields[0]?.question.multiple).toBe(true) + expect(claudeACPElicitationContent(fields, [["Economy", "Politics"], ["3"]])).toEqual({ + sections: ["economy", "politics"], + limit: 3, + }) + }) +}) + +describe("Claude ACP permissions", () => { + it("does not widen one-time OpenCode approval to ACP allow-always", async () => { + const result = await requestPermissionForActive( + activePermission({ + ask: async () => "once", + }), + permissionRequest([ + { optionId: "always", kind: "allow_always", name: "Always allow" }, + { optionId: "deny", kind: "reject_once", name: "Deny" }, + ]), + ) + + expect(result).toEqual({ outcome: { outcome: "cancelled" } }) + }) + + it("keeps OpenCode always approvals as ACP allow-once selections", async () => { + const result = await requestPermissionForActive( + activePermission({ + ask: async () => "always", + }), + permissionRequest([ + { optionId: "allow", kind: "allow_once", name: "Allow" }, + { optionId: "always", kind: "allow_always", name: "Always allow" }, + { optionId: "deny", kind: "reject_once", name: "Deny" }, + ]), + ) + + expect(result).toEqual({ outcome: { outcome: "selected", optionId: "allow" } }) + }) + + it("honors denied rules from the active OpenCode ruleset", async () => { + const ruleset: PermissionV1.Ruleset = [{ permission: "bash", pattern: "*", action: "deny" }] + const asked: PermissionV1.AskInput[] = [] + + const result = await requestPermissionForActive( + activePermission({ + ruleset, + ask: askByRuleset(asked), + }), + permissionRequest(), + ) + + expect(result).toEqual({ outcome: { outcome: "selected", optionId: "deny" } }) + expect(asked[0]?.ruleset).toEqual(ruleset) + }) + + it("uses specific unknown ACP tool names so OpenCode rules can deny them", async () => { + const ruleset: PermissionV1.Ruleset = [{ permission: "mcp__server__tool", pattern: "*", action: "deny" }] + const asked: PermissionV1.AskInput[] = [] + + const result = await requestPermissionForActive( + activePermission({ + ruleset, + ask: askByRuleset(asked), + }), + unknownToolPermissionRequest(), + ) + + expect(result).toEqual({ outcome: { outcome: "selected", optionId: "deny" } }) + expect(asked[0]?.permission).toBe("mcp__server__tool") + expect(asked[0]?.patterns).toEqual(["mcp__server__tool"]) + }) + + it("preserves explicit ask rules from the active OpenCode ruleset", async () => { + const ruleset: PermissionV1.Ruleset = [ + { permission: "bash", pattern: "*", action: "deny" }, + { permission: "bash", pattern: "printf hello", action: "ask" }, + ] + const asked: PermissionV1.AskInput[] = [] + + const result = await requestPermissionForActive( + activePermission({ + ruleset, + ask: askByRuleset(asked), + }), + permissionRequest(), + ) + + expect(result).toEqual({ outcome: { outcome: "selected", optionId: "allow" } }) + expect(asked[0]?.ruleset).toEqual(ruleset) + }) + + it("cancels ACP permission requests when the OpenCode bridge fails before user selection", async () => { + const abort = new AbortController() + + const result = await requestPermissionForActive( + { + sessionID: SessionID.make("ses_test"), + abort: abort.signal, + ruleset: [], + permission: { + ask: async () => { + throw new PermissionV1.NotFoundError({ requestID: PermissionV1.ID.make("per_missing") }) + }, + reply: async () => {}, + }, + }, + permissionRequest(), + ) + + expect(result).toEqual({ outcome: { outcome: "cancelled" } }) + }) + + it("keeps explicit OpenCode permission rejections as ACP reject selections", async () => { + const abort = new AbortController() + + const result = await requestPermissionForActive( + { + sessionID: SessionID.make("ses_test"), + abort: abort.signal, + ruleset: [], + permission: { + ask: async () => { + throw new PermissionV1.RejectedError() + }, + reply: async () => {}, + }, + }, + permissionRequest(), + ) + + expect(result).toEqual({ outcome: { outcome: "selected", optionId: "deny" } }) + }) + + it("still asks for edit permission when Claude sends incomplete diff content", async () => { + const abort = new AbortController() + const replies: PermissionV1.AskInput[] = [] + + const result = await requestPermissionForActive( + { + sessionID: SessionID.make("ses_test"), + abort: abort.signal, + ruleset: [], + permission: { + ask: async (input) => { + replies.push(input) + return "once" + }, + reply: async () => {}, + }, + }, + editPermissionRequestWithIncompleteDiff(), + ) + + expect(result).toEqual({ outcome: { outcome: "selected", optionId: "allow" } }) + expect(replies).toHaveLength(1) + expect(replies[0]?.permission).toBe("edit") + expect(replies[0]?.patterns).toEqual(["C:/Users/matt/new-file.txt"]) + }) +}) + +describe("Claude ACP filesystem", () => { + it("keeps absolute ACP file paths instead of scoping them under cwd", () => { + expect(resolveACPPath("C:/Users/matt/Projects/opencode", "C:/Users/matt/acp-permission-test.txt")).toBe( + "C:\\Users\\matt\\acp-permission-test.txt", + ) + }) + + it("builds direct read permission checks with external directory guardrails", () => { + expect( + claudeACPDirectPermissionChecks({ + kind: "read", + cwd: "C:/Users/matt/Projects/opencode", + path: "C:/Users/matt/outside/secret.txt", + }), + ).toEqual([ + { + permission: "external_directory", + patterns: ["C:\\Users\\matt\\outside\\*"], + always: ["C:\\Users\\matt\\outside\\*"], + metadata: { + filepath: "C:\\Users\\matt\\outside\\secret.txt", + parentDir: "C:\\Users\\matt\\outside", + }, + }, + { + permission: "read", + patterns: ["C:\\Users\\matt\\outside\\secret.txt"], + always: ["*"], + metadata: { + filepath: "C:\\Users\\matt\\outside\\secret.txt", + }, + }, + ]) + }) + + it("builds direct write permission checks before file mutation", () => { + expect( + claudeACPDirectPermissionChecks({ + kind: "write", + cwd: "C:/Users/matt/Projects/opencode", + path: "src/new-file.ts", + }), + ).toEqual([ + { + permission: "edit", + patterns: ["src\\new-file.ts"], + always: ["*"], + metadata: { + filepath: "C:\\Users\\matt\\Projects\\opencode\\src\\new-file.ts", + }, + }, + ]) + }) + + it("builds direct terminal permission checks with cwd guardrails", () => { + expect( + claudeACPDirectPermissionChecks({ + kind: "terminal", + cwd: "C:/Users/matt/Projects/opencode", + command: "node", + args: ["script.js"], + terminalCwd: "C:/Users/matt/outside", + }), + ).toEqual([ + { + permission: "external_directory", + patterns: ["C:\\Users\\matt\\outside\\*"], + always: ["C:\\Users\\matt\\outside\\*"], + metadata: { + filepath: "C:\\Users\\matt\\outside", + parentDir: "C:\\Users\\matt\\outside", + }, + }, + { + permission: "bash", + patterns: ["node script.js"], + always: ["node *"], + metadata: { + command: "node script.js", + cwd: "C:\\Users\\matt\\outside", + }, + }, + ]) + }) + + it("builds direct terminal permission checks for external path arguments", () => { + expect( + claudeACPDirectPermissionChecks({ + kind: "terminal", + cwd: "C:/Users/matt/Projects/opencode", + command: "node", + args: ["script.js", "C:/Users/matt/outside/secret.txt"], + }), + ).toEqual([ + { + permission: "external_directory", + patterns: ["C:\\Users\\matt\\outside\\*"], + always: ["C:\\Users\\matt\\outside\\*"], + metadata: { + filepath: "C:\\Users\\matt\\outside\\secret.txt", + parentDir: "C:\\Users\\matt\\outside", + }, + }, + { + permission: "bash", + patterns: ["node script.js C:/Users/matt/outside/secret.txt"], + always: ["node *"], + metadata: { + command: "node script.js C:/Users/matt/outside/secret.txt", + cwd: "C:\\Users\\matt\\Projects\\opencode", + }, + }, + ]) + }) +}) + +describe("Claude ACP terminal output", () => { + it("clamps output byte limits to a finite range", () => { + expect(claudeACPTerminalOutputLimit(undefined)).toBe(128_000) + expect(claudeACPTerminalOutputLimit(Number.NaN)).toBe(128_000) + expect(claudeACPTerminalOutputLimit(-1)).toBe(0) + expect(claudeACPTerminalOutputLimit(1_500_000)).toBe(1_000_000) + }) + + it("truncates terminal output by bytes without splitting characters", () => { + const output = ["aé"] + + expect(claudeACPAppendOutput(output, "b", 2)).toBe(true) + expect(output.join("")).toBe("b") + expect(Buffer.byteLength(output.join(""), "utf8")).toBeLessThanOrEqual(2) + }) +}) + +function activePermission(input: { + readonly ruleset?: PermissionV1.Ruleset + readonly ask: (request: PermissionV1.AskInput) => Promise +}) { + const abort = new AbortController() + return { + sessionID: SessionID.make("ses_test"), + abort: abort.signal, + ruleset: input.ruleset ?? [], + permission: { + ask: input.ask, + reply: async () => {}, + }, + } +} + +function askByRuleset(asked: PermissionV1.AskInput[]) { + return async (input: PermissionV1.AskInput): Promise => { + asked.push(input) + const rules = input.patterns.map((pattern) => Permission.evaluate(input.permission, pattern, input.ruleset)) + if (rules.some((rule) => rule.action === "deny")) throw new PermissionV1.DeniedError({ ruleset: input.ruleset }) + return "once" + } +} + +function permissionRequest(options?: RequestPermissionRequest["options"]) { + return { + sessionId: "claude_session", + toolCall: { + toolCallId: "call_1", + kind: "execute", + title: "printf hello", + rawInput: { command: "printf hello" }, + }, + options: options ?? [ + { optionId: "allow", kind: "allow_once", name: "Allow" }, + { optionId: "deny", kind: "reject_once", name: "Deny" }, + ], + } satisfies RequestPermissionRequest +} + +function unknownToolPermissionRequest() { + return { + sessionId: "claude_session", + toolCall: { + toolCallId: "call_mcp", + kind: "other", + title: "Call MCP tool", + rawInput: { name: "mcp__server__tool" }, + }, + options: [ + { optionId: "allow", kind: "allow_once", name: "Allow" }, + { optionId: "deny", kind: "reject_once", name: "Deny" }, + ], + } satisfies RequestPermissionRequest +} + +function editPermissionRequestWithIncompleteDiff() { + const content = [ + { + type: "diff", + path: "C:/Users/matt/new-file.txt", + } as NonNullable[number], + ] + + return { + sessionId: "claude_session", + toolCall: { + toolCallId: "call_edit", + kind: "edit", + title: "Create C:/Users/matt/new-file.txt", + rawInput: { filePath: "C:/Users/matt/new-file.txt" }, + content, + }, + options: [ + { optionId: "allow", kind: "allow_once", name: "Allow" }, + { optionId: "deny", kind: "reject_once", name: "Deny" }, + ], + } satisfies RequestPermissionRequest +} + +function toolUpdate( + input: Partial> & { + sessionUpdate: "tool_call" | "tool_call_update" + }, +) { + return { + toolCallId: "call_read", + title: "Read README.md", + kind: "read", + rawInput: { filePath: "README.md" }, + ...input, + } as Extract +} diff --git a/packages/opencode/test/session/llm.test.ts b/packages/opencode/test/session/llm.test.ts index 3bfc722e2bec..29846a2dac34 100644 --- a/packages/opencode/test/session/llm.test.ts +++ b/packages/opencode/test/session/llm.test.ts @@ -1,16 +1,19 @@ import { PermissionV1 } from "@opencode-ai/core/v1/permission" import { ConfigV1 } from "@opencode-ai/core/v1/config/config" -import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test" +import { afterAll, beforeAll, beforeEach, describe, expect, spyOn, test } from "bun:test" import { SessionV1 } from "@opencode-ai/core/v1/session" import path from "path" import { tool, type ModelMessage } from "ai" import { Cause, Effect, Exit, Fiber, Layer, Stream } from "effect" import { InstanceRef } from "../../src/effect/instance-ref" +import { EffectBridge } from "../../src/effect/bridge" import { HttpClientRequest, HttpClientResponse } from "effect/unstable/http" import z from "zod" import { LLM } from "../../src/session/llm" +import { claudeACPPromptBridge } from "../../src/session/llm" +import { ClaudeACP } from "../../src/session/llm/claude-acp" import { LLMClient, RequestExecutor } from "@opencode-ai/llm/route" -import { Provider } from "@/provider/provider" +import { ClaudeACPModelID, ClaudeACPProviderID, Provider } from "@/provider/provider" import { ProviderTransform } from "@/provider/transform" import { ModelsDev } from "@opencode-ai/core/models-dev" @@ -27,6 +30,7 @@ import { ModelV2 } from "@opencode-ai/core/model" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { LayerNodePlatform } from "@opencode-ai/core/effect/app-node-platform" +import { LLMEvent } from "@opencode-ai/llm" type ConfigModel = NonNullable[string]["models"]>[string] @@ -172,6 +176,107 @@ describe("session.llm.hasToolCalls", () => { }) }) +describe("session.llm.claudeACPPromptBridge", () => { + test("preserves InstanceRef for async Claude ACP callbacks", async () => { + const ctx = { + directory: "C:/Users/matt/Projects/opencode", + worktree: "C:/Users/matt/Projects/opencode", + project: {} as never, + } + + const callbacks = await Effect.runPromise( + Effect.gen(function* () { + const bridge = yield* EffectBridge.make() + return claudeACPPromptBridge({ + bridge, + abort: new AbortController().signal, + permission: { + ask: () => Effect.void, + askWithReply: () => + Effect.gen(function* () { + const current = yield* InstanceRef + if (!current) return yield* Effect.die("InstanceRef not provided") + return current.directory === ctx.directory ? "once" : "always" + }), + reply: () => Effect.void, + list: () => Effect.succeed([]), + }, + question: { + ask: () => + Effect.gen(function* () { + const current = yield* InstanceRef + if (!current) return yield* Effect.die("InstanceRef not provided") + return [[current.directory]] + }), + reply: () => Effect.void, + reject: () => Effect.void, + list: () => Effect.succeed([]), + }, + }) + }).pipe(Effect.provideService(InstanceRef, ctx)), + ) + + await Promise.resolve() + + expect(await callbacks.permission.ask({} as PermissionV1.AskInput)).toBe("once") + expect(await callbacks.question.ask({ sessionID: SessionID.make("ses_test"), questions: [] })).toEqual([ + [ctx.directory], + ]) + }) + + test("wires abort signal into Claude ACP permission asks", async () => { + const abort = new AbortController() + let interrupted = false + let release: ((reply: PermissionV1.Reply) => void) | undefined + + const callbacks = await Effect.runPromise( + Effect.gen(function* () { + const bridge = yield* EffectBridge.make() + return claudeACPPromptBridge({ + bridge, + abort: abort.signal, + permission: { + ask: () => Effect.void, + askWithReply: () => + Effect.callback((resume) => { + release = (reply) => resume(Effect.succeed(reply)) + return Effect.sync(() => { + interrupted = true + }) + }), + reply: () => Effect.void, + list: () => Effect.succeed([]), + }, + question: { + ask: () => Effect.succeed([]), + reply: () => Effect.void, + reject: () => Effect.void, + list: () => Effect.succeed([]), + }, + }) + }), + ) + + const pending = callbacks.permission.ask({} as PermissionV1.AskInput) + abort.abort() + + const outcome = await Promise.race([ + pending.then( + () => "resolved" as const, + () => "rejected" as const, + ), + new Promise<"timeout">((resolve) => setTimeout(() => resolve("timeout"), 100)), + ]) + if (outcome === "timeout") { + release?.("once") + await pending + } + + expect(outcome).toBe("rejected") + expect(interrupted).toBe(true) + }) +}) + describe("session.llm.ai-sdk adapter", () => { type AISDKAdapterEvent = Parameters[1] @@ -664,8 +769,8 @@ beforeEach(() => { state.queue.length = 0 }) -afterAll(() => { - void state.server?.stop() +afterAll(async () => { + await state.server?.stop() }) function createChatStream(text: string) { @@ -752,6 +857,160 @@ function createEventResponse(chunks: unknown[], includeDone = false) { } describe("session.llm.stream", () => { + const claudeACPDone = () => + Stream.make( + LLMEvent.stepStart({ index: 0 }), + LLMEvent.stepFinish({ index: 0, reason: "stop" }), + LLMEvent.finish({ reason: "stop" }), + ) + + it.instance( + "passes session cwd to Claude ACP instead of process cwd", + () => + Effect.gen(function* () { + const ctx = yield* InstanceRef + if (!ctx) return yield* Effect.die("InstanceRef not provided") + const resolved = yield* Provider.use.getModel(ClaudeACPProviderID, ClaudeACPModelID) + const sessionID = SessionID.make("session-test-claude-acp-cwd") + const agent = { + name: "test", + mode: "primary", + options: {}, + permission: [{ permission: "*", pattern: "*", action: "allow" }], + } satisfies Agent.Info + let captured: Parameters[0] | undefined + const stream = spyOn(ClaudeACP, "stream").mockImplementation((input) => { + captured = input + return claudeACPDone() + }) + + try { + yield* drain({ + cwd: ctx.directory, + user: { + id: MessageID.make("msg_user-claude-acp-cwd"), + sessionID, + role: "user", + time: { created: Date.now() }, + agent: agent.name, + model: { providerID: ClaudeACPProviderID, modelID: resolved.id }, + } satisfies SessionV1.User, + sessionID, + model: resolved, + agent, + system: [], + messages: [{ role: "user", content: "Hello" }], + tools: {}, + } satisfies LLM.StreamInput & { cwd: string }) + } finally { + stream.mockRestore() + } + + expect(captured?.cwd).toBe(ctx.directory) + expect(captured?.cwd).not.toBe(process.cwd()) + }), + { config: () => ({ enabled_providers: [ClaudeACPProviderID] }) }, + ) + + it.instance( + "passes OpenCode system text into Claude ACP prompt messages", + () => + Effect.gen(function* () { + const ctx = yield* InstanceRef + if (!ctx) return yield* Effect.die("InstanceRef not provided") + const resolved = yield* Provider.use.getModel(ClaudeACPProviderID, ClaudeACPModelID) + const sessionID = SessionID.make("session-test-claude-acp-system") + const agent = { + name: "test", + mode: "primary", + options: {}, + permission: [{ permission: "*", pattern: "*", action: "allow" }], + } satisfies Agent.Info + let captured: Parameters[0] | undefined + const stream = spyOn(ClaudeACP, "stream").mockImplementation((input) => { + captured = input + return claudeACPDone() + }) + + try { + yield* drain({ + cwd: ctx.directory, + user: { + id: MessageID.make("msg_user-claude-acp-system"), + sessionID, + role: "user", + time: { created: Date.now() }, + agent: agent.name, + model: { providerID: ClaudeACPProviderID, modelID: resolved.id }, + } satisfies SessionV1.User, + sessionID, + model: resolved, + agent, + system: ["OpenCode system instruction.", "Project context instruction."], + messages: [{ role: "user", content: "Hello" }], + tools: {}, + } satisfies LLM.StreamInput & { cwd: string }) + } finally { + stream.mockRestore() + } + + expect(captured?.messages[0]).toEqual({ + role: "system", + content: "OpenCode system instruction.\n\nProject context instruction.", + }) + }), + { config: () => ({ enabled_providers: [ClaudeACPProviderID] }) }, + ) + + it.instance( + "fails explicitly when Claude ACP receives unsupported required toolChoice", + () => + Effect.gen(function* () { + const ctx = yield* InstanceRef + if (!ctx) return yield* Effect.die("InstanceRef not provided") + const resolved = yield* Provider.use.getModel(ClaudeACPProviderID, ClaudeACPModelID) + const sessionID = SessionID.make("session-test-claude-acp-tool-choice") + const agent = { + name: "test", + mode: "primary", + options: {}, + permission: [{ permission: "*", pattern: "*", action: "allow" }], + } satisfies Agent.Info + let called = false + const stream = spyOn(ClaudeACP, "stream").mockImplementation(() => { + called = true + return claudeACPDone() + }) + + const exit = yield* drain({ + cwd: ctx.directory, + user: { + id: MessageID.make("msg_user-claude-acp-tool-choice"), + sessionID, + role: "user", + time: { created: Date.now() }, + agent: agent.name, + model: { providerID: ClaudeACPProviderID, modelID: resolved.id }, + } satisfies SessionV1.User, + sessionID, + model: resolved, + agent, + system: [], + messages: [{ role: "user", content: "Hello" }], + tools: {}, + toolChoice: "required", + } satisfies LLM.StreamInput & { cwd: string }).pipe(Effect.exit) + stream.mockRestore() + + expect(called).toBe(false) + expect(Exit.isFailure(exit)).toBe(true) + if (Exit.isFailure(exit)) { + expect(Cause.pretty(exit.cause)).toContain('Claude ACP does not support toolChoice "required"') + } + }), + { config: () => ({ enabled_providers: [ClaudeACPProviderID] }) }, + ) + const vivgridFixture = { providerID: "vivgrid", modelID: "gemini-3.1-pro-preview" } it.instance( "sends temperature, tokens, and reasoning options for openai-compatible models", diff --git a/packages/opencode/test/session/processor-effect.test.ts b/packages/opencode/test/session/processor-effect.test.ts index 052477d0a2e7..4250a57c7994 100644 --- a/packages/opencode/test/session/processor-effect.test.ts +++ b/packages/opencode/test/session/processor-effect.test.ts @@ -26,6 +26,7 @@ import { ProviderV2 } from "@opencode-ai/core/provider" import { ModelV2 } from "@opencode-ai/core/model" import { SessionProjector } from "@opencode-ai/core/session/projector" import { LLMEvent } from "@opencode-ai/llm" +import { ProviderTest } from "../fake/provider" const summary = Layer.succeed( SessionSummary.Service, @@ -226,6 +227,47 @@ const fragmentFailureLLM = Layer.succeed( const fragmentFailureEnv = LayerNode.compile(root, [...replacements, [LLM.node, fragmentFailureLLM]]) const itFragmentFailure = testEffect(fragmentFailureEnv) +const interruptedLLM = Layer.succeed( + LLM.Service, + LLM.Service.of({ + stream: () => + Stream.concat( + Stream.make( + LLMEvent.stepStart({ index: 0 }), + LLMEvent.textStart({ id: "text-1" }), + LLMEvent.textDelta({ id: "text-1", text: "partial interrupted output" }), + ), + Stream.never, + ), + }), +) +const interruptedProvider = ProviderTest.fake({ + model: ProviderTest.model({ id: ref.modelID, providerID: ref.providerID }), +}) +const interruptedEnv = LayerNode.compile(root, [ + ...replacements, + [LLM.node, interruptedLLM], + [Provider.node, interruptedProvider.layer], +]) +const itInterrupted = testEffect(interruptedEnv) + +let capturedProcessorInput: (LLM.StreamInput & { cwd?: string }) | undefined +const captureInputLLM = Layer.succeed( + LLM.Service, + LLM.Service.of({ + stream: (input) => { + capturedProcessorInput = input + return Stream.make( + LLMEvent.stepStart({ index: 0 }), + LLMEvent.stepFinish({ index: 0, reason: "stop" }), + LLMEvent.finish({ reason: "stop" }), + ) + }, + }), +) +const captureInputEnv = LayerNode.compile(root, [...replacements, [LLM.node, captureInputLLM]]) +const itCaptureInput = testEffect(captureInputEnv) + const boot = Effect.fn("test.boot")(function* () { const processors = yield* SessionProcessor.Service const session = yield* Session.Service @@ -285,6 +327,126 @@ it.live("session.processor effect tests capture llm input cleanly", () => ), ) +itCaptureInput.live("session.processor effect tests forward assistant cwd to llm input", () => + provideTmpdirInstance( + (dir) => + Effect.gen(function* () { + capturedProcessorInput = undefined + const { processors, session, provider } = yield* boot() + + const chat = yield* session.create({}) + const parent = yield* user(chat.id, "cwd") + const sessionCwd = path.join(path.resolve(dir), "session-cwd") + const msg = yield* assistant(chat.id, parent.id, sessionCwd) + const mdl = yield* provider.getModel(ref.providerID, ref.modelID) + const handle = yield* processors.create({ + assistantMessage: msg, + sessionID: chat.id, + model: mdl, + }) + + const value = yield* handle.process({ + user: { + id: parent.id, + sessionID: chat.id, + role: "user", + time: parent.time, + agent: parent.agent, + model: { providerID: ref.providerID, modelID: ref.modelID }, + } satisfies SessionV1.User, + sessionID: chat.id, + model: mdl, + agent: agent(), + system: [], + messages: [{ role: "user", content: "cwd" }], + tools: {}, + }) + + expect(value).toBe("continue") + const captured = capturedProcessorInput as (LLM.StreamInput & { cwd?: string }) | undefined + expect(captured?.cwd).toBe(sessionCwd) + expect(captured?.cwd).not.toBe(process.cwd()) + }), + { config: cfg }, + ), +) + +itInterrupted.live("estimates context usage for interrupted turns without provider usage", () => + provideTmpdirInstance((dir) => + Effect.gen(function* () { + const database = yield* Database.Service + const { processors, session, provider } = yield* boot() + + const chat = yield* session.create({}) + const parent = yield* user(chat.id, "hi") + const msg = yield* session.updateMessage({ + id: MessageID.ascending(), + role: "assistant", + sessionID: chat.id, + mode: "build", + agent: "build", + path: { cwd: path.resolve(dir), root: path.resolve(dir) }, + cost: 0, + tokens: { + total: 0, + input: 0, + output: 0, + reasoning: 0, + cache: { read: 0, write: 0 }, + }, + modelID: ref.modelID, + providerID: ref.providerID, + parentID: parent.id, + time: { created: Date.now() }, + } satisfies SessionV1.Assistant) + const mdl = yield* provider.getModel(ref.providerID, ref.modelID) + const handle = yield* processors.create({ + assistantMessage: msg, + sessionID: chat.id, + model: mdl, + }) + + const fiber = yield* handle + .process({ + user: { + id: parent.id, + sessionID: chat.id, + role: "user", + time: parent.time, + agent: parent.agent, + model: { providerID: ref.providerID, modelID: ref.modelID }, + } satisfies SessionV1.User, + sessionID: chat.id, + model: mdl, + agent: agent(), + system: [], + messages: [{ role: "user", content: "hi" }], + tools: {}, + }) + .pipe(Effect.forkChild) + + yield* waitFor( + MessageV2.parts(msg.id).pipe( + Effect.map((parts) => (parts.some((part) => part.type === "text") ? parts : undefined)), + Effect.provideService(Database.Service, database), + ), + "partial text was not persisted", + ) + yield* Fiber.interrupt(fiber) + + const messages = yield* session.messages({ sessionID: chat.id }) + const updated = messages.find((item) => item.info.id === msg.id)?.info + const parts = yield* MessageV2.parts(msg.id).pipe(Effect.provideService(Database.Service, database)) + + if (!updated || updated.role !== "assistant") throw new Error("missing assistant") + expect(updated.error?.name).toBe("MessageAbortedError") + expect(updated.tokens.input).toBeGreaterThan(0) + expect(updated.tokens.output).toBeGreaterThan(0) + expect(parts.some((part) => part.type === "step-finish" && part.tokens.input > 0)).toBe(true) + }), + ), +) + it.live("session.processor effect tests preserve text start time", () => provideTmpdirServer( ({ dir, llm }) => diff --git a/packages/tui/src/component/dialog-provider.tsx b/packages/tui/src/component/dialog-provider.tsx index 0fd51e3c1c71..a6d467ae156f 100644 --- a/packages/tui/src/component/dialog-provider.tsx +++ b/packages/tui/src/component/dialog-provider.tsx @@ -15,6 +15,7 @@ import { isConsoleManagedProvider } from "../util/provider-origin" import { useConnected } from "./use-connected" import { useBindings } from "../keymap" import { useClipboard } from "../context/clipboard" +import { ClaudeACPProviderID } from "../util/claude-acp" const PROVIDER_PRIORITY: Record = { opencode: 0, @@ -61,6 +62,7 @@ export function providerOptions(list: { id: string; name: string }[]): ProviderO description: { opencode: "(Recommended)", anthropic: "(API key)", + [ClaudeACPProviderID]: "(Claude Code)", openai: "(ChatGPT Plus/Pro or API key)", "opencode-go": "Low cost subscription for everyone", }[provider.id], @@ -144,6 +146,14 @@ export function createDialogProviderOptions() { gutter: connected && onboarded() ? () => : undefined, async onSelect() { if (consoleManaged) return + if (providerID === ClaudeACPProviderID) { + toast.show({ + variant: "info", + message: "Claude uses Claude Code auth. Select it from /models.", + }) + dialog.clear() + return + } const methods = sync.data.provider_auth[providerID] ?? [ { diff --git a/packages/tui/src/component/prompt/autocomplete.tsx b/packages/tui/src/component/prompt/autocomplete.tsx index 099fa9d83eb7..ca44bed66906 100644 --- a/packages/tui/src/component/prompt/autocomplete.tsx +++ b/packages/tui/src/component/prompt/autocomplete.tsx @@ -23,6 +23,8 @@ import { useFrecency } from "../../prompt/frecency" import { useBindings, useCommandSlashes, useOpencodeModeStack } from "../../keymap" import { displayCharAt, mentionTriggerIndex } from "../../prompt/display" import type { FileSystemEntry } from "@opencode-ai/sdk/v2" +import { useLocal } from "../../context/local" +import { ClaudeACPProviderID, ClaudeACPSlashCommands, isClaudeACPSlashCommand } from "../../util/claude-acp" function removeLineRange(input: string) { const hashIndex = input.lastIndexOf("#") @@ -89,6 +91,7 @@ export function Autocomplete(props: { const sync = useSync() const data = useData() const project = useProject() + const local = useLocal() const slashes = useCommandSlashes() const modeStack = useOpencodeModeStack() const { theme } = useTheme() @@ -239,6 +242,14 @@ export function Autocomplete(props: { } } + function insertSlashCommand(name: string) { + const newText = "/" + name + " " + const cursor = props.input().logicalCursor + props.input().deleteRange(0, 0, cursor.row, cursor.col) + props.input().insertText(newText) + props.input().cursorOffset = Bun.stringWidth(newText) + } + function createFilePart( item: FileSystemEntry, filePath: string, @@ -444,22 +455,44 @@ export function Autocomplete(props: { ), ) + const isClaudeACPSelected = createMemo(() => local.model.current()?.providerID === ClaudeACPProviderID) + + const claudeACPSlashes = createMemo((): AutocompleteOption[] => { + if (!isClaudeACPSelected()) return [] + return ClaudeACPSlashCommands.map((command) => ({ + display: "/" + command.name, + description: command.hint ? `${command.hint} ${command.description}` : command.description, + onSelect: () => insertSlashCommand(command.name), + })) + }) + + function localSlashForClaudeACP(item: AutocompleteOption) { + if (!isClaudeACPSelected()) return item + if (isClaudeACPSlashCommand(item.display.trimEnd())) return + const aliases = item.aliases?.filter((alias) => !isClaudeACPSlashCommand(alias.trimEnd())) + return { + ...item, + aliases: aliases?.length ? aliases : undefined, + } + } + const commands = createMemo((): AutocompleteOption[] => { - const results: AutocompleteOption[] = [...slashes()] + const results: AutocompleteOption[] = [ + ...slashes().flatMap((item) => { + const slash = localSlashForClaudeACP(item) + return slash ? [slash] : [] + }), + ...claudeACPSlashes(), + ] for (const serverCommand of sync.data.command) { if (serverCommand.source === "skill") continue + if (isClaudeACPSelected() && isClaudeACPSlashCommand(serverCommand.name)) continue const label = serverCommand.source === "mcp" ? ":mcp" : "" results.push({ display: "/" + serverCommand.name + label, description: serverCommand.description, - onSelect: () => { - const newText = "/" + serverCommand.name + " " - const cursor = props.input().logicalCursor - props.input().deleteRange(0, 0, cursor.row, cursor.col) - props.input().insertText(newText) - props.input().cursorOffset = Bun.stringWidth(newText) - }, + onSelect: () => insertSlashCommand(serverCommand.name), }) } diff --git a/packages/tui/src/component/prompt/index.tsx b/packages/tui/src/component/prompt/index.tsx index fe7f4a22f75f..7b4e3a62312d 100644 --- a/packages/tui/src/component/prompt/index.tsx +++ b/packages/tui/src/component/prompt/index.tsx @@ -37,8 +37,10 @@ import { usePromptStash } from "../../prompt/stash" import { DialogStash } from "../dialog-stash" import { type AutocompleteRef, Autocomplete } from "./autocomplete" import { useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid" -import type { AssistantMessage, FilePart, UserMessage } from "@opencode-ai/sdk/v2" +import type { FilePart, UserMessage } from "@opencode-ai/sdk/v2" +import { ClaudeACPProviderID, claudeACPFooterState, isClaudeACPSlashCommand } from "../../util/claude-acp" import { Locale } from "../../util/locale" +import { assistantContextTokens, latestAssistantContextMessage } from "../../util/session" import { errorMessage } from "../../util/error" import { formatDuration } from "../../util/format" import { createColors, createFrames } from "../../ui/spinner" @@ -210,7 +212,11 @@ export function Prompt(props: PromptProps) { const workspace = usePromptWorkspace(props.sessionID) const move = usePromptMove({ projectID: project.project, sessionID: () => props.sessionID }) const [cursorVersion, setCursorVersion] = createSignal(0) - const currentProviderLabel = createMemo(() => local.model.parsed().provider) + const currentProviderLabel = createMemo(() => { + const model = local.model.parsed() + if (model.provider === model.model) return + return model.provider + }) const hasRightContent = createMemo(() => Boolean(props.right)) function promptModelWarning() { @@ -265,11 +271,10 @@ export function Prompt(props: PromptProps) { if (!props.sessionID) return const session = sync.session.get(props.sessionID) const msg = sync.data.message[props.sessionID] ?? [] - const last = msg.findLast((item): item is AssistantMessage => item.role === "assistant" && item.tokens.output > 0) + const last = latestAssistantContextMessage(msg) if (!last) return - const tokens = - last.tokens.input + last.tokens.output + last.tokens.reasoning + last.tokens.cache.read + last.tokens.cache.write + const tokens = assistantContextTokens(last) if (tokens <= 0) return const model = sync.data.provider.find((item) => item.id === last.providerID)?.models[last.modelID] @@ -1068,10 +1073,7 @@ export function Prompt(props: PromptProps) { command: inputText, }) setStore("mode", "normal") - } else if ( - inputText.startsWith("/") && - sync.data.command.some((x) => x.name === inputText.split("\n")[0].split(" ")[0].slice(1)) - ) { + } else if (shouldRunOpencodeSlashCommand(inputText, selectedModel.providerID)) { move.startSubmit() // Parse command from first line, preserve multi-line content in arguments const firstLineEnd = inputText.indexOf("\n") @@ -1146,6 +1148,13 @@ export function Prompt(props: PromptProps) { return true } + function shouldRunOpencodeSlashCommand(inputText: string, providerID: string) { + if (!inputText.startsWith("/")) return false + const command = inputText.split("\n")[0].split(" ")[0].slice(1) + if (providerID === ClaudeACPProviderID && isClaudeACPSlashCommand(command)) return false + return sync.data.command.some((item) => item.name === command) + } + function pasteText(text: string, virtualText: string) { const currentOffset = input.cursorOffset const extmarkStart = currentOffset @@ -1300,12 +1309,28 @@ export function Prompt(props: PromptProps) { return !!current }) + const claudeAcpFooter = createMemo(() => { + if (local.model.current()?.providerID !== ClaudeACPProviderID) return + if (!props.sessionID) return + return claudeACPFooterState(sync.session.get(props.sessionID)?.metadata) + }) + + const showClaudeAcpEffort = createMemo(() => !!claudeAcpFooter()?.effort) + const showClaudeAcpFast = createMemo(() => claudeAcpFooter()?.fast === true) + const agentMetaAlpha = createFadeIn(() => !!local.agent.current(), animationsEnabled) const modelMetaAlpha = createFadeIn(() => !!local.agent.current() && store.mode === "normal", animationsEnabled) const variantMetaAlpha = createFadeIn( () => !!local.agent.current() && store.mode === "normal" && showVariant(), animationsEnabled, ) + const claudeAcpMetaAlpha = createFadeIn( + () => + !!local.agent.current() && + store.mode === "normal" && + (showClaudeAcpEffort() || showClaudeAcpFast()), + animationsEnabled, + ) const borderHighlight = createMemo(() => tint(theme.border, highlight(), agentMetaAlpha())) const placeholderText = createMemo(() => { @@ -1461,7 +1486,25 @@ export function Prompt(props: PromptProps) { > {local.model.parsed().model} - {currentProviderLabel()} + + · + + + {claudeAcpFooter()?.effort} + + + + + · + + + fast + + + + + {(provider) => {provider()}} + · diff --git a/packages/tui/src/context/sync.tsx b/packages/tui/src/context/sync.tsx index 71e050d11e68..745bc2c50ddd 100644 --- a/packages/tui/src/context/sync.tsx +++ b/packages/tui/src/context/sync.tsx @@ -51,6 +51,36 @@ function search(items: T[], target: string, key: (item: T) => string) { return { found: false, index: left } } +function groupedRequests(items: T[]) { + return items.toSorted((a, b) => a.id.localeCompare(b.id)).reduce>((acc, item) => { + acc[item.sessionID] = [...(acc[item.sessionID] ?? []), item] + return acc + }, {}) +} + +function requestIDs(items: Record) { + return new Set(Object.values(items).flatMap((group) => group.map((item) => item.id))) +} + +function mergeGroupedRequests( + current: Record, + incoming: T[], + settled: Set, + preexisting: Set, +) { + const incomingRequests = new Map( + incoming.filter((item) => !settled.has(item.id)).map((item) => [item.id, item] as const), + ) + return groupedRequests( + [ + ...incomingRequests.values(), + ...Object.values(current) + .flat() + .filter((item) => !settled.has(item.id) && !preexisting.has(item.id) && !incomingRequests.has(item.id)), + ], + ) +} + function compareMessage(a: Message, b: Message) { return a.time.created - b.time.created || a.id.localeCompare(b.id) } @@ -150,6 +180,8 @@ export const { const fullSyncedSessions = new Set() const syncingSessions = new Map>() const hydratingSessions = new Map; parts: Set }>() + const settledPermissions = new Set() + const settledQuestions = new Set() const touchMessage = (sessionID: string, messageID: string) => { hydratingSessions.get(sessionID)?.messages.add(messageID) } @@ -179,6 +211,7 @@ export const { void bootstrap() break case "permission.replied": { + settledPermissions.add(event.properties.requestID) const requests = store.permission[event.properties.sessionID] if (!requests) break const match = search(requests, event.properties.requestID, (r) => r.id) @@ -195,6 +228,7 @@ export const { case "permission.asked": { const request = event.properties + settledPermissions.delete(request.id) if (permission.mode === "auto") { void sdk.client.permission.reply({ requestID: request.id, @@ -226,6 +260,7 @@ export const { case "question.replied": case "question.rejected": { + settledQuestions.add(event.properties.requestID) const requests = store.question[event.properties.sessionID] if (!requests) break const match = search(requests, event.properties.requestID, (r) => r.id) @@ -242,6 +277,7 @@ export const { case "question.asked": { const request = event.properties + settledQuestions.delete(request.id) const requests = store.question[request.sessionID] if (!requests) { setStore("question", request.sessionID, [request]) @@ -527,6 +563,28 @@ export const { .list({ workspace }) .then((x) => setStore("mcp_resource", reconcile(x.data ?? {}))), sdk.client.formatter.status({ workspace }).then((x) => setStore("formatter", reconcile(x.data ?? []))), + (() => { + const preexisting = requestIDs(store.permission) + return sdk.client.permission + .list({ workspace }) + .then((x) => + setStore( + "permission", + reconcile(mergeGroupedRequests(store.permission, x.data ?? [], settledPermissions, preexisting)), + ), + ) + })(), + (() => { + const preexisting = requestIDs(store.question) + return sdk.client.question + .list({ workspace }) + .then((x) => + setStore( + "question", + reconcile(mergeGroupedRequests(store.question, x.data ?? [], settledQuestions, preexisting)), + ), + ) + })(), sdk.client.session.status({ workspace }).then((x) => { setStore("session_status", reconcile(x.data ?? {})) }), diff --git a/packages/tui/src/feature-plugins/sidebar/context.tsx b/packages/tui/src/feature-plugins/sidebar/context.tsx index f1c99d9679ce..50cafad5dc18 100644 --- a/packages/tui/src/feature-plugins/sidebar/context.tsx +++ b/packages/tui/src/feature-plugins/sidebar/context.tsx @@ -1,7 +1,7 @@ -import type { AssistantMessage } from "@opencode-ai/sdk/v2" import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui" import type { BuiltinTuiPlugin } from "../builtins" import { createMemo } from "solid-js" +import { assistantContextTokens, latestAssistantContextMessage } from "../../util/session" const id = "internal:sidebar-context" @@ -17,7 +17,7 @@ function View(props: { api: TuiPluginApi; session_id: string }) { const cost = createMemo(() => session()?.cost ?? 0) const state = createMemo(() => { - const last = msg().findLast((item): item is AssistantMessage => item.role === "assistant" && item.tokens.output > 0) + const last = latestAssistantContextMessage(msg()) if (!last) { return { tokens: 0, @@ -25,8 +25,7 @@ function View(props: { api: TuiPluginApi; session_id: string }) { } } - const tokens = - last.tokens.input + last.tokens.output + last.tokens.reasoning + last.tokens.cache.read + last.tokens.cache.write + const tokens = assistantContextTokens(last) const model = props.api.state.provider.find((item) => item.id === last.providerID)?.models[last.modelID] return { tokens, diff --git a/packages/tui/src/routes/session/footer.tsx b/packages/tui/src/routes/session/footer.tsx index c3a96254e98b..bb844360746a 100644 --- a/packages/tui/src/routes/session/footer.tsx +++ b/packages/tui/src/routes/session/footer.tsx @@ -5,6 +5,7 @@ import { useDirectory } from "../../context/directory" import { useConnected } from "../../component/use-connected" import { createStore } from "solid-js/store" import { useRoute } from "../../context/route" +import { visibleSessionRequests } from "./request" export function Footer() { const { theme } = useTheme() @@ -15,7 +16,12 @@ export function Footer() { const lsp = createMemo(() => Object.keys(sync.data.lsp)) const permissions = createMemo(() => { if (route.data.type !== "session") return [] - return sync.data.permission[route.data.sessionID] ?? [] + return visibleSessionRequests({ + routeSessionID: route.data.sessionID, + currentSession: sync.session.get(route.data.sessionID), + sessions: sync.data.session, + requests: sync.data.permission, + }) }) const directory = useDirectory() const connected = useConnected() diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index 61abe4abd8d9..0cd84fb44fa4 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -66,6 +66,7 @@ import { useEpilogue } from "../../context/epilogue" import { normalizePath } from "../../util/path" import { PermissionPrompt } from "./permission" import { QuestionPrompt } from "./question" +import { visibleSessionRequests } from "./request" import { DialogExportOptions } from "../../ui/dialog-export-options" import * as Model from "../../util/model" import { formatTranscript } from "../../util/transcript" @@ -231,12 +232,20 @@ export function Session() { : [], ) const permissions = createMemo(() => { - if (session()?.parentID) return [] - return children().flatMap((x) => sync.data.permission[x.id] ?? []) + return visibleSessionRequests({ + routeSessionID: route.sessionID, + currentSession: session(), + sessions: sync.data.session, + requests: sync.data.permission, + }) }) const questions = createMemo(() => { - if (session()?.parentID) return [] - return children().flatMap((x) => sync.data.question[x.id] ?? []) + return visibleSessionRequests({ + routeSessionID: route.sessionID, + currentSession: session(), + sessions: sync.data.session, + requests: sync.data.question, + }) }) const visible = createMemo(() => !session()?.parentID && permissions().length === 0 && questions().length === 0) const disabled = createMemo(() => permissions().length > 0 || questions().length > 0) diff --git a/packages/tui/src/routes/session/permission.tsx b/packages/tui/src/routes/session/permission.tsx index fbe3aacbe247..74e0883b7f9a 100644 --- a/packages/tui/src/routes/session/permission.tsx +++ b/packages/tui/src/routes/session/permission.tsx @@ -1,6 +1,6 @@ import { createStore } from "solid-js/store" import { dirname } from "node:path" -import { createMemo, For, Match, Show, Switch } from "solid-js" +import { createEffect, createMemo, For, Match, on, Show, Switch } from "solid-js" import { Portal, useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid" import type { TextareaRenderable } from "@opentui/core" import { useTheme, selectedForeground } from "../../context/theme" @@ -108,6 +108,11 @@ function TextBody(props: { title: string; description?: string; icon?: string }) ) } +function recordValue(value: unknown): Record { + if (!value || typeof value !== "object" || Array.isArray(value)) return {} + return value as Record +} + export function PermissionPrompt(props: { request: PermissionRequest; directory?: string }) { const sdk = useSDK() const project = useProject() @@ -116,19 +121,21 @@ export function PermissionPrompt(props: { request: PermissionRequest; directory? stage: "permission" as PermissionStage, }) const pathFormatter = usePathFormatter() + createEffect(on(() => props.request.id, () => setStore("stage", "permission"), { defer: true })) const session = createMemo(() => sync.data.session.find((s) => s.id === props.request.sessionID)) const input = createMemo(() => { + const metadata = props.request.metadata ?? {} const tool = props.request.tool - if (!tool) return {} + if (!tool) return metadata const parts = sync.data.part[tool.messageID] ?? [] for (const part of parts) { if (part.type === "tool" && part.callID === tool.callID && part.state.status !== "pending") { - return part.state.input ?? {} + return { ...metadata, ...recordValue(part.state.input) } } } - return {} + return metadata }) const { theme } = useTheme() @@ -137,6 +144,7 @@ export function PermissionPrompt(props: { request: PermissionRequest; directory? @@ -399,6 +407,7 @@ export function PermissionPrompt(props: { request: PermissionRequest; directory? const body = ( void; onCancel: ( } function Prompt>(props: { + resetKey?: string title: string header?: JSX.Element body: JSX.Element @@ -539,6 +549,13 @@ function Prompt>(props: { selected: keys[0], expanded: false, }) + createEffect( + on( + () => props.resetKey, + () => setStore({ selected: keys[0], expanded: false }), + { defer: true }, + ), + ) const narrow = createMemo(() => dimensions().width < 80) const fullscreenHint = useCommandShortcut("permission.prompt.fullscreen") diff --git a/packages/tui/src/routes/session/request.ts b/packages/tui/src/routes/session/request.ts new file mode 100644 index 000000000000..11fd806fc9ee --- /dev/null +++ b/packages/tui/src/routes/session/request.ts @@ -0,0 +1,27 @@ +type SessionRequest = { + id: string +} + +type SessionInfo = { + id: string + parentID?: string +} + +export function visibleSessionRequests(input: { + routeSessionID: string + currentSession: SessionInfo | undefined + sessions: readonly SessionInfo[] + requests: Record +}) { + const children = input.sessions.reduce>((acc, session) => { + if (!session.parentID) return acc + acc[session.parentID] = [...(acc[session.parentID] ?? []), session.id] + return acc + }, {}) + const descendants = (sessionID: string): string[] => + (children[sessionID] ?? []).flatMap((childID) => [childID, ...descendants(childID)]) + const parentID = input.currentSession?.parentID ? input.routeSessionID : (input.currentSession?.id ?? input.routeSessionID) + const sessionIDs = new Set([input.routeSessionID, parentID, ...descendants(parentID)]) + + return [...sessionIDs].toSorted().flatMap((sessionID) => input.requests[sessionID] ?? []) +} diff --git a/packages/tui/src/routes/session/subagent-footer.tsx b/packages/tui/src/routes/session/subagent-footer.tsx index 0aadb4f43c96..9e116a42ac67 100644 --- a/packages/tui/src/routes/session/subagent-footer.tsx +++ b/packages/tui/src/routes/session/subagent-footer.tsx @@ -3,8 +3,8 @@ import { useRouteData } from "../../context/route" import { useSync } from "../../context/sync" import { useTheme } from "../../context/theme" import { SplitBorder } from "../../ui/border" -import type { AssistantMessage } from "@opencode-ai/sdk/v2" import { Locale } from "../../util/locale" +import { assistantContextTokens, latestAssistantContextMessage } from "../../util/session" import { useTerminalDimensions } from "@opentui/solid" import { useCommandShortcut, useOpencodeKeymap } from "../../keymap" @@ -32,11 +32,10 @@ export function SubagentFooter() { const usage = createMemo(() => { const msg = messages() - const last = msg.findLast((item): item is AssistantMessage => item.role === "assistant" && item.tokens.output > 0) + const last = latestAssistantContextMessage(msg) if (!last) return - const tokens = - last.tokens.input + last.tokens.output + last.tokens.reasoning + last.tokens.cache.read + last.tokens.cache.write + const tokens = assistantContextTokens(last) if (tokens <= 0) return const model = sync.data.provider.find((item) => item.id === last.providerID)?.models[last.modelID] diff --git a/packages/tui/src/util/claude-acp.ts b/packages/tui/src/util/claude-acp.ts new file mode 100644 index 000000000000..811b2d04704b --- /dev/null +++ b/packages/tui/src/util/claude-acp.ts @@ -0,0 +1,111 @@ +export const ClaudeACPProviderID = "claude-acp" + +type ClaudeACPSlashCommand = { + readonly name: string + readonly hint?: string + readonly description: string +} + +export const ClaudeACPSlashCommands: readonly ClaudeACPSlashCommand[] = [ + { + name: "compact", + hint: "", + description: "Summarize the current conversation context", + }, + { + name: "config", + hint: "key=value", + description: "Update Claude Code configuration", + }, + { + name: "context", + description: "Show current context usage", + }, + { + name: "debug", + hint: "[issue description]", + description: "Enable debug logging for this session", + }, + { + name: "effort", + hint: "[low|medium|high|max]", + description: "Set Claude Code reasoning effort", + }, + { + name: "fast", + hint: "[on|off]", + description: "Toggle Claude Code fast mode (Opus)", + }, + { + name: "heapdump", + description: "Dump the JS heap to Desktop", + }, + { + name: "init", + description: "Initialize CLAUDE.md guidance", + }, + { + name: "model", + hint: "[model]", + description: "Switch the Claude Code model", + }, + { + name: "reload-skills", + description: "Reload Claude Code skills from disk", + }, + { + name: "review", + hint: "[pr number]", + description: "Review a pull request", + }, + { + name: "security-review", + description: "Review pending changes for security issues", + }, + { + name: "usage", + description: "Show session usage", + }, + { + name: "usage-credits", + description: "Configure usage credits", + }, + { + name: "extra-usage", + description: "Alias for usage credits", + }, + { + name: "insights", + description: "Analyze Claude Code sessions", + }, + { + name: "goal", + description: "Set a goal for Claude Code to work toward", + }, + { + name: "team-onboarding", + description: "Create teammate onboarding guidance from Claude Code usage", + }, +] + +const ClaudeACPSlashCommandNames = new Set(ClaudeACPSlashCommands.map((command) => command.name)) + +export function isClaudeACPSlashCommand(command: string) { + return ClaudeACPSlashCommandNames.has(command.replace(/^\//, "")) +} + +export type ClaudeACPFooterState = { + readonly effort?: string + readonly fast?: boolean +} + +export function claudeACPFooterState(metadata: Record | undefined): ClaudeACPFooterState { + const raw = metadata?.claudeAcp + if (!raw || typeof raw !== "object") return {} + const effort = "effort" in raw && typeof raw.effort === "string" && raw.effort !== "default" ? raw.effort : undefined + const fast = "fast" in raw && raw.fast === true + return { + ...(effort ? { effort } : {}), + ...(fast ? { fast: true } : {}), + } +} diff --git a/packages/tui/src/util/session.ts b/packages/tui/src/util/session.ts index 94ccad22d093..56c2d6c6bcaa 100644 --- a/packages/tui/src/util/session.ts +++ b/packages/tui/src/util/session.ts @@ -1,3 +1,38 @@ +import type { AssistantMessage, Message } from "@opencode-ai/sdk/v2" + export function isDefaultTitle(title: string) { return /^(New session - |Child session - )\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(title) } + +export function assistantContextTokens(message: AssistantMessage) { + return ( + message.tokens.total ?? + message.tokens.input + + message.tokens.output + + message.tokens.reasoning + + message.tokens.cache.read + + message.tokens.cache.write + ) +} + +const withUsage = (message: Message): message is AssistantMessage => + message.role === "assistant" && assistantContextTokens(message) > 0 + +// Interrupted turns get locally estimated tokens (no provider-reported +// `total`); an estimate must never displace a provider-reported value. +const withEstimatedUsage = (message: AssistantMessage) => + message.tokens.total === undefined && message.error?.name === "MessageAbortedError" + +/** + * The message whose usage the context meter shows: the latest assistant + * message with provider-reported usage, last write wins. Genuine reports move + * the meter in both directions — context shrinks when the provider compacts + * its own history, so picking a maximum would pin the meter at the + * pre-compaction peak forever. + */ +export function latestAssistantContextMessage(messages: readonly Message[]) { + return ( + messages.findLast((message): message is AssistantMessage => withUsage(message) && !withEstimatedUsage(message)) ?? + messages.findLast(withUsage) + ) +} diff --git a/packages/tui/test/cli/cmd/tui/session-requests.test.ts b/packages/tui/test/cli/cmd/tui/session-requests.test.ts new file mode 100644 index 000000000000..5ed99b7cfc71 --- /dev/null +++ b/packages/tui/test/cli/cmd/tui/session-requests.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, test } from "bun:test" +import { visibleSessionRequests } from "../../../../src/routes/session/request" + +describe("session request visibility", () => { + test("shows requests for the route session before the session record is hydrated", () => { + expect( + visibleSessionRequests({ + routeSessionID: "ses_parent", + currentSession: undefined, + sessions: [], + requests: { + ses_parent: [{ id: "perm_parent" }], + }, + }).map((item) => item.id), + ).toEqual(["perm_parent"]) + }) + + test("shows known child session requests from the parent route", () => { + expect( + visibleSessionRequests({ + routeSessionID: "ses_parent", + currentSession: { id: "ses_parent" }, + sessions: [{ id: "ses_parent" }, { id: "ses_child", parentID: "ses_parent" }], + requests: { + ses_child: [{ id: "perm_child" }], + }, + }).map((item) => item.id), + ).toEqual(["perm_child"]) + }) + + test("shows requests for the child route itself", () => { + expect( + visibleSessionRequests({ + routeSessionID: "ses_child", + currentSession: { id: "ses_child", parentID: "ses_parent" }, + sessions: [{ id: "ses_parent" }, { id: "ses_child", parentID: "ses_parent" }], + requests: { + ses_child: [{ id: "perm_child" }], + }, + }).map((item) => item.id), + ).toEqual(["perm_child"]) + }) + + test("shows nested child session requests from the parent route", () => { + expect( + visibleSessionRequests({ + routeSessionID: "ses_parent", + currentSession: { id: "ses_parent" }, + sessions: [ + { id: "ses_parent" }, + { id: "ses_child", parentID: "ses_parent" }, + { id: "ses_grandchild", parentID: "ses_child" }, + ], + requests: { + ses_grandchild: [{ id: "perm_grandchild" }], + }, + }).map((item) => item.id), + ).toEqual(["perm_grandchild"]) + }) + + test("shows nested child session requests from a child route", () => { + expect( + visibleSessionRequests({ + routeSessionID: "ses_child", + currentSession: { id: "ses_child", parentID: "ses_parent" }, + sessions: [ + { id: "ses_parent" }, + { id: "ses_child", parentID: "ses_parent" }, + { id: "ses_grandchild", parentID: "ses_child" }, + ], + requests: { + ses_grandchild: [{ id: "perm_grandchild" }], + }, + }).map((item) => item.id), + ).toEqual(["perm_grandchild"]) + }) + + test("does not show parent requests from a child route", () => { + expect( + visibleSessionRequests({ + routeSessionID: "ses_child", + currentSession: { id: "ses_child", parentID: "ses_parent" }, + sessions: [{ id: "ses_parent" }, { id: "ses_child", parentID: "ses_parent" }], + requests: { + ses_parent: [{ id: "perm_parent" }], + }, + }), + ).toEqual([]) + }) +}) diff --git a/packages/tui/test/cli/cmd/tui/sync-fixture.tsx b/packages/tui/test/cli/cmd/tui/sync-fixture.tsx index f81c4401a61a..a4622e1dbaf9 100644 --- a/packages/tui/test/cli/cmd/tui/sync-fixture.tsx +++ b/packages/tui/test/cli/cmd/tui/sync-fixture.tsx @@ -22,7 +22,7 @@ export async function wait(fn: () => boolean, timeout = 2000) { type Ctx = { kv: ReturnType; project: ReturnType; sync: ReturnType } -export async function mount(override?: FetchHandler, state?: string) { +export async function mount(override?: FetchHandler, state?: string, input: { waitForComplete?: boolean } = {}) { const calls = createFetch(override) const events = createEventSource() let sync!: ReturnType @@ -65,6 +65,6 @@ export async function mount(override?: FetchHandler, state?: string) { )) await ready - await wait(() => sync.status === "complete") + if (input.waitForComplete !== false) await wait(() => sync.status === "complete") return { app, emit: events.emit, kv, project, sync, session: calls.session } } diff --git a/packages/tui/test/cli/cmd/tui/sync.test.tsx b/packages/tui/test/cli/cmd/tui/sync.test.tsx index 6c70295b69c6..ebe132a7eff9 100644 --- a/packages/tui/test/cli/cmd/tui/sync.test.tsx +++ b/packages/tui/test/cli/cmd/tui/sync.test.tsx @@ -1,7 +1,7 @@ /** @jsxImportSource @opentui/solid */ import { describe, expect, test } from "bun:test" import { tmpdir } from "../../../fixture/fixture" -import { mount, wait } from "./sync-fixture" +import { json, mount, wait } from "./sync-fixture" import type { GlobalEvent } from "@opencode-ai/sdk/v2" function branchEvent(branch: string, workspace?: string): GlobalEvent { @@ -17,6 +17,10 @@ function branchEvent(branch: string, workspace?: string): GlobalEvent { } } +function globalEvent(payload: GlobalEvent["payload"]): GlobalEvent { + return { directory: "/tmp/other", project: "proj_test", payload } +} + describe("tui sync", () => { test("refresh scopes sessions by default and lists project sessions when disabled", async () => { await using tmp = await tmpdir() @@ -62,4 +66,196 @@ describe("tui sync", () => { app.renderer.destroy() } }) + + test("bootstraps pending permission and question requests", async () => { + await using tmp = await tmpdir() + await Bun.write(`${tmp.path}/kv.json`, "{}") + const { app, sync } = await mount((url) => { + if (url.pathname === "/permission") { + return json([ + { + id: "perm_2", + sessionID: "ses_pending", + permission: "edit", + patterns: ["C:/Users/matt/permission-test.txt"], + metadata: { filepath: "C:/Users/matt/permission-test.txt" }, + always: ["C:/Users/matt/permission-test.txt"], + }, + { + id: "perm_1", + sessionID: "ses_pending", + permission: "bash", + patterns: ["bun test"], + metadata: { command: "bun test" }, + always: ["bun test"], + }, + ]) + } + if (url.pathname === "/question") { + return json([ + { + id: "ques_1", + sessionID: "ses_pending", + questions: [ + { + type: "select", + header: "Continue?", + question: "Continue?", + options: [ + { + label: "Yes", + description: "Continue", + }, + ], + }, + ], + }, + ]) + } + return undefined + }, tmp.path) + + try { + await wait(() => sync.data.permission.ses_pending !== undefined) + expect(sync.data.permission.ses_pending.map((item) => item.id)).toEqual(["perm_1", "perm_2"]) + expect(sync.data.question.ses_pending.map((item) => item.id)).toEqual(["ques_1"]) + } finally { + app.renderer.destroy() + } + }) + + test("keeps live permission requests when bootstrap list resolves stale", async () => { + await using tmp = await tmpdir() + await Bun.write(`${tmp.path}/kv.json`, "{}") + let resolvePermission!: (response: Response) => void + const permissionList = new Promise((resolve) => { + resolvePermission = resolve + }) + const { app, emit, sync } = await mount( + (url) => { + if (url.pathname === "/permission") return permissionList + if (url.pathname === "/question") return json([]) + return undefined + }, + tmp.path, + { waitForComplete: false }, + ) + + try { + await Bun.sleep(0) + emit( + globalEvent({ + id: "evt_perm_live", + type: "permission.asked", + properties: { + id: "perm_live", + sessionID: "ses_live", + permission: "edit", + patterns: ["C:/Users/matt/permission-test.txt"], + metadata: { filepath: "C:/Users/matt/permission-test.txt" }, + always: ["C:/Users/matt/permission-test.txt"], + }, + }), + ) + await wait(() => sync.data.permission.ses_live?.length === 1) + + resolvePermission(json([])) + await wait(() => sync.status === "complete") + + expect(sync.data.permission.ses_live.map((item) => item.id)).toEqual(["perm_live"]) + } finally { + app.renderer.destroy() + } + }) + + test("deduplicates live permission requests also present in bootstrap list", async () => { + await using tmp = await tmpdir() + await Bun.write(`${tmp.path}/kv.json`, "{}") + let resolvePermission!: (response: Response) => void + const permissionList = new Promise((resolve) => { + resolvePermission = resolve + }) + const request = { + id: "perm_live", + sessionID: "ses_live", + permission: "edit", + patterns: ["C:/Users/matt/permission-test.txt"], + metadata: { filepath: "C:/Users/matt/permission-test.txt" }, + always: ["C:/Users/matt/permission-test.txt"], + } + const { app, emit, sync } = await mount( + (url) => { + if (url.pathname === "/permission") return permissionList + if (url.pathname === "/question") return json([]) + return undefined + }, + tmp.path, + { waitForComplete: false }, + ) + + try { + await Bun.sleep(0) + emit( + globalEvent({ + id: "evt_perm_live", + type: "permission.asked", + properties: request, + }), + ) + await wait(() => sync.data.permission.ses_live?.length === 1) + + resolvePermission(json([request])) + await wait(() => sync.status === "complete") + + expect(sync.data.permission.ses_live.map((item) => item.id)).toEqual(["perm_live"]) + } finally { + app.renderer.destroy() + } + }) + + test("drops pre-existing permission requests missing from bootstrap list", async () => { + await using tmp = await tmpdir() + await Bun.write(`${tmp.path}/kv.json`, "{}") + let permissionLists = 0 + const { app, emit, sync } = await mount((url) => { + if (url.pathname === "/permission") { + permissionLists++ + return json([]) + } + if (url.pathname === "/question") return json([]) + return undefined + }, tmp.path) + + try { + emit( + globalEvent({ + id: "evt_perm_stale", + type: "permission.asked", + properties: { + id: "perm_stale", + sessionID: "ses_stale", + permission: "edit", + patterns: ["C:/Users/matt/permission-test.txt"], + metadata: { filepath: "C:/Users/matt/permission-test.txt" }, + always: ["C:/Users/matt/permission-test.txt"], + }, + }), + ) + await wait(() => sync.data.permission.ses_stale?.length === 1) + + const beforeReconnect = permissionLists + emit( + globalEvent({ + id: "evt_reconnect", + type: "server.instance.disposed", + properties: { directory: "/tmp/other" }, + }), + ) + await wait(() => permissionLists > beforeReconnect) + + expect(sync.data.permission.ses_stale ?? []).toEqual([]) + } finally { + app.renderer.destroy() + } + }) }) diff --git a/packages/tui/test/fixture/tui-sdk.ts b/packages/tui/test/fixture/tui-sdk.ts index d1cf3c7dfc2a..8e7c411c9f5c 100644 --- a/packages/tui/test/fixture/tui-sdk.ts +++ b/packages/tui/test/fixture/tui-sdk.ts @@ -101,6 +101,7 @@ export function createFetch(override?: FetchHandler, events?: ReturnType { test("recognizes generated parent and child titles", () => { @@ -7,4 +8,135 @@ describe("util.session", () => { expect(isDefaultTitle("Child session - 2026-06-06T12:34:56.789Z")).toBeTrue() expect(isDefaultTitle("New session - custom")).toBeFalse() }) + + test("uses provider total for assistant context when available", () => { + const message = { + tokens: { + total: 34_452, + input: 30_000, + output: 4_000, + reasoning: 452, + cache: { + read: 32_000, + write: 700, + }, + }, + } as AssistantMessage + + expect(assistantContextTokens(message)).toBe(34_452) + }) + + test("sums assistant tokens when provider total is unavailable", () => { + const message = { + tokens: { + input: 30_000, + output: 4_000, + reasoning: 452, + cache: { + read: 32_000, + write: 700, + }, + }, + } as AssistantMessage + + expect(assistantContextTokens(message)).toBe(67_152) + }) + + test("prefers previous reported usage over a later interrupt estimate", () => { + const completed = { + id: "completed", + role: "assistant", + tokens: { + total: 143_725, + input: 143_000, + output: 725, + reasoning: 0, + cache: { read: 0, write: 0 }, + }, + } as AssistantMessage + const estimated = { + id: "estimated", + role: "assistant", + error: { name: "MessageAbortedError" }, + tokens: { + input: 13_000, + output: 278, + reasoning: 0, + cache: { read: 0, write: 0 }, + }, + } as AssistantMessage + + expect(latestAssistantContextMessage([completed, estimated] as Message[])?.id).toBe("completed") + }) + + test("follows a later smaller reported usage (provider compacted its context)", () => { + const beforeCompaction = { + id: "before", + role: "assistant", + tokens: { + total: 156_044, + input: 106, + output: 8_740, + reasoning: 0, + cache: { read: 124_001, write: 22_516 }, + }, + } as AssistantMessage + const afterCompaction = { + id: "after", + role: "assistant", + tokens: { + total: 29_240, + input: 10, + output: 450, + reasoning: 0, + cache: { read: 21_158, write: 7_622 }, + }, + } as AssistantMessage + + expect(latestAssistantContextMessage([beforeCompaction, afterCompaction] as Message[])?.id).toBe("after") + }) + + test("follows a reported total on an aborted turn", () => { + const completed = { + id: "completed", + role: "assistant", + tokens: { + total: 143_725, + input: 143_000, + output: 725, + reasoning: 0, + cache: { read: 0, write: 0 }, + }, + } as AssistantMessage + const aborted = { + id: "aborted", + role: "assistant", + error: { name: "MessageAbortedError" }, + tokens: { + total: 13_278, + input: 0, + output: 0, + reasoning: 0, + cache: { read: 0, write: 0 }, + }, + } as AssistantMessage + + expect(latestAssistantContextMessage([completed, aborted] as Message[])?.id).toBe("aborted") + }) + + test("uses an interrupt estimate when no reported assistant usage exists", () => { + const estimated = { + id: "estimated", + role: "assistant", + error: { name: "MessageAbortedError" }, + tokens: { + input: 13_000, + output: 278, + reasoning: 0, + cache: { read: 0, write: 0 }, + }, + } as AssistantMessage + + expect(latestAssistantContextMessage([estimated] as Message[])?.id).toBe("estimated") + }) })