diff --git a/apps/server/scripts/record-claude-agent-sdk-replay-fixture.ts b/apps/server/scripts/record-claude-agent-sdk-replay-fixture.ts index 52bf6d57d..22a6df8ee 100644 --- a/apps/server/scripts/record-claude-agent-sdk-replay-fixture.ts +++ b/apps/server/scripts/record-claude-agent-sdk-replay-fixture.ts @@ -70,6 +70,7 @@ import { import { CLAUDE_BACKGROUND_MONITOR_WAKE_PROMPT } from "../src/orchestration-v2/testkit/fixtures/claude_background_monitor_wake/input.ts"; import { CLAUDE_NESTED_BACKGROUND_SUBAGENT_WAKE_PROMPT } from "../src/orchestration-v2/testkit/fixtures/claude_nested_background_subagent_wake/input.ts"; import { CLAUDE_NESTED_SUBAGENT_MODEL_PROMPT } from "../src/orchestration-v2/testkit/fixtures/claude_nested_subagent_model/input.ts"; +import { CLAUDE_MCP_TOOL_PRESENTATION_PROMPT } from "../src/orchestration-v2/testkit/fixtures/claude_mcp_tool_presentation/input.ts"; import { CLAUDE_BACKGROUND_TASK_INTERRUPT_PROMPT } from "../src/orchestration-v2/testkit/fixtures/claude_background_task_interrupt/input.ts"; import { CLAUDE_BACKGROUND_WAKE_BEFORE_QUEUED_PROMPT_LAUNCH_PROMPT } from "../src/orchestration-v2/testkit/fixtures/claude_background_wake_before_queued_prompt/input.ts"; import { @@ -253,6 +254,14 @@ const CLAUDE_RECORDINGS = { queryMode: "streaming", enableTools: true, }, + // Needs the claude.ai Firecrawl connector on the recording account. Claude + // Code describes MCP tool uses in an undeclared `tool_use_meta` field. + claude_mcp_tool_presentation: { + prompts: [CLAUDE_MCP_TOOL_PRESENTATION_PROMPT], + defaultTranscriptFile: "fixtures/claude_mcp_tool_presentation/claude_transcript.ndjson", + queryMode: "streaming", + enableTools: true, + }, subagent: { prompts: [SUBAGENT_PROMPT], defaultTranscriptFile: "fixtures/subagent/claude_transcript.ndjson", diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index 174e7e083..b837575c1 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -45,6 +45,8 @@ import * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; import { Tool } from "effect/unstable/ai"; import { formatClaudeResumeCompactionQuestion } from "@t3tools/shared/claudeCompaction"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import { SpawnExecutableResolution } from "@t3tools/shared/shell"; import { attachmentRelativePath } from "../../attachmentStore.ts"; import * as ServerConfig from "../../config.ts"; @@ -55,6 +57,7 @@ import { ProjectToolkit } from "../../mcp/toolkits/project/tools.ts"; import { WorktreeToolkit } from "../../mcp/toolkits/worktree/tools.ts"; import { ThreadToolkit } from "../../mcp/toolkits/thread/tools.ts"; import { OrchestratorToolkit } from "../../mcp/toolkits/orchestrator/tools.ts"; +import { ClaudeExecutableFileCheck } from "../../provider/Drivers/ClaudeExecutable.ts"; import type { EventNdjsonLogger } from "../../provider/Layers/EventNdjsonLogger.ts"; import { ProviderAdapterV2RuntimePolicy, @@ -62,6 +65,7 @@ import { type ProviderAdapterV2TurnInput, } from "../ProviderAdapter.ts"; import type { ProviderContinuationRequest } from "../ProviderContinuationRequests.ts"; +import { makeProviderFailure } from "../ProviderFailure.ts"; import * as ClaudeAdapterV2 from "./ClaudeAdapterV2.ts"; import * as IdAllocator from "../IdAllocator.ts"; @@ -943,6 +947,7 @@ describe("ClaudeAdapterV2 Auto-accept edits", () => { messages: Stream.never, offer: () => Effect.void, setModel: () => Effect.void, + setPermissionMode: () => Effect.void, interrupt: Effect.void, close: Effect.void, }; @@ -1063,72 +1068,98 @@ describe("ClaudeAdapterV2 approval cancellation", () => { ); }); +// Opens a session with the given configured binary path, runs one turn, and +// returns the executable paths the SDK was asked to spawn. +const captureSdkExecutablePaths = Effect.fn("captureSdkExecutablePaths")(function* ( + binaryPath: string, +) { + const executablePaths: Array = []; + const adapter = yield* ClaudeAdapterV2.createClaudeAdapterV2( + { + instanceId: ClaudeAdapterV2.CLAUDE_DEFAULT_INSTANCE_ID, + displayName: undefined, + environment: [], + enabled: true, + config: { ...DEFAULT_CLAUDE_SETTINGS, binaryPath }, + }, + {}, + ).pipe( + Effect.provide( + ServerConfig.layerTest(process.cwd(), { + prefix: "t3-claude-binary-path-", + }), + ), + Effect.provideService(ClaudeAdapterV2.ClaudeAgentSdkQueryRunner, { + allocateSessionId: Effect.succeed("native-thread-claude-binary-path"), + open: (input) => + Effect.sync(() => { + executablePaths.push(input.options.pathToClaudeCodeExecutable); + return { + messages: Stream.never, + offer: () => Effect.void, + setModel: () => Effect.void, + setPermissionMode: () => Effect.void, + interrupt: Effect.void, + close: Effect.void, + }; + }), + forkSession: () => Effect.die("unused"), + subagentLaunchToolUseId: () => Effect.succeed(null), + assertComplete: Effect.void, + }), + ); + const threadId = ThreadId.make("thread-claude-binary-path"); + const runtime = yield* adapter.openSession({ + threadId, + providerSessionId: ProviderSessionId.make("provider-session-claude-binary-path"), + modelSelection: CLAUDE_TEST_MODEL_SELECTION, + runtimePolicy: CLAUDE_TEST_RUNTIME_POLICY, + }); + const providerThread = yield* runtime.ensureThread({ + threadId, + modelSelection: CLAUDE_TEST_MODEL_SELECTION, + runtimePolicy: CLAUDE_TEST_RUNTIME_POLICY, + }); + yield* runtime.startTurn( + makeClaudeTestTurnInput({ + threadId, + providerThread, + now: yield* DateTime.now, + attemptId: RunAttemptId.make("attempt-claude-binary-path"), + text: "hello", + attachments: [], + }), + ); + return executablePaths; +}); + describe("ClaudeAdapterV2 executable path", () => { it.effect("expands ~ in the configured binary path for the SDK", () => Effect.scoped( Effect.gen(function* () { const path = yield* Path.Path; - const executablePaths: Array = []; - const adapter = yield* ClaudeAdapterV2.createClaudeAdapterV2( - { - instanceId: ClaudeAdapterV2.CLAUDE_DEFAULT_INSTANCE_ID, - displayName: undefined, - environment: [], - enabled: true, - config: { ...DEFAULT_CLAUDE_SETTINGS, binaryPath: "~/bin/claude" }, - }, - {}, - ).pipe( - Effect.provide( - ServerConfig.layerTest(process.cwd(), { - prefix: "t3-claude-binary-home-", - }), - ), - Effect.provideService(ClaudeAdapterV2.ClaudeAgentSdkQueryRunner, { - allocateSessionId: Effect.succeed("native-thread-claude-binary-home"), - open: (input) => - Effect.sync(() => { - executablePaths.push(input.options.pathToClaudeCodeExecutable); - return { - messages: Stream.never, - offer: () => Effect.void, - setModel: () => Effect.void, - interrupt: Effect.void, - close: Effect.void, - }; - }), - forkSession: () => Effect.die("unused"), - subagentLaunchToolUseId: () => Effect.succeed(null), - assertComplete: Effect.void, - }), - ); - const threadId = ThreadId.make("thread-claude-binary-home"); - const runtime = yield* adapter.openSession({ - threadId, - providerSessionId: ProviderSessionId.make("provider-session-claude-binary-home"), - modelSelection: CLAUDE_TEST_MODEL_SELECTION, - runtimePolicy: CLAUDE_TEST_RUNTIME_POLICY, - }); - const providerThread = yield* runtime.ensureThread({ - threadId, - modelSelection: CLAUDE_TEST_MODEL_SELECTION, - runtimePolicy: CLAUDE_TEST_RUNTIME_POLICY, - }); - yield* runtime.startTurn( - makeClaudeTestTurnInput({ - threadId, - providerThread, - now: yield* DateTime.now, - attemptId: RunAttemptId.make("attempt-claude-binary-home"), - text: "hello", - attachments: [], - }), - ); + const executablePaths = yield* captureSdkExecutablePaths("~/bin/claude"); assert.deepEqual(executablePaths, [path.join(NodeOS.homedir(), "bin", "claude")]); }), ).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), ); + + it.effect("follows a bare claude on Windows to the npm package executable", () => + Effect.scoped( + Effect.gen(function* () { + const npmDir = "C:\\Users\\dev\\AppData\\Roaming\\npm"; + const packageExe = `${npmDir}\\node_modules\\@anthropic-ai\\claude-code\\bin\\claude.exe`; + const executablePaths = yield* captureSdkExecutablePaths("claude").pipe( + Effect.provideService(HostProcessPlatform, "win32"), + Effect.provideService(SpawnExecutableResolution, () => `${npmDir}\\claude.cmd`), + Effect.provideService(ClaudeExecutableFileCheck, (filePath) => filePath === packageExe), + ); + + assert.deepEqual(executablePaths, [packageExe]); + }), + ).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), + ); }); describe("ClaudeAdapterV2 resume compaction", () => { @@ -1158,6 +1189,7 @@ describe("ClaudeAdapterV2 resume compaction", () => { messages: Stream.never, offer: () => Effect.void, setModel: () => Effect.void, + setPermissionMode: () => Effect.void, interrupt: Effect.void, close: Effect.void, }; @@ -1377,6 +1409,7 @@ describe("ClaudeAdapterV2 attachments", () => { offeredMessages.push(message); }), setModel: () => Effect.void, + setPermissionMode: () => Effect.void, interrupt: Effect.void, close: Effect.void, }), @@ -1515,6 +1548,7 @@ describe("ClaudeAdapterV2 attachments", () => { messages: Stream.never, offer: () => Effect.void, setModel: () => Effect.void, + setPermissionMode: () => Effect.void, interrupt: Effect.void, close: Effect.void, }; @@ -1603,6 +1637,7 @@ describe("ClaudeAdapterV2 native fork", () => { messages: Stream.empty, offer: () => Effect.void, setModel: () => Effect.void, + setPermissionMode: () => Effect.void, interrupt: Effect.void, close: Effect.void, }; @@ -1774,6 +1809,7 @@ describe("ClaudeAdapterV2 native session identity", () => { messages: Stream.empty, offer: () => Effect.void, setModel: () => Effect.void, + setPermissionMode: () => Effect.void, interrupt: Effect.void, close: Effect.void, }; @@ -1975,6 +2011,13 @@ describe("ClaudeAdapterV2 background wake turns", () => { uuid: "00000000-0000-4000-8000-000000000107", text: WAKE_ASSISTANT_TEXT, }); + // The CLI opens the wake turn with `init`, seconds before its first output. + const wakeTurnInit = claudeSdkFrame({ + type: "system", + subtype: "init", + uuid: "00000000-0000-4000-8000-000000000110", + session_id: WAKE_NATIVE_SESSION, + }); const wakeResult = makeResultFrame({ uuid: "00000000-0000-4000-8000-000000000104", result: WAKE_RESULT_TEXT, @@ -2022,6 +2065,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { yield* Deferred.await(processed); }); const offeredMessages: Array = []; + const permissionModeChanges: Array = []; const continuationRequests: Array = []; const terminalReceipts = yield* Queue.unbounded>(); @@ -2070,6 +2114,10 @@ describe("ClaudeAdapterV2 background wake turns", () => { offeredMessages.push(message); }), setModel: () => Effect.void, + setPermissionMode: (mode) => + Effect.sync(() => { + permissionModeChanges.push(mode); + }), interrupt: options?.interrupt ?? Effect.void, close: options?.close?.(sdkMessages) ?? Effect.void, }; @@ -2122,6 +2170,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { sdkMessages, offerAndWait, offeredMessages, + permissionModeChanges, continuationRequests, events, terminalReceipts, @@ -2949,6 +2998,18 @@ describe("ClaudeAdapterV2 background wake turns", () => { harness.sdkMessages, toolResults("00000000-0000-4000-8000-000000000502", ["tool-todo-1"]), ); + // Claude entered plan mode on its own (EnterPlanMode). + yield* Queue.offer( + harness.sdkMessages, + claudeSdkFrame({ + type: "system", + subtype: "status", + status: null, + permissionMode: "plan", + uuid: "00000000-0000-4000-8000-000000000508", + session_id: WAKE_NATIVE_SESSION, + }), + ); yield* Queue.offer( harness.sdkMessages, makeResultFrame({ @@ -3059,6 +3120,9 @@ describe("ClaudeAdapterV2 background wake turns", () => { ); const proposedPlan = [...plans.values()].find((plan) => plan.kind === "proposed_plan"); assert.equal(proposedPlan?.status, "active"); + // The second prompt reuses the live process, which is still in the + // plan mode Claude entered, so it is put back in the thread's mode. + assert.deepEqual(harness.permissionModeChanges, ["bypassPermissions"]); }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), ), ); @@ -3579,6 +3643,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { messages: Stream.fromQueue(sdkMessages), offer: () => Effect.void, setModel: () => Effect.void, + setPermissionMode: () => Effect.void, interrupt: Effect.void, // The first CLI process keeps streaming until the test ends // it, so Stop stays parked waiting for it to exit. @@ -3828,6 +3893,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { messages: Stream.fromQueue(queue), offer: () => Effect.void, setModel: () => Effect.void, + setPermissionMode: () => Effect.void, interrupt: Effect.void, close: Queue.shutdown(queue), }; @@ -4612,6 +4678,63 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ); + it.effect("starts the wake run when Claude opens the wake turn, before its output", () => + Effect.scoped( + Effect.gen(function* () { + const harness = yield* makeWakeHarness; + const now = yield* DateTime.now; + + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("attempt-claude-wake-init-1"), + text: "Run the build in the background.", + attachments: [], + }), + ); + yield* Queue.offer(harness.sdkMessages, wakeTaskStarted); + yield* Queue.offer(harness.sdkMessages, turnOneResult); + yield* awaitUntil(() => harness.terminalEvents().length === 1, "first turn terminal"); + + yield* harness.offerAndWait(wakeNotification); + assert.lengthOf(harness.continuationRequests, 0); + yield* harness.offerAndWait(wakeTurnInit); + assert.lengthOf(harness.continuationRequests, 1); + assert.equal(harness.continuationRequests[0]?.detail, WAKE_SUMMARY); + + // The run attaches while Claude still thinks: only the notification + // and `init` are buffered, so the run waits for the turn's output. + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("attempt-claude-wake-init-2"), + text: "Background task completed.", + attachments: [], + providerTurnOrdinal: 2, + messageCreatedBy: "agent", + messageCreationSource: "provider", + }), + ); + assert.lengthOf(harness.terminalEvents(), 1); + + yield* harness.offerAndWait(wakeAssistant); + yield* harness.offerAndWait(wakeResult); + yield* awaitUntil(() => harness.terminalEvents().length === 2, "wake run terminal"); + assert.equal(harness.terminalEvents()[1]?.status, "completed"); + assert.lengthOf(harness.continuationRequests, 1); + assert.isTrue( + harness.events.some( + (event) => event.type === "message.updated" && event.message.text === WAKE_RESULT_TEXT, + ), + ); + }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), + ), + ); + it.effect("does not offer a continuation for notification-only opaque work", () => Effect.scoped( Effect.gen(function* () { @@ -6562,6 +6685,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { messages: Stream.fromQueue(sdkMessages), offer: () => Effect.void, setModel: () => Effect.void, + setPermissionMode: () => Effect.void, interrupt: Effect.void, // End this process stream so openQuery can replace it. close: Queue.shutdown(sdkMessages), @@ -6611,12 +6735,25 @@ describe("ClaudeAdapterV2 background wake turns", () => { assert.equal(processQueues.length, 1); const firstProcess = processQueues[0]!; yield* Queue.offer(firstProcess, wakeTaskStarted); + // The shell leaves the roster before its notification arrives, so + // nothing runs in this process any more and a model change may + // replace it. Wake eligibility outlives the empty level. + yield* Queue.offer( + firstProcess, + claudeSdkFrame({ + type: "system", + subtype: "background_tasks_changed", + tasks: [], + uuid: "00000000-0000-4000-8000-000000000603", + session_id: WAKE_NATIVE_SESSION, + }), + ); yield* Queue.offer(firstProcess, turnOneResult); yield* awaitUntil( () => events.some((event) => event.type === "turn.terminal"), "first turn terminal", ); - assert.isTrue(yield* hasPendingBackgroundWork); + assert.isFalse(yield* hasPendingBackgroundWork); const alternateModel = { ...CLAUDE_TEST_MODEL_SELECTION, @@ -6731,6 +6868,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { messages: Stream.fromQueue(sdkMessages), offer: () => Effect.void, setModel: () => Effect.void, + setPermissionMode: () => Effect.void, interrupt: Effect.void, close: Queue.shutdown(sdkMessages), }; @@ -6965,6 +7103,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { messages: Stream.fromQueue(sdkMessages), offer: () => Effect.void, setModel: () => Effect.void, + setPermissionMode: () => Effect.void, interrupt: Effect.void, close: Queue.shutdown(sdkMessages), }; @@ -7120,8 +7259,173 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ); + it.effect("refuses a model change that would kill a running background subagent", () => + Effect.scoped( + Effect.gen(function* () { + const SUBAGENT_TASK_ID = "task-model-change-running-subagent"; + const SUBAGENT_TOOL_USE_ID = "toolu-model-change-running-subagent"; + const fileSystem = yield* FileSystem.FileSystem; + const idAllocator = yield* IdAllocator.IdAllocatorV2; + const attachmentsDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-claude-v2-model-change-running-subagent-", + }); + const processQueues: Array> = []; + const events: Array = []; + const adapter = ClaudeAdapterV2.makeClaudeAdapterV2({ + instanceId: ClaudeAdapterV2.CLAUDE_DEFAULT_INSTANCE_ID, + settings: DEFAULT_CLAUDE_SETTINGS, + environment: {}, + attachmentsDir, + fileSystem, + path: yield* Path.Path, + idAllocator, + continuationRequests: { offer: () => Effect.void }, + queryRunner: { + allocateSessionId: Effect.succeed(WAKE_NATIVE_SESSION), + open: () => + Effect.gen(function* () { + const sdkMessages = yield* Queue.unbounded(); + processQueues.push(sdkMessages); + return { + messages: Stream.fromQueue(sdkMessages), + offer: () => Effect.void, + setModel: () => Effect.void, + setPermissionMode: () => Effect.void, + interrupt: Effect.void, + close: Queue.shutdown(sdkMessages), + }; + }), + forkSession: () => Effect.die("unused forkSession"), + subagentLaunchToolUseId: () => Effect.succeed(null), + assertComplete: Effect.void, + }, + }); + const threadId = ThreadId.make("thread-claude-model-change-running-subagent"); + const runtime = yield* adapter.openSession({ + threadId, + providerSessionId: ProviderSessionId.make( + "provider-session-claude-model-change-running-subagent", + ), + modelSelection: CLAUDE_TEST_MODEL_SELECTION, + runtimePolicy: CLAUDE_TEST_RUNTIME_POLICY, + }); + const providerThread = yield* runtime.ensureThread({ + threadId, + modelSelection: CLAUDE_TEST_MODEL_SELECTION, + runtimePolicy: CLAUDE_TEST_RUNTIME_POLICY, + }); + yield* runtime.events.pipe( + Stream.runForEach((event) => + Effect.sync(() => { + events.push(event); + }), + ), + Effect.forkScoped, + ); + const terminals = () => events.filter((event) => event.type === "turn.terminal"); + const now = yield* DateTime.now; + + yield* runtime.startTurn( + makeClaudeTestTurnInput({ + threadId, + providerThread, + now, + attemptId: RunAttemptId.make("attempt-claude-model-change-running-subagent-a"), + text: "Spawn a background subagent and stop.", + attachments: [], + }), + ); + const firstProcess = processQueues[0]!; + yield* Queue.offer( + firstProcess, + claudeSdkFrame({ + type: "system", + subtype: "task_started", + task_id: SUBAGENT_TASK_ID, + tool_use_id: SUBAGENT_TOOL_USE_ID, + description: "Background research", + subagent_type: "general-purpose", + task_type: "local_agent", + prompt: "Research, then report.", + uuid: "00000000-0000-4000-8000-000000000901", + session_id: WAKE_NATIVE_SESSION, + }), + ); + yield* Queue.offer( + firstProcess, + makeResultFrame({ + uuid: "00000000-0000-4000-8000-000000000902", + result: "Spawned the subagent in the background.", + }), + ); + yield* awaitUntil(() => terminals().length === 1, "first turn terminal"); + const settledTurn = terminals()[0]!; + + // The subagent runs inside the first CLI process. Another model needs + // another process, so the turn must not start and close this one. + const alternateModel = { + ...CLAUDE_TEST_MODEL_SELECTION, + model: "claude-haiku-4-5-20251001", + } satisfies ModelSelection; + const switchTurn = (attempt: string) => + runtime.startTurn( + makeClaudeTestTurnInput({ + threadId, + providerThread: { ...providerThread, status: "active" }, + now, + attemptId: RunAttemptId.make(attempt), + text: "Switch model while the subagent runs.", + attachments: [], + providerTurnOrdinal: 2, + modelSelection: alternateModel, + }), + ); + const refused = yield* switchTurn("attempt-claude-model-change-running-subagent-b").pipe( + Effect.flip, + ); + assert.equal( + makeProviderFailure({ cause: refused, class: "provider_error" }).message, + new ClaudeAdapterV2.ClaudeBackgroundWorkBlocksQueryReplacementError().message, + ); + assert.lengthOf(processQueues, 1); + + // Stop ends the background work, so the switch may replace the process. + yield* runtime.interruptTurn({ + providerThread, + providerTurnId: settledTurn.providerTurnId, + requestRuntimeRestart: true, + }); + yield* switchTurn("attempt-claude-model-change-running-subagent-c"); + assert.lengthOf(processQueues, 2); + + // The stopped subagent never reports its end, so it must not block + // later changes on the replacement process either. + yield* Queue.offer( + processQueues[1]!, + makeResultFrame({ + uuid: "00000000-0000-4000-8000-000000000903", + result: "Switched model.", + }), + ); + yield* awaitUntil(() => terminals().length === 2, "switched turn terminal"); + yield* runtime.startTurn( + makeClaudeTestTurnInput({ + threadId, + providerThread: { ...providerThread, status: "active" }, + now, + attemptId: RunAttemptId.make("attempt-claude-model-change-running-subagent-d"), + text: "Switch back.", + attachments: [], + providerTurnOrdinal: 3, + }), + ); + assert.lengthOf(processQueues, 3); + }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), + ), + ); + it.effect( - "clears process-scoped roster when same-native-thread replacement open fails after close", + "keeps the process and its roster when a model change meets a running background shell", () => Effect.scoped( Effect.gen(function* () { @@ -7163,6 +7467,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { messages: Stream.fromQueue(sdkMessages), offer: () => Effect.void, setModel: () => Effect.void, + setPermissionMode: () => Effect.void, interrupt: Effect.void, close: Queue.shutdown(sdkMessages), }; @@ -7239,17 +7544,10 @@ describe("ClaudeAdapterV2 background wake turns", () => { ) .pipe(Effect.exit); assert.isTrue(Exit.isFailure(failedStart)); - // Old process was closed before the failed open: roster must not stick. - yield* awaitUntil( - () => - providerThreadRosterEvents(events).some( - (event) => - event.providerThread.status === "idle" && - (event.providerThread.pendingBackgroundTasks?.length ?? 0) === 0, - ), - "roster cleared after failed same-thread replacement open", - ); - assert.isFalse(yield* hasPendingBackgroundWork); + // The shell runs in the first process, so it is never closed and + // no replacement is opened. + assert.equal(openCount, 1); + assert.isTrue(yield* hasPendingBackgroundWork); }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), ), ); @@ -7301,6 +7599,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { messages: Stream.fromQueue(sdkMessages), offer: () => Effect.void, setModel: () => Effect.void, + setPermissionMode: () => Effect.void, interrupt: Effect.void, close: Queue.shutdown(sdkMessages), }; diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.testkit.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.testkit.ts index 9c602dae2..39b0d44ba 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.testkit.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.testkit.ts @@ -184,6 +184,11 @@ interface ClaudeQuerySetModelFrame { readonly model: string; } +interface ClaudeQuerySetPermissionModeFrame { + readonly type: "query.set_permission_mode"; + readonly mode: string; +} + interface ClaudeQueryInterruptFrame { readonly type: "query.interrupt"; } @@ -239,6 +244,7 @@ type ClaudeOutboundFrame = | ClaudeQueryOpenFrame | ClaudePromptOfferFrame | ClaudeQuerySetModelFrame + | ClaudeQuerySetPermissionModeFrame | ClaudeQueryInterruptFrame | ClaudePermissionResponseFrame | ClaudeSessionForkFrame @@ -811,6 +817,13 @@ function makeReplayQueryRunner( model, }); }), + setPermissionMode: (mode) => + replayEffect(() => { + assertNextOutboundFrame({ + type: "query.set_permission_mode", + mode, + }); + }), interrupt: replayEffect(() => { assertNextOutboundFrame({ type: "query.interrupt" }); }), @@ -1116,6 +1129,11 @@ function sanitizeSdkMessageForReplay(input: { session_id: message.session_id, }; } + // Like init's slash_commands, the recording account's commands and skills + // are local configuration, not protocol. + if (message.type === "system" && message.subtype === "commands_changed") { + return { ...message, commands: [] }; + } if (message.type === "rate_limit_event") { return { ...message, diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index b8ea6dd92..0aca36abe 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -67,6 +67,7 @@ import { type ProviderUserInputAnswers, type ProviderThreadId, type ThreadId, + type ToolActivitySource, } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; @@ -86,6 +87,7 @@ import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; import { resolveAttachmentPath } from "../../attachmentStore.ts"; +import { resolveClaudeSdkExecutablePath } from "../../provider/Drivers/ClaudeExecutable.ts"; import { planClaudeSkillDispatch } from "../../provider/Drivers/ClaudeSkillDispatch.ts"; import { discoverClaudeSkills } from "../../provider/Drivers/ClaudeSkills.ts"; import { compileClaudeModelSelection } from "../../claudeModelOptions.ts"; @@ -325,6 +327,9 @@ export interface ClaudeAgentSdkQuerySession { readonly messages: Stream.Stream; readonly offer: (message: SDKUserMessage) => Effect.Effect; readonly setModel: (model: string) => Effect.Effect; + readonly setPermissionMode: ( + mode: PermissionMode, + ) => Effect.Effect; readonly interrupt: Effect.Effect; readonly close: Effect.Effect; } @@ -343,6 +348,15 @@ export class ClaudeAgentSdkQueryRunnerError extends Schema.TaggedError()( + "ClaudeBackgroundWorkBlocksQueryReplacementError", + {}, +) { + override get message(): string { + return "Claude is still running background agents or commands, and this model or setting change would end them. Wait for them to finish, or press Stop, then send the message again."; + } +} + export interface ClaudeAgentSdkQueryRunnerShape { readonly allocateSessionId: Effect.Effect; readonly open: ( @@ -455,6 +469,14 @@ export type ClaudeAgentSdkProtocolLogEvent = readonly model: string; }; } + | { + readonly direction: "outgoing"; + readonly stage: "decoded"; + readonly payload: { + readonly type: "query.set_permission_mode"; + readonly mode: PermissionMode; + }; + } | { readonly direction: "outgoing"; readonly stage: "decoded"; @@ -659,6 +681,22 @@ export const claudeAgentSdkQueryRunnerLiveLayer: Layer.Layer< }), ), ), + setPermissionMode: (mode) => + Effect.tryPromise({ + try: () => queryRuntime.setPermissionMode(mode), + catch: (cause) => queryRunnerError(cause, "setPermissionMode"), + }).pipe( + Effect.tap(() => + logProtocolEvent({ + direction: "outgoing", + stage: "decoded", + payload: { + type: "query.set_permission_mode", + mode, + }, + }), + ), + ), interrupt: Effect.tryPromise({ try: () => queryRuntime.interrupt(), catch: (cause) => queryRunnerError(cause, "interrupt"), @@ -1728,6 +1766,13 @@ function isClaudeBackgroundTasksChangedMessage(message: SDKMessage): boolean { ); } +// Claude opens every turn it runs with a root `init` frame. Outside a T3 turn +// that turn is a wake, and `init` comes 20-110 ms after the notification that +// caused it but seconds before its first output (model thinking time). +function isClaudeTurnStartMessage(message: SDKMessage): boolean { + return message.type === "system" && message.subtype === "init"; +} + function claudePendingBackgroundTasksFromRoster( roster: ReadonlyMap, ): ReadonlyArray { @@ -2020,6 +2065,66 @@ function claudeToolUseBlocksFromAssistantMessage( return message.message.content.filter(isClaudeToolUseContentBlock); } +interface ClaudeToolPresentation { + readonly title: string; + readonly toolSource?: ToolActivitySource; +} + +function boundedMetaText(value: unknown, maxLength: number): string | undefined { + if (typeof value !== "string") return undefined; + const text = value.trim().replace(/\s+/gu, " "); + return text.length > 0 && text.length <= maxLength ? text : undefined; +} + +function metaHttpUrl(value: unknown): string | undefined { + if (typeof value !== "string" || value.length > 4096) return undefined; + try { + const url = new URL(value); + return (url.protocol === "https:" || url.protocol === "http:") && url.href.length <= 4096 + ? url.href + : undefined; + } catch { + return undefined; + } +} + +/** + * Claude Code's own label for each tool use (the MCP tool's title, or its + * humanized name) plus the server's name and icon. The CLI sends these as + * `tool_use_meta` on assistant frames; the SDK types do not declare it yet. + */ +function claudeToolPresentationsFromAssistantMessage( + message: SDKMessage, +): ReadonlyMap { + const presentations = new Map(); + const meta = message.type === "assistant" ? Reflect.get(message, "tool_use_meta") : undefined; + if (!Array.isArray(meta)) return presentations; + for (const entry of meta) { + if (typeof entry !== "object" || entry === null) continue; + const id = boundedMetaText(Reflect.get(entry, "id"), 512); + const title = boundedMetaText(Reflect.get(entry, "display_name"), 160); + if (id === undefined || title === undefined) continue; + const serverName = boundedMetaText(Reflect.get(entry, "server_display_name"), 160); + const iconUrl = metaHttpUrl(Reflect.get(entry, "icon_url")); + presentations.set(id, { + title, + ...(serverName === undefined + ? {} + : { + toolSource: { + key: `mcp:${serverName.toLowerCase()}`, + name: serverName, + kind: "integration", + ...(iconUrl === undefined + ? {} + : { icon: { _tag: "themed-logo", logoUrl: iconUrl } as const }), + }, + }), + }); + } + return presentations; +} + function claudeToolResultBlocksFromAssistantMessage( message: SDKMessage, ): ReadonlyArray { @@ -2610,6 +2715,17 @@ interface ClaudeLiveQueryContext { // uuid before any echo, so it echoes, but a resume's own turns can still // run ahead of that prompt. promptEchoMode: "unknown" | "acknowledged" | "early" | "result_only"; + // The mode this process was opened in, and the mode the CLI last reported + // (init and status frames). Claude changes the latter itself through + // EnterPlanMode. + readonly openedPermissionMode: PermissionMode; + permissionMode: PermissionMode; + // Stop, rollback or fork is closing this process; its work is ending. + stopping: boolean; + // Registry entries still running when this process opened. Their process + // is gone and never reports their end; any later task_started replaces the + // entry, so an entry still in this set runs nowhere. + readonly subagentsFromEarlierProcesses: ReadonlySet; } interface ActiveClaudeToolCall { @@ -2623,6 +2739,7 @@ interface ActiveClaudeToolCall { readonly parentNodeId: OrchestrationV2ExecutionNode["id"]; readonly ordinal: number; readonly startedAt: DateTime.Utc; + readonly presentation?: ClaudeToolPresentation; } // What is known about a subagent before its task_started registers it, @@ -3588,10 +3705,11 @@ export function makeClaudeAdapterV2( readonly output: ClaudeNativeToolOutput; readonly status: Extract< OrchestrationV2TurnItem["status"], - "running" | "completed" | "failed" + "running" | "completed" | "failed" | "interrupted" | "cancelled" >; readonly startedAt: DateTime.Utc; readonly updatedAt: DateTime.Utc; + readonly presentation: ClaudeToolPresentation | undefined; }) => { const completedAt = input.status === "running" ? null : input.updatedAt; const nodeId = idAllocator.derive.nodeFromProviderItem({ @@ -3652,7 +3770,10 @@ export function makeClaudeAdapterV2( title: readPath !== undefined ? formatReadToolLabel(readPath) - : (searchTitle ?? dynamicToolTitle(input.toolName, nativeToolInput) ?? null), + : (searchTitle ?? + dynamicToolTitle(input.toolName, nativeToolInput) ?? + input.presentation?.title ?? + null), startedAt: input.startedAt, completedAt, updatedAt: input.updatedAt, @@ -3715,6 +3836,9 @@ export function makeClaudeAdapterV2( : { ...itemBase, type: "dynamic_tool", + ...(input.presentation?.toolSource === undefined + ? {} + : { toolSource: input.presentation.toolSource }), toolName: input.toolName, ...(viewedImagePath === undefined ? {} : { viewedImagePath }), input: claudeNativeToolInputValue(input.toolInput), @@ -4385,10 +4509,38 @@ export function makeClaudeAdapterV2( readonly toolName: string; readonly toolInput: ClaudeNativeToolInput; readonly parentToolUseId: string | null; + readonly presentation?: ClaudeToolPresentation | undefined; }) { const existing = input.context.toolCalls.get(input.nativeItemId); if (existing !== undefined) { - return existing; + // The permission callback can start a call before its assistant + // frame arrives with the tool's display name and icon. + if (input.presentation === undefined || existing.presentation !== undefined) { + return existing; + } + const presented = { ...existing, presentation: input.presentation }; + input.context.toolCalls.set(input.nativeItemId, presented); + const updatedAt = yield* DateTime.now; + yield* emitToolCallArtifacts( + buildToolCallArtifacts({ + context: input.context, + nativeItemId: presented.nativeItemId, + toolName: presented.toolName, + classification: presented.classification, + toolInput: presented.input, + threadId: presented.threadId, + runId: presented.runId, + rootNodeId: presented.rootNodeId, + parentNodeId: presented.parentNodeId, + ordinal: presented.ordinal, + output: NO_CLAUDE_NATIVE_TOOL_OUTPUT, + status: "running", + startedAt: presented.startedAt, + updatedAt, + presentation: input.presentation, + }), + ); + return presented; } const startedAt = yield* DateTime.now; const classification = classifyClaudeNativeTool(input.toolName); @@ -4415,6 +4567,7 @@ export function makeClaudeAdapterV2( parentNodeId, ordinal, startedAt, + ...(input.presentation === undefined ? {} : { presentation: input.presentation }), }; input.context.toolCalls.set(input.nativeItemId, toolCall); yield* emitToolCallArtifacts( @@ -4433,6 +4586,7 @@ export function makeClaudeAdapterV2( status: "running", startedAt, updatedAt: startedAt, + presentation: input.presentation, }), ); return toolCall; @@ -4618,9 +4772,12 @@ export function makeClaudeAdapterV2( parentNodeId: toolCall.parentNodeId, ordinal: toolCall.ordinal, output: NO_CLAUDE_NATIVE_TOOL_OUTPUT, - status: "failed", + // A stopped turn cuts its open tools short; only a turn that + // ended on its own leaves them failed. + status: input.status === "completed" ? "failed" : input.status, startedAt: toolCall.startedAt, updatedAt: input.completedAt, + presentation: toolCall.presentation, }); yield* emitToolCallArtifacts(artifacts); } @@ -4979,12 +5136,14 @@ export function makeClaudeAdapterV2( // replay them to the turn that was still starting when they // arrived; the offer gate below keeps them from requesting a // continuation on their own. + const isWakeTurnStart = isClaudeTurnStartMessage(message); const isWakeEvidence = isPendingTaskNotification || isPendingSubagentNotification || isNestedSubagentNotification || isKnownSubagentTaskStarted || isNewSubagentTaskStarted || + isWakeTurnStart || message.type === "assistant" || message.type === "user" || message.type === "result" || @@ -5041,12 +5200,14 @@ export function makeClaudeAdapterV2( } // A terminal task notification can clear the Waiting roster without // Claude dequeuing it into a native model turn. Buffer it for replay, - // but do not open an opaque-task continuation until native user, - // assistant, or result output proves that Claude actually began the - // wake turn. Subagent notifications retain their existing immediate - // offer because their projected lifecycle owns the continuation. - // Only root output proves it: a background subagent keeps streaming - // its own frames while the root is idle. + // but do not open an opaque-task continuation until the turn's `init`, + // or native user, assistant, or result output, proves that Claude + // actually began the wake turn. `init` comes first, so the thread + // shows working while Claude thinks instead of looking finished. + // Subagent notifications retain their existing immediate offer + // because their projected lifecycle owns the continuation. Only + // root frames prove it: a background subagent keeps streaming its + // own frames while the root is idle. const buffered = (yield* Ref.get(wakeBuffers)).get(wakeInput.nativeThreadId); const hasBufferedNotification = buffered?.messages.some( @@ -5059,6 +5220,7 @@ export function makeClaudeAdapterV2( if ( !isPendingSubagentNotification && !isNativeOpaqueWakeFrame && + !isWakeTurnStart && message.type !== "result" ) { return; @@ -5823,6 +5985,7 @@ export function makeClaudeAdapterV2( } } + const toolPresentations = claudeToolPresentationsFromAssistantMessage(message); for (const toolUse of claudeToolUseBlocksFromAssistantMessage(message)) { const nativeToolInput = claudeNativeToolInputFromUnknown(toolUse.input); if (toolUse.name === "Agent") { @@ -5848,6 +6011,7 @@ export function makeClaudeAdapterV2( toolName: toolUse.name, toolInput: nativeToolInput, parentToolUseId: parentToolUseIdFromSdkMessage(message), + presentation: toolPresentations.get(toolUse.id), }); const heldPlan = heldProposedPlansByToolUseId.get(toolUse.id); if (heldPlan !== undefined) { @@ -5911,6 +6075,7 @@ export function makeClaudeAdapterV2( status: isClaudeToolResultError(toolResult) ? "failed" : "completed", startedAt: toolCall.startedAt, updatedAt: completedAt, + presentation: toolCall.presentation, }); yield* emitToolCallArtifacts(artifacts); context.toolCalls.delete(toolCall.nativeItemId); @@ -6657,6 +6822,37 @@ export function makeClaudeAdapterV2( }), ); + // Work the live process still runs. A subagent whose completion is + // already buffered is done: the buffer outlives the process. + const liveProcessRunsBackgroundWork = Effect.fnUntraced(function* ( + live: ClaudeLiveQueryContext, + ) { + if ( + rosterForNativeThread( + yield* Ref.get(pendingBackgroundTasksByNativeThread), + live.nativeThreadId, + ).size > 0 + ) { + return true; + } + const buffered = (yield* Ref.get(wakeBuffers)).get(live.nativeThreadId)?.messages ?? []; + for (const [taskId, subagent] of yield* Ref.get(sessionSubagentsByTaskId)) { + if ( + subagent.task.status === "running" && + !live.subagentsFromEarlierProcesses.has(subagent) && + !buffered.some( + (message) => + message.type === "system" && + message.subtype === "task_notification" && + message.task_id === taskId, + ) + ) { + return true; + } + } + return false; + }); + const openQuery = Effect.fnUntraced(function* ( turnInput: ProviderAdapter.ProviderAdapterV2TurnInput, nativeThreadId: string, @@ -6674,15 +6870,45 @@ export function makeClaudeAdapterV2( const compiledSelection = compileClaudeModelSelection(turnInput.modelSelection); const resumeSessionAt = yield* getNativeConversationHeadId(turnInput.providerThread); const existing = yield* Ref.get(queryContext); + // A continuation prompts nothing: it drains output the live process + // already produced, so it keeps that process whatever its selection. + if ( + existing !== null && + existing.nativeThreadId === nativeThreadId && + isClaudeProviderContinuationTurn(turnInput) + ) { + return existing; + } if ( existing !== null && existing.nativeThreadId === nativeThreadId && existing.queryPolicyKey === queryPolicyKey && existing.selectionKey === compiledSelection.queryIdentity ) { + // Claude can switch its own mode mid-session (EnterPlanMode), and + // a denied ExitPlanMode leaves it there. Put the live process back + // in the thread's mode before the next prompt. + if (existing.permissionMode !== existing.openedPermissionMode) { + yield* existing.query.setPermissionMode(existing.openedPermissionMode); + existing.permissionMode = existing.openedPermissionMode; + } return existing; } + // Background agents and shells run inside the CLI process, so a + // new selection would kill them and lose their results. Refuse until + // they finish or the user presses Stop, which closes the process. + // Another native thread on this session is one the app thread has + // left (Claude sessions serve one app thread), so it is replaced. + if ( + existing !== null && + existing.nativeThreadId === nativeThreadId && + !existing.stopping && + (yield* liveProcessRunsBackgroundWork(existing)) + ) { + return yield* new ClaudeBackgroundWorkBlocksQueryReplacementError(); + } + // openQuery owns one live process. Closing it for another native // thread kills that sibling's CLI; it can never emit a roster clear, // so drop its process-scoped Waiting/wake state immediately. Closing @@ -6708,31 +6934,32 @@ export function makeClaudeAdapterV2( const hasPersistedProviderTurn = turnInput.providerTurnOrdinal > 1; const shouldResume = resumeSessionAt !== undefined || openedWithResume || hasPersistedProviderTurn; + const queryOptions = makeClaudeQueryOptions({ + modelSelection: turnInput.modelSelection, + nativeThreadId, + resume: shouldResume, + ...(resumeSessionAt === undefined ? {} : { resumeSessionAt }), + cwd: turnInput.runtimePolicy.cwd, + attachmentsDir, + settings: adapterOptions.settings, + environment: adapterOptions.environment, + tools: queryPolicy.tools ?? CLAUDE_CODE_PRESET_TOOLS, + ...mcpOverrides, + permissionMode: queryPolicy.permissionMode, + ...(queryPolicy.allowDangerouslySkipPermissions === undefined + ? {} + : { + allowDangerouslySkipPermissions: queryPolicy.allowDangerouslySkipPermissions, + }), + canUseTool, + onUserDialog, + supportedDialogKinds: ["resume_return"], + }); const querySession = yield* queryRunner .open({ threadId: turnInput.threadId, providerSessionId: input.providerSessionId, - options: makeClaudeQueryOptions({ - modelSelection: turnInput.modelSelection, - nativeThreadId, - resume: shouldResume, - ...(resumeSessionAt === undefined ? {} : { resumeSessionAt }), - cwd: turnInput.runtimePolicy.cwd, - attachmentsDir, - settings: adapterOptions.settings, - environment: adapterOptions.environment, - tools: queryPolicy.tools ?? CLAUDE_CODE_PRESET_TOOLS, - ...mcpOverrides, - permissionMode: queryPolicy.permissionMode, - ...(queryPolicy.allowDangerouslySkipPermissions === undefined - ? {} - : { - allowDangerouslySkipPermissions: queryPolicy.allowDangerouslySkipPermissions, - }), - canUseTool, - onUserDialog, - supportedDialogKinds: ["resume_return"], - }), + options: queryOptions, }) .pipe( Effect.tapError(() => @@ -6779,10 +7006,27 @@ export function makeClaudeAdapterV2( selectionKey: compiledSelection.queryIdentity, closed, promptEchoMode: "unknown", + openedPermissionMode: queryOptions.permissionMode, + permissionMode: queryOptions.permissionMode, + stopping: false, + subagentsFromEarlierProcesses: new Set( + [...(yield* Ref.get(sessionSubagentsByTaskId)).values()].filter( + (subagent) => subagent.task.status === "running", + ), + ), }; yield* Ref.set(queryContext, context); yield* querySession.messages.pipe( - Stream.runForEach((message) => handleSdkMessage({ query: querySession, message })), + Stream.runForEach((message) => { + if ( + message.type === "system" && + (message.subtype === "init" || message.subtype === "status") && + message.permissionMode !== undefined + ) { + context.permissionMode = message.permissionMode; + } + return handleSdkMessage({ query: querySession, message }); + }), Effect.exit, Effect.flatMap( Effect.fnUntraced(function* (exit: ClaudeQueryStreamExit) { @@ -6962,8 +7206,13 @@ export function makeClaudeAdapterV2( yield* handleSdkMessage({ query: querySession.query, message: lastResult }); return; } + // A drained `init` means Claude began the wake turn, so its output + // may still be on the way: stay open for it. const hasNativeWakeFrame = drained.some( - (entry) => entry.type === "user" || entry.type === "assistant", + (entry) => + entry.type === "user" || + entry.type === "assistant" || + isClaudeTurnStartMessage(entry), ); if (hasOpaqueTaskNotification && !hasNativeWakeFrame) { const completedAt = yield* DateTime.now; @@ -7143,6 +7392,7 @@ export function makeClaudeAdapterV2( return; } + existing.stopping = true; yield* existing.query.close.pipe(Effect.ignore); const closed = yield* Deferred.await(existing.closed).pipe( Effect.timeoutOption("10 seconds"), @@ -7493,9 +7743,13 @@ export const createClaudeAdapterV2 = Effect.fn("ClaudeAdapterV2Driver.create")( const baseEnvironment = mergeProviderInstanceEnvironment(environment, hostEnvironment); const claudeEnvironment = yield* makeClaudeEnvironment(config, baseEnvironment); const path = yield* Path.Path; + const binaryPath = yield* resolveClaudeSdkExecutablePath( + expandHomePath(config.binaryPath), + claudeEnvironment, + ); return makeClaudeAdapterV2({ instanceId, - settings: { ...config, enabled, binaryPath: expandHomePath(config.binaryPath) }, + settings: { ...config, enabled, binaryPath }, environment: claudeEnvironment, attachmentsDir: serverConfig.attachmentsDir, fileSystem, diff --git a/apps/server/src/orchestration-v2/ProviderFailure.ts b/apps/server/src/orchestration-v2/ProviderFailure.ts index b0c7e5fdd..6cb927501 100644 --- a/apps/server/src/orchestration-v2/ProviderFailure.ts +++ b/apps/server/src/orchestration-v2/ProviderFailure.ts @@ -36,6 +36,8 @@ function causeMessage(cause: unknown): string | undefined { switch ((cause as Record)._tag) { case "ContextHandoffBudgetError": return new ContextHandoffBudgetError().message; + case "ClaudeBackgroundWorkBlocksQueryReplacementError": + return stringField(cause, "message"); case "ContextHandoffDeliveryUncertainError": return "T3 could not confirm whether conversation history reached the provider. Retry the turn to recover the session."; case "ProviderAdapterTurnStartError": diff --git a/apps/server/src/orchestration-v2/testkit/ClaudeReplayFixtures.integration.test.ts b/apps/server/src/orchestration-v2/testkit/ClaudeReplayFixtures.integration.test.ts index fff9e79a8..aa4acfa52 100644 --- a/apps/server/src/orchestration-v2/testkit/ClaudeReplayFixtures.integration.test.ts +++ b/apps/server/src/orchestration-v2/testkit/ClaudeReplayFixtures.integration.test.ts @@ -211,7 +211,8 @@ describe("Claude Agent SDK replay fixtures", () => { for (const toolName of claudeToolUseNamesFromTranscript(transcript)) { seenToolNames.add(toolName); const classification = classifyClaudeNativeTool(toolName); - if (!classification.known) { + // MCP tools are open-ended and deliberately become dynamic tools. + if (!classification.known && !toolName.startsWith("mcp__")) { unknownToolNames.add(`${fixture.name}:${toolName}`); } } diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/claude_background_task_interrupt/output.ts b/apps/server/src/orchestration-v2/testkit/fixtures/claude_background_task_interrupt/output.ts index 120f1cfc4..c58112b37 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/claude_background_task_interrupt/output.ts +++ b/apps/server/src/orchestration-v2/testkit/fixtures/claude_background_task_interrupt/output.ts @@ -44,7 +44,7 @@ export function assertClaudeBackgroundTaskInterruptOutput( projection.turnItems.flatMap((item) => item.type === "command_execution" ? [item.status] : [], ), - ["completed", "failed"], - "the background launch completed; the interrupted foreground command did not", + ["completed", "interrupted"], + "the background launch completed; the foreground command was interrupted", ); } diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/claude_mcp_tool_presentation/claude_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/claude_mcp_tool_presentation/claude_transcript.ndjson new file mode 100644 index 000000000..150d6f486 --- /dev/null +++ b/apps/server/src/orchestration-v2/testkit/fixtures/claude_mcp_tool_presentation/claude_transcript.ndjson @@ -0,0 +1,94 @@ +{"type":"transcript_start","provider":"claudeAgent","protocol":"claude-agent-sdk.query","version":"0.2.111","scenario":"claude_mcp_tool_presentation","metadata":{"prompts":["Do exactly this, one tool call at a time, with no other tool calls:\n1) Call the Firecrawl firecrawl_scrape tool on https://example.com with formats [\"markdown\"].\n2) Call the Firecrawl firecrawl_map tool on https://example.com with limit 1.\n3) Reply with exactly: mcp presentation fixture complete"],"model":"claude-sonnet-4-6","nativeSessionId":"e51402d3-25be-4866-81d5-2ab30cecb268","queryMode":"streaming","tools":"claude_code","permissionMode":"bypassPermissions","handEdits":["Removed tool_use_meta from the firecrawl_map assistant frame, standing in for a Claude Code build that sends no display metadata for an MCP tool."],"generatedBy":"recordClaudeAgentSdkReplayTranscript"}} +{"type":"expect_outbound","label":"query.open","frame":{"type":"query.open","options":{"model":"claude-sonnet-4-6","tools":{"type":"preset","preset":"claude_code"},"permissionMode":"bypassPermissions","settings":{"showThinkingSummaries":true},"allowDangerouslySkipPermissions":true,"sessionId":"e51402d3-25be-4866-81d5-2ab30cecb268"}}} +{"type":"expect_outbound","label":"prompt.offer:1","frame":{"type":"prompt.offer","message":{"type":"user","message":{"role":"user","content":"Do exactly this, one tool call at a time, with no other tool calls:\n1) Call the Firecrawl firecrawl_scrape tool on https://example.com with formats [\"markdown\"].\n2) Call the Firecrawl firecrawl_map tool on https://example.com with limit 1.\n3) Reply with exactly: mcp presentation fixture complete"},"parent_tool_use_id":null,"uuid":"8935142f-0551-46e6-9a6b-95034332fa1a"}}} +{"type":"emit_inbound","label":"command_lifecycle","frame":{"type":"command_lifecycle","command_uuid":"8935142f-0551-46e6-9a6b-95034332fa1a","state":"queued","uuid":"042fc299-7a91-42e7-a7c8-7ad83c342c61","session_id":"e51402d3-25be-4866-81d5-2ab30cecb268"}} +{"type":"emit_inbound","label":"rate_limit_event","frame":{"type":"rate_limit_event","rate_limit_info":{"status":"allowed"},"uuid":"16a8e60b-317a-4de1-b45e-2707aebd957e","session_id":"e51402d3-25be-4866-81d5-2ab30cecb268"}} +{"type":"emit_inbound","label":"system","frame":{"type":"system","subtype":"commands_changed","commands":[],"uuid":"5882c810-a76d-43ef-a7d8-20a4ce7272f7","session_id":"e51402d3-25be-4866-81d5-2ab30cecb268"}} +{"type":"emit_inbound","label":"command_lifecycle","frame":{"type":"command_lifecycle","command_uuid":"8935142f-0551-46e6-9a6b-95034332fa1a","state":"started","uuid":"0b876c44-1d79-45b9-84e1-8ac9d006e1eb","session_id":"e51402d3-25be-4866-81d5-2ab30cecb268"}} +{"type":"emit_inbound","label":"system","frame":{"type":"system","subtype":"init","agents":[],"apiKeySource":"none","claude_code_version":"2.1.282","cwd":"/tmp/claude-replay-claude_mcp_tool_presentation","tools":[],"mcp_servers":[],"model":"claude-sonnet-4-6","permissionMode":"bypassPermissions","slash_commands":[],"output_style":"default","skills":[],"plugins":[],"fast_mode_state":"off","uuid":"17b07fd4-a49e-4f29-908d-2eb4e79079f2","session_id":"e51402d3-25be-4866-81d5-2ab30cecb268"}} +{"type":"emit_inbound","label":"system","frame":{"type":"system","subtype":"status","status":"requesting","session_id":"e51402d3-25be-4866-81d5-2ab30cecb268","uuid":"25df71c1-b001-403e-bd02-20f7a131e6be"}} +{"type":"emit_inbound","label":"stream_event","frame":{"type":"stream_event","event":{"type":"message_start","message":{"model":"claude-sonnet-4-6","id":"msg_011CfXyMTdhN1SHi8KRaXCaz","type":"message","role":"assistant","content":[],"container":null,"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":3,"cache_creation_input_tokens":13908,"cache_read_input_tokens":120650,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":13908},"output_tokens":8,"service_tier":"standard","inference_geo":"not_available"},"diagnostics":null,"context_management":null}},"session_id":"e51402d3-25be-4866-81d5-2ab30cecb268","parent_tool_use_id":null,"uuid":"ee9a690a-66c6-419c-beb6-178282c2b2d6","ttft_ms":1398,"user_message_uuid":"8935142f-0551-46e6-9a6b-95034332fa1a","user_message_uuids":["8935142f-0551-46e6-9a6b-95034332fa1a"]}} +{"type":"emit_inbound","label":"stream_event","frame":{"type":"stream_event","event":{"type":"content_block_start","index":0,"content_block":{"type":"thinking","thinking":"","signature":""}},"session_id":"e51402d3-25be-4866-81d5-2ab30cecb268","parent_tool_use_id":null,"uuid":"5531e320-0b9f-44ed-bb84-9217aec3a7de"}} +{"type":"emit_inbound","label":"system","frame":{"type":"system","subtype":"thinking_tokens","estimated_tokens":1,"estimated_tokens_delta":1,"session_id":"e51402d3-25be-4866-81d5-2ab30cecb268","uuid":"e131358c-9526-42f7-9893-a9f21c8da7b1","user_message_uuid":"8935142f-0551-46e6-9a6b-95034332fa1a"}} +{"type":"emit_inbound","label":"stream_event","frame":{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":"The","estimated_tokens":null}},"session_id":"e51402d3-25be-4866-81d5-2ab30cecb268","parent_tool_use_id":null,"uuid":"4e3389b2-8998-4002-9b79-7e4cba7710cf"}} +{"type":"emit_inbound","label":"system","frame":{"type":"system","subtype":"thinking_tokens","estimated_tokens":6,"estimated_tokens_delta":5,"session_id":"e51402d3-25be-4866-81d5-2ab30cecb268","uuid":"c8a1f6f6-779c-4464-944c-224cfa5ff337","user_message_uuid":"8935142f-0551-46e6-9a6b-95034332fa1a"}} +{"type":"emit_inbound","label":"stream_event","frame":{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":" user wants me to do","estimated_tokens":null}},"session_id":"e51402d3-25be-4866-81d5-2ab30cecb268","parent_tool_use_id":null,"uuid":"b0fc7c7a-7e9f-4703-be79-2a84a1ae779c"}} +{"type":"emit_inbound","label":"system","frame":{"type":"system","subtype":"thinking_tokens","estimated_tokens":10,"estimated_tokens_delta":4,"session_id":"e51402d3-25be-4866-81d5-2ab30cecb268","uuid":"b8bff8ce-d9d9-42cf-8531-eca61d512198","user_message_uuid":"8935142f-0551-46e6-9a6b-95034332fa1a"}} +{"type":"emit_inbound","label":"stream_event","frame":{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":" exactly three","estimated_tokens":null}},"session_id":"e51402d3-25be-4866-81d5-2ab30cecb268","parent_tool_use_id":null,"uuid":"8d7902d0-2c10-4f31-b104-325f009187a7"}} +{"type":"emit_inbound","label":"system","frame":{"type":"system","subtype":"thinking_tokens","estimated_tokens":12,"estimated_tokens_delta":2,"session_id":"e51402d3-25be-4866-81d5-2ab30cecb268","uuid":"803f3856-8179-4080-9fbd-cbd2446c94e6","user_message_uuid":"8935142f-0551-46e6-9a6b-95034332fa1a"}} +{"type":"emit_inbound","label":"stream_event","frame":{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":" things","estimated_tokens":null}},"session_id":"e51402d3-25be-4866-81d5-2ab30cecb268","parent_tool_use_id":null,"uuid":"27f9d2ee-21cb-4d7d-8ef6-e2dd7c8c013b"}} +{"type":"emit_inbound","label":"system","frame":{"type":"system","subtype":"thinking_tokens","estimated_tokens":13,"estimated_tokens_delta":1,"session_id":"e51402d3-25be-4866-81d5-2ab30cecb268","uuid":"b15bdd50-2536-4142-89d4-0968478a199d","user_message_uuid":"8935142f-0551-46e6-9a6b-95034332fa1a"}} +{"type":"emit_inbound","label":"stream_event","frame":{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":" in","estimated_tokens":null}},"session_id":"e51402d3-25be-4866-81d5-2ab30cecb268","parent_tool_use_id":null,"uuid":"c8d8ea67-b12c-47f8-b3b2-39e6a1d649bb"}} +{"type":"emit_inbound","label":"system","frame":{"type":"system","subtype":"thinking_tokens","estimated_tokens":16,"estimated_tokens_delta":3,"session_id":"e51402d3-25be-4866-81d5-2ab30cecb268","uuid":"9f07ec5f-2c77-4f10-9732-86d72b2023ce","user_message_uuid":"8935142f-0551-46e6-9a6b-95034332fa1a"}} +{"type":"emit_inbound","label":"stream_event","frame":{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":" sequence,","estimated_tokens":null}},"session_id":"e51402d3-25be-4866-81d5-2ab30cecb268","parent_tool_use_id":null,"uuid":"5678559a-f58c-4ad5-a601-ea66ddf38a29"}} +{"type":"emit_inbound","label":"system","frame":{"type":"system","subtype":"thinking_tokens","estimated_tokens":23,"estimated_tokens_delta":7,"session_id":"e51402d3-25be-4866-81d5-2ab30cecb268","uuid":"c0512ed5-0470-4688-b424-d87248fc558b","user_message_uuid":"8935142f-0551-46e6-9a6b-95034332fa1a"}} +{"type":"emit_inbound","label":"stream_event","frame":{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":" one tool call at a time:","estimated_tokens":null}},"session_id":"e51402d3-25be-4866-81d5-2ab30cecb268","parent_tool_use_id":null,"uuid":"488b1f98-da86-496f-8808-b97a17e548eb"}} +{"type":"emit_inbound","label":"system","frame":{"type":"system","subtype":"thinking_tokens","estimated_tokens":26,"estimated_tokens_delta":3,"session_id":"e51402d3-25be-4866-81d5-2ab30cecb268","uuid":"53e7bbf1-7787-436d-bb2f-fb227d9ec08e","user_message_uuid":"8935142f-0551-46e6-9a6b-95034332fa1a"}} +{"type":"emit_inbound","label":"stream_event","frame":{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":"\n1. Call fi","estimated_tokens":null}},"session_id":"e51402d3-25be-4866-81d5-2ab30cecb268","parent_tool_use_id":null,"uuid":"95bcdca5-05da-4af8-8476-db503f8634d3"}} +{"type":"emit_inbound","label":"system","frame":{"type":"system","subtype":"thinking_tokens","estimated_tokens":31,"estimated_tokens_delta":5,"session_id":"e51402d3-25be-4866-81d5-2ab30cecb268","uuid":"1b44ef1e-7a1b-4f9f-b31f-2827e1cd4b98","user_message_uuid":"8935142f-0551-46e6-9a6b-95034332fa1a"}} +{"type":"emit_inbound","label":"stream_event","frame":{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":"recrawl_scrape on","estimated_tokens":null}},"session_id":"e51402d3-25be-4866-81d5-2ab30cecb268","parent_tool_use_id":null,"uuid":"0ca78758-21a6-4278-8647-17346dc8b7a9"}} +{"type":"emit_inbound","label":"system","frame":{"type":"system","subtype":"thinking_tokens","estimated_tokens":40,"estimated_tokens_delta":9,"session_id":"e51402d3-25be-4866-81d5-2ab30cecb268","uuid":"263d1905-60db-44fb-bb8a-aff133cfd1c5","user_message_uuid":"8935142f-0551-46e6-9a6b-95034332fa1a"}} +{"type":"emit_inbound","label":"stream_event","frame":{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":" https://example.com with formats [\"","estimated_tokens":null}},"session_id":"e51402d3-25be-4866-81d5-2ab30cecb268","parent_tool_use_id":null,"uuid":"2a487a60-db75-4949-bf04-abd72be141c9"}} +{"type":"emit_inbound","label":"system","frame":{"type":"system","subtype":"thinking_tokens","estimated_tokens":46,"estimated_tokens_delta":6,"session_id":"e51402d3-25be-4866-81d5-2ab30cecb268","uuid":"26b28799-60aa-4402-b4cf-6131ac9208c0","user_message_uuid":"8935142f-0551-46e6-9a6b-95034332fa1a"}} +{"type":"emit_inbound","label":"stream_event","frame":{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":"markdown\"]\n2. Call firec","estimated_tokens":null}},"session_id":"e51402d3-25be-4866-81d5-2ab30cecb268","parent_tool_use_id":null,"uuid":"47c553fa-1067-4ec1-9d03-43c179ab77c1"}} +{"type":"emit_inbound","label":"system","frame":{"type":"system","subtype":"thinking_tokens","estimated_tokens":53,"estimated_tokens_delta":7,"session_id":"e51402d3-25be-4866-81d5-2ab30cecb268","uuid":"8b22a64d-0ff3-42a0-8bf9-cf334970484c","user_message_uuid":"8935142f-0551-46e6-9a6b-95034332fa1a"}} +{"type":"emit_inbound","label":"stream_event","frame":{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":"rawl_map on https://example","estimated_tokens":null}},"session_id":"e51402d3-25be-4866-81d5-2ab30cecb268","parent_tool_use_id":null,"uuid":"85ff3f48-9b5f-4bfb-9424-304286e6feb9"}} +{"type":"emit_inbound","label":"system","frame":{"type":"system","subtype":"thinking_tokens","estimated_tokens":58,"estimated_tokens_delta":5,"session_id":"e51402d3-25be-4866-81d5-2ab30cecb268","uuid":"27537bbc-ea6d-4ab2-82fa-a1d155e47c24","user_message_uuid":"8935142f-0551-46e6-9a6b-95034332fa1a"}} +{"type":"emit_inbound","label":"stream_event","frame":{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":".com with limit 1\n3","estimated_tokens":null}},"session_id":"e51402d3-25be-4866-81d5-2ab30cecb268","parent_tool_use_id":null,"uuid":"9228b117-54b5-4735-91c0-36c2c8ab3589"}} +{"type":"emit_inbound","label":"system","frame":{"type":"system","subtype":"thinking_tokens","estimated_tokens":62,"estimated_tokens_delta":4,"session_id":"e51402d3-25be-4866-81d5-2ab30cecb268","uuid":"dc661ed1-0053-4137-9ba3-b047fc5673a2","user_message_uuid":"8935142f-0551-46e6-9a6b-95034332fa1a"}} +{"type":"emit_inbound","label":"stream_event","frame":{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":". Reply with \"","estimated_tokens":null}},"session_id":"e51402d3-25be-4866-81d5-2ab30cecb268","parent_tool_use_id":null,"uuid":"1f3a14f3-ab46-4901-bd67-01ccc56e9e53"}} +{"type":"emit_inbound","label":"system","frame":{"type":"system","subtype":"thinking_tokens","estimated_tokens":72,"estimated_tokens_delta":10,"session_id":"e51402d3-25be-4866-81d5-2ab30cecb268","uuid":"c8752e2c-b468-4565-83d6-b0cf93958dcc","user_message_uuid":"8935142f-0551-46e6-9a6b-95034332fa1a"}} +{"type":"emit_inbound","label":"stream_event","frame":{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":"mcp presentation fixture complete\"\n\nLet","estimated_tokens":null}},"session_id":"e51402d3-25be-4866-81d5-2ab30cecb268","parent_tool_use_id":null,"uuid":"799ac3de-dc15-456b-ba7f-7ed646091650"}} +{"type":"emit_inbound","label":"system","frame":{"type":"system","subtype":"thinking_tokens","estimated_tokens":74,"estimated_tokens_delta":2,"session_id":"e51402d3-25be-4866-81d5-2ab30cecb268","uuid":"252003d8-27ca-459d-83d2-76c3954ab781","user_message_uuid":"8935142f-0551-46e6-9a6b-95034332fa1a"}} +{"type":"emit_inbound","label":"stream_event","frame":{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":" me do","estimated_tokens":null}},"session_id":"e51402d3-25be-4866-81d5-2ab30cecb268","parent_tool_use_id":null,"uuid":"98477c6a-267d-47ec-9ab9-23b6ac9ed9be"}} +{"type":"emit_inbound","label":"system","frame":{"type":"system","subtype":"thinking_tokens","estimated_tokens":76,"estimated_tokens_delta":2,"session_id":"e51402d3-25be-4866-81d5-2ab30cecb268","uuid":"79faa185-c65f-4d35-902b-e90c67e71328","user_message_uuid":"8935142f-0551-46e6-9a6b-95034332fa1a"}} +{"type":"emit_inbound","label":"stream_event","frame":{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":" these","estimated_tokens":null}},"session_id":"e51402d3-25be-4866-81d5-2ab30cecb268","parent_tool_use_id":null,"uuid":"61e9467c-47a6-47ac-b1a8-3fbd2c4017cb"}} +{"type":"emit_inbound","label":"system","frame":{"type":"system","subtype":"thinking_tokens","estimated_tokens":80,"estimated_tokens_delta":4,"session_id":"e51402d3-25be-4866-81d5-2ab30cecb268","uuid":"62df28bc-7825-4c68-bf8c-2c55330e8bcd","user_message_uuid":"8935142f-0551-46e6-9a6b-95034332fa1a"}} +{"type":"emit_inbound","label":"stream_event","frame":{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":" one at a time.","estimated_tokens":null}},"session_id":"e51402d3-25be-4866-81d5-2ab30cecb268","parent_tool_use_id":null,"uuid":"e81841c1-d71a-4f05-8e3e-ccc487761c94"}} +{"type":"emit_inbound","label":"system","frame":{"type":"system","subtype":"thinking_tokens","estimated_tokens":144,"estimated_tokens_delta":64,"session_id":"e51402d3-25be-4866-81d5-2ab30cecb268","uuid":"7c601f46-605b-46d4-afcd-459d3a0778a4","user_message_uuid":"8935142f-0551-46e6-9a6b-95034332fa1a"}} +{"type":"emit_inbound","label":"stream_event","frame":{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"signature_delta","signature":"ErcECqoBCBIYAipAr/7SWAcYeXs7o2aRtpsjID+2LXjJzbSBehaQmjIpalZdQ/zDlKo5HdSV5/hjsXI5PLCptKaONWLOUH72laOx2jIRY2xhdWRlLXNvbm5ldC00LTY4AEIIdGhpbmtpbmdaJGFmOTIzNWNiLWRjNTItNDViMC05MTI2LTNmZTBkZmJmNzNkY3IQOUPUj6Q5Zqq7Ny6ZW11Rk4gBAagBgLTv1QawAQISDFxgTUNaIxrJGThAcxoM1pccePQyi26dbGkQIjBasphSlIQMWY/cGUpkz8rfvlDWGHElBlHgWxNzbEs5sHqyq/gMPFYpuu1VfCUuRXgquQKpt01uhZkzPA91ZoWdnE2uwB9CP7DrueotB4xeqPC8If0jI7EU2E52jEO9FJvPWKvrv40DPI9dUyAT82i3QfMfVbmslyw8lWv8JFmjod4MPRdI3DBj8zDztU+YRjtKMiy91u5NG5oJyI4M8DLrsVD7WRpA3BEb5vREl15X8T41rhYh14w8TXglDpEXgwPsJUd6+oJhR3dYrX71pdj6JILMrh0xQ1WNVm+fSXDIYxCUV8wmerH1ewTP4FwLip1icXtGi8lQSYjKT0hnNlPgMG13AbSMM/dI3A50XB8FighdGSaY+OoKZuZWfOA6XeNRUqKi6qaUb5eJnV0UsvSfxe++TgvGiaQubZ0NQm0GRcSz5LHJBxccNcaLBnqEp5l4Y7fQ2Lo/Kwzgndnq21jXyVpPEd0OcsQOzCxsGAE="}},"session_id":"e51402d3-25be-4866-81d5-2ab30cecb268","parent_tool_use_id":null,"uuid":"941464bd-9b05-453c-8ee7-1529998aac44"}} +{"type":"emit_inbound","label":"assistant","frame":{"type":"assistant","message":{"model":"claude-sonnet-4-6","id":"msg_011CfXyMTdhN1SHi8KRaXCaz","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"The user wants me to do exactly three things in sequence, one tool call at a time:\n1. Call firecrawl_scrape on https://example.com with formats [\"markdown\"]\n2. Call firecrawl_map on https://example.com with limit 1\n3. Reply with \"mcp presentation fixture complete\"\n\nLet me do these one at a time.","signature":"ErcECqoBCBIYAipAr/7SWAcYeXs7o2aRtpsjID+2LXjJzbSBehaQmjIpalZdQ/zDlKo5HdSV5/hjsXI5PLCptKaONWLOUH72laOx2jIRY2xhdWRlLXNvbm5ldC00LTY4AEIIdGhpbmtpbmdaJGFmOTIzNWNiLWRjNTItNDViMC05MTI2LTNmZTBkZmJmNzNkY3IQOUPUj6Q5Zqq7Ny6ZW11Rk4gBAagBgLTv1QawAQISDFxgTUNaIxrJGThAcxoM1pccePQyi26dbGkQIjBasphSlIQMWY/cGUpkz8rfvlDWGHElBlHgWxNzbEs5sHqyq/gMPFYpuu1VfCUuRXgquQKpt01uhZkzPA91ZoWdnE2uwB9CP7DrueotB4xeqPC8If0jI7EU2E52jEO9FJvPWKvrv40DPI9dUyAT82i3QfMfVbmslyw8lWv8JFmjod4MPRdI3DBj8zDztU+YRjtKMiy91u5NG5oJyI4M8DLrsVD7WRpA3BEb5vREl15X8T41rhYh14w8TXglDpEXgwPsJUd6+oJhR3dYrX71pdj6JILMrh0xQ1WNVm+fSXDIYxCUV8wmerH1ewTP4FwLip1icXtGi8lQSYjKT0hnNlPgMG13AbSMM/dI3A50XB8FighdGSaY+OoKZuZWfOA6XeNRUqKi6qaUb5eJnV0UsvSfxe++TgvGiaQubZ0NQm0GRcSz5LHJBxccNcaLBnqEp5l4Y7fQ2Lo/Kwzgndnq21jXyVpPEd0OcsQOzCxsGAE="}],"container":null,"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":3,"cache_creation_input_tokens":13908,"cache_read_input_tokens":120650,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":13908},"output_tokens":8,"service_tier":"standard","inference_geo":"not_available"},"diagnostics":null,"context_management":null},"parent_tool_use_id":null,"session_id":"e51402d3-25be-4866-81d5-2ab30cecb268","uuid":"95deaf17-0eb1-4b10-9b64-4b3d86784821","timestamp":"2026-09-29T15:32:16.925Z","user_message_uuid":"8935142f-0551-46e6-9a6b-95034332fa1a","user_message_uuids":["8935142f-0551-46e6-9a6b-95034332fa1a"]}} +{"type":"emit_inbound","label":"stream_event","frame":{"type":"stream_event","event":{"type":"content_block_stop","index":0},"session_id":"e51402d3-25be-4866-81d5-2ab30cecb268","parent_tool_use_id":null,"uuid":"e33d797d-cb58-4208-b4a1-6997bf585ee2"}} +{"type":"emit_inbound","label":"stream_event","frame":{"type":"stream_event","event":{"type":"content_block_start","index":1,"content_block":{"type":"tool_use","id":"toolu_01CqVNFCESeShHVUiLMLPsCg","name":"mcp__claude_ai_Firecrawl__firecrawl_scrape","input":{},"caller":{"type":"direct"}}},"session_id":"e51402d3-25be-4866-81d5-2ab30cecb268","parent_tool_use_id":null,"uuid":"9d628e2d-81bf-4206-b587-0cff5caf1aed"}} +{"type":"emit_inbound","label":"stream_event","frame":{"type":"stream_event","event":{"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":""}},"session_id":"e51402d3-25be-4866-81d5-2ab30cecb268","parent_tool_use_id":null,"uuid":"b5f7700a-9aff-44cf-96d9-1655cad51f8a"}} +{"type":"emit_inbound","label":"stream_event","frame":{"type":"stream_event","event":{"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":"{\"url\":"}},"session_id":"e51402d3-25be-4866-81d5-2ab30cecb268","parent_tool_use_id":null,"uuid":"fc274f0d-de9c-44d8-b142-4f8e21bd3427"}} +{"type":"emit_inbound","label":"stream_event","frame":{"type":"stream_event","event":{"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":" \"https"}},"session_id":"e51402d3-25be-4866-81d5-2ab30cecb268","parent_tool_use_id":null,"uuid":"41ef7bfc-f8c4-4c0d-98d9-146e4a4cc394"}} +{"type":"emit_inbound","label":"stream_event","frame":{"type":"stream_event","event":{"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":"://exam"}},"session_id":"e51402d3-25be-4866-81d5-2ab30cecb268","parent_tool_use_id":null,"uuid":"9a63da1a-67d1-43d6-951d-f1c60e112a62"}} +{"type":"emit_inbound","label":"stream_event","frame":{"type":"stream_event","event":{"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":"ple.com"}},"session_id":"e51402d3-25be-4866-81d5-2ab30cecb268","parent_tool_use_id":null,"uuid":"7891419a-e906-43be-84b0-4b2f16be6223"}} +{"type":"emit_inbound","label":"stream_event","frame":{"type":"stream_event","event":{"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":"\""}},"session_id":"e51402d3-25be-4866-81d5-2ab30cecb268","parent_tool_use_id":null,"uuid":"1a5d24aa-f26c-4e17-b5cc-f122c8261b2e"}} +{"type":"emit_inbound","label":"stream_event","frame":{"type":"stream_event","event":{"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":", \"form"}},"session_id":"e51402d3-25be-4866-81d5-2ab30cecb268","parent_tool_use_id":null,"uuid":"229e2bfe-a912-4a27-9f25-49f14fdafe24"}} +{"type":"emit_inbound","label":"stream_event","frame":{"type":"stream_event","event":{"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":"ats\": ["}},"session_id":"e51402d3-25be-4866-81d5-2ab30cecb268","parent_tool_use_id":null,"uuid":"cf50db11-2827-4ed7-8407-0cbecb3c8c31"}} +{"type":"emit_inbound","label":"stream_event","frame":{"type":"stream_event","event":{"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":"\"markdo"}},"session_id":"e51402d3-25be-4866-81d5-2ab30cecb268","parent_tool_use_id":null,"uuid":"39a14b30-36a6-462c-ad1d-1a07bf08403d"}} +{"type":"emit_inbound","label":"stream_event","frame":{"type":"stream_event","event":{"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":"wn\"]}"}},"session_id":"e51402d3-25be-4866-81d5-2ab30cecb268","parent_tool_use_id":null,"uuid":"a9ed2753-02fb-48cc-9425-d0ea87e23fd1"}} +{"type":"emit_inbound","label":"assistant","frame":{"type":"assistant","message":{"model":"claude-sonnet-4-6","id":"msg_011CfXyMTdhN1SHi8KRaXCaz","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01CqVNFCESeShHVUiLMLPsCg","name":"mcp__claude_ai_Firecrawl__firecrawl_scrape","input":{"url":"https://example.com","formats":["markdown"]},"caller":{"type":"direct"}}],"container":null,"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":3,"cache_creation_input_tokens":13908,"cache_read_input_tokens":120650,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":13908},"output_tokens":8,"service_tier":"standard","inference_geo":"not_available"},"diagnostics":null,"context_management":null},"parent_tool_use_id":null,"session_id":"e51402d3-25be-4866-81d5-2ab30cecb268","uuid":"61e61e08-0d57-4e0b-9685-0000f4eb29a0","timestamp":"2026-09-29T15:32:17.004Z","wire_tool_inputs":{"toolu_01CqVNFCESeShHVUiLMLPsCg":{"url":"https://example.com","formats":["markdown"]}},"tool_use_meta":[{"id":"toolu_01CqVNFCESeShHVUiLMLPsCg","display_name":"Firecrawl scrape","server_display_name":"Firecrawl","icon_url":"https://www.google.com/s2/favicons?domain=firecrawl.dev&sz=64"}]}} +{"type":"emit_inbound","label":"stream_event","frame":{"type":"stream_event","event":{"type":"content_block_stop","index":1},"session_id":"e51402d3-25be-4866-81d5-2ab30cecb268","parent_tool_use_id":null,"uuid":"2429ac0f-a65e-401a-a3b1-3eed4a37f28d"}} +{"type":"emit_inbound","label":"stream_event","frame":{"type":"stream_event","event":{"type":"message_delta","delta":{"stop_reason":"tool_use","stop_sequence":null,"stop_details":null,"container":null},"usage":{"input_tokens":3,"cache_creation_input_tokens":13908,"cache_read_input_tokens":120650,"output_tokens":191,"output_tokens_details":{"thinking_tokens":95},"iterations":[{"input_tokens":3,"output_tokens":191,"cache_read_input_tokens":120650,"cache_creation_input_tokens":13908,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":13908},"type":"message"}]},"context_management":{"applied_edits":[]}},"session_id":"e51402d3-25be-4866-81d5-2ab30cecb268","parent_tool_use_id":null,"uuid":"50843da5-a0f0-4e95-a344-f841aa6d9499"}} +{"type":"emit_inbound","label":"stream_event","frame":{"type":"stream_event","event":{"type":"message_stop"},"session_id":"e51402d3-25be-4866-81d5-2ab30cecb268","parent_tool_use_id":null,"uuid":"f5993155-901f-4e2d-a3a9-4d5b23c1bbf5"}} +{"type":"emit_inbound","label":"user","frame":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01CqVNFCESeShHVUiLMLPsCg","type":"tool_result","content":"{\"markdown\":\"Thisdomainisforuseindocumentationexampleswithoutneedingpermission.Thisisnotaservice,avoidrelyingonitfortestingandmonitoringpurposes.\\n\\nهذا النطاق مُخصص للاستخدام في أمثلة التوثيق دون الحاجة إلى إذن. هذه ليست خدمة، يُرجى تجنب الاعتماد عليها لأغراض الاختبار والمراقبة.\\n\\n该域名仅用于文档示例,无需获得许可。这并非一项服务,请勿将其用于测试和监控目的。\\n\\nL’usagedecedomaineestréservéàdesexemplesdedocumentation,sansautorisationpréalable.Ilnes’agitpasd’unservice;sonutilisationàdesfinsdetestoudesurveillanceestàéviter.\\n\\nДанныйдоменпредназначендляиспользованиявпримерахдокументациибезнеобходимостиполученияпредварительногоразрешения.Этонесервис;нерекомендуетсяегоиспользованиедлятестированияимониторинга.\\n\\nEstedominioestádestinadoalusoenejemplosdedocumentaciónsinnecesidaddepermiso.Estonoesunservicio,evitarutilizarlopararealizarpruebasomonitoreos.\\n\\n [Learn more](https://iana.org/help/example-domains)\",\"metadata\":{\"viewport\":\"width=device-width,initial-scale=1\",\"title\":\"Example Domain\",\"language\":\"en\",\"favicon\":\"data:,\",\"scrapeId\":\"01a0edcb-958c-76df-93e9-f9d9dbd6ae55\",\"sourceURL\":\"https://example.com\",\"url\":\"https://example.com/\",\"statusCode\":200,\"contentType\":\"text/html; charset=utf-8\",\"proxyUsed\":\"basic\",\"cacheState\":\"hit\",\"cachedAt\":\"2026-09-29T15:32:11.421Z\",\"creditsUsed\":1,\"concurrencyLimited\":false}}"}]},"parent_tool_use_id":null,"session_id":"e51402d3-25be-4866-81d5-2ab30cecb268","uuid":"863025d4-d60a-4ea5-95e5-7fb64a0b386a","timestamp":"2026-09-29T15:32:17.556Z","tool_use_result":{"content":"{\"markdown\":\"Thisdomainisforuseindocumentationexampleswithoutneedingpermission.Thisisnotaservice,avoidrelyingonitfortestingandmonitoringpurposes.\\n\\nهذا النطاق مُخصص للاستخدام في أمثلة التوثيق دون الحاجة إلى إذن. هذه ليست خدمة، يُرجى تجنب الاعتماد عليها لأغراض الاختبار والمراقبة.\\n\\n该域名仅用于文档示例,无需获得许可。这并非一项服务,请勿将其用于测试和监控目的。\\n\\nL’usagedecedomaineestréservéàdesexemplesdedocumentation,sansautorisationpréalable.Ilnes’agitpasd’unservice;sonutilisationàdesfinsdetestoudesurveillanceestàéviter.\\n\\nДанныйдоменпредназначендляиспользованиявпримерахдокументациибезнеобходимостиполученияпредварительногоразрешения.Этонесервис;нерекомендуетсяегоиспользованиедлятестированияимониторинга.\\n\\nEstedominioestádestinadoalusoenejemplosdedocumentaciónsinnecesidaddepermiso.Estonoesunservicio,evitarutilizarlopararealizarpruebasomonitoreos.\\n\\n [Learn more](https://iana.org/help/example-domains)\",\"metadata\":{\"viewport\":\"width=device-width,initial-scale=1\",\"title\":\"Example Domain\",\"language\":\"en\",\"favicon\":\"data:,\",\"scrapeId\":\"01a0edcb-958c-76df-93e9-f9d9dbd6ae55\",\"sourceURL\":\"https://example.com\",\"url\":\"https://example.com/\",\"statusCode\":200,\"contentType\":\"text/html; charset=utf-8\",\"proxyUsed\":\"basic\",\"cacheState\":\"hit\",\"cachedAt\":\"2026-09-29T15:32:11.421Z\",\"creditsUsed\":1,\"concurrencyLimited\":false}}","structuredContent":{"markdown":"Thisdomainisforuseindocumentationexampleswithoutneedingpermission.Thisisnotaservice,avoidrelyingonitfortestingandmonitoringpurposes.\n\nهذا النطاق مُخصص للاستخدام في أمثلة التوثيق دون الحاجة إلى إذن. هذه ليست خدمة، يُرجى تجنب الاعتماد عليها لأغراض الاختبار والمراقبة.\n\n该域名仅用于文档示例,无需获得许可。这并非一项服务,请勿将其用于测试和监控目的。\n\nL’usagedecedomaineestréservéàdesexemplesdedocumentation,sansautorisationpréalable.Ilnes’agitpasd’unservice;sonutilisationàdesfinsdetestoudesurveillanceestàéviter.\n\nДанныйдоменпредназначендляиспользованиявпримерахдокументациибезнеобходимостиполученияпредварительногоразрешения.Этонесервис;нерекомендуетсяегоиспользованиедлятестированияимониторинга.\n\nEstedominioestádestinadoalusoenejemplosdedocumentaciónsinnecesidaddepermiso.Estonoesunservicio,evitarutilizarlopararealizarpruebasomonitoreos.\n\n [Learn more](https://iana.org/help/example-domains)","metadata":{"viewport":"width=device-width,initial-scale=1","title":"Example Domain","language":"en","favicon":"data:,","scrapeId":"01a0edcb-958c-76df-93e9-f9d9dbd6ae55","sourceURL":"https://example.com","url":"https://example.com/","statusCode":200,"contentType":"text/html; charset=utf-8","proxyUsed":"basic","cacheState":"hit","cachedAt":"2026-09-29T15:32:11.421Z","creditsUsed":1,"concurrencyLimited":false}}}}} +{"type":"emit_inbound","label":"system","frame":{"type":"system","subtype":"status","status":"requesting","session_id":"e51402d3-25be-4866-81d5-2ab30cecb268","uuid":"cece3d42-d089-4279-89b6-cc1ecfcfa1de"}} +{"type":"emit_inbound","label":"stream_event","frame":{"type":"stream_event","event":{"type":"message_start","message":{"model":"claude-sonnet-4-6","id":"msg_011CfXyMiXtkRS4bQYoe7LVs","type":"message","role":"assistant","content":[],"container":null,"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":787,"cache_read_input_tokens":134558,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":787},"output_tokens":37,"service_tier":"standard","inference_geo":"not_available"},"diagnostics":null,"context_management":null}},"session_id":"e51402d3-25be-4866-81d5-2ab30cecb268","parent_tool_use_id":null,"uuid":"dc3467a0-7e44-4bbc-a952-84aeae32d2c9","ttft_ms":2543}} +{"type":"emit_inbound","label":"stream_event","frame":{"type":"stream_event","event":{"type":"content_block_start","index":0,"content_block":{"type":"tool_use","id":"toolu_019X6ALn5NrExXqfhzC3LHCP","name":"mcp__claude_ai_Firecrawl__firecrawl_map","input":{},"caller":{"type":"direct"}}},"session_id":"e51402d3-25be-4866-81d5-2ab30cecb268","parent_tool_use_id":null,"uuid":"ade642dc-47a3-4cc8-ab74-a78d1ddb4917"}} +{"type":"emit_inbound","label":"stream_event","frame":{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":""}},"session_id":"e51402d3-25be-4866-81d5-2ab30cecb268","parent_tool_use_id":null,"uuid":"3b9a9dc0-2368-4ea0-8bdb-99b40333405a"}} +{"type":"emit_inbound","label":"stream_event","frame":{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"{\"url\":"}},"session_id":"e51402d3-25be-4866-81d5-2ab30cecb268","parent_tool_use_id":null,"uuid":"30cfa68f-ba26-49b4-817c-2360cb007e55"}} +{"type":"emit_inbound","label":"stream_event","frame":{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":" \"https"}},"session_id":"e51402d3-25be-4866-81d5-2ab30cecb268","parent_tool_use_id":null,"uuid":"b5c6bb6f-2e1b-4eeb-9cd6-8c715de7dec2"}} +{"type":"emit_inbound","label":"stream_event","frame":{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"://exam"}},"session_id":"e51402d3-25be-4866-81d5-2ab30cecb268","parent_tool_use_id":null,"uuid":"106034cd-4582-48b1-9ac7-5fd3334e93d2"}} +{"type":"emit_inbound","label":"stream_event","frame":{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"ple.com"}},"session_id":"e51402d3-25be-4866-81d5-2ab30cecb268","parent_tool_use_id":null,"uuid":"80eda895-c023-415c-9393-c58d70e82b38"}} +{"type":"emit_inbound","label":"stream_event","frame":{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"\""}},"session_id":"e51402d3-25be-4866-81d5-2ab30cecb268","parent_tool_use_id":null,"uuid":"36249eb1-accf-414b-88f1-b7f2f55c68a2"}} +{"type":"emit_inbound","label":"stream_event","frame":{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":", \"limi"}},"session_id":"e51402d3-25be-4866-81d5-2ab30cecb268","parent_tool_use_id":null,"uuid":"21b45389-6404-4e93-8e1b-700e996bfbb5"}} +{"type":"emit_inbound","label":"stream_event","frame":{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"t\": 1}"}},"session_id":"e51402d3-25be-4866-81d5-2ab30cecb268","parent_tool_use_id":null,"uuid":"493d77b0-2e83-47fe-a953-c6e7cb2eda59"}} +{"type":"emit_inbound","label":"assistant","frame":{"type":"assistant","message":{"model":"claude-sonnet-4-6","id":"msg_011CfXyMiXtkRS4bQYoe7LVs","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_019X6ALn5NrExXqfhzC3LHCP","name":"mcp__claude_ai_Firecrawl__firecrawl_map","input":{"url":"https://example.com","limit":1},"caller":{"type":"direct"}}],"container":null,"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":787,"cache_read_input_tokens":134558,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":787},"output_tokens":37,"service_tier":"standard","inference_geo":"not_available"},"diagnostics":null,"context_management":null},"parent_tool_use_id":null,"session_id":"e51402d3-25be-4866-81d5-2ab30cecb268","uuid":"0b290b49-0022-43e3-a828-aedb55fdd349","timestamp":"2026-09-29T15:32:20.649Z","wire_tool_inputs":{"toolu_019X6ALn5NrExXqfhzC3LHCP":{"url":"https://example.com","limit":1}}}} +{"type":"emit_inbound","label":"stream_event","frame":{"type":"stream_event","event":{"type":"content_block_stop","index":0},"session_id":"e51402d3-25be-4866-81d5-2ab30cecb268","parent_tool_use_id":null,"uuid":"864fb881-63f5-4b90-9c77-1e725ab597a4"}} +{"type":"emit_inbound","label":"stream_event","frame":{"type":"stream_event","event":{"type":"message_delta","delta":{"stop_reason":"tool_use","stop_sequence":null,"stop_details":null,"container":null},"usage":{"input_tokens":1,"cache_creation_input_tokens":787,"cache_read_input_tokens":134558,"output_tokens":90,"output_tokens_details":{"thinking_tokens":0},"iterations":[{"input_tokens":1,"output_tokens":90,"cache_read_input_tokens":134558,"cache_creation_input_tokens":787,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":787},"type":"message"}]},"context_management":{"applied_edits":[]}},"session_id":"e51402d3-25be-4866-81d5-2ab30cecb268","parent_tool_use_id":null,"uuid":"f89382a7-88b9-4b7c-8a34-844fd01020c0"}} +{"type":"emit_inbound","label":"stream_event","frame":{"type":"stream_event","event":{"type":"message_stop"},"session_id":"e51402d3-25be-4866-81d5-2ab30cecb268","parent_tool_use_id":null,"uuid":"9557dc84-46de-4ae5-8d21-c75b8d931ddb"}} +{"type":"emit_inbound","label":"user","frame":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_019X6ALn5NrExXqfhzC3LHCP","type":"tool_result","content":"{\"links\":[{\"url\":\"https://example.com/\",\"title\":\"Example Domain\"}],\"id\":\"01a0edcb-a3ec-762d-b334-eac9eaf6e0e2\"}"}]},"parent_tool_use_id":null,"session_id":"e51402d3-25be-4866-81d5-2ab30cecb268","uuid":"e11ebe37-f6cc-4566-b1d3-1ef09fe3b555","timestamp":"2026-09-29T15:32:21.366Z","tool_use_result":{"content":"{\"links\":[{\"url\":\"https://example.com/\",\"title\":\"Example Domain\"}],\"id\":\"01a0edcb-a3ec-762d-b334-eac9eaf6e0e2\"}","structuredContent":{"links":[{"url":"https://example.com/","title":"Example Domain"}],"id":"01a0edcb-a3ec-762d-b334-eac9eaf6e0e2"}}}} +{"type":"emit_inbound","label":"system","frame":{"type":"system","subtype":"status","status":"requesting","session_id":"e51402d3-25be-4866-81d5-2ab30cecb268","uuid":"5bc88c9e-c3a2-4f4c-9627-526f1328149d"}} +{"type":"emit_inbound","label":"stream_event","frame":{"type":"stream_event","event":{"type":"message_start","message":{"model":"claude-sonnet-4-6","id":"msg_011CfXyN16JbrX8sT6DrKgDY","type":"message","role":"assistant","content":[],"container":null,"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":153,"cache_read_input_tokens":135345,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":153},"output_tokens":1,"service_tier":"standard","inference_geo":"not_available"},"diagnostics":null,"context_management":null}},"session_id":"e51402d3-25be-4866-81d5-2ab30cecb268","parent_tool_use_id":null,"uuid":"29cd9f10-713b-4fa5-b078-cf002afa0f36","ttft_ms":1348}} +{"type":"emit_inbound","label":"stream_event","frame":{"type":"stream_event","event":{"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}},"session_id":"e51402d3-25be-4866-81d5-2ab30cecb268","parent_tool_use_id":null,"uuid":"3d567d6d-5900-46b1-93fc-d7fa3af9a497"}} +{"type":"emit_inbound","label":"stream_event","frame":{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"m"}},"session_id":"e51402d3-25be-4866-81d5-2ab30cecb268","parent_tool_use_id":null,"uuid":"c0fe9b4c-25ad-4b78-9ec7-46ee5ec7902c"}} +{"type":"emit_inbound","label":"stream_event","frame":{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"cp presentation fixture complete"}},"session_id":"e51402d3-25be-4866-81d5-2ab30cecb268","parent_tool_use_id":null,"uuid":"2d9455f8-b3ae-4745-975a-364397a78a48"}} +{"type":"emit_inbound","label":"assistant","frame":{"type":"assistant","message":{"model":"claude-sonnet-4-6","id":"msg_011CfXyN16JbrX8sT6DrKgDY","type":"message","role":"assistant","content":[{"type":"text","text":"mcp presentation fixture complete"}],"container":null,"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":153,"cache_read_input_tokens":135345,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":153},"output_tokens":1,"service_tier":"standard","inference_geo":"not_available"},"diagnostics":null,"context_management":null},"parent_tool_use_id":null,"session_id":"e51402d3-25be-4866-81d5-2ab30cecb268","uuid":"9e83bfdd-927a-4434-bc3e-7065c7fd4198","timestamp":"2026-09-29T15:32:22.734Z"}} +{"type":"emit_inbound","label":"stream_event","frame":{"type":"stream_event","event":{"type":"content_block_stop","index":0},"session_id":"e51402d3-25be-4866-81d5-2ab30cecb268","parent_tool_use_id":null,"uuid":"ba30c145-2f37-42a3-a72c-88bfb16d1b63"}} +{"type":"emit_inbound","label":"stream_event","frame":{"type":"stream_event","event":{"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null,"stop_details":null,"container":null},"usage":{"input_tokens":1,"cache_creation_input_tokens":153,"cache_read_input_tokens":135345,"output_tokens":8,"output_tokens_details":{"thinking_tokens":0},"iterations":[{"input_tokens":1,"output_tokens":8,"cache_read_input_tokens":135345,"cache_creation_input_tokens":153,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":153},"type":"message"}]},"context_management":{"applied_edits":[]}},"session_id":"e51402d3-25be-4866-81d5-2ab30cecb268","parent_tool_use_id":null,"uuid":"4275a6ef-1a6a-488d-82ab-5c5f04ef272a"}} +{"type":"emit_inbound","label":"stream_event","frame":{"type":"stream_event","event":{"type":"message_stop"},"session_id":"e51402d3-25be-4866-81d5-2ab30cecb268","parent_tool_use_id":null,"uuid":"c82d8732-2755-4c3c-8447-ece5d38ee460"}} +{"type":"emit_inbound","label":"result","frame":{"duration_api_ms":8468,"stop_reason":"end_turn","session_id":"e51402d3-25be-4866-81d5-2ab30cecb268","total_cost_usd":0.2111049,"usage":{"input_tokens":5,"cache_creation_input_tokens":14848,"cache_read_input_tokens":390553,"output_tokens":289,"output_tokens_details":{"thinking_tokens":95},"server_tool_use":{"web_search_requests":0,"web_fetch_requests":0},"service_tier":"standard","cache_creation":{"ephemeral_1h_input_tokens":14848,"ephemeral_5m_input_tokens":0},"inference_geo":"not_available","iterations":[{"input_tokens":1,"output_tokens":8,"cache_read_input_tokens":135345,"cache_creation_input_tokens":153,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":153},"type":"message"}],"speed":"standard"},"modelUsage":{"claude-sonnet-4-6":{"inputTokens":8,"outputTokens":300,"cacheReadInputTokens":391643,"cacheCreationInputTokens":14848,"webSearchRequests":0,"costUSD":0.2111049,"contextWindow":200000,"maxOutputTokens":32000,"thinkingTokens":95,"canonicalModel":"claude-sonnet-4-6","provider":"firstParty","costBasis":"list"}},"permission_denials":[],"terminal_reason":"completed","fast_mode_state":"off","fast_mode_disabled_reason":"sdk_opt_in_required","subagent_stats":{"spawned":0,"requested":{"background":0,"foreground":0,"unset":0},"started_in_background":0,"max_depth":0,"spawned_by_subagents":0,"completed":0,"failed":0,"killed":{"parent":0,"user":0,"system":0},"refused":{"depth_limit":0,"concurrency_limit":0,"budget":0},"by_type":{}},"is_error":false,"num_turns":3,"subtype":"success","api_error_status":null,"result":"mcp presentation fixture complete","ttft_ms":2932,"type":"result","duration_ms":8858,"uuid":"4561c900-5246-467b-8b6a-b16f2a116f64","ttft_stream_ms":1429,"time_to_request_ms":31,"first_content_frame_ms":1429,"user_message_uuid":"8935142f-0551-46e6-9a6b-95034332fa1a","user_message_uuids":["8935142f-0551-46e6-9a6b-95034332fa1a"],"request_sent_wall_ms":1790695934024,"queued_turn_count":0,"result_index":0}} +{"type":"runtime_exit","status":"success"} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/claude_mcp_tool_presentation/input.ts b/apps/server/src/orchestration-v2/testkit/fixtures/claude_mcp_tool_presentation/input.ts new file mode 100644 index 000000000..5c0796108 --- /dev/null +++ b/apps/server/src/orchestration-v2/testkit/fixtures/claude_mcp_tool_presentation/input.ts @@ -0,0 +1,14 @@ +import type { OrchestratorFixtureInput } from "../shared.ts"; + +export const CLAUDE_MCP_TOOL_PRESENTATION_PROMPT = [ + "Do exactly this, one tool call at a time, with no other tool calls:", + '1) Call the Firecrawl firecrawl_scrape tool on https://example.com with formats ["markdown"].', + "2) Call the Firecrawl firecrawl_map tool on https://example.com with limit 1.", + "3) Reply with exactly: mcp presentation fixture complete", +].join("\n"); + +export function claudeMcpToolPresentationInput(): OrchestratorFixtureInput { + return { + steps: [{ type: "message", text: CLAUDE_MCP_TOOL_PRESENTATION_PROMPT }], + }; +} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/claude_mcp_tool_presentation/output.ts b/apps/server/src/orchestration-v2/testkit/fixtures/claude_mcp_tool_presentation/output.ts new file mode 100644 index 000000000..c68b57d83 --- /dev/null +++ b/apps/server/src/orchestration-v2/testkit/fixtures/claude_mcp_tool_presentation/output.ts @@ -0,0 +1,51 @@ +import { assert } from "@effect/vitest"; +import type { ProviderReplayTranscript } from "@t3tools/contracts"; + +import type { OrchestratorV2ScenarioResult } from "../../OrchestratorScenario.ts"; +import { + assertBaseProjection, + assertSemanticProjectionIntegrity, + assertUserMessagesInclude, + assertVisibleTurnItemsMirrorLocalTurnItems, + projectionFor, +} from "../shared.ts"; +import { CLAUDE_MCP_TOOL_PRESENTATION_PROMPT } from "./input.ts"; + +// The scrape frame carries Claude Code's `tool_use_meta` as recorded. The map +// frame's entry was removed from the recording, so it stands in for an MCP +// tool Claude Code sends no display metadata for. +export function assertClaudeMcpToolPresentationOutput( + result: OrchestratorV2ScenarioResult, + transcript: ProviderReplayTranscript, +) { + assertBaseProjection({ result, transcript, runCount: 1, runStatuses: ["completed"] }); + const projection = projectionFor(result, transcript.scenario); + assertSemanticProjectionIntegrity(projection); + assertVisibleTurnItemsMirrorLocalTurnItems(projection); + assertUserMessagesInclude(projection, [CLAUDE_MCP_TOOL_PRESENTATION_PROMPT]); + + const tools = projection.turnItems.flatMap((item) => + item.type === "dynamic_tool" ? [item] : [], + ); + assert.deepEqual( + tools.map((item) => item.toolName), + ["mcp__claude_ai_Firecrawl__firecrawl_scrape", "mcp__claude_ai_Firecrawl__firecrawl_map"], + ); + const [scrape, map] = tools; + + assert.equal(scrape?.status, "completed"); + assert.equal(scrape?.title, "Firecrawl scrape"); + assert.deepEqual(scrape?.toolSource, { + key: "mcp:firecrawl", + name: "Firecrawl", + kind: "integration", + icon: { + _tag: "themed-logo", + logoUrl: "https://www.google.com/s2/favicons?domain=firecrawl.dev&sz=64", + }, + }); + + assert.equal(map?.status, "completed"); + assert.isNull(map?.title); + assert.notProperty(map, "toolSource"); +} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/index.ts b/apps/server/src/orchestration-v2/testkit/fixtures/index.ts index f45e75bad..5e3fac3fc 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/index.ts +++ b/apps/server/src/orchestration-v2/testkit/fixtures/index.ts @@ -32,6 +32,8 @@ import { claudeNestedBackgroundSubagentWakeInput } from "./claude_nested_backgro import { assertClaudeNestedBackgroundSubagentWakeOutput } from "./claude_nested_background_subagent_wake/output.ts"; import { claudeNestedSubagentModelInput } from "./claude_nested_subagent_model/input.ts"; import { assertClaudeNestedSubagentModelOutput } from "./claude_nested_subagent_model/output.ts"; +import { claudeMcpToolPresentationInput } from "./claude_mcp_tool_presentation/input.ts"; +import { assertClaudeMcpToolPresentationOutput } from "./claude_mcp_tool_presentation/output.ts"; import { claudeResultIsErrorInput } from "./claude_result_is_error/input.ts"; import { assertClaudeResultIsErrorOutput } from "./claude_result_is_error/output.ts"; import { grokAutoBlockedCommandInput } from "./grok_auto_blocked_command/input.ts"; @@ -421,6 +423,21 @@ export const ORCHESTRATOR_REPLAY_FIXTURES: ReadonlyArray item.type === "command_execution"); assert.isDefined(commandItem); - assert.equal(commandItem.status, "failed"); + assert.equal(commandItem.status, "interrupted"); assert.include(commandItem.input, "node -e"); const outboundFrames = transcript.entries.flatMap((entry) => diff --git a/apps/server/src/provider/Layers/ClaudeCapabilitiesProbe.test.ts b/apps/server/src/provider/Layers/ClaudeCapabilitiesProbe.test.ts index cf5fe1201..220e7b7a7 100644 --- a/apps/server/src/provider/Layers/ClaudeCapabilitiesProbe.test.ts +++ b/apps/server/src/provider/Layers/ClaudeCapabilitiesProbe.test.ts @@ -51,7 +51,9 @@ it.layer(NodeServices.layer)("Claude capability probe SDK boundary", (it) => { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-claude-probe-sdk-" }); - const executablePath = path.join(tempDir, "fake-claude.mjs"); + const executablePath = yield* path.fromFileUrl( + new URL("./testing/ClaudeCapabilitiesProbe.fixture.mjs", import.meta.url), + ); const invocationPath = path.join(tempDir, "invocation.json"); // The probe aborts the SDK without awaiting the child's exit, and on // Windows a directory that is still some process's cwd cannot be @@ -73,51 +75,6 @@ it.layer(NodeServices.layer)("Claude capability probe SDK boundary", (it) => { ), ); - yield* fs.writeFileString( - executablePath, - [ - "#!/usr/bin/env node", - 'import { existsSync, readFileSync, writeFileSync } from "node:fs";', - 'import { createInterface } from "node:readline";', - "const args = process.argv.slice(2);", - 'const mcpConfigIndex = args.indexOf("--mcp-config");', - "const rawMcpConfig = mcpConfigIndex >= 0 ? args[mcpConfigIndex + 1] : undefined;", - "let mcpConfig;", - "if (rawMcpConfig) {", - ' const contents = existsSync(rawMcpConfig) ? readFileSync(rawMcpConfig, "utf8") : rawMcpConfig;', - " try { mcpConfig = JSON.parse(contents); } catch { mcpConfig = contents; }", - "}", - "writeFileSync(process.env.T3_PROBE_INVOCATION_PATH, JSON.stringify({", - " args,", - " cwd: process.cwd(),", - " connectorEnv: process.env.ENABLE_CLAUDEAI_MCP_SERVERS,", - " mcpConfig,", - "}));", - "const lines = createInterface({ input: process.stdin });", - 'lines.on("line", (line) => {', - " const message = JSON.parse(line);", - ' if (message.type !== "control_request" || message.request?.subtype !== "initialize") return;', - " process.stdout.write(JSON.stringify({", - ' type: "control_response",', - " response: {", - ' subtype: "success",', - " request_id: message.request_id,", - " response: {", - ' commands: [{ name: "review", description: "Review changes", argumentHint: "[path]" }],', - " agents: [],", - ' output_style: "default",', - ' available_output_styles: ["default"],', - " models: [],", - ' account: { email: "dev@example.com", subscriptionType: "pro", tokenSource: "oauth" },', - " },", - " },", - ' }) + "\\n");', - "});", - "", - ].join("\n"), - ); - yield* fs.chmod(executablePath, 0o755); - const capabilities = yield* probeClaudeCapabilities( decodeClaudeSettings({ binaryPath: executablePath }), { diff --git a/apps/server/src/provider/Layers/testing/ClaudeCapabilitiesProbe.fixture.mjs b/apps/server/src/provider/Layers/testing/ClaudeCapabilitiesProbe.fixture.mjs new file mode 100755 index 000000000..351781415 --- /dev/null +++ b/apps/server/src/provider/Layers/testing/ClaudeCapabilitiesProbe.fixture.mjs @@ -0,0 +1,49 @@ +#!/usr/bin/env node +import * as NodeFS from "node:fs"; +import * as NodeReadline from "node:readline"; +const args = process.argv.slice(2); +const mcpConfigIndex = args.indexOf("--mcp-config"); +const rawMcpConfig = mcpConfigIndex >= 0 ? args[mcpConfigIndex + 1] : undefined; +let mcpConfig; +if (rawMcpConfig) { + const contents = NodeFS.existsSync(rawMcpConfig) + ? NodeFS.readFileSync(rawMcpConfig, "utf8") + : rawMcpConfig; + try { + mcpConfig = JSON.parse(contents); + } catch { + mcpConfig = contents; + } +} +NodeFS.writeFileSync( + process.env.T3_PROBE_INVOCATION_PATH, + JSON.stringify({ + args, + cwd: process.cwd(), + connectorEnv: process.env.ENABLE_CLAUDEAI_MCP_SERVERS, + mcpConfig, + }), +); +// Only stdin keeps this process alive, so it exits with its parent. +const lines = NodeReadline.createInterface({ input: process.stdin }); +lines.on("line", (line) => { + const message = JSON.parse(line); + if (message.type !== "control_request" || message.request?.subtype !== "initialize") return; + process.stdout.write( + JSON.stringify({ + type: "control_response", + response: { + subtype: "success", + request_id: message.request_id, + response: { + commands: [{ name: "review", description: "Review changes", argumentHint: "[path]" }], + agents: [], + output_style: "default", + available_output_styles: ["default"], + models: [], + account: { email: "dev@example.com", subscriptionType: "pro", tokenSource: "oauth" }, + }, + }, + }) + "\n", + ); +});