diff --git a/packages/agent/package.json b/packages/agent/package.json index 9e2f687673..a3d4665a7c 100644 --- a/packages/agent/package.json +++ b/packages/agent/package.json @@ -129,9 +129,9 @@ "vitest": "^4.1.8" }, "dependencies": { - "@agentclientprotocol/sdk": "0.25.0", - "@anthropic-ai/claude-agent-sdk": "0.3.170", - "@anthropic-ai/sdk": "0.104.1", + "@agentclientprotocol/sdk": "1.1.0", + "@anthropic-ai/claude-agent-sdk": "0.3.197", + "@anthropic-ai/sdk": "0.109.0", "@hono/node-server": "^1.19.9", "@openai/codex": "0.140.0", "@opentelemetry/api-logs": "^0.208.0", diff --git a/packages/agent/src/acp-extensions.ts b/packages/agent/src/acp-extensions.ts index 332972e79e..008f865246 100644 --- a/packages/agent/src/acp-extensions.ts +++ b/packages/agent/src/acp-extensions.ts @@ -25,6 +25,11 @@ export const POSTHOG_NOTIFICATIONS = { /** Agent finished processing a turn (prompt returned, waiting for next input) */ TURN_COMPLETE: "_posthog/turn_complete", + /** Background/task-notification-triggered reply finished. Same rendering + * effect as TURN_COMPLETE (closes out the current turn) without touching + * the tracked prompt lifecycle that TURN_COMPLETE drives on the agent side. */ + BACKGROUND_TURN_COMPLETE: "_posthog/background_turn_complete", + /** Error occurred during task execution */ ERROR: "_posthog/error", diff --git a/packages/agent/src/adapters/claude/SKILL.md b/packages/agent/src/adapters/claude/SKILL.md index ce8ab70e5b..c14269f989 100644 --- a/packages/agent/src/adapters/claude/SKILL.md +++ b/packages/agent/src/adapters/claude/SKILL.md @@ -152,6 +152,17 @@ pnpm --filter code test - **Single session.** The agent owns one `this.session` (from `BaseAcpAgent`), not a `sessions` map. Upstream's per-session refactors usually collapse to "just use `this.session`". +- **Prompt loop is a persistent consumer** (since the v0.54.1 sync, upstream #780): `prompt()` + enqueues a `Turn` deferred; `runConsumer` drains the query stream for the session's life, settles + turns at their terminal `result`, and captures `query` + `session.queryGeneration` so the + fork-only `refreshSession()` can retire it (bump generation → abort wake-up → end input). Steer + mode, `interruptReason`, per-turn broadcast-at-activation and the unsupported-slash-command gate + all live inside it — port upstream prompt-loop changes into the consumer, not a per-prompt loop. +- **ACP connection classes are the deprecated ones on purpose.** The fork stays on + `AgentSideConnection`/`ClientSideConnection` (still shipped in ACP 1.x) because they carry the + `extMethod`/`extNotification` surface `_posthog/*` uses; permission requests reach the client via + the class's generic `request(..., { cancellationSignal })`. Don't port the `agent()` builder + without a plan for the extension surface. - **Renderer uses config options only.** Model/mode/effort selection is `SessionConfigOption` end to end; the renderer never reads the legacy `models` response field or calls `unstable_setSessionModel`. That's why upstream's ACP-0.24/0.25 model-state removals are safe to follow. diff --git a/packages/agent/src/adapters/claude/UPSTREAM.md b/packages/agent/src/adapters/claude/UPSTREAM.md index dc0cc666d4..b5e9c8af58 100644 --- a/packages/agent/src/adapters/claude/UPSTREAM.md +++ b/packages/agent/src/adapters/claude/UPSTREAM.md @@ -5,8 +5,8 @@ Fork of `@anthropic-ai/claude-agent-acp`. Upstream repo: https://github.com/anth ## Fork Point - **Forked**: v0.10.9, commit `5411e0f4`, Dec 2 2025 -- **Last sync**: v0.44.0, commit `7de5e4b`, Jun 11 2026 -- **SDK**: `@anthropic-ai/claude-agent-sdk` 0.3.170, `@agentclientprotocol/sdk` 0.25.0, `@anthropic-ai/sdk` 0.104.1 +- **Last sync**: v0.54.1, commit `8d5febf`, Jul 1 2026 +- **SDK**: `@anthropic-ai/claude-agent-sdk` 0.3.197, `@agentclientprotocol/sdk` 1.1.0, `@anthropic-ai/sdk` 0.109.0 ## File Mapping @@ -60,8 +60,118 @@ Fork of `@anthropic-ai/claude-agent-acp`. Upstream repo: https://github.com/anth | Auth methods | `claude-ai-login` + `console-login` | Returns empty `authMethods` | Auth handled externally | | Session fingerprinting | Implicit teardown on cwd/mcp change | Explicit `refreshSession()` | Caller-initiated is more predictable | | Shutdown on ACP close | Process exits | No standalone process | Agent is embedded in server | -| Unsupported slash commands | Loops silently on early idle | Emits "Unsupported slash command" chunk, gated on `initializationResult().commands` so plugin/skill commands (e.g. `/skills-store`) whose echoes use a fresh uuid are not false-flagged | The SDK consumes some slash commands without producing output (e.g. `/plugin` in non-interactive mode); without this we hang. The known-commands gate avoids racing plugin/skill loads where idle can arrive before the transformed user-message echo. | -| Prompt-loop cancel race | `Promise.race([query.next(), cancelWake])` each iteration (#742) | `withAbort(query.next(), cancelController.signal)` helper in `utils/common.ts`, also guarding the `compact_boundary` `getContextUsage` fetch | The classic `Promise.race` leak (nodejs/node#17469): each race call parks a reaction on the turn-lived `cancelWake` promise that retains that iteration's settled value, so every yielded message (and every stream event, since `includePartialMessages` is on) stays reachable until the turn ends. Long high-reasoning turns could pin tens of MB. `withAbort` removes its abort listener as soon as `next()` settles, so nothing accumulates. Cancel semantics are unchanged, including the force-cancel backstop. | +| Unsupported slash commands | Loops silently on early idle | Emits "Unsupported slash command" chunk, gated on `initializationResult().commands` so plugin/skill commands (e.g. `/skills-store`) whose echoes use a fresh uuid are not false-flagged. Lives in the consumer's idle handler: fires only when idle arrives with no active turn, an unsettled head turn whose leading command is unknown, and no pending orphan results. | The SDK consumes some slash commands without producing output (e.g. `/plugin` in non-interactive mode); without this we hang. The known-commands gate avoids racing plugin/skill loads where idle can arrive before the transformed user-message echo. | +| Prompt-loop cancel race | Per-iteration `addEventListener`/`removeEventListener` race in the consumer (#780) | `withAbort(query.next(), cancelController.signal)` helper in `utils/common.ts`, also guarding the `compact_boundary` `getContextUsage` fetch | Same effect (no listener/reaction accumulation on the long-lived wake-up promise), different helper. `withAbort` removes its abort listener as soon as `next()` settles; the consumer re-arms a fresh controller after each abort fire, matching upstream's re-arm. | +| ACP connection wiring | `agent({name}).onRequest(...).connect(stream)` builder + narrow `AcpClient` interface (#790) | Keeps `AgentSideConnection` / `ClientSideConnection` (deprecated but fully functional in ACP 1.1.0) in `acp-connection.ts` / `base-acp-agent.ts` / codex | The fork is embedded (in-process streams, `extMethod`/`extNotification` extension surface) and the deprecated classes still route optional `extMethod`/`extNotification` to the Agent/Client. Revisit when ACP removes them; permission cancellation already uses the class's generic `request(..., { cancellationSignal })`. | +| Consumer ownership | Per-session map; consumer keyed by `sessions[id]` | Single `this.session`; consumer captures `query` + `queryGeneration` and exits quietly on mismatch | `refreshSession()` (fork-only) swaps `query`/`input` in place on the same session object; the generation guard keeps a retired consumer from tearing down the refreshed session. | + +## Changes Ported in v0.54.1 Sync + +- **SDK bumps**: claude-agent-sdk 0.3.170 -> 0.3.197, ACP SDK 0.25.0 -> 1.1.0, anthropic SDK + 0.104.1 -> 0.109.0. The ACP 1.x major is source-compatible for the fork: the deprecated + `AgentSideConnection`/`ClientSideConnection` classes are still shipped and still route + `extMethod`/`extNotification` (see Intentional Divergences). Only in-repo break was the SDK + `Query` interface gaining `setMcpPermissionModeOverride` and `reinitialize` (test mock updated). +- **Persistent consumer + turn queue** (#780, 4f273a2): The per-prompt message loop became a + single long-lived consumer per session. `prompt()` now enqueues a `Turn` (deferred) and returns; + the consumer drains the query stream for the session's whole life, activates turns via their + user-message echoes (promoting the queue head for echo-less local-only/compaction results, with + orphan-result accounting after cancels), settles turns at their terminal `result` instead of + waiting for the SDK's trailing `idle` (which can lag behind background tasks — upstream issues + #773/#679/#688), forwards between-turn/background output live, and rejects turns with a clear + "session has ended" error once the stream dies (`queryClosed`). Upstream's fixes folded in: + fresh-abort-listener per iteration (kept as `withAbort` + re-armed controller), error results + via `failActive` without killing the consumer (replaces the drain-after-error loop, #706's + successor), process-death teardown via `failAllTurns` + `closeQueryStream`. Fork adaptations: + single-session, steer mode untouched (mid-turn push + benign end_turn), `interruptReason` + carried on every cancelled settle, per-turn broadcast fired at activation (preserves the old + "broadcast when the turn takes over" timing), the unsupported-slash-command gate re-anchored on + "idle with an unactivated head turn", `toolUseStreamCache` cleared on cancel/error settles, and + a `queryGeneration` guard so `refreshSession()` retires the old consumer cleanly. +- **Content-based streamed-block dedupe** (#785 12d34e6, #789 1c80bf8, #800 960f62d — ported as + the final #800 state): `StreamedAssistantBlocks` switched from per-message-id + `textIds`/`thinkingIds` sets to an ordered accumulated-text record; the consolidated assistant + message prefix-diffs each block against what streamed and forwards only the un-streamed + remainder (nothing / whole block / cut-short tail). Robust to gateways whose consolidated + message id doesn't match the stream. Record cleared at each top-level `message_start` and after + consumption; consumer-lived so mid-message turn activation can't drop it. New unit tests cover + tail-forwarding, id-mismatch dedupe, residue clearing and empty-delta stalls. +- **Skip empty thinking chunks** (#793, 15fdf26): `handleThinkingChunk` drops signature-only + (empty) thinking blocks that models with `thinking.display: "omitted"` stream; empty deltas are + also excluded from the streamed-block record so they can't stall the diff cursor. +- **Emit tool_call before permission request** (#820, c95fc88): New agent-lived + `emittedToolCalls` set shared between the streamed tool_use path and the permission flow. + `requestPermissionFromClient` eagerly emits the referenced `tool_call` (Task*/TodoWrite + excluded; Bash carries `terminal_info`) so the client has it before being asked to approve; + whichever side runs second emits a `tool_call_update` instead of a duplicate. Pruned at + `tool_result` alongside `toolUseCache`. +- **Permission request cancellation** (#801, 9013d1d): All five permission-request sites now go + through `client.request(methods.client.session.requestPermission, params, { cancellationSignal: + signal })`, so cancelling a turn sends `$/cancel_request` and the client can dismiss the open + dialog; an abort-time rejection maps to the existing "Tool use aborted". +- **Terminal error rendering** (#776, db6eaaf): Bash `is_error` results keep flowing through the + terminal-output `_meta` channel (when the client supports it) instead of short-circuiting to + plain error content. +- **Bash image output** (#617, a759e64): Array tool_result content that isn't text-only (e.g. an + image from a piped data URI) bypasses the terminal channel and surfaces as ACP content blocks + instead of being silently dropped. +- **`informational` system subtype** (rode in with SDK 0.3.178, #777 58549ff): Surfaced as an + `agent_message_chunk` (level folded into the text for non-info levels) so hook-blocked stops are + no longer silent. `worker_shutting_down` no-ops via the existing `default: break`. +- **Sonnet 5 model-version matching** (#826, ef42c46): `MODEL_FAMILY_VERSION_PATTERN` accepts + single-number generations (`5`) and `extractModelFamilyVersion` strips `[1m]`-style context + hints before matching, so `sonnet 5` resolves and `claude-sonnet-4-6` can't cross-match a + Sonnet 5 alias. Unit tests added. +- **Session title push at turn end** (#812, 1fe7ec0): `maybeUpdateSessionTitle` polls + `getSessionInfo` at each `idle` and pushes a `session_info_update` (ACP 1.1) when the + SDK-generated `customTitle`/`summary` changes. +- **Fast mode session config** (#828, fa949a2, adapted to gateway models): New `fast` on/off + select config option, surfaced only for models in `MODELS_WITH_FAST_MODE` + (claude-opus-4-8/-4-7). Toggling calls `query.applyFlagSettings({ fastMode })`; the intent is + retained across model switches (`session.fastModeEnabled`), seeded from + `initializationResult.fast_mode_state`, and reconciled with SDK-reported `fast_mode_state` on + init and user-turn results (`cooldown` never flaps the toggle). Boolean-typed config options + were not adopted — the renderer consumes selects; revisit if it advertises + `sessionConfigOptions.boolean`. +- **ReportFindings tool rendering** (#826, ef42c46): Not ported to `toolInfoFromToolUse` — see + Skipped (the fork renders unknown tools generically and PostHog Code has no code-review + ReportFindings flow); re-evaluate if the SDK starts emitting it in our sessions. +- **Test mock**: added `setMcpPermissionModeOverride` and `reinitialize` to the SDK `MockQuery` + (new methods on the SDK `Query` interface by 0.3.197). + +## Skipped in v0.54.1 Sync + +- **ACP builder-pattern migration** (#790, 2554c7b): Kept the deprecated connection classes — + recorded as an Intentional Divergence (they still ship in 1.1.0 and carry the + `extMethod`/`extNotification` surface the fork's `_posthog/*` extensions rely on). +- **Elicitation fixes** (#774 d58004a, #779 b364059): Upstream's AskUserQuestion runs through + ACP's unstable elicitation API; ours uses its own `questions/` machinery behind the permission + flow and the renderer does not advertise elicitation. Same standing skip as the v0.44 sync. +- **ACP logout support** (#816, 0a0468c): Fork returns empty `authMethods` (auth handled + externally by PostHog); there is no CLI credential store to clear from the embedded agent. +- **Version flag handling** (#813, 9616bda): `src/index.ts` CLI-entrypoint concern; the fork is + embedded in the agent server and has no standalone binary. +- **Agent selection dropdown** (#794, 5729c47): Surfaces custom main-thread agent personas + (`supportedAgents()` minus built-ins) as an `agent` config option. PostHog Code drives its own + agent concepts; defer until product wants persona selection in the picker. +- **availableModels allowlist fixes** (#768 cc2885f, #827 98c284b) and **1M inference from model + descriptions** (#799, 508453c): All operate on upstream's SDK-settings model pipeline + (`ANTHROPIC_CUSTOM_MODEL_OPTION`, `modelOverrides`, `ModelInfo.description` scans). The fork's + models and context windows come from the PostHog gateway (`fetchGatewayModels`, + `getContextWindowForModel`), which has none of those inputs. +- **ReportFindings rendering** (#826): See above — no ReportFindings flow reaches the fork today; + the generic tool_call rendering is acceptable if it ever does. +- **`model_refusal_no_fallback` status subtype** (SDK 0.3.193, #818 5dd8746): Our + `handleSystemMessage` status handling is non-exhaustive, so the new subtype already no-ops + (same precedent as `thinking_tokens` / `model_refusal_fallback`). +- **Idle-time `usage_update`**: Dropped along with the #780 port (upstream removed it when turns + began settling at their terminal result). The mid-stream and result-time usage updates remain; + the idle-time emission double-counted cumulative loop usage in rare paths anyway. +- **Test-only upstream changes** (#769 41cde99 CLAUDE_CONFIG_DIR isolation, #792 9f38cb6 tmp + dirs): Upstream test-harness hygiene; our tests use their own fixtures. +- **Release / CI / dep-group bumps** (#772, #775, #778, #784, #788, #795, #802, #803, #808, + #811, #817, #821, #822, #823, #829, #831 and the pure SDK-bump commits #771, #783, #791, #798, + #806, #807, #810, #818 beyond the versions captured above): No fork-relevant code. ## Changes Ported in v0.44.0 Sync @@ -276,7 +386,7 @@ Fork of `@anthropic-ai/claude-agent-acp`. Upstream repo: https://github.com/anth ## Next Sync -1. Check upstream changelog since v0.44.0 +1. Check upstream changelog since v0.54.1 2. Diff upstream source against PostHog Code using the file mapping above 3. Port in phases: bug fixes first, then features 4. After each phase: `pnpm --filter agent typecheck && pnpm --filter agent build && pnpm lint` diff --git a/packages/agent/src/adapters/claude/claude-agent.refresh.test.ts b/packages/agent/src/adapters/claude/claude-agent.refresh.test.ts index e75287e18b..4222b68dd5 100644 --- a/packages/agent/src/adapters/claude/claude-agent.refresh.test.ts +++ b/packages/agent/src/adapters/claude/claude-agent.refresh.test.ts @@ -128,9 +128,10 @@ function installFakeSession( }, sessionResources: new Set(), configOptions: [], - promptRunning: false, - pendingMessages: new Map(), - nextPendingOrder: 0, + turnQueue: [], + activeTurn: null, + pendingOrphanResults: 0, + queryGeneration: 0, cwd: "/tmp/repo", notificationHistory: [{ foo: "bar" }], taskRunId: "run-1", @@ -201,7 +202,9 @@ describe("ClaudeAcpAgent.extMethod refresh_session", () => { it("rejects refresh while a prompt is in flight", async () => { const agent = makeAgent(); const { session } = installFakeSession(agent, "s-1"); - (session as unknown as { promptRunning: boolean }).promptRunning = true; + (session as unknown as { turnQueue: unknown[] }).turnQueue = [ + { promptUuid: "u-1", settled: false }, + ]; await expect( agent.extMethod(POSTHOG_METHODS.REFRESH_SESSION, { diff --git a/packages/agent/src/adapters/claude/claude-agent.slash-command.test.ts b/packages/agent/src/adapters/claude/claude-agent.slash-command.test.ts index 850002be2c..0e9f422a08 100644 --- a/packages/agent/src/adapters/claude/claude-agent.slash-command.test.ts +++ b/packages/agent/src/adapters/claude/claude-agent.slash-command.test.ts @@ -62,9 +62,10 @@ function installFakeSession( }, sessionResources: new Set(), configOptions: [], - promptRunning: false, - pendingMessages: new Map(), - nextPendingOrder: 0, + turnQueue: [], + activeTurn: null, + pendingOrphanResults: 0, + queryGeneration: 0, cwd: "/tmp/repo", notificationHistory: [] as unknown[], taskRunId: "run-1", @@ -184,11 +185,8 @@ describe("ClaudeAcpAgent.prompt — early idle handling", () => { expect(text).toContain(tc.commandInMessage); } } else { - // No unsupported chunk; loop falls through to the existing - // "Session did not end in result" failure path. - await expect(promptPromise).rejects.toThrow( - /Session did not end in result/, - ); + // Idle absorbed; the stream then ends before the turn ever starts. + await expect(promptPromise).rejects.toThrow(/session has ended/); expect( findUnsupportedChunkText(client.sessionUpdate.mock.calls), ).toBeUndefined(); @@ -205,6 +203,21 @@ describe("ClaudeAcpAgent.prompt — force-cancel backstop", () => { vi.clearAllMocks(); }); + function echoQueuedTurn(agent: Agent, query: MockQuery): void { + const turn = ( + agent as unknown as { + session: { turnQueue: Array<{ promptUuid: string }> }; + } + ).session.turnQueue[0]; + query._mockHelpers.sendMessage({ + type: "user", + uuid: turn.promptUuid, + session_id: "s", + parent_tool_use_id: null, + message: { role: "user", content: "echo" }, + } as unknown as SDKMessage); + } + it("returns 'cancelled' when the SDK never yields after interrupt (issue #680)", async () => { const { agent } = makeAgent(); const sessionId = "s-wedged"; @@ -217,6 +230,9 @@ describe("ClaudeAcpAgent.prompt — force-cancel backstop", () => { prompt: [{ type: "text", text: "do something slow" }], }); + await new Promise((resolve) => setImmediate(resolve)); + // cancel() only arms the backstop for an activated (echoed) turn. + echoQueuedTurn(agent, query); await new Promise((resolve) => setImmediate(resolve)); await agent.cancel({ sessionId }); @@ -228,7 +244,7 @@ describe("ClaudeAcpAgent.prompt — force-cancel backstop", () => { it("clears the backstop timer on a healthy cancel (interrupt yields)", async () => { const { agent } = makeAgent(); const sessionId = "s-healthy"; - installFakeSession(agent, sessionId); + const query = installFakeSession(agent, sessionId); (agent as unknown as { forceCancelGraceMs: number }).forceCancelGraceMs = 50_000; @@ -237,6 +253,8 @@ describe("ClaudeAcpAgent.prompt — force-cancel backstop", () => { prompt: [{ type: "text", text: "do something" }], }); await new Promise((resolve) => setImmediate(resolve)); + echoQueuedTurn(agent, query); + await new Promise((resolve) => setImmediate(resolve)); await agent.cancel({ sessionId }); @@ -247,4 +265,29 @@ describe("ClaudeAcpAgent.prompt — force-cancel backstop", () => { .forceCancelTimer, ).toBeUndefined(); }); + + it("settles a still-queued turn immediately on cancel", async () => { + const { agent } = makeAgent(); + const sessionId = "s-queued"; + const query = installFakeSession(agent, sessionId); + query.interrupt.mockImplementation(async () => {}); + + const promptPromise = agent.prompt({ + sessionId, + prompt: [{ type: "text", text: "never echoed" }], + }); + await new Promise((resolve) => setImmediate(resolve)); + + await agent.cancel({ sessionId }); + + const result = await promptPromise; + expect(result.stopReason).toBe("cancelled"); + const session = ( + agent as unknown as { + session: { pendingOrphanResults: number; turnQueue: unknown[] }; + } + ).session; + expect(session.turnQueue).toHaveLength(0); + expect(session.pendingOrphanResults).toBe(1); + }); }); diff --git a/packages/agent/src/adapters/claude/claude-agent.streamed-text.test.ts b/packages/agent/src/adapters/claude/claude-agent.streamed-text.test.ts index b7f8d0a201..96775fa040 100644 --- a/packages/agent/src/adapters/claude/claude-agent.streamed-text.test.ts +++ b/packages/agent/src/adapters/claude/claude-agent.streamed-text.test.ts @@ -66,9 +66,10 @@ function installFakeSession( }, sessionResources: new Set(), configOptions: [], - promptRunning: false, - pendingMessages: new Map(), - nextPendingOrder: 0, + turnQueue: [], + activeTurn: null, + pendingOrphanResults: 0, + queryGeneration: 0, cwd: "/tmp/repo", notificationHistory: [] as unknown[], taskRunId: "run-1", diff --git a/packages/agent/src/adapters/claude/claude-agent.task-notification.test.ts b/packages/agent/src/adapters/claude/claude-agent.task-notification.test.ts new file mode 100644 index 0000000000..5151e8dae1 --- /dev/null +++ b/packages/agent/src/adapters/claude/claude-agent.task-notification.test.ts @@ -0,0 +1,245 @@ +import type { AgentSideConnection } from "@agentclientprotocol/sdk"; +import type { + SDKMessage, + SDKUserMessage, +} from "@anthropic-ai/claude-agent-sdk"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { createMockQuery, type MockQuery } from "../../test/mocks/claude-sdk"; +import { Pushable } from "../../utils/streams"; + +vi.mock("@anthropic-ai/claude-agent-sdk", () => ({ + query: vi.fn(), +})); + +vi.mock("./mcp/tool-metadata", () => ({ + fetchMcpToolMetadata: vi.fn().mockResolvedValue(undefined), + getConnectedMcpServerNames: vi.fn().mockReturnValue([]), + getCachedMcpTools: vi.fn().mockReturnValue([]), + clearMcpToolMetadataCache: vi.fn(), + setMcpToolApprovalStates: vi.fn(), + isMcpToolReadOnly: vi.fn().mockReturnValue(false), + getMcpToolMetadata: vi.fn().mockReturnValue(undefined), + getMcpToolApprovalState: vi.fn().mockReturnValue(undefined), +})); + +const { ClaudeAcpAgent } = await import("./claude-agent"); +type Agent = InstanceType; + +interface ClientMocks { + sessionUpdate: ReturnType; + extNotification: ReturnType; +} + +function makeAgent(): { agent: Agent; client: ClientMocks } { + const client: ClientMocks = { + sessionUpdate: vi.fn().mockResolvedValue(undefined), + extNotification: vi.fn().mockResolvedValue(undefined), + }; + const agent = new ClaudeAcpAgent(client as unknown as AgentSideConnection); + return { agent, client }; +} + +function installFakeSession( + agent: Agent, + sessionId: string, +): { query: MockQuery; input: Pushable } { + const query = createMockQuery(); + const input = new Pushable(); + const abortController = new AbortController(); + + const session = { + query, + queryOptions: { sessionId, cwd: "/tmp/repo", abortController }, + buildInProcessMcpServers: () => ({}), + localToolsServerNames: [] as string[], + input, + cancelled: false, + interruptReason: undefined, + settingsManager: { dispose: vi.fn(), getRepoRoot: () => "/tmp/repo" }, + permissionMode: "default" as const, + abortController, + accumulatedUsage: { + inputTokens: 0, + outputTokens: 0, + cachedReadTokens: 0, + cachedWriteTokens: 0, + }, + sessionResources: new Set(), + configOptions: [], + turnQueue: [], + activeTurn: null, + pendingOrphanResults: 0, + queryGeneration: 0, + cwd: "/tmp/repo", + notificationHistory: [] as unknown[], + taskRunId: "run-1", + lastContextWindowSize: 200_000, + modelId: "claude-sonnet-4-6", + taskState: new Map(), + }; + + (agent as unknown as { session: typeof session }).session = session; + (agent as unknown as { sessionId: string }).sessionId = sessionId; + + return { query, input }; +} + +function tick(): Promise { + return new Promise((resolve) => setImmediate(resolve)); +} + +async function send(query: MockQuery, message: unknown): Promise { + query._mockHelpers.sendMessage(message as SDKMessage); + await tick(); +} + +// Replays the prompt's own user message back through the query so the +// terminal `result` message is recognized as belonging to the tracked turn. +async function echoUserMessage( + query: MockQuery, + input: Pushable, +): Promise { + const { value: pushed } = await input[Symbol.asyncIterator]().next(); + await send(query, pushed); +} + +function assistantMessage(sessionId: string, apiId: string, text: string) { + return { + type: "assistant", + parent_tool_use_id: null, + session_id: sessionId, + uuid: `assistant-${apiId}`, + message: { + id: apiId, + role: "assistant", + content: [{ type: "text", text }], + }, + }; +} + +function resultSuccess(sessionId: string, uuid: string) { + return { + type: "result", + subtype: "success", + session_id: sessionId, + uuid, + result: "", + is_error: false, + usage: {}, + modelUsage: {}, + }; +} + +// Mirrors what the SDK emits when a ScheduleWakeup/Monitor event resumes the +// session outside of a tracked prompt() call. +function taskNotificationUserMessage(sessionId: string, event: string) { + return { + type: "user", + parent_tool_use_id: null, + session_id: sessionId, + uuid: `task-notification-${event}`, + message: { + role: "user", + content: `\n${event}\n`, + }, + }; +} + +function taskNotificationResult(sessionId: string, uuid: string) { + return { + ...resultSuccess(sessionId, uuid), + origin: { kind: "task-notification" }, + }; +} + +function extNotificationCalls( + calls: ClientMocks["extNotification"]["mock"]["calls"], + method: string, +) { + return calls.filter(([calledMethod]) => calledMethod === method); +} + +describe("ClaudeAcpAgent — task-notification results", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("emits background_turn_complete instead of settling the tracked turn", async () => { + const { agent, client } = makeAgent(); + const sessionId = "s-task-notification"; + const { query, input } = installFakeSession(agent, sessionId); + + const promptPromise = agent.prompt({ + sessionId, + prompt: [{ type: "text", text: "hi" }], + }); + await tick(); + await echoUserMessage(query, input); + await send(query, assistantMessage(sessionId, "msg_1", "hello")); + await send(query, resultSuccess(sessionId, "result-1")); + const result = await promptPromise; + expect(result.stopReason).toBe("end_turn"); + + client.extNotification.mockClear(); + + // A ScheduleWakeup/Monitor event resumes the session with no queued + // turn behind it — this must not settle/reactivate the tracked turn. + await send(query, taskNotificationUserMessage(sessionId, "ping 1")); + await send(query, assistantMessage(sessionId, "msg_2", "ping 1 received.")); + await send(query, taskNotificationResult(sessionId, "result-2")); + + expect( + extNotificationCalls( + client.extNotification.mock.calls, + "_posthog/background_turn_complete", + ), + ).toEqual([ + [ + "_posthog/background_turn_complete", + { sessionId, stopReason: "end_turn" }, + ], + ]); + expect( + extNotificationCalls( + client.extNotification.mock.calls, + "_posthog/turn_complete", + ), + ).toHaveLength(0); + }); + + it("emits background_turn_complete with a refusal stop reason on a task-notification refusal", async () => { + const { agent, client } = makeAgent(); + const sessionId = "s-task-notification-refusal"; + const { query, input } = installFakeSession(agent, sessionId); + + const promptPromise = agent.prompt({ + sessionId, + prompt: [{ type: "text", text: "hi" }], + }); + await tick(); + await echoUserMessage(query, input); + await send(query, assistantMessage(sessionId, "msg_1", "hello")); + await send(query, resultSuccess(sessionId, "result-1")); + await promptPromise; + + client.extNotification.mockClear(); + + await send(query, taskNotificationUserMessage(sessionId, "ping 1")); + await send(query, { + ...taskNotificationResult(sessionId, "result-2"), + stop_reason: "refusal", + }); + + expect( + extNotificationCalls( + client.extNotification.mock.calls, + "_posthog/background_turn_complete", + ), + ).toEqual([ + [ + "_posthog/background_turn_complete", + { sessionId, stopReason: "refusal" }, + ], + ]); + }); +}); diff --git a/packages/agent/src/adapters/claude/claude-agent.ts b/packages/agent/src/adapters/claude/claude-agent.ts index e20e9d520a..8ec2a4f88c 100644 --- a/packages/agent/src/adapters/claude/claude-agent.ts +++ b/packages/agent/src/adapters/claude/claude-agent.ts @@ -32,6 +32,8 @@ import { } from "@agentclientprotocol/sdk"; import { type CanUseTool, + type FastModeState, + getSessionInfo, getSessionMessages, listSessions, type McpSdkServerConfigWithInstance, @@ -113,10 +115,12 @@ import { import { DEFAULT_EFFORT, DEFAULT_MODEL, + fastModeStateEnabled, getEffortOptions, resolveEffortForModel, resolveModelPreference, supports1MContext, + supportsFastMode, supportsMcpInjection, toSdkModelId, } from "./session/models"; @@ -141,6 +145,7 @@ import type { ToolUpdateMeta, ToolUseCache, ToolUseStreamCache, + Turn, } from "./types"; const SESSION_VALIDATION_TIMEOUT_MS = 30_000; @@ -151,6 +156,9 @@ const MCP_STATUS_TIMEOUT_MS = 5_000; const DEFAULT_FORCE_CANCEL_GRACE_MS = 30_000; +const SESSION_ENDED_MESSAGE = + "The Claude Agent session has ended. Please start a new session."; + const MAX_TITLE_LENGTH = 256; const LOCAL_ONLY_COMMANDS = new Set(["/context", "/heapdump", "/extra-usage"]); @@ -246,6 +254,9 @@ export class ClaudeAcpAgent extends BaseAcpAgent { readonly adapterName = "claude"; declare session: Session; toolUseCache: ToolUseCache; + /** Tool_use ids already surfaced as a `tool_call` (permission requests emit + * eagerly); the second emitter refines instead of duplicating. */ + emittedToolCalls: Set; toolUseStreamCache: ToolUseStreamCache; backgroundTerminals: { [key: string]: BackgroundTerminal } = {}; clientCapabilities?: ClientCapabilities; @@ -258,6 +269,7 @@ export class ClaudeAcpAgent extends BaseAcpAgent { super(client); this.options = options; this.toolUseCache = {}; + this.emittedToolCalls = new Set(); this.toolUseStreamCache = new Map(); this.logger = new Logger({ debug: true, prefix: "[ClaudeAcpAgent]" }); this.enrichment = createEnrichment(options?.posthogApiConfig, this.logger); @@ -438,7 +450,6 @@ export class ClaudeAcpAgent extends BaseAcpAgent { const userMessage = promptToClaude(params); const promptUuid = randomUUID(); userMessage.uuid = promptUuid; - let promptReplayed = false; let isLocalOnlyCommand = false; // Detect local-only slash commands that return results without model invocation @@ -457,65 +468,117 @@ export class ClaudeAcpAgent extends BaseAcpAgent { const commandMatch = firstTextPart.match(/^(\/\S+)/); if (commandMatch && LOCAL_ONLY_COMMANDS.has(commandMatch[1])) { isLocalOnlyCommand = true; - promptReplayed = true; } if (commandMatch && !isLocalOnlyCommand) { await this.refreshSlashCommandsForPrompt(commandMatch[1]); } - if (this.session.promptRunning) { - const isSteer = isSteerMeta(params._meta); - if (isSteer) { - // Fold this message into the turn already running instead of queueing a - // new turn. promptToClaude tagged it priority:"next" so the SDK delivers - // it at the next tool-call boundary. Return immediately with a benign - // end_turn: the in-flight turn (not this call) owns the loop and the - // real stop reason. The client tells steers apart by the request's - // _meta.steer, not by this value. - this.session.input.push(userMessage); - await this.broadcastUserMessage(params); - return { stopReason: "end_turn" }; - } + if (this.session.queryClosed) { + throw RequestError.internalError(undefined, SESSION_ENDED_MESSAGE); + } + + const hasInFlightTurns = + this.session.activeTurn !== null || this.session.turnQueue.length > 0; + + if (hasInFlightTurns && isSteerMeta(params._meta)) { + // Fold into the running turn (promptToClaude tagged it priority:"next"); + // the benign end_turn is ignored by clients, which key off _meta.steer. this.session.input.push(userMessage); - const order = this.session.nextPendingOrder++; - const cancelled = await new Promise((resolve) => { - this.session.pendingMessages.set(promptUuid, { resolve, order }); - }); - if (cancelled) { - return { stopReason: "cancelled" }; - } - promptReplayed = true; - } else { + await this.broadcastUserMessage(params); + return { stopReason: "end_turn" }; + } + + if (!hasInFlightTurns && !isLocalOnlyCommand) { // Reconnect the signed-commit server before the turn (guard hook backstops). - if (!isLocalOnlyCommand) { - await this.ensureLocalToolsConnected("pre-prompt"); - } - this.session.input.push(userMessage); + await this.ensureLocalToolsConnected("pre-prompt"); + } + + if (this.session.lastContextWindowSize == null) { + this.session.lastContextWindowSize = this.getContextWindowForModel( + this.session.modelId ?? "", + ); + this.logger.debug("Initial context window size from gateway", { + modelId: this.session.modelId, + contextWindowSize: this.session.lastContextWindowSize, + }); } - // Reset session state here (after the queued-wait) rather than at the - // top of prompt(). Otherwise a new prompt() call would wipe cancelled=true - // on the previous still-running loop, causing it to return end_turn - // instead of the cancelled stop reason the spec requires. - this.session.cancelled = false; - this.session.interruptReason = undefined; - this.session.accumulatedUsage = { - inputTokens: 0, - outputTokens: 0, - cachedReadTokens: 0, - cachedWriteTokens: 0, + const turn: Turn = { + promptUuid, + isLocalOnlyCommand, + commandName: commandMatch?.[1], + broadcast: () => this.broadcastUserMessage(params), + settled: false, + resolve: () => {}, + reject: () => {}, }; - // sessionResources is intentionally NOT reset here — the products list - // accumulates across the whole session and is deduped, not per-turn. + const response = new Promise((resolve, reject) => { + turn.resolve = resolve; + turn.reject = reject; + }); - await this.broadcastUserMessage(params); + this.session.turnQueue.push(turn); + this.session.input.push(userMessage); + this.ensureConsumer(params.sessionId); + return response; + } - this.session.promptRunning = true; - const cancelController = new AbortController(); - this.session.cancelController = cancelController; - let handedOff = false; - let errored = false; + private ensureConsumer(sessionId: string): void { + const session = this.session; + if (session.consumer) { + return; + } + session.cancelController = new AbortController(); + session.consumer = this.runConsumer(session, sessionId); + session.consumer.catch((error) => { + this.logger.error("Consumer terminated unexpectedly", { + sessionId, + error: error instanceof Error ? error.message : String(error), + }); + }); + } + + private cancelledResponse(): PromptResponse { + return { + stopReason: "cancelled", + _meta: this.session.interruptReason + ? { interruptReason: this.session.interruptReason } + : undefined, + }; + } + + /** Idempotent teardown once the query iterator is unrevivable. */ + private closeQueryStream(session: Session): void { + session.queryClosed = true; + session.consumer = undefined; + if (session.forceCancelTimer) { + clearTimeout(session.forceCancelTimer); + session.forceCancelTimer = undefined; + } + session.cancelController = undefined; + session.settingsManager.dispose(); + session.input.end(); + this.toolUseStreamCache.clear(); + this.emittedToolCalls.clear(); + } + + /** Long-lived consumer of the session's SDK query stream: forwards every + * message (including between-turn output) and settles Turn deferreds. */ + private async runConsumer( + session: Session, + sessionId: string, + ): Promise { + // refreshSession swaps query/input in place and bumps the generation; a + // retired consumer must exit without tearing the refreshed session down. + const query = session.query; + const generation = session.queryGeneration; + const refreshed = () => + this.session !== session || + session.query !== query || + session.queryGeneration !== generation; + + // Per-turn scratch, reset on activation. let lastAssistantTotalUsage: number | null = null; let lastRefusalExplanation: string | null = null; let lastRefusalCategory: string | null = null; @@ -532,16 +595,12 @@ export class ClaudeAcpAgent extends BaseAcpAgent { // `compacting` status sets it again, so every distinct compaction (e.g. // repeated auto-compactions in a long turn) is still shown. let compactionInProgress = false; - if (this.session.lastContextWindowSize == null) { - this.session.lastContextWindowSize = this.getContextWindowForModel( - this.session.modelId ?? "", - ); - this.logger.debug("Initial context window size from gateway", { - modelId: this.session.modelId, - contextWindowSize: this.session.lastContextWindowSize, - }); - } - let lastContextWindowSize = this.session.lastContextWindowSize; + let stopReason: PromptResponse["stopReason"] = "end_turn"; + + // Read live: model switches reset session.lastContextWindowSize. + const windowSize = () => + this.session.lastContextWindowSize ?? + this.getContextWindowForModel(this.session.modelId ?? ""); const supportsTerminalOutput = ( @@ -551,89 +610,235 @@ export class ClaudeAcpAgent extends BaseAcpAgent { )?.terminal_output === true; const context = { - session: this.session, - sessionId: params.sessionId, + session, + sessionId, client: this.client, toolUseCache: this.toolUseCache, + emittedToolCalls: this.emittedToolCalls, toolUseStreamCache: this.toolUseStreamCache, fileContentCache: this.fileContentCache, enrichedReadCache: this.enrichedReadCache, logger: this.logger, supportsTerminalOutput, - streamedAssistantBlocks: { - textIds: new Set(), - thinkingIds: new Set(), - }, + // Consumer-lived: turn activation can fire mid-message, so this must + // not reset per turn (it is cleared per message instead). + streamedAssistantBlocks: { blocks: [] }, + }; + + const sessionUsage = (): Usage => { + const acc = session.accumulatedUsage; + return { + inputTokens: acc.inputTokens, + outputTokens: acc.outputTokens, + cachedReadTokens: acc.cachedReadTokens, + cachedWriteTokens: acc.cachedWriteTokens, + totalTokens: + acc.inputTokens + + acc.outputTokens + + acc.cachedReadTokens + + acc.cachedWriteTokens, + }; + }; + + const resetTurnScratch = () => { + lastAssistantTotalUsage = null; + lastRefusalExplanation = null; + lastRefusalCategory = null; + lastStreamUsage = { + input_tokens: 0, + output_tokens: 0, + cache_read_input_tokens: 0, + cache_creation_input_tokens: 0, + }; + compactionInProgress = false; + stopReason = "end_turn"; + // sessionResources is intentionally NOT reset — the products list + // accumulates across the whole session and is deduped, not per-turn. + session.accumulatedUsage = { + inputTokens: 0, + outputTokens: 0, + cachedReadTokens: 0, + cachedWriteTokens: 0, + }; + }; + + const activateTurn = async (turn: Turn) => { + session.activeTurn = turn; + session.cancelled = false; + session.interruptReason = undefined; + session.pendingOrphanResults = 0; + resetTurnScratch(); + try { + await turn.broadcast(); + } catch (error) { + this.logger.warn("Failed to broadcast user message", { + sessionId, + error: error instanceof Error ? error.message : String(error), + }); + } + }; + + // Promote the queue head for echo-less results (local-only commands, + // compaction), skipping any orphan results owed by cancelled-while-queued + // turns so they can't be misattributed to a later prompt. + const ensureActiveTurn = async () => { + if (session.activeTurn) { + return; + } + const head = session.turnQueue.find((t) => !t.settled); + if (!head) { + return; + } + if (session.pendingOrphanResults > 0) { + session.pendingOrphanResults--; + return; + } + await activateTurn(head); }; + const settleActive = (result: PromptResponse) => { + const turn = session.activeTurn; + if (!turn || turn.settled) { + return; + } + turn.settled = true; + if (session.forceCancelTimer) { + clearTimeout(session.forceCancelTimer); + session.forceCancelTimer = undefined; + } + session.turnQueue = session.turnQueue.filter((t) => t !== turn); + session.activeTurn = null; + turn.resolve(result); + }; + + // Reject the active turn without tearing down the consumer. + const failActive = (error: unknown) => { + if (session.forceCancelTimer) { + clearTimeout(session.forceCancelTimer); + session.forceCancelTimer = undefined; + } + const turn = session.activeTurn; + if (!turn || turn.settled) { + return; + } + turn.settled = true; + session.turnQueue = session.turnQueue.filter((t) => t !== turn); + session.activeTurn = null; + this.toolUseStreamCache.clear(); + turn.reject(error); + }; + + // Reject every in-flight turn when the stream dies. + const failAllTurns = (error: unknown) => { + if (session.forceCancelTimer) { + clearTimeout(session.forceCancelTimer); + session.forceCancelTimer = undefined; + } + const turns = session.activeTurn + ? [ + session.activeTurn, + ...session.turnQueue.filter((t) => t !== session.activeTurn), + ] + : [...session.turnQueue]; + session.activeTurn = null; + session.turnQueue = []; + this.toolUseStreamCache.clear(); + for (const turn of turns) { + if (!turn.settled) { + turn.settled = true; + turn.reject(error); + } + } + }; + + let cancelController = session.cancelController as AbortController; + try { while (true) { - const nextMessage = this.session.query.next(); + const nextMessage = query.next(); const next = await withAbort(nextMessage, cancelController.signal); if (next.result === "aborted" || cancelController.signal.aborted) { + // Abandon the in-flight next(), swallowing any later rejection. void nextMessage.catch((err) => this.logger.warn("in-flight query.next() rejected after cancel", { - sessionId: params.sessionId, + sessionId, error: err instanceof Error ? err.message : String(err), }), ); - return { - stopReason: "cancelled", - _meta: this.session.interruptReason - ? { interruptReason: this.session.interruptReason } - : undefined, - }; + settleActive(this.cancelledResponse()); + this.toolUseStreamCache.clear(); + if (refreshed() || session.queryClosed) { + return; + } + cancelController = new AbortController(); + session.cancelController = cancelController; + continue; } const { value: message, done } = next.value; if (done || !message) { - if (this.session.cancelled) { - return { - stopReason: "cancelled", - _meta: this.session.interruptReason - ? { interruptReason: this.session.interruptReason } - : undefined, - }; + if (refreshed()) { + return; } - break; + settleActive( + session.cancelled + ? this.cancelledResponse() + : { stopReason, usage: sessionUsage() }, + ); + // Queued turns the SDK never started produced no output; reject + // them rather than report a success. + for (const queued of [...session.turnQueue]) { + if (!queued.settled) { + queued.settled = true; + queued.reject( + RequestError.internalError(undefined, SESSION_ENDED_MESSAGE), + ); + } + } + session.turnQueue = []; + this.closeQueryStream(session); + return; } if ( - this.session.emitRawSDKMessages && - shouldEmitRawMessage(this.session.emitRawSDKMessages, message) + session.emitRawSDKMessages && + shouldEmitRawMessage(session.emitRawSDKMessages, message) ) { await this.client.extNotification("_claude/sdkMessage", { - sessionId: params.sessionId, + sessionId, message: message as Record, }); } switch (message.type) { case "system": + if (message.subtype === "init") { + await this.syncFastModeState(message.fast_mode_state); + } if (message.subtype === "compact_boundary") { + await ensureActiveTurn(); const usedTokens = await withAbort( - fetchContextUsedTokens(this.session.query, this.logger), + fetchContextUsedTokens(query, this.logger), cancelController.signal, ); lastAssistantTotalUsage = usedTokens.result === "success" ? (usedTokens.value ?? 0) : 0; - promptReplayed = true; await this.client.sessionUpdate({ - sessionId: params.sessionId, + sessionId, update: { sessionUpdate: "usage_update", used: lastAssistantTotalUsage, - size: lastContextWindowSize, + size: windowSize(), }, }); } if (message.subtype === "commands_changed") { - this.session.knownSlashCommands = collectKnownSlashCommands( + session.knownSlashCommands = collectKnownSlashCommands( message.commands, ); const available = getAvailableSlashCommands(message.commands); await this.client.sessionUpdate({ - sessionId: params.sessionId, + sessionId, update: { sessionUpdate: "available_commands_update", availableCommands: available, @@ -646,7 +851,7 @@ export class ClaudeAcpAgent extends BaseAcpAgent { break; } if (message.subtype === "local_command_output") { - promptReplayed = true; + await ensureActiveTurn(); } if (message.subtype === "status") { // The SDK signals manual `/compact` completion with a status @@ -665,7 +870,7 @@ export class ClaudeAcpAgent extends BaseAcpAgent { ) { compactionInProgress = false; await this.client.sessionUpdate({ - sessionId: params.sessionId, + sessionId, update: { sessionUpdate: "agent_message_chunk", content: { @@ -680,7 +885,7 @@ export class ClaudeAcpAgent extends BaseAcpAgent { await this.client.extNotification( POSTHOG_NOTIFICATIONS.STATUS, { - sessionId: params.sessionId, + sessionId, status: "compacting", isComplete: true, }, @@ -698,7 +903,7 @@ export class ClaudeAcpAgent extends BaseAcpAgent { await this.client.extNotification( POSTHOG_NOTIFICATIONS.STATUS, { - sessionId: params.sessionId, + sessionId, status: "compacting_failed", error: message.compact_error ?? undefined, }, @@ -710,93 +915,91 @@ export class ClaudeAcpAgent extends BaseAcpAgent { message.subtype === "session_state_changed" && (message as Record).state === "idle" ) { - if (!promptReplayed) { - // The SDK consumed a slash command we do not handle locally - // and produced no output (e.g. /plugin in a non-interactive - // context). Without this branch we would loop forever waiting - // for an echo that never comes; surface a clear error instead. - // - // Only fire for commands the SDK does NOT recognize. Plugin - // and skill commands (e.g. /skills-store) produce a fresh - // user-message echo with a new uuid that our replay check - // can't match, so an early idle here is a race, not a real - // "unsupported" — fall through and let the loop continue. - const cmdName = commandMatch?.[1].slice(1); - const known = - cmdName !== undefined && - this.session.knownSlashCommands?.has(cmdName) === true; - if (commandMatch && !known) { - const cmd = commandMatch[1]; - this.logger.warn( - "Slash command produced no output; treating as unsupported", - { sessionId: params.sessionId, command: cmd }, - ); - await this.client.sessionUpdate({ - sessionId: params.sessionId, - update: { - sessionUpdate: "agent_message_chunk", - content: { - type: "text", - text: `Unsupported slash command: \`${cmd}\`. PostHog Code does not implement this command.`, - }, - }, - }); - return { stopReason: "end_turn" }; + if (session.activeTurn) { + // Only a cancelled turn settles at idle; its result was + // dropped at the `session.cancelled` guard. + if (session.cancelled) { + settleActive(this.cancelledResponse()); } - this.logger.debug("Skipping idle state before prompt replay", { - sessionId: params.sessionId, - command: commandMatch?.[1], - known, + await this.maybeUpdateSessionTitle(sessionId, session); + break; + } + await this.maybeUpdateSessionTitle(sessionId, session); + // An unknown command the SDK consumed silently never echoes; + // known plugin/skill commands echo late (race, not unsupported). + const head = session.turnQueue.find((t) => !t.settled); + if ( + head?.commandName && + session.pendingOrphanResults === 0 && + session.knownSlashCommands?.has(head.commandName.slice(1)) !== + true + ) { + const cmd = head.commandName; + this.logger.warn( + "Slash command produced no output; treating as unsupported", + { sessionId, command: cmd }, + ); + await this.client.sessionUpdate({ + sessionId, + update: { + sessionUpdate: "agent_message_chunk", + content: { + type: "text", + text: `Unsupported slash command: \`${cmd}\`. PostHog Code does not implement this command.`, + }, + }, }); + head.settled = true; + session.turnQueue = session.turnQueue.filter((t) => t !== head); + head.resolve({ stopReason: "end_turn" }); break; } - - const acc = this.session.accumulatedUsage; - const totalUsed = - acc.inputTokens + - acc.outputTokens + - acc.cachedReadTokens + - acc.cachedWriteTokens; - - await this.client.sessionUpdate({ - sessionId: params.sessionId, - update: { - sessionUpdate: "usage_update", - used: totalUsed, - size: lastContextWindowSize, - }, + this.logger.debug("Idle without an active turn", { + sessionId, + queuedTurns: session.turnQueue.length, + command: head?.commandName, }); - - return { - stopReason: this.session.cancelled ? "cancelled" : "end_turn", - }; + break; } await handleSystemMessage(message, context); break; case "result": { - // Skip results from background tasks that finished after our prompt started - if (!promptReplayed) { - this.logger.debug( - "Skipping background task result before prompt replay", - { sessionId: params.sessionId }, + // Task-notification followups are background work: they must not + // touch the user-turn lifecycle, but their cost is still reported. + const isTaskNotification = + (message as { origin?: { kind?: string } }).origin?.kind === + "task-notification"; + + if (!isTaskNotification) { + await this.syncFastModeState( + (message as { fast_mode_state?: FastModeState }) + .fast_mode_state, ); - break; } - if (this.session.cancelled) { - return { stopReason: "cancelled" }; + // Promote before accumulating usage: activation resets the + // accumulator. + if (!isTaskNotification) { + await ensureActiveTurn(); } - // Accumulate usage from this result (guard against null from SDK) - this.session.accumulatedUsage.inputTokens += - message.usage.input_tokens ?? 0; - this.session.accumulatedUsage.outputTokens += - message.usage.output_tokens ?? 0; - this.session.accumulatedUsage.cachedReadTokens += - message.usage.cache_read_input_tokens ?? 0; - this.session.accumulatedUsage.cachedWriteTokens += - message.usage.cache_creation_input_tokens ?? 0; + // A cancelled turn settles at idle (or the backstop) instead. + if (session.cancelled) { + break; + } + + if (!isTaskNotification) { + // Accumulate usage from this result (guard against null from SDK) + session.accumulatedUsage.inputTokens += + message.usage.input_tokens ?? 0; + session.accumulatedUsage.outputTokens += + message.usage.output_tokens ?? 0; + session.accumulatedUsage.cachedReadTokens += + message.usage.cache_read_input_tokens ?? 0; + session.accumulatedUsage.cachedWriteTokens += + message.usage.cache_creation_input_tokens ?? 0; + } // SDK can underreport context window (e.g. 200k for 1M models). // Use SDK value only if it's larger than what gateway reported. @@ -805,25 +1008,24 @@ export class ClaudeAcpAgent extends BaseAcpAgent { ); if (contextWindows.length > 0) { const sdkContextWindow = Math.min(...contextWindows); - if (sdkContextWindow > lastContextWindowSize) { - lastContextWindowSize = sdkContextWindow; + if (sdkContextWindow > windowSize()) { + session.lastContextWindowSize = sdkContextWindow; } } - this.session.lastContextWindowSize = lastContextWindowSize; - this.session.contextSize = lastContextWindowSize; + session.contextSize = windowSize(); if (lastAssistantTotalUsage !== null) { - this.session.contextUsed = lastAssistantTotalUsage; + session.contextUsed = lastAssistantTotalUsage; } // Send usage_update notification if (lastAssistantTotalUsage !== null) { await this.client.sessionUpdate({ - sessionId: params.sessionId, + sessionId, update: { sessionUpdate: "usage_update", used: lastAssistantTotalUsage, - size: lastContextWindowSize, + size: windowSize(), cost: { amount: message.total_cost_usd, currency: "USD", @@ -841,7 +1043,7 @@ export class ClaudeAcpAgent extends BaseAcpAgent { await this.client.extNotification( POSTHOG_NOTIFICATIONS.USAGE_UPDATE, { - sessionId: params.sessionId, + sessionId, used: { inputTokens: message.usage.input_tokens, outputTokens: message.usage.output_tokens, @@ -850,25 +1052,12 @@ export class ClaudeAcpAgent extends BaseAcpAgent { }, cost: message.total_cost_usd, breakdown: buildBreakdown( - this.session.contextBreakdownBaseline ?? emptyBaseline(), + session.contextBreakdownBaseline ?? emptyBaseline(), breakdownInputTokens, ), }, ); - const usage: Usage = { - inputTokens: this.session.accumulatedUsage.inputTokens, - outputTokens: this.session.accumulatedUsage.outputTokens, - cachedReadTokens: this.session.accumulatedUsage.cachedReadTokens, - cachedWriteTokens: - this.session.accumulatedUsage.cachedWriteTokens, - totalTokens: - this.session.accumulatedUsage.inputTokens + - this.session.accumulatedUsage.outputTokens + - this.session.accumulatedUsage.cachedReadTokens + - this.session.accumulatedUsage.cachedWriteTokens, - }; - if ( (message as { stop_reason?: string }).stop_reason === "refusal" ) { @@ -876,18 +1065,35 @@ export class ClaudeAcpAgent extends BaseAcpAgent { // so surface the refusal as a structured status row rather than // assistant text. await this.client.extNotification(POSTHOG_NOTIFICATIONS.STATUS, { - sessionId: params.sessionId, + sessionId, status: "refusal", ...(lastRefusalExplanation && { explanation: lastRefusalExplanation, }), ...(lastRefusalCategory && { category: lastRefusalCategory }), }); - return { stopReason: "refusal", usage }; + if (isTaskNotification) { + // Background work never activates a turn, so there is no + // settle path to broadcast completion — send it directly so + // the UI still closes this reply out as its own turn. + await this.client.extNotification( + POSTHOG_NOTIFICATIONS.BACKGROUND_TURN_COMPLETE, + { sessionId, stopReason: "refusal" }, + ); + } else { + stopReason = "refusal"; + settleActive({ stopReason: "refusal", usage: sessionUsage() }); + } + break; } const result = handleResultMessage(message); - if (result.error) throw result.error; + if (result.error) { + if (!isTaskNotification) { + failActive(result.error); + } + break; + } // Deliver structured output from SDK's native outputFormat if ( @@ -902,12 +1108,13 @@ export class ClaudeAcpAgent extends BaseAcpAgent { // For local-only commands, forward the result text to the client if ( - isLocalOnlyCommand && + session.activeTurn?.isLocalOnlyCommand && + !isTaskNotification && message.subtype === "success" && message.result ) { await this.client.sessionUpdate({ - sessionId: params.sessionId, + sessionId, update: { sessionUpdate: "agent_message_chunk", content: { type: "text", text: message.result }, @@ -915,7 +1122,22 @@ export class ClaudeAcpAgent extends BaseAcpAgent { }); } - return { stopReason: result.stopReason ?? "end_turn", usage }; + // Settle at the terminal result rather than the trailing idle, + // which can lag behind background work. + if (isTaskNotification) { + // Background work never activates a turn, so there is no + // settle path to broadcast completion — send it directly so + // the UI still closes this reply out as its own turn instead + // of merging the next one into it. + await this.client.extNotification( + POSTHOG_NOTIFICATIONS.BACKGROUND_TURN_COMPLETE, + { sessionId, stopReason: result.stopReason ?? "end_turn" }, + ); + } else { + stopReason = result.stopReason ?? "end_turn"; + settleActive({ stopReason, usage: sessionUsage() }); + } + break; } case "stream_event": { @@ -956,11 +1178,11 @@ export class ClaudeAcpAgent extends BaseAcpAgent { if (nextTotal !== lastAssistantTotalUsage) { lastAssistantTotalUsage = nextTotal; await this.client.sessionUpdate({ - sessionId: params.sessionId, + sessionId, update: { sessionUpdate: "usage_update", used: nextTotal, - size: lastContextWindowSize, + size: windowSize(), }, }); } @@ -971,31 +1193,41 @@ export class ClaudeAcpAgent extends BaseAcpAgent { case "user": case "assistant": { - // Check for prompt replay (our own message echoed back) + // A user echo promotes its queued turn (handing off any still- + // active one first), then drops from the feed. Runs before the + // cancelled guard so a turn enqueued after a cancel still starts. if (message.type === "user" && "uuid" in message && message.uuid) { - if (message.uuid === promptUuid) { - promptReplayed = true; + const queued = session.turnQueue.find( + (t) => t.promptUuid === message.uuid && !t.settled, + ); + if (queued) { + // A turn promoted early by its result must not have its + // usage reset by its own echo. + if (session.activeTurn !== queued) { + if (session.activeTurn) { + settleActive( + session.cancelled + ? this.cancelledResponse() + : { stopReason: "end_turn", usage: sessionUsage() }, + ); + } + await activateTurn(queued); + } break; } - - const pending = this.session.pendingMessages.get( - message.uuid as string, - ); - if (pending) { - pending.resolve(false); - this.session.pendingMessages.delete(message.uuid as string); - handedOff = true; - return { - stopReason: this.session.cancelled ? "cancelled" : "end_turn", - }; + if ( + "isReplay" in message && + (message as Record).isReplay + ) { + break; } } - if (this.session.cancelled) { + if (session.cancelled) { break; } - // Skip replayed user messages that aren't pending prompts + // Skip replayed messages that aren't queued prompts if ( "isReplay" in message && (message as Record).isReplay @@ -1043,27 +1275,30 @@ export class ClaudeAcpAgent extends BaseAcpAgent { (usage.cache_creation_input_tokens ?? 0); await this.client.sessionUpdate({ - sessionId: params.sessionId, + sessionId, update: { sessionUpdate: "usage_update", used: lastAssistantTotalUsage, - size: lastContextWindowSize, + size: windowSize(), cost: null, }, }); } const result = await handleUserAssistantMessage(message, context); - if (result.error) throw result.error; + if (result.error) { + failActive(result.error); + break; + } if (result.shouldStop) { - return { stopReason: "end_turn" }; + settleActive({ stopReason: "end_turn" }); } break; } case "tool_progress": { await this.client.sessionUpdate({ - sessionId: params.sessionId, + sessionId, update: { sessionUpdate: "tool_call_update", toolCallId: message.tool_use_id, @@ -1083,11 +1318,11 @@ export class ClaudeAcpAgent extends BaseAcpAgent { case "rate_limit_event": { if (lastAssistantTotalUsage !== null) { await this.client.sessionUpdate({ - sessionId: params.sessionId, + sessionId, update: { sessionUpdate: "usage_update", used: lastAssistantTotalUsage, - size: lastContextWindowSize, + size: windowSize(), _meta: { "_claude/rateLimit": message.rate_limit_info }, }, }); @@ -1104,116 +1339,71 @@ export class ClaudeAcpAgent extends BaseAcpAgent { break; } } - throw new Error("Session did not end in result"); } catch (error) { - errored = true; - // A failed turn typically leaves a trailing `session_state_changed: idle` - // (and possibly more) in the query iterator. If we don't drain it here, - // the next prompt's first `query.next()` consumes that stale idle and - // short-circuits to end_turn with zero usage. - try { - await this.session.query.interrupt(); - const MAX_DRAIN = 100; - for (let i = 0; i < MAX_DRAIN; i++) { - const { value: m, done } = await this.session.query.next(); - if (done || !m) break; - if ( - m.type === "system" && - m.subtype === "session_state_changed" && - (m as Record).state === "idle" - ) { - break; - } - if (i === MAX_DRAIN - 1) { - this.logger.error( - `Session ${params.sessionId}: drained ${MAX_DRAIN} messages after error without observing idle`, - ); - } - } - } catch (drainErr) { - this.logger.error( - `Session ${params.sessionId}: failed to drain query after prompt error`, - { error: drainErr }, - ); - } - - if (error instanceof RequestError || !(error instanceof Error)) { - throw error; + // Only stream-level errors reach here; turn-level failures were + // rejected inline via failActive. + if (refreshed()) { + this.logger.debug("Consumer for a refreshed query exiting on error", { + sessionId, + error: error instanceof Error ? error.message : String(error), + }); + return; } - const msg = error.message; - if ( - msg.includes("ProcessTransport") || - msg.includes("terminated process") || - msg.includes("process exited with") || - msg.includes("process terminated by signal") || - msg.includes("Failed to write to process stdin") - ) { + const msg = error instanceof Error ? error.message : String(error); + const processDied = + error instanceof Error && + (msg.includes("ProcessTransport") || + msg.includes("terminated process") || + msg.includes("process exited with") || + msg.includes("process terminated by signal") || + msg.includes("Failed to write to process stdin")); + if (processDied) { this.logger.error(`Process died: ${msg}`, { sessionId: this.sessionId, }); - this.session.settingsManager.dispose(); - this.session.input.end(); - throw RequestError.internalError( - { details: msg }, - "The Claude Agent process exited unexpectedly. Please start a new session.", + failAllTurns( + RequestError.internalError( + { details: msg }, + "The Claude Agent process exited unexpectedly. Please start a new session.", + ), ); + } else { + this.logger.error("Query stream error", { sessionId, error: msg }); + failAllTurns(error); } - throw error; - } finally { - if (this.session.forceCancelTimer) { - clearTimeout(this.session.forceCancelTimer); - this.session.forceCancelTimer = undefined; - } - if (this.session.cancelController === cancelController) { - this.session.cancelController = undefined; - } - // Drop any leftover streaming-input buffers. Normally cleared per index - // on `content_block_stop`, but a cancelled or errored turn may leave - // entries behind; without this they'd carry over into the next turn - // and collide with new content-block indices. - this.toolUseStreamCache.clear(); - if (!handedOff) { - this.session.promptRunning = false; - if (errored) { - // The query stream was just drained — handing pending prompts off - // onto it would let them race with the recovery. Cancel them so - // each waiting prompt() returns stopReason "cancelled" and the - // client can decide whether to retry. - for (const pending of this.session.pendingMessages.values()) { - pending.resolve(true); - } - this.session.pendingMessages.clear(); - } else if (this.session.pendingMessages.size > 0) { - // Clean exit with queued prompts: hand off the lowest-order one - // so it can proceed. The rest stay queued for their own turn. - const next = [...this.session.pendingMessages.entries()].sort( - (a, b) => a[1].order - b[1].order, - )[0]; - if (next) { - next[1].resolve(false); - this.session.pendingMessages.delete(next[0]); - } - } - } + this.closeQueryStream(session); } } // Called by BaseAcpAgent#cancel() to interrupt the session protected async interrupt(): Promise { - this.session.cancelled = true; - for (const [, pending] of this.session.pendingMessages) { - pending.resolve(true); + const session = this.session; + if (session.queryClosed) { + return; + } + session.cancelled = true; + + // Settle not-yet-echoed turns immediately; the SDK still runs their + // pushed messages, so count the echo-less results they owe as orphans. + for (const turn of [...session.turnQueue]) { + if (turn === session.activeTurn || turn.settled) { + continue; + } + turn.settled = true; + session.turnQueue = session.turnQueue.filter((t) => t !== turn); + session.pendingOrphanResults += 1; + turn.resolve(this.cancelledResponse()); } - this.session.pendingMessages.clear(); + // Backstop for an SDK that never yields after interrupt() (issue #680). if ( - this.session.promptRunning && - this.session.cancelController && - !this.session.cancelController.signal.aborted && - !this.session.forceCancelTimer + session.activeTurn && + session.cancelController && + !session.cancelController.signal.aborted && + !session.forceCancelTimer ) { - const cancelController = this.session.cancelController; - this.session.forceCancelTimer = setTimeout(() => { + const cancelController = session.cancelController; + session.forceCancelTimer = setTimeout(() => { this.logger.error( `Session ${this.sessionId}: cancel floor elapsed without the SDK yielding; forcing "cancelled". The underlying query may still be wedged — a new session may be required.`, ); @@ -1221,7 +1411,7 @@ export class ClaudeAcpAgent extends BaseAcpAgent { }, this.forceCancelGraceMs); } - await this.session.query.interrupt(); + await session.query.interrupt(); } /** @@ -1280,7 +1470,7 @@ export class ClaudeAcpAgent extends BaseAcpAgent { mcpServers: Record, ): Promise { const prev = this.session; - if (prev.promptRunning) { + if (prev.activeTurn !== null || prev.turnQueue.length > 0) { throw new RequestError( -32002, "Cannot refresh session while a prompt turn is in flight", @@ -1298,6 +1488,13 @@ export class ClaudeAcpAgent extends BaseAcpAgent { sessionId: this.sessionId, }); + // Retire the old consumer: the generation bump makes it exit quietly. + prev.queryGeneration += 1; + const oldConsumer = prev.consumer; + prev.consumer = undefined; + prev.cancelController?.abort(); + prev.cancelController = undefined; + // Abort FIRST so any stuck in-flight HTTP request unblocks — otherwise // interrupt() can deadlock waiting on an API call that never returns. // We allocate a fresh controller for the new Query below so aborting @@ -1312,6 +1509,10 @@ export class ClaudeAcpAgent extends BaseAcpAgent { }); } prev.input.end(); + if (oldConsumer) { + // Bounded so a wedged old query can't block the refresh. + await withTimeout(oldConsumer, 5_000); + } // Reuse every option from the running session; swap mcpServers, re-root // identity on `resume` instead of `sessionId`, and give the new Query a @@ -1502,6 +1703,7 @@ export class ClaudeAcpAgent extends BaseAcpAgent { this.session.lastContextWindowSize = this.getContextWindowForModel(resolvedValue); this.rebuildEffortConfigOption(resolvedValue); + this.rebuildFastModeConfigOption(resolvedValue); } else if (params.configId === "effort") { const newEffort = resolvedValue as EffortLevel; this.session.effort = newEffort; @@ -1510,6 +1712,11 @@ export class ClaudeAcpAgent extends BaseAcpAgent { // @ts-expect-error SDK Settings.effortLevel omits "max" but runtime accepts it effortLevel: newEffort, }); + } else if (params.configId === "fast") { + // SDK flag first: a rejected control request leaves state untouched. + const enabled = resolvedValue === "on"; + await this.session.query.applyFlagSettings({ fastMode: enabled }); + this.session.fastModeEnabled = enabled; } this.session.configOptions = this.session.configOptions.map((o) => @@ -1801,9 +2008,11 @@ export class ClaudeAcpAgent extends BaseAcpAgent { sessionResources: new Set(), effort, configOptions: [], - promptRunning: false, - pendingMessages: new Map(), - nextPendingOrder: 0, + turnQueue: [], + activeTurn: null, + pendingOrphanResults: 0, + queryGeneration: 0, + fastModeEnabled: false, emitRawSDKMessages: meta?.claudeCode?.emitRawSDKMessages ?? false, contextBreakdownBaseline: { ...emptyBaseline(), @@ -1817,6 +2026,8 @@ export class ClaudeAcpAgent extends BaseAcpAgent { notificationHistory: [], taskRunId: meta?.taskRunId, }; + // A replaced session's consumer never reaches closeQueryStream. + this.emittedToolCalls.clear(); this.session = session; this.sessionId = sessionId; @@ -1838,6 +2049,9 @@ export class ClaudeAcpAgent extends BaseAcpAgent { session.knownSlashCommands = collectKnownSlashCommands( result.value.commands, ); + session.fastModeEnabled = fastModeStateEnabled( + result.value.fast_mode_state, + ); } catch (err) { settingsManager.dispose(); this.terminateQuery(q, abortController); @@ -1907,6 +2121,9 @@ export class ClaudeAcpAgent extends BaseAcpAgent { session.knownSlashCommands = collectKnownSlashCommands( initResult.value.commands, ); + session.fastModeEnabled = fastModeStateEnabled( + initResult.value.fast_mode_state, + ); this.logger.info("Session initialized", { sessionId, taskId, @@ -1977,6 +2194,7 @@ export class ClaudeAcpAgent extends BaseAcpAgent { permissionMode, modelOptions, this.session.effort ?? DEFAULT_EFFORT, + session.fastModeEnabled, ); session.configOptions = configOptions; @@ -2007,6 +2225,13 @@ export class ClaudeAcpAgent extends BaseAcpAgent { this.updateConfigOption(configId, value), applySessionMode: (modeId: string) => this.applySessionMode(modeId), allowedDomains, + emittedToolCalls: this.emittedToolCalls, + supportsTerminalOutput: + ( + this.clientCapabilities?._meta as + | ClientCapabilities["_meta"] + | undefined + )?.terminal_output === true, }); } @@ -2089,6 +2314,7 @@ export class ClaudeAcpAgent extends BaseAcpAgent { options: SessionConfigSelectOption[]; }, currentEffort: EffortLevel = DEFAULT_EFFORT, + fastModeEnabled?: boolean, ): SessionConfigOption[] { const modeOptions = getAvailableModes().map((mode) => ({ value: mode.id, @@ -2131,9 +2357,95 @@ export class ClaudeAcpAgent extends BaseAcpAgent { }); } + if (supportsFastMode(modelOptions.currentModelId)) { + configOptions.push(this.fastModeConfigOption(fastModeEnabled ?? false)); + } + return configOptions; } + private fastModeConfigOption(enabled: boolean): SessionConfigOption { + return { + id: "fast", + name: "Fast mode", + type: "select", + currentValue: enabled ? "on" : "off", + options: [ + { value: "on", name: "On" }, + { value: "off", name: "Off" }, + ], + description: "Faster responses on supported models", + }; + } + + private rebuildFastModeConfigOption(modelId: string): void { + const withoutFast = this.session.configOptions.filter( + (o) => o.id !== "fast", + ); + this.session.configOptions = supportsFastMode(modelId) + ? [ + ...withoutFast, + this.fastModeConfigOption(this.session.fastModeEnabled), + ] + : withoutFast; + } + + // Mirror SDK-reported fast mode flips into the config option. A hidden + // option means the state reflects capability, not intent, and cooldown is + // transient; neither may touch the retained toggle. + private async syncFastModeState( + state: FastModeState | undefined, + ): Promise { + if (state === undefined || state === "cooldown") { + return; + } + if (!this.session.configOptions.some((o) => o.id === "fast")) { + return; + } + const enabled = state === "on"; + if (enabled === this.session.fastModeEnabled) { + return; + } + this.session.fastModeEnabled = enabled; + await this.updateConfigOption("fast", enabled ? "on" : "off"); + } + + // The SDK has no push event for the title it generates in the background, + // so poll it at turn-end; failures are non-fatal and retried next turn. + private async maybeUpdateSessionTitle( + sessionId: string, + session: Session, + ): Promise { + let info: Awaited>; + try { + info = await getSessionInfo(sessionId, { dir: session.cwd }); + } catch (error) { + this.logger.warn("Failed to read session info for title update", { + sessionId, + error: error instanceof Error ? error.message : String(error), + }); + return; + } + // customTitle is a user rename; prefer it over the generated summary. + const rawTitle = info?.customTitle ?? info?.summary; + if (!rawTitle) { + return; + } + const title = sanitizeTitle(rawTitle); + if (!title || title === session.lastTitle) { + return; + } + session.lastTitle = title; + await this.client.sessionUpdate({ + sessionId, + update: { + sessionUpdate: "session_info_update", + title, + updatedAt: new Date(info?.lastModified ?? Date.now()).toISOString(), + }, + }); + } + private rebuildEffortConfigOption(modelId: string): void { const effortOptions = getEffortOptions(modelId); const existingEffort = this.session.configOptions.find( @@ -2280,6 +2592,7 @@ export class ClaudeAcpAgent extends BaseAcpAgent { sessionId, client: this.client, toolUseCache: this.toolUseCache, + emittedToolCalls: this.emittedToolCalls, toolUseStreamCache: this.toolUseStreamCache, fileContentCache: this.fileContentCache, enrichedReadCache: this.enrichedReadCache, diff --git a/packages/agent/src/adapters/claude/conversion/sdk-to-acp.test.ts b/packages/agent/src/adapters/claude/conversion/sdk-to-acp.test.ts index 2ca3b563ba..595fa7e11f 100644 --- a/packages/agent/src/adapters/claude/conversion/sdk-to-acp.test.ts +++ b/packages/agent/src/adapters/claude/conversion/sdk-to-acp.test.ts @@ -87,10 +87,7 @@ function createHandlerContext() { toolUseStreamCache: new Map(), fileContentCache: {}, logger: new Logger({ debug: false }), - streamedAssistantBlocks: { - textIds: new Set(), - thinkingIds: new Set(), - }, + streamedAssistantBlocks: { blocks: [] }, }; return { context, updates, notifications }; } @@ -192,7 +189,7 @@ describe("assembled assistant text fallback", () => { ]); }); - it("tracks streamed ids per message so a later message still falls back", async () => { + it("tracks streamed content per message so a later message still falls back", async () => { const { context, updates } = createHandlerContext(); await streamLiveText(context, "msg_1", "streamed"); updates.length = 0; @@ -205,6 +202,78 @@ describe("assembled assistant text fallback", () => { ]); }); + it.each([ + { + label: "forwards only the un-streamed tail when the stream was cut short", + streams: [["msg_1", "hello wor"]] as const, + assembled: { id: "msg_1", text: "hello world" }, + expected: ["ld"], + }, + { + label: + "dedupes by content when the consolidated message id differs from the stream", + streams: [["msg_gateway_1", "same text"]] as const, + assembled: { id: "msg_other_id", text: "same text" }, + expected: [], + }, + { + // msg_1 never got its consolidated message (e.g. cancelled turn); + // its residue must not swallow or truncate msg_2's block. + label: "clears streamed residue when a new top-level message starts", + streams: [ + ["msg_1", "cancelled turn text"], + ["msg_2", "cancelled"], + ] as const, + assembled: { id: "msg_2", text: "cancelled turn" }, + expected: [" turn"], + }, + ])("$label", async ({ streams, assembled, expected }) => { + const { context, updates } = createHandlerContext(); + for (const [apiId, text] of streams) { + await streamLiveText(context, apiId, text); + } + updates.length = 0; + await handleUserAssistantMessage( + assistantMessage(assembled.id, [{ type: "text", text: assembled.text }]), + context, + ); + expect(chunkTexts(updates, "agent_message_chunk")).toEqual(expected); + }); + + it("ignores empty streamed deltas so they cannot stall the diff cursor", async () => { + const { context, updates } = createHandlerContext(); + await handleStreamEvent( + streamEvent({ type: "message_start", message: { id: "msg_1" } }), + context, + ); + await handleStreamEvent( + streamEvent({ + type: "content_block_delta", + index: 0, + delta: { type: "thinking_delta", thinking: "" }, + }), + context, + ); + await handleStreamEvent( + streamEvent({ + type: "content_block_delta", + index: 1, + delta: { type: "text_delta", text: "answer" }, + }), + context, + ); + updates.length = 0; + await handleUserAssistantMessage( + assistantMessage("msg_1", [ + { type: "thinking", thinking: "" }, + { type: "text", text: "answer" }, + ]), + context, + ); + expect(chunkTexts(updates, "agent_message_chunk")).toEqual([]); + expect(chunkTexts(updates, "agent_thought_chunk")).toEqual([]); + }); + it("drops empty assembled blocks", async () => { const { context, updates } = createHandlerContext(); await handleUserAssistantMessage( diff --git a/packages/agent/src/adapters/claude/conversion/sdk-to-acp.ts b/packages/agent/src/adapters/claude/conversion/sdk-to-acp.ts index 2cbe1d9c08..83e9985635 100644 --- a/packages/agent/src/adapters/claude/conversion/sdk-to-acp.ts +++ b/packages/agent/src/adapters/claude/conversion/sdk-to-acp.ts @@ -66,6 +66,9 @@ interface AnthropicMessageWithContent { type ChunkHandlerContext = { sessionId: string; toolUseCache: ToolUseCache; + /** Tool_use ids already surfaced as a `tool_call` (permission requests emit + * eagerly); the second emitter refines instead of duplicating. */ + emittedToolCalls?: Set; fileContentCache: { [key: string]: string }; enrichedReadCache?: EnrichedReadCache; client: AgentSideConnection; @@ -81,19 +84,14 @@ type ChunkHandlerContext = { }; /** - * Per-turn record of which top-level assistant message ids actually streamed - * text/thinking live via `stream_event` deltas. The consolidated assistant - * message normally has its text/thinking blocks dropped as duplicates of the - * streamed chunks, but gateways that return a turn as a single non-streamed - * block (common with OpenAI-compatible proxies) never fire deltas, so the - * assembled block is the only copy the client will ever see. Tracked per - * block type so a gateway that streams text but not thinking (or vice versa) - * doesn't lose the un-streamed block. + * Text/thinking actually streamed live for the in-flight message, in order. + * The consolidated assistant message prefix-diffs its blocks against this and + * forwards only the un-streamed remainder. Content matching (not message ids) + * keeps dedupe robust to gateways whose ids don't line up; cleared per + * message so it stays bounded. */ export interface StreamedAssistantBlocks { - currentStreamMessageId?: string; - textIds: Set; - thinkingIds: Set; + blocks: { index: number; type: "text" | "thinking"; text: string }[]; } export interface MessageHandlerContext { @@ -101,6 +99,8 @@ export interface MessageHandlerContext { sessionId: string; client: AgentSideConnection; toolUseCache: ToolUseCache; + /** See `ChunkHandlerContext.emittedToolCalls`. */ + emittedToolCalls?: Set; /** Buffers `input_json_delta` partial JSON per content-block index. */ toolUseStreamCache: ToolUseStreamCache; fileContentCache: { [key: string]: string }; @@ -177,7 +177,12 @@ function handleImageChunk( function handleThinkingChunk( chunk: { thinking: string }, parentToolCallId?: string, -): SessionUpdate { +): SessionUpdate | null { + // Recent models default `thinking.display` to "omitted", which streams + // signature-only thinking blocks whose text is empty. + if (chunk.thinking.length === 0) { + return null; + } const update: SessionUpdate = { sessionUpdate: "agent_thought_chunk", content: text(chunk.thinking), @@ -197,6 +202,8 @@ function handleToolUseChunk( ctx: ChunkHandlerContext, ): SessionUpdate | null { const alreadyCached = chunk.id in ctx.toolUseCache; + const alreadyEmitted = + alreadyCached || ctx.emittedToolCalls?.has(chunk.id) === true; ctx.toolUseCache[chunk.id] = chunk; // Suppress Task* tool_calls — plan updates are emitted from the matching @@ -209,6 +216,7 @@ function handleToolUseChunk( ) { return null; } + ctx.emittedToolCalls?.add(chunk.id); if (!alreadyCached && ctx.registerHooks !== false) { const toolName = chunk.name; @@ -260,11 +268,11 @@ function handleToolUseChunk( bashCommandFromToolUse(chunk), ), }; - if (chunk.name === "Bash" && ctx.supportsTerminalOutput && !alreadyCached) { + if (chunk.name === "Bash" && ctx.supportsTerminalOutput && !alreadyEmitted) { meta.terminal_info = { terminal_id: chunk.id }; } - if (alreadyCached) { + if (alreadyEmitted) { return { _meta: meta, toolCallId: chunk.id, @@ -361,6 +369,7 @@ function handleToolResultChunk( } delete ctx.toolUseCache[chunk.tool_use_id]; + ctx.emittedToolCalls?.delete(chunk.tool_use_id); if ( toolUse.name === "TaskCreate" || @@ -544,6 +553,7 @@ function toAcpNotifications( mcpToolUseResult?: Record, enrichedReadCache?: EnrichedReadCache, taskState?: TaskState, + emittedToolCalls?: Set, ): SessionNotification[] { if (typeof content === "string") { const update: SessionUpdate = { @@ -563,6 +573,7 @@ function toAcpNotifications( const ctx: ChunkHandlerContext = { sessionId, toolUseCache, + emittedToolCalls, fileContentCache, enrichedReadCache, client, @@ -599,6 +610,7 @@ function streamEventToAcpNotifications( cwd?: string, enrichedReadCache?: EnrichedReadCache, taskState?: TaskState, + emittedToolCalls?: Set, ): SessionNotification[] { const event = message.event; switch (event.type) { @@ -625,6 +637,7 @@ function streamEventToAcpNotifications( undefined, enrichedReadCache, taskState, + emittedToolCalls, ); } case "content_block_delta": { @@ -651,6 +664,7 @@ function streamEventToAcpNotifications( undefined, enrichedReadCache, taskState, + emittedToolCalls, ); } case "content_block_stop": @@ -819,6 +833,22 @@ export async function handleSystemMessage( }); break; } + case "informational": { + // Surface hook-blocked stops; the level is folded into the text since + // agent_message_chunk has no severity field. + const informationalText = + message.level === "info" + ? message.content + : `**${message.level[0].toUpperCase()}${message.level.slice(1)}:** ${message.content}`; + await client.sessionUpdate({ + sessionId, + update: { + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: informationalText }, + }, + }); + break; + } case "permission_denied": { const reason = message.decision_reason ?? message.message; await client.sessionUpdate({ @@ -977,20 +1007,36 @@ export async function handleStreamEvent( const streamed = context.streamedAssistantBlocks; if (streamed) { - if (message.event.type === "message_start") { - streamed.currentStreamMessageId = message.event.message.id || undefined; + // Clear residue from a message that never reached its consolidated reset + // (e.g. a cancelled turn); indices restart per message and would collide. + if ( + message.event.type === "message_start" && + message.parent_tool_use_id === null + ) { + streamed.blocks.length = 0; } - // Only top-level streams are recorded — subagent text is never streamed - // and must stay filtered, as it is internal to the tool call. + // Record only top-level streams; subagent text is never streamed and + // must stay filtered. if ( - streamed.currentStreamMessageId && message.parent_tool_use_id === null && message.event.type === "content_block_delta" ) { - if (message.event.delta.type === "text_delta") { - streamed.textIds.add(streamed.currentStreamMessageId); - } else if (message.event.delta.type === "thinking_delta") { - streamed.thinkingIds.add(streamed.currentStreamMessageId); + const delta = message.event.delta; + const chunk = + delta.type === "text_delta" + ? { type: "text" as const, text: delta.text } + : delta.type === "thinking_delta" + ? { type: "thinking" as const, text: delta.thinking } + : undefined; + // An empty entry would stall the diff cursor in the assistant handler. + if (chunk && chunk.text.length > 0) { + const index = message.event.index; + const last = streamed.blocks[streamed.blocks.length - 1]; + if (last && last.index === index && last.type === chunk.type) { + last.text += chunk.text; + } else { + streamed.blocks.push({ index, type: chunk.type, text: chunk.text }); + } } } } @@ -1009,6 +1055,7 @@ export async function handleStreamEvent( context.session.cwd, context.enrichedReadCache, context.session.taskState, + context.emittedToolCalls, )) { await client.sessionUpdate(notification); context.session.notificationHistory.push(notification); @@ -1145,14 +1192,9 @@ function logSpecialMessages( } } -/** - * Drops assistant `text`/`thinking` blocks that already reached the client as - * streamed chunks, while forwarding (as a fallback) any non-empty block that - * never streamed — see `StreamedAssistantBlocks`. Subagent assistant content - * (`parent_tool_use_id !== null`) is never streamed and stays internal to its - * tool call, so it is always dropped, as is everything when no tracker is - * available (replay). - */ +// Forwards only the un-streamed remainder of each assistant text/thinking +// block: nothing, the whole block (non-streaming gateway) or a cut-short +// tail. Subagent content and tracker-less replay stay dropped. function filterAssistantContent( message: SDKAssistantMessage, streamed: StreamedAssistantBlocks | undefined, @@ -1175,22 +1217,46 @@ function filterAssistantContent( ); } - const id = message.message.id || undefined; - return content.filter((block) => { + // streamPos walks the streamed record in step with the assembled + // text/thinking blocks; other block types pass through without advancing. + const kept: typeof content = []; + let streamPos = 0; + for (const block of content) { if (block.type !== "text" && block.type !== "thinking") { - return true; + kept.push(block); + continue; } - const streamedLive = - id !== undefined && - (block.type === "text" ? streamed.textIds : streamed.thinkingIds).has(id); - if (streamedLive) { - return false; + const full = block.type === "text" ? block.text : block.thinking; + if (full.length === 0) { + continue; } - // Some gateways emit an empty `thinking` block before the real text — - // don't forward stray empty chunks. - const blockText = block.type === "text" ? block.text : block.thinking; - return blockText.length > 0; - }); + // A same-type streamed prefix means the block (or its head) was already + // delivered as chunks; consume it and forward only what's left. + const streamedBlock = streamed.blocks[streamPos]; + if ( + streamedBlock && + streamedBlock.type === block.type && + streamedBlock.text.length > 0 && + full.startsWith(streamedBlock.text) + ) { + streamPos++; + const remainder = full.slice(streamedBlock.text.length); + if (remainder.length === 0) { + continue; + } + // Overwrite in place so the block keeps its exact SDK type. + if (block.type === "text") { + block.text = remainder; + } else { + block.thinking = remainder; + } + kept.push(block); + continue; + } + kept.push(block); + } + streamed.blocks.length = 0; + return kept; } export async function handleUserAssistantMessage( @@ -1296,6 +1362,7 @@ export async function handleUserAssistantMessage( mcpToolUseResult, context.enrichedReadCache, session.taskState, + context.emittedToolCalls, )) { // The renderer ignores raw user chunks; mark imported ones so the load path can promote them. if ( diff --git a/packages/agent/src/adapters/claude/conversion/tool-use-to-acp.ts b/packages/agent/src/adapters/claude/conversion/tool-use-to-acp.ts index 744fe796eb..5e925ea12a 100644 --- a/packages/agent/src/adapters/claude/conversion/tool-use-to-acp.ts +++ b/packages/agent/src/adapters/claude/conversion/tool-use-to-acp.ts @@ -569,7 +569,9 @@ export function toolUpdateFromToolResult( "is_error" in toolResult && toolResult.is_error && toolResult.content && - (toolResult.content as unknown[]).length > 0 + (toolResult.content as unknown[]).length > 0 && + // Bash errors keep rendering through the terminal-output channel below. + !(toolUse?.name === "Bash" && options?.supportsTerminalOutput) ) { return toAcpContentUpdate(toolResult.content, true); } @@ -661,13 +663,22 @@ export function toolUpdateFromToolResult( exitCode = bashResult.return_code; } else if (typeof result === "string") { output = result; - } else if ( - Array.isArray(result) && - result.length > 0 && - "text" in result[0] && - typeof result[0].text === "string" - ) { - output = result.map((c: { text?: string }) => c.text ?? "").join("\n"); + } else if (Array.isArray(result) && result.length > 0) { + const textOnly = result.every( + (c) => + c && + typeof c === "object" && + typeof (c as { text?: unknown }).text === "string", + ); + if (textOnly) { + output = result + .map((c: { text?: string }) => c.text ?? "") + .join("\n"); + } else { + // Binary payloads can't ride the terminal-output _meta channel; + // surface image/mixed content as ACP content blocks instead. + return toAcpContentUpdate(result, isError === true); + } } if (options?.supportsTerminalOutput) { diff --git a/packages/agent/src/adapters/claude/permissions/permission-handlers.test.ts b/packages/agent/src/adapters/claude/permissions/permission-handlers.test.ts index 46cebe33f1..ad6102f4d6 100644 --- a/packages/agent/src/adapters/claude/permissions/permission-handlers.test.ts +++ b/packages/agent/src/adapters/claude/permissions/permission-handlers.test.ts @@ -5,6 +5,13 @@ import { } from "../mcp/tool-metadata"; import { canUseTool } from "./permission-handlers"; +function createClient(response: Record) { + return { + sessionUpdate: vi.fn().mockResolvedValue(undefined), + requestPermission: vi.fn().mockResolvedValue(response), + }; +} + function createContext( toolName: string, overrides: Record = {}, @@ -22,12 +29,9 @@ function createContext( toolUseID: "test-tool-use-id", suggestions: undefined, signal: undefined, - client: { - sessionUpdate: vi.fn().mockResolvedValue(undefined), - requestPermission: vi.fn().mockResolvedValue({ - outcome: { outcome: "selected", optionId: "allow" }, - }), - }, + client: createClient({ + outcome: { outcome: "selected", optionId: "allow" }, + }), sessionId: "test-session", fileContentCache: {}, logger: { @@ -252,12 +256,9 @@ describe("canUseTool MCP approval enforcement", () => { addPostHogExecApproval: addApproval, }, }, - client: { - sessionUpdate: vi.fn().mockResolvedValue(undefined), - requestPermission: vi.fn().mockResolvedValue({ - outcome: { outcome: "selected", optionId: "allow_always" }, - }), - }, + client: createClient({ + outcome: { outcome: "selected", optionId: "allow_always" }, + }), }); const result = await canUseTool(context); @@ -287,12 +288,9 @@ describe("canUseTool MCP approval enforcement", () => { addPostHogExecApproval: addApproval, }, }, - client: { - sessionUpdate: vi.fn().mockResolvedValue(undefined), - requestPermission: vi.fn().mockResolvedValue({ - outcome: { outcome: "selected", optionId: "allow" }, - }), - }, + client: createClient({ + outcome: { outcome: "selected", optionId: "allow" }, + }), }); const result = await canUseTool(context); diff --git a/packages/agent/src/adapters/claude/permissions/permission-handlers.ts b/packages/agent/src/adapters/claude/permissions/permission-handlers.ts index 44933130bd..8eddc69777 100644 --- a/packages/agent/src/adapters/claude/permissions/permission-handlers.ts +++ b/packages/agent/src/adapters/claude/permissions/permission-handlers.ts @@ -1,5 +1,6 @@ import type { AgentSideConnection, + RequestPermissionRequest, RequestPermissionResponse, } from "@agentclientprotocol/sdk"; import type { @@ -63,6 +64,77 @@ interface ToolHandlerContext { updateConfigOption: (configId: string, value: string) => Promise; applySessionMode: (modeId: string) => Promise; allowedDomains?: string[]; + /** Shared with the streamed tool_use path; first emitter wins. */ + emittedToolCalls?: Set; + supportsTerminalOutput?: boolean; +} + +// Task*/TodoWrite render as plans, never as standalone tool_calls. +function shouldEmitToolCall(toolName: string): boolean { + return ( + toolName !== "TodoWrite" && + toolName !== "TaskCreate" && + toolName !== "TaskUpdate" && + toolName !== "TaskList" && + toolName !== "TaskGet" + ); +} + +// The SDK can invoke canUseTool before the tool_use block streams; make +// sure the tool_call exists before the client is asked to approve it. +async function ensureToolCallEmitted( + context: ToolHandlerContext, +): Promise { + const { emittedToolCalls, toolName, toolUseID, toolInput } = context; + if (!emittedToolCalls || !shouldEmitToolCall(toolName)) { + return; + } + if (emittedToolCalls.has(toolUseID)) { + return; + } + emittedToolCalls.add(toolUseID); + const toolInfo = toolInfoFromToolUse( + { name: toolName, input: toolInput }, + { + supportsTerminalOutput: context.supportsTerminalOutput, + toolUseId: toolUseID, + cachedFileContent: context.fileContentCache, + cwd: context.session.cwd, + }, + ); + await context.client.sessionUpdate({ + sessionId: context.sessionId, + update: { + _meta: { + claudeCode: { toolName }, + ...(toolName === "Bash" && context.supportsTerminalOutput + ? { terminal_info: { terminal_id: toolUseID } } + : {}), + }, + toolCallId: toolUseID, + sessionUpdate: "tool_call", + rawInput: toolInput, + status: "pending", + ...toolInfo, + }, + }); +} + +// The cancellationSignal lets a turn cancel dismiss the client's open +// dialog ($/cancel_request) instead of leaving this await hanging. +async function requestPermissionFromClient( + context: ToolHandlerContext, + params: RequestPermissionRequest, +): Promise { + await ensureToolCallEmitted(context); + try { + return await context.client.requestPermission(params); + } catch (error) { + if (context.signal?.aborted) { + throw new Error("Tool use aborted", { cause: error }); + } + throw error; + } } async function emitToolDenial( @@ -159,14 +231,14 @@ async function requestPlanApproval( context: ToolHandlerContext, updatedInput: Record, ): Promise { - const { client, sessionId, toolUseID, session } = context; + const { sessionId, toolUseID, session } = context; const toolInfo = toolInfoFromToolUse({ name: context.toolName, input: updatedInput, }); - return await client.requestPermission({ + return await requestPermissionFromClient(context, { options: buildExitPlanModePermissionOptions(session.modeBeforePlan), sessionId, toolCall: { @@ -281,7 +353,7 @@ async function handleAskUserQuestionTool( }; } - const { client, sessionId, toolUseID, toolInput } = context; + const { sessionId, toolUseID, toolInput } = context; const firstQuestion = questions[0]; const options = buildQuestionOptions(firstQuestion); @@ -290,7 +362,7 @@ async function handleAskUserQuestionTool( input: toolInput, }); - const response = await client.requestPermission({ + const response = await requestPermissionFromClient(context, { options, sessionId, toolCall: { @@ -356,15 +428,8 @@ async function handleAskUserQuestionTool( async function handleDefaultPermissionFlow( context: ToolHandlerContext, ): Promise { - const { - session, - toolName, - toolInput, - toolUseID, - client, - sessionId, - suggestions, - } = context; + const { session, toolName, toolInput, toolUseID, sessionId, suggestions } = + context; const toolInfo = toolInfoFromToolUse( { name: toolName, input: toolInput }, @@ -384,7 +449,7 @@ async function handleDefaultPermissionFlow( // and just shows the bare tool name (e.g. "exec") with no context. const isMcpTool = toolName.startsWith("mcp__"); - const response = await client.requestPermission({ + const response = await requestPermissionFromClient(context, { options, sessionId, toolCall: { @@ -446,7 +511,7 @@ function parseMcpToolName(toolName: string): { async function handleMcpApprovalFlow( context: ToolHandlerContext, ): Promise { - const { toolName, toolInput, toolUseID, client, sessionId } = context; + const { toolName, toolInput, toolUseID, sessionId } = context; const { serverName, tool: displayTool } = parseMcpToolName(toolName); const metadata = getMcpToolMetadata(toolName); @@ -454,7 +519,7 @@ async function handleMcpApprovalFlow( ? `\n\n${metadata.description}` : ""; - const response = await client.requestPermission({ + const response = await requestPermissionFromClient(context, { options: [ { kind: "allow_once", name: "Yes", optionId: "allow" }, { @@ -517,10 +582,9 @@ async function handlePostHogExecApprovalFlow( context: ToolHandlerContext, subTool: string, ): Promise { - const { toolName, toolInput, toolUseID, client, sessionId, session } = - context; + const { toolName, toolInput, toolUseID, sessionId, session } = context; - const response = await client.requestPermission({ + const response = await requestPermissionFromClient(context, { options: [ { kind: "allow_once", name: "Yes", optionId: "allow" }, { diff --git a/packages/agent/src/adapters/claude/session/models.test.ts b/packages/agent/src/adapters/claude/session/models.test.ts index b56c9001cf..c4797ff76a 100644 --- a/packages/agent/src/adapters/claude/session/models.test.ts +++ b/packages/agent/src/adapters/claude/session/models.test.ts @@ -223,4 +223,32 @@ describe("resolveModelPreference", () => { expect(resolveModelPreference("best", options)).toBeNull(); expect(resolveModelPreference("default", options)).toBeNull(); }); + + it.each([ + { + label: "resolves single-number family versions like Sonnet 5", + preference: "sonnet 5", + models: [ + { value: "claude-sonnet-5", name: "Claude Sonnet 5" }, + { value: "claude-sonnet-4-6", name: "Claude Sonnet 4.6" }, + ], + expected: "claude-sonnet-5", + }, + { + label: "refuses a cross-version match between bare and dotted versions", + preference: "claude-sonnet-4-6", + models: [{ value: "sonnet", name: "Claude Sonnet 5" }], + expected: null, + }, + { + // "sonnet[1m]" carries no version; a versioned preference must still + // match it rather than being rejected on the hint's "1". + label: "does not read a [1m] context hint as a family version", + preference: "claude-sonnet-4-6", + models: [{ value: "sonnet[1m]", name: "Claude Sonnet (1M)" }], + expected: "sonnet[1m]", + }, + ])("$label", ({ preference, models, expected }) => { + expect(resolveModelPreference(preference, models)).toBe(expected); + }); }); diff --git a/packages/agent/src/adapters/claude/session/models.ts b/packages/agent/src/adapters/claude/session/models.ts index 4ea33fd269..49e4482e1d 100644 --- a/packages/agent/src/adapters/claude/session/models.ts +++ b/packages/agent/src/adapters/claude/session/models.ts @@ -70,6 +70,17 @@ export function supportsMcpInjection(modelId: string): boolean { return !MODELS_TO_EXCLUDE_MCP_TOOLS.has(modelId); } +const MODELS_WITH_FAST_MODE = new Set(["claude-opus-4-7", "claude-opus-4-8"]); + +export function supportsFastMode(modelId: string): boolean { + return MODELS_WITH_FAST_MODE.has(modelId); +} + +// cooldown keeps the toggle on (user intent); only an explicit off clears it. +export function fastModeStateEnabled(state: string | undefined): boolean { + return state !== undefined && state !== "off"; +} + interface EffortOption { value: string; name: string; @@ -128,15 +139,18 @@ interface ModelOption { description?: string; } -// Captures a model family version such as `4-6` or `4.7` so we can keep -// `claude-opus-4-7` from being copied onto the SDK's `opus` alias when that -// alias currently resolves to a different family version (e.g. Opus 4.8). -const MODEL_FAMILY_VERSION_PATTERN = /\b(\d+)[-.](\d+)\b/; +// Captures a model family version: `4-6`/`4.7` for dated generations, or a +// bare `5` for single-number ones like "Sonnet 5". Used to keep a pinned +// `claude-opus-4-7` from matching the `opus` alias once it points at 4.8. +const MODEL_FAMILY_VERSION_PATTERN = /\b(\d+)(?:[-.](\d+))?\b/; function extractModelFamilyVersion(s: string | undefined): string | null { if (!s) return null; - const match = s.match(MODEL_FAMILY_VERSION_PATTERN); - return match ? `${match[1]}.${match[2]}` : null; + // Strip "[1m]"-style context hints first — that digit is context window + // size, not a model generation version. + const match = s.replace(/\[\d+m\]/gi, "").match(MODEL_FAMILY_VERSION_PATTERN); + if (!match) return null; + return match[2] ? `${match[1]}.${match[2]}` : match[1]; } function modelVersionsCompatible( diff --git a/packages/agent/src/adapters/claude/types.ts b/packages/agent/src/adapters/claude/types.ts index 3c1973125a..3d423746d1 100644 --- a/packages/agent/src/adapters/claude/types.ts +++ b/packages/agent/src/adapters/claude/types.ts @@ -1,4 +1,5 @@ import type { + PromptResponse, SessionConfigOption, TerminalHandle, TerminalOutputResponse, @@ -38,9 +39,16 @@ export type BackgroundTerminal = pendingOutput: TerminalOutputResponse; }; -export type PendingMessage = { - resolve: (cancelled: boolean) => void; - order: number; +/** One in-flight `prompt()` call, settled by the session's consumer. */ +export type Turn = { + promptUuid: string; + isLocalOnlyCommand: boolean; + commandName?: string; + /** Invoked once at activation, matching the pre-consumer broadcast timing. */ + broadcast: () => Promise; + settled: boolean; + resolve: (response: PromptResponse) => void; + reject: (error: unknown) => void; }; export type Session = BaseSession & { @@ -66,6 +74,10 @@ export type Session = BaseSession & { lastPlanFilePath?: string; lastPlanContent?: string; effort?: EffortLevel; + /** User intent; retained while a non-fast model hides the "fast" option. */ + fastModeEnabled: boolean; + /** Last title pushed via `session_info_update`, to dedupe turn-end polls. */ + lastTitle?: string; configOptions: SessionConfigOption[]; accumulatedUsage: AccumulatedUsage; /** PostHog products used during this session, derived from MCP exec calls. @@ -79,11 +91,18 @@ export type Session = BaseSession & { contextSize?: number; /** Persists across prompt() calls so SDK-reported values survive turn boundaries */ lastContextWindowSize?: number; - promptRunning: boolean; + /** FIFO of in-flight prompts; the SDK echoes them back in order. */ + turnQueue: Turn[]; + activeTurn: Turn | null; + /** Echo-less results still owed by turns cancelled while queued. */ + pendingOrphanResults: number; + consumer?: Promise; + /** Bumped by refreshSession so the retired consumer exits quietly. */ + queryGeneration: number; + /** The query iterator ended and can't be revived; new prompts reject. */ + queryClosed?: boolean; cancelController?: AbortController; forceCancelTimer?: ReturnType; - pendingMessages: Map; - nextPendingOrder: number; emitRawSDKMessages: boolean | SDKMessageFilter[]; /** Refreshed at session init and on MCP/skill changes. */ contextBreakdownBaseline?: ContextBreakdownBaseline; diff --git a/packages/agent/src/test/mocks/claude-sdk.ts b/packages/agent/src/test/mocks/claude-sdk.ts index 4bb084931d..3534cfc12a 100644 --- a/packages/agent/src/test/mocks/claude-sdk.ts +++ b/packages/agent/src/test/mocks/claude-sdk.ts @@ -108,6 +108,8 @@ export function createMockQuery( .mockResolvedValue({}), reloadPlugins: vi.fn().mockResolvedValue(undefined), reloadSkills: vi.fn().mockResolvedValue(undefined), + setMcpPermissionModeOverride: vi.fn().mockResolvedValue({}), + reinitialize: vi.fn().mockResolvedValue({}), seedReadState: vi.fn().mockResolvedValue(undefined), readFile: vi.fn().mockResolvedValue(""), backgroundTasks: vi.fn().mockResolvedValue([]), diff --git a/packages/core/src/sessions/acpNotifications.ts b/packages/core/src/sessions/acpNotifications.ts index 81e0cb25ab..bc6026994a 100644 --- a/packages/core/src/sessions/acpNotifications.ts +++ b/packages/core/src/sessions/acpNotifications.ts @@ -3,6 +3,7 @@ export const POSTHOG_NOTIFICATIONS = { RUN_STARTED: "_posthog/run_started", TASK_COMPLETE: "_posthog/task_complete", TURN_COMPLETE: "_posthog/turn_complete", + BACKGROUND_TURN_COMPLETE: "_posthog/background_turn_complete", ERROR: "_posthog/error", CONSOLE: "_posthog/console", SDK_SESSION: "_posthog/sdk_session", diff --git a/packages/ui/src/features/sessions/components/buildConversationItems.test.ts b/packages/ui/src/features/sessions/components/buildConversationItems.test.ts index 3e767ff200..db02f8839b 100644 --- a/packages/ui/src/features/sessions/components/buildConversationItems.test.ts +++ b/packages/ui/src/features/sessions/components/buildConversationItems.test.ts @@ -74,6 +74,21 @@ function turnCompleteMsg(ts: number, stopReason = "end_turn"): AcpMessage { }; } +function backgroundTurnCompleteMsg( + ts: number, + stopReason = "end_turn", +): AcpMessage { + return { + type: "acp_message", + ts, + message: { + jsonrpc: "2.0", + method: "_posthog/background_turn_complete", + params: { sessionId: "session-1", stopReason }, + }, + }; +} + function agentMessageMsg(ts: number, text: string): AcpMessage { return { type: "acp_message", @@ -106,6 +121,20 @@ function resourcesUsedMsg( }; } +function usageUpdateMsg(ts: number): AcpMessage { + return { + type: "acp_message", + ts, + message: { + jsonrpc: "2.0", + method: "session/update", + params: { + update: { sessionUpdate: "usage_update", used: 100, size: 200_000 }, + }, + }, + }; +} + function statusMsg( ts: number, status: string, @@ -804,6 +833,120 @@ describe("buildConversationItems", () => { expect(item?.timestamp).toBe(4); }); }); + + describe("turn boundaries after completion", () => { + function isTextChunk(item: ConversationItem): item is Extract< + ConversationItem, + { type: "session_update" } + > & { + update: { + sessionUpdate: "agent_message_chunk"; + content: { type: "text"; text: string }; + }; + } { + return ( + item.type === "session_update" && + item.update.sessionUpdate === "agent_message_chunk" && + item.update.content.type === "text" + ); + } + + it("does not merge untracked content into an already-completed turn", () => { + // Mirrors a scheduled wakeup: it resumes the session outside of + // session/prompt, so this chunk arrives with no queued turn behind it. + const events = [ + userPromptMsg(1, 1, "hi"), + agentMessageMsg(2, "you'll get a ping shortly."), + turnCompleteMsg(3), + agentMessageMsg(4, "ping"), + ]; + + const items = buildConversationItems(events, true).items; + const chunks = items.filter(isTextChunk); + + expect(chunks.map((c) => c.update.content.text)).toEqual([ + "you'll get a ping shortly.", + "ping", + ]); + expect(chunks[0].turnContext).not.toBe(chunks[1].turnContext); + }); + + it("still merges consecutive chunks within the same open turn", () => { + const events = [ + userPromptMsg(1, 1, "hi"), + agentMessageMsg(2, "Hello"), + agentMessageMsg(3, " there"), + ]; + + const items = buildConversationItems(events, true).items; + const chunks = items.filter(isTextChunk); + + expect(chunks.map((c) => c.update.content.text)).toEqual(["Hello there"]); + }); + + it("separates consecutive background replies with no queued turn behind either", () => { + // Mirrors a Monitor tool streaming several background events in a + // row: each reply resumes the session outside of session/prompt, so + // none of them ever gets a real user turn in between to reset on. + const events = [ + userPromptMsg(1, 1, "use a monitor"), + agentMessageMsg(2, "Monitor is running."), + turnCompleteMsg(3), + agentMessageMsg(10, "ping 1 received."), + backgroundTurnCompleteMsg(11), + agentMessageMsg(20, "ping 2 received."), + backgroundTurnCompleteMsg(21), + agentMessageMsg(30, "ping 3 received."), + backgroundTurnCompleteMsg(31), + ]; + + const items = buildConversationItems(events, true).items; + const chunks = items.filter(isTextChunk); + + expect(chunks.map((c) => c.update.content.text)).toEqual([ + "Monitor is running.", + "ping 1 received.", + "ping 2 received.", + "ping 3 received.", + ]); + const distinctContexts = new Set(chunks.map((c) => c.turnContext)); + expect(distinctContexts.size).toBe(4); + }); + + it("computes a real duration for an implicit turn once a background reply completes it", () => { + const events = [ + userPromptMsg(1, 1, "use a monitor"), + agentMessageMsg(2, "Monitor is running."), + turnCompleteMsg(3), + agentMessageMsg(10, "ping 1 received."), + backgroundTurnCompleteMsg(35), + ]; + + const { lastTurnInfo } = buildConversationItems(events, true); + + expect(lastTurnInfo?.isComplete).toBe(true); + expect(lastTurnInfo?.durationMs).toBeGreaterThan(0); + }); + + it("does not spawn a phantom turn for a silent trailing update like usage_update", () => { + // A usage_update (or any other content-less session/update) commonly + // trails the final background reply. It must not reopen a turn on its + // own and clobber the real reply's duration. + const events = [ + userPromptMsg(1, 1, "use a monitor"), + agentMessageMsg(2, "Monitor is running."), + turnCompleteMsg(3), + agentMessageMsg(10, "ping 1 received."), + backgroundTurnCompleteMsg(35), + usageUpdateMsg(50), + ]; + + const { lastTurnInfo } = buildConversationItems(events, true); + + expect(lastTurnInfo?.isComplete).toBe(true); + expect(lastTurnInfo?.durationMs).toBeGreaterThan(0); + }); + }); }); // Local alias kept intentionally narrow to the shape we care about in tests. diff --git a/packages/ui/src/features/sessions/components/buildConversationItems.ts b/packages/ui/src/features/sessions/components/buildConversationItems.ts index 608c039103..c46a2db492 100644 --- a/packages/ui/src/features/sessions/components/buildConversationItems.ts +++ b/packages/ui/src/features/sessions/components/buildConversationItems.ts @@ -425,9 +425,7 @@ function completePromptTurn( if (turn.isComplete) return; turn.isComplete = true; - if (turn.promptId !== -1) { - turn.durationMs += ts; - } + turn.durationMs += ts; turn.stopReason = result?.stopReason; turn.interruptReason = result?.interruptReason; @@ -486,9 +484,6 @@ function handleNotification( if (msg.method === "session/update") { const update = (msg.params as SessionNotification)?.update; if (!update) return; - if (!b.currentTurn) { - ensureImplicitTurn(b, ts); - } processSessionUpdate(b, update, ts); return; } @@ -497,7 +492,10 @@ function handleNotification( // products are surfaced as a persistent, de-duplicated bar above the composer // (see accumulateSessionResources / SessionResourcesBar). - if (isNotification(msg.method, POSTHOG_NOTIFICATIONS.TURN_COMPLETE)) { + if ( + isNotification(msg.method, POSTHOG_NOTIFICATIONS.TURN_COMPLETE) || + isNotification(msg.method, POSTHOG_NOTIFICATIONS.BACKGROUND_TURN_COMPLETE) + ) { const params = msg.params as { stopReason?: string } | undefined; if (!b.currentTurn) return; completePromptTurn(b, b.currentTurn, ts, { @@ -511,7 +509,7 @@ function handleNotification( if (!params?.message) return; const level = params.level ?? "info"; if (level === "debug" && !options?.showDebugLogs) return; - if (!b.currentTurn) ensureImplicitTurn(b, ts); + ensureImplicitTurn(b, ts); pushItem(b, { sessionUpdate: "console", level, @@ -542,7 +540,7 @@ function handleNotification( } if (isNotification(msg.method, POSTHOG_NOTIFICATIONS.COMPACT_BOUNDARY)) { - if (!b.currentTurn) ensureImplicitTurn(b, ts); + ensureImplicitTurn(b, ts); const params = msg.params as { trigger: "manual" | "auto"; preTokens: number; @@ -559,7 +557,7 @@ function handleNotification( } if (isNotification(msg.method, POSTHOG_NOTIFICATIONS.STATUS)) { - if (!b.currentTurn) ensureImplicitTurn(b, ts); + ensureImplicitTurn(b, ts); const params = msg.params as { status: string; isComplete?: boolean; @@ -614,7 +612,7 @@ function ensureProgressCardForGroup( const existing = b.progressCards.get(group); if (existing) return existing; - if (!b.currentTurn) ensureImplicitTurn(b, ts); + ensureImplicitTurn(b, ts); if (!b.currentTurn) return null; const renderItem = { @@ -700,7 +698,7 @@ function markCompactingStatusComplete(b: ItemBuilder) { } function ensureImplicitTurn(b: ItemBuilder, ts: number) { - if (b.currentTurn) return; + if (b.currentTurn && !b.currentTurn.isComplete) return; b.currentTurnStartIndex = b.items.length; const turnId = `turn-${ts}-implicit`; @@ -717,7 +715,7 @@ function ensureImplicitTurn(b: ItemBuilder, ts: number) { id: turnId, promptId: -1, isComplete: false, - durationMs: 0, + durationMs: -ts, toolCalls, context, gitAction: { isGitAction: false, actionType: null, prompt: "" }, @@ -828,12 +826,14 @@ function processSessionUpdate( if (parentId) { appendTextChunkToChildren(b, parentId, update); } else { + ensureImplicitTurn(b, ts); appendTextChunk(b, update, ts); } break; } case "tool_call": { + ensureImplicitTurn(b, ts); const turn = b.currentTurn; if (!turn) break; const existing = turn.toolCalls.get(update.toolCallId); @@ -890,6 +890,7 @@ function processSessionUpdate( }; if (customUpdate.sessionUpdate === "agent_message") { if (customUpdate.content?.type === "text") { + ensureImplicitTurn(b, ts); appendTextChunk( b, { @@ -903,6 +904,7 @@ function processSessionUpdate( customUpdate.sessionUpdate === "status" || customUpdate.sessionUpdate === "error" ) { + ensureImplicitTurn(b, ts); pushItem(b, customUpdate as unknown as SessionUpdate, ts); } break; @@ -922,6 +924,7 @@ function appendTextChunk( const lastItem = b.items[b.items.length - 1]; if ( lastItem?.type === "session_update" && + lastItem.turnContext === b.currentTurn?.context && lastItem.update.sessionUpdate === update.sessionUpdate && "content" in lastItem.update && lastItem.update.content.type === "text" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a49c0410b2..0e258e91eb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -686,14 +686,14 @@ importers: packages/agent: dependencies: '@agentclientprotocol/sdk': - specifier: 0.25.0 - version: 0.25.0(zod@4.4.3) + specifier: 1.1.0 + version: 1.1.0(zod@4.4.3) '@anthropic-ai/claude-agent-sdk': - specifier: 0.3.170 - version: 0.3.170(@anthropic-ai/sdk@0.104.1(zod@4.4.3))(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(zod@4.4.3) + specifier: 0.3.197 + version: 0.3.197(@anthropic-ai/sdk@0.109.0(zod@4.4.3))(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(zod@4.4.3) '@anthropic-ai/sdk': - specifier: 0.104.1 - version: 0.104.1(zod@4.4.3) + specifier: 0.109.0 + version: 0.109.0(zod@4.4.3) '@hono/node-server': specifier: ^1.19.9 version: 1.19.9(hono@4.11.7) @@ -1473,7 +1473,7 @@ importers: version: 0.22.1(zod@4.4.3) '@anthropic-ai/claude-agent-sdk': specifier: 0.3.156 - version: 0.3.156(@anthropic-ai/sdk@0.104.1(zod@4.4.3))(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(zod@4.4.3) + version: 0.3.156(@anthropic-ai/sdk@0.109.0(zod@4.4.3))(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(zod@4.4.3) '@hono/node-server': specifier: 'catalog:' version: 1.19.9(hono@4.11.7) @@ -1591,8 +1591,8 @@ packages: peerDependencies: zod: ^3.25.0 || ^4.0.0 - '@agentclientprotocol/sdk@0.25.0': - resolution: {integrity: sha512-wU1VgXNtMvdVotX49txc3WJUDV+/QbLpsgjMvFhlRmp37osdLbI7L7y+iwAlQATwfjLxcv1r1p3ZxZBcXlGhcQ==} + '@agentclientprotocol/sdk@1.1.0': + resolution: {integrity: sha512-NT2KqphUJ3w6EksUL51ZhJgIYgq/ZLGcBPkyMKgRSO5PMVwe9DnKKX+Htnvk6KHh6dUuh34UHK4gKp+4te1Mdg==} peerDependencies: zod: ^3.25.0 || ^4.0.0 @@ -1605,8 +1605,8 @@ packages: cpu: [arm64] os: [darwin] - '@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.170': - resolution: {integrity: sha512-rwfgArIa5WI0QPNqFsRBgvtSI0mrtpynUm0oK6+l6/KX4hcgnYGEzciZR1bOeD9/7sSZlTdIgt+T9alKeZmXcg==} + '@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.197': + resolution: {integrity: sha512-jC6WvH5Hr6APTfbMjo4nC6LlyMMqbpCMwiHXIw7/AsQXIHQhZ+cRRMesQlV6UFI1l3O53gLZHzsG9cXwfrPHKw==} cpu: [arm64] os: [darwin] @@ -1615,8 +1615,8 @@ packages: cpu: [x64] os: [darwin] - '@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.170': - resolution: {integrity: sha512-0e58h8UQMtsQxLGIv9r4foxfBFWKZ7NeDtoplLhuD7EwQonehomw1sBXCch77t/IfUS+q5vQ5zv+fOGmap5nLQ==} + '@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.197': + resolution: {integrity: sha512-ZQNvGkMrTyatBlHTIQ4w2i2aLBuvq355UP/FDLnVXIH8l23RsL1x/0w9P+dqB7EmY9OZi/cPxSrpskpo+dZWLA==} cpu: [x64] os: [darwin] @@ -1626,8 +1626,8 @@ packages: os: [linux] libc: [musl] - '@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.3.170': - resolution: {integrity: sha512-SRYfQcsXlOq+CD/FqkQBTSHbaD++w73GnnO+NUV9adLYrca3kfetRwWT1iguY1cNS0l34dCR3rlzCPq78vg1Jg==} + '@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.3.197': + resolution: {integrity: sha512-VuIGXsLGK/aqSQ0tTBqqPVNzjefWS5SWnK8mlYyQitT4s5UDzHXJm0UZBTGxRtlcS0e2+QAHKwbGBCq1ZKSXjg==} cpu: [arm64] os: [linux] libc: [musl] @@ -1638,8 +1638,8 @@ packages: os: [linux] libc: [glibc] - '@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.170': - resolution: {integrity: sha512-gLbaFqcGppFJQd4DLNV4IXoeahejT/p2/M8bSSvRDbla9GOsBr1AxV5XLRyBn1e7xFGozZIAIQr3+1chp7NJgQ==} + '@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.197': + resolution: {integrity: sha512-pWhQgCtAft4EGM4Zn24HRad1a/k2u6oA+2uM/KCdjehfKtooDiHfMNd1yzXY/n9AEBWP0RHB2Vz3mJ30X2pVAg==} cpu: [arm64] os: [linux] libc: [glibc] @@ -1650,8 +1650,8 @@ packages: os: [linux] libc: [musl] - '@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.170': - resolution: {integrity: sha512-m4+I0qBEk7cxRKS+pL+eoWXbXTFOAo83fQ0tQvap4z/mDMm06IWJtEPoYTaMBwsp32GJWLkHWKbZSBCHZnp2DQ==} + '@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.197': + resolution: {integrity: sha512-3Tuy7XhD4UIKE4A4RPmKJcbL7Q/3dcB1hEWQt2lKP7c/DlixeEv+tRzvpnFZKhFX2hy0tkBk3QjkozSAacMC/w==} cpu: [x64] os: [linux] libc: [musl] @@ -1662,8 +1662,8 @@ packages: os: [linux] libc: [glibc] - '@anthropic-ai/claude-agent-sdk-linux-x64@0.3.170': - resolution: {integrity: sha512-Xl/m7TaSC3T5IDBdHrZQ9fCQYyDmPELN34CL+MoyPIf7uSmuZnjE9fUOqDh2Rv26JxWssi1M6X+BBvVuKd6Cpg==} + '@anthropic-ai/claude-agent-sdk-linux-x64@0.3.197': + resolution: {integrity: sha512-AUccrbdcv4Hy/GteP/gYLjG/zDP+fe2BFtDMctEfRFVz40DazYDcOyW1+nIgSTQtxf5jSTAVVf3cNuXB2CZwlw==} cpu: [x64] os: [linux] libc: [glibc] @@ -1673,8 +1673,8 @@ packages: cpu: [arm64] os: [win32] - '@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.170': - resolution: {integrity: sha512-IG+8isJNNJKbnnhO7m+PGhfVCg+XoQ/MDxGde5eigFI0WsEfitjuWSWwx82bT9ghxI1aa6qNvI+UPgPcZuo5Fg==} + '@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.197': + resolution: {integrity: sha512-Wx8uiAKBenDuL8lWQmrqnX5ppljaH5unQ9cKiCz2/9Kgf09dgnrwbX8n/FhndCZR8PmYw539eWwYVrSVc/bl6w==} cpu: [arm64] os: [win32] @@ -1683,8 +1683,8 @@ packages: cpu: [x64] os: [win32] - '@anthropic-ai/claude-agent-sdk-win32-x64@0.3.170': - resolution: {integrity: sha512-7cuqSKbHVItPGVwRbd3A0BEJwcNtc7Fhoh6qHN4C6yrmjSrvdYYx3MLvq/VI768/RoG7mAMDxb+j7WfEfoP9BA==} + '@anthropic-ai/claude-agent-sdk-win32-x64@0.3.197': + resolution: {integrity: sha512-ZXJO/VvR3SI4G0gwthWeFXWdHB5RXPu3rtfGRcKZ/YgtDeW17rQ+LZIJTk2ywzbLb8EvlghR5JPgn293hC179Q==} cpu: [x64] os: [win32] @@ -1696,16 +1696,16 @@ packages: '@modelcontextprotocol/sdk': ^1.29.0 zod: ^4.0.0 - '@anthropic-ai/claude-agent-sdk@0.3.170': - resolution: {integrity: sha512-pAvhfk+iTodXZ6RF18Kz7BEUWFjL7EcR3tKuhUNdPpE1NAYCR3mSHGbafi72JsrNwKEDIs7FU31z3fqhwy8QzA==} + '@anthropic-ai/claude-agent-sdk@0.3.197': + resolution: {integrity: sha512-XNIi8W1tb+QfMkcK+5kepOC6BsxG8wtupd72H+pIPzIJypVQhHy7FoX+KBMtTRYwtl+5dsjKyABhjWXebeUilw==} engines: {node: '>=18.0.0'} peerDependencies: '@anthropic-ai/sdk': '>=0.93.0' '@modelcontextprotocol/sdk': ^1.29.0 zod: ^4.0.0 - '@anthropic-ai/sdk@0.104.1': - resolution: {integrity: sha512-gGACa/+IaiXzRRmF96aOhamoBgapKRBiFWbmmTFP8aMkpaEcuStF+Q61bjo4vPxBM7gqWJNZqsngslRdnLHv0Q==} + '@anthropic-ai/sdk@0.109.0': + resolution: {integrity: sha512-y7P4eLyW5uNut4fXpOUEHqhJwx7dnxrWAfCQE4Lcgm0hSFQuIeHa7CWEKE5dFolEjQJE/RFKkppjri05r2OK/Q==} hasBin: true peerDependencies: zod: ^3.25.0 || ^4.0.0 @@ -14752,7 +14752,7 @@ snapshots: dependencies: zod: 4.4.3 - '@agentclientprotocol/sdk@0.25.0(zod@4.4.3)': + '@agentclientprotocol/sdk@1.1.0(zod@4.4.3)': dependencies: zod: 4.4.3 @@ -14761,54 +14761,54 @@ snapshots: '@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.156': optional: true - '@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.170': + '@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.197': optional: true '@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.156': optional: true - '@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.170': + '@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.197': optional: true '@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.3.156': optional: true - '@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.3.170': + '@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.3.197': optional: true '@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.156': optional: true - '@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.170': + '@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.197': optional: true '@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.156': optional: true - '@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.170': + '@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.197': optional: true '@anthropic-ai/claude-agent-sdk-linux-x64@0.3.156': optional: true - '@anthropic-ai/claude-agent-sdk-linux-x64@0.3.170': + '@anthropic-ai/claude-agent-sdk-linux-x64@0.3.197': optional: true '@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.156': optional: true - '@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.170': + '@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.197': optional: true '@anthropic-ai/claude-agent-sdk-win32-x64@0.3.156': optional: true - '@anthropic-ai/claude-agent-sdk-win32-x64@0.3.170': + '@anthropic-ai/claude-agent-sdk-win32-x64@0.3.197': optional: true - '@anthropic-ai/claude-agent-sdk@0.3.156(@anthropic-ai/sdk@0.104.1(zod@4.4.3))(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(zod@4.4.3)': + '@anthropic-ai/claude-agent-sdk@0.3.156(@anthropic-ai/sdk@0.109.0(zod@4.4.3))(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(zod@4.4.3)': dependencies: - '@anthropic-ai/sdk': 0.104.1(zod@4.4.3) + '@anthropic-ai/sdk': 0.109.0(zod@4.4.3) '@modelcontextprotocol/sdk': 1.29.0(zod@4.4.3) zod: 4.4.3 optionalDependencies: @@ -14821,22 +14821,22 @@ snapshots: '@anthropic-ai/claude-agent-sdk-win32-arm64': 0.3.156 '@anthropic-ai/claude-agent-sdk-win32-x64': 0.3.156 - '@anthropic-ai/claude-agent-sdk@0.3.170(@anthropic-ai/sdk@0.104.1(zod@4.4.3))(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(zod@4.4.3)': + '@anthropic-ai/claude-agent-sdk@0.3.197(@anthropic-ai/sdk@0.109.0(zod@4.4.3))(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(zod@4.4.3)': dependencies: - '@anthropic-ai/sdk': 0.104.1(zod@4.4.3) + '@anthropic-ai/sdk': 0.109.0(zod@4.4.3) '@modelcontextprotocol/sdk': 1.29.0(zod@4.4.3) zod: 4.4.3 optionalDependencies: - '@anthropic-ai/claude-agent-sdk-darwin-arm64': 0.3.170 - '@anthropic-ai/claude-agent-sdk-darwin-x64': 0.3.170 - '@anthropic-ai/claude-agent-sdk-linux-arm64': 0.3.170 - '@anthropic-ai/claude-agent-sdk-linux-arm64-musl': 0.3.170 - '@anthropic-ai/claude-agent-sdk-linux-x64': 0.3.170 - '@anthropic-ai/claude-agent-sdk-linux-x64-musl': 0.3.170 - '@anthropic-ai/claude-agent-sdk-win32-arm64': 0.3.170 - '@anthropic-ai/claude-agent-sdk-win32-x64': 0.3.170 - - '@anthropic-ai/sdk@0.104.1(zod@4.4.3)': + '@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 + + '@anthropic-ai/sdk@0.109.0(zod@4.4.3)': dependencies: json-schema-to-ts: 3.1.1 standardwebhooks: 1.0.0