diff --git a/src/agentMode/sdk/ClaudeSdkBackendProcess.test.ts b/src/agentMode/sdk/ClaudeSdkBackendProcess.test.ts index 8b9b0bbb..663aa178 100644 --- a/src/agentMode/sdk/ClaudeSdkBackendProcess.test.ts +++ b/src/agentMode/sdk/ClaudeSdkBackendProcess.test.ts @@ -360,6 +360,75 @@ describe("ClaudeSdkBackendProcess", () => { ).rejects.toThrow(new Error(USAGE_LIMIT_MESSAGE)); }); + it("preserves background task identity across prompt queries", async () => { + queryMock + .mockImplementationOnce(() => + makeQuery([ + streamEvent({ + type: "content_block_start", + index: 0, + content_block: { type: "tool_use", id: "tu-launch", name: "Agent", input: {} }, + }), + { + type: "user", + tool_use_result: { + isAsync: true, + status: "async_launched", + agentId: "task-a", + }, + message: { + content: [ + { + type: "tool_result", + tool_use_id: "tu-launch", + content: "Async agent launched successfully.", + }, + ], + }, + parent_tool_use_id: null, + session_id: "irrelevant", + } as unknown as SDKMessage, + resultMessage(), + ]) + ) + .mockImplementationOnce(() => + makeQuery([ + { + type: "system", + subtype: "task_notification", + task_id: "task-a", + status: "completed", + summary: "late report", + } as unknown as SDKMessage, + resultMessage(), + ]) + ); + + const proc = new ClaudeSdkBackendProcess({ + pathToClaudeCodeExecutable: "/usr/local/bin/claude", + // eslint-disable-next-line @typescript-eslint/no-explicit-any + app: { vault: {} } as any, + clientVersion: "1.2.3", + descriptor: fakeDescriptor(), + }); + const { sessionId } = await proc.newSession({ cwd: "/vault", mcpServers: [] }); + const events: SessionEvent[] = []; + proc.registerSessionHandler(sessionId, (event) => events.push(event)); + + await proc.prompt({ sessionId, prompt: [{ type: "text", text: "start" }] }); + await proc.prompt({ sessionId, prompt: [{ type: "text", text: "continue" }] }); + + expect(events).toContainEqual({ + sessionId, + update: { + sessionUpdate: "tool_call_update", + toolCallId: "tu-launch", + status: "completed", + content: [{ type: "content", content: { type: "text", text: "late report" } }], + }, + }); + }); + it("forwards the composed system prompt via systemPrompt append on the claude_code preset", async () => { queryMock.mockImplementation(() => makeQuery([streamEvent({ type: "message_start", message: {} }), resultMessage()]) diff --git a/src/agentMode/sdk/ClaudeSdkBackendProcess.ts b/src/agentMode/sdk/ClaudeSdkBackendProcess.ts index df37e3c6..708d533b 100644 --- a/src/agentMode/sdk/ClaudeSdkBackendProcess.ts +++ b/src/agentMode/sdk/ClaudeSdkBackendProcess.ts @@ -61,6 +61,7 @@ import type { import type { ProjectScopeId } from "@/agentMode/session/scope"; import { AuthRequiredError, MethodUnsupportedError } from "@/agentMode/session/errors"; import { createClaudeTaskPlanState, type ClaudeTaskPlanState } from "./claudeTodoPlan"; +import { ClaudeBackgroundTaskStateMachine } from "./claudeTaskProtocol"; import { createTranslatorState, mapStopReason, translateSdkMessage } from "./sdkMessageTranslator"; import { PermissionBridge, type AskUserQuestionPrompter } from "./permissionBridge"; import { @@ -116,6 +117,8 @@ interface SessionState { * (translator state is per-query; Task ids must survive turns). */ claudeTaskPlan: ClaudeTaskPlanState; + /** Session-lived correlation for background launches that outlast one query. */ + backgroundTasks: ClaudeBackgroundTaskStateMachine; active?: Query; /** * Snapshot of the composed Copilot system prompt (base framing + pill-syntax @@ -366,6 +369,7 @@ export class ClaudeSdkBackendProcess implements BackendProcess { additionalDirectories: params.additionalDirectories, systemPromptAppend: this.resolveSystemPromptAppend(params.projectId), claudeTaskPlan: createClaudeTaskPlanState(), + backgroundTasks: new ClaudeBackgroundTaskStateMachine(), }); const state = this.computeState(sessionId); @@ -504,7 +508,7 @@ export class ClaudeSdkBackendProcess implements BackendProcess { onStall: (idleMs) => logSdkError("←", "stream:stalled", { idleMs }, params.sessionId), }); - const translatorState = createTranslatorState(session.claudeTaskPlan); + const translatorState = createTranslatorState(session.claudeTaskPlan, session.backgroundTasks); let stopReason: StopReason = "end_turn"; let resultErrorMessage: string | null = null; try { @@ -774,6 +778,7 @@ export class ClaudeSdkBackendProcess implements BackendProcess { additionalDirectories: params.additionalDirectories, systemPromptAppend: this.resolveSystemPromptAppend(params.projectId), claudeTaskPlan: createClaudeTaskPlanState(), + backgroundTasks: new ClaudeBackgroundTaskStateMachine(), }); const state = this.computeState(params.sessionId); diff --git a/src/agentMode/sdk/claudeTaskProtocol.test.ts b/src/agentMode/sdk/claudeTaskProtocol.test.ts new file mode 100644 index 00000000..7ad0eccd --- /dev/null +++ b/src/agentMode/sdk/claudeTaskProtocol.test.ts @@ -0,0 +1,672 @@ +import type { SDKMessage } from "@anthropic-ai/claude-agent-sdk"; +import { ClaudeBackgroundTaskStateMachine } from "./claudeTaskProtocol"; + +// These fixtures mirror carriers captured from Claude Code 2.1.206. Keeping +// the carrier shapes visible makes each test read as one protocol transcript. +interface ToolResultFixture { + id: string; + content?: unknown; + isError?: boolean; +} + +function userMessage( + toolResults: ToolResultFixture[], + toolUseResult?: Record +): SDKMessage { + return { + type: "user", + ...(toolUseResult ? { tool_use_result: toolUseResult } : {}), + message: { + content: toolResults.map((result) => ({ + type: "tool_result", + tool_use_id: result.id, + content: result.content ?? "done", + ...(result.isError ? { is_error: true } : {}), + })), + }, + parent_tool_use_id: null, + session_id: "test-session", + } as unknown as SDKMessage; +} + +function launchAck(agentId: string, ...toolCallIds: string[]): SDKMessage { + return userMessage( + toolCallIds.map((id) => ({ id, content: "Async agent launched successfully." })), + { isAsync: true, status: "async_launched", agentId } + ); +} + +function systemMessage(frame: Record): SDKMessage { + return { type: "system", ...frame } as unknown as SDKMessage; +} + +function observeTool( + protocol: ClaudeBackgroundTaskStateMachine, + toolCallId: string, + nativeToolName: string +): void { + protocol.accept({ kind: "tool_snapshot", toolCallId, nativeToolName }); +} + +function acceptMessage(protocol: ClaudeBackgroundTaskStateMachine, message: SDKMessage) { + return protocol.accept({ kind: "sdk_message", message }); +} + +describe("claudeTaskProtocol", () => { + describe("ClaudeBackgroundTaskStateMachine", () => { + describe("accept()", () => { + it("returns one stable empty decision for unrelated carriers", () => { + const protocol = new ClaudeBackgroundTaskStateMachine(); + + const first = protocol.accept({ + kind: "tool_snapshot", + toolCallId: "read-a", + nativeToolName: "Read", + }); + const second = protocol.accept({ + kind: "tool_snapshot", + toolCallId: "mcp-task", + }); + + expect(first).toEqual({ updates: [], resultActions: new Map() }); + expect(second).toBe(first); + }); + + it("binds a launch from task_started before its asynchronous acknowledgement", () => { + const protocol = new ClaudeBackgroundTaskStateMachine(); + observeTool(protocol, "launch", "Agent"); + + const started = acceptMessage( + protocol, + systemMessage({ + subtype: "task_started", + task_id: "task-a", + tool_use_id: "launch", + }) + ); + const acknowledged = acceptMessage(protocol, launchAck("task-a", "launch")); + + expect(started.updates).toEqual([{ toolCallId: "launch", status: "in_progress" }]); + expect(acknowledged.resultActions.get("launch")).toEqual({ kind: "omit" }); + }); + + it("correlates task-only progress and terminal frames after the launch acknowledgement", () => { + const protocol = new ClaudeBackgroundTaskStateMachine(); + observeTool(protocol, "launch", "Agent"); + acceptMessage(protocol, launchAck("task-a", "launch")); + + const progress = acceptMessage( + protocol, + systemMessage({ + subtype: "task_progress", + task_id: "task-a", + description: "Count notes", + }) + ); + const completed = acceptMessage( + protocol, + systemMessage({ + subtype: "task_notification", + task_id: "task-a", + status: "completed", + summary: "done", + }) + ); + + expect(progress.updates).toEqual([ + { + toolCallId: "launch", + status: "in_progress", + progress: { description: "Count notes" }, + }, + ]); + expect(completed.updates).toEqual([ + { + toolCallId: "launch", + status: "completed", + content: [{ type: "content", content: { type: "text", text: "done" } }], + }, + ]); + }); + + it("keeps identified foreground task identity terminal after its ordinary result", () => { + const protocol = new ClaudeBackgroundTaskStateMachine(); + observeTool(protocol, "launch", "Agent"); + acceptMessage( + protocol, + systemMessage({ + subtype: "task_started", + task_id: "task-a", + tool_use_id: "launch", + }) + ); + + const result = acceptMessage( + protocol, + userMessage([{ id: "launch", content: "foreground report" }]) + ); + const lateProgress = acceptMessage( + protocol, + systemMessage({ + subtype: "task_progress", + task_id: "task-a", + description: "too late", + }) + ); + observeTool(protocol, "replacement", "Agent"); + const duplicateIdentity = acceptMessage( + protocol, + systemMessage({ + subtype: "task_started", + task_id: "task-a", + tool_use_id: "replacement", + }) + ); + + expect(result).toEqual({ updates: [], resultActions: new Map() }); + expect(lateProgress.updates).toEqual([]); + expect(duplicateIdentity.updates).toEqual([]); + }); + + it("binds late foreground task identity without reopening a terminal launch", () => { + const protocol = new ClaudeBackgroundTaskStateMachine(); + observeTool(protocol, "launch", "Agent"); + acceptMessage( + protocol, + userMessage([{ id: "launch", content: "foreground report", isError: true }]) + ); + + const lateStart = acceptMessage( + protocol, + systemMessage({ + subtype: "task_started", + task_id: "task-a", + tool_use_id: "launch", + }) + ); + const lateProgress = acceptMessage( + protocol, + systemMessage({ + subtype: "task_progress", + task_id: "task-a", + description: "too late", + }) + ); + observeTool(protocol, "replacement", "Task"); + const duplicateIdentity = acceptMessage( + protocol, + systemMessage({ + subtype: "task_started", + task_id: "task-a", + tool_use_id: "replacement", + }) + ); + + expect(lateStart.updates).toEqual([]); + expect(lateProgress.updates).toEqual([]); + expect(duplicateIdentity.updates).toEqual([]); + }); + + it("ignores task-only frames without an unambiguous launch binding", () => { + const unknown = new ClaudeBackgroundTaskStateMachine(); + expect( + acceptMessage( + unknown, + systemMessage({ + subtype: "task_progress", + task_id: "unknown-task", + description: "working", + }) + ).updates + ).toEqual([]); + + const ambiguous = new ClaudeBackgroundTaskStateMachine(); + observeTool(ambiguous, "launch-a", "Agent"); + observeTool(ambiguous, "launch-b", "Task"); + acceptMessage(ambiguous, launchAck("task-a", "launch-a", "launch-b")); + + expect( + acceptMessage( + ambiguous, + systemMessage({ + subtype: "task_notification", + task_id: "task-a", + status: "completed", + summary: "done", + }) + ).updates + ).toEqual([]); + }); + + it("does not let an unsupported system subtype establish task identity", () => { + const protocol = new ClaudeBackgroundTaskStateMachine(); + observeTool(protocol, "launch", "Agent"); + + const unsupported = acceptMessage( + protocol, + systemMessage({ + subtype: "task_checkpoint", + task_id: "task-a", + tool_use_id: "launch", + }) + ); + const laterStart = acceptMessage( + protocol, + systemMessage({ + subtype: "task_started", + task_id: "task-b", + tool_use_id: "launch", + }) + ); + + expect(unsupported.updates).toEqual([]); + expect(laterStart.updates).toEqual([{ toolCallId: "launch", status: "in_progress" }]); + }); + + it("settles a bound launch from a terminal task_updated patch", () => { + const protocol = new ClaudeBackgroundTaskStateMachine(); + observeTool(protocol, "launch", "Agent"); + acceptMessage( + protocol, + systemMessage({ + subtype: "task_started", + task_id: "task-a", + tool_use_id: "launch", + }) + ); + + const updated = acceptMessage( + protocol, + systemMessage({ + subtype: "task_updated", + task_id: "task-a", + patch: { status: "completed" }, + }) + ); + + expect(updated.updates).toEqual([{ toolCallId: "launch", status: "completed" }]); + }); + + it("fails a bound launch from a killed task_updated patch and surfaces its error", () => { + const protocol = new ClaudeBackgroundTaskStateMachine(); + observeTool(protocol, "launch", "Agent"); + acceptMessage(protocol, launchAck("task-a", "launch")); + + const killed = acceptMessage( + protocol, + systemMessage({ + subtype: "task_updated", + task_id: "task-a", + patch: { status: "killed", error: "killed by user" }, + }) + ); + + expect(killed.updates).toEqual([ + { + toolCallId: "launch", + status: "failed", + content: [{ type: "content", content: { type: "text", text: "killed by user" } }], + }, + ]); + }); + + it("ignores non-terminal task_updated patches", () => { + const protocol = new ClaudeBackgroundTaskStateMachine(); + observeTool(protocol, "launch", "Agent"); + acceptMessage(protocol, launchAck("task-a", "launch")); + + const running = acceptMessage( + protocol, + systemMessage({ + subtype: "task_updated", + task_id: "task-a", + patch: { status: "running", description: "still going" }, + }) + ); + + expect(running.updates).toEqual([]); + }); + + it("replays a task-only terminal frame once the launch acknowledgement binds", () => { + const protocol = new ClaudeBackgroundTaskStateMachine(); + observeTool(protocol, "launch", "Agent"); + + const early = acceptMessage( + protocol, + systemMessage({ + subtype: "task_notification", + task_id: "task-a", + status: "completed", + summary: "done", + }) + ); + const acknowledged = acceptMessage(protocol, launchAck("task-a", "launch")); + + expect(early.updates).toEqual([]); + expect(acknowledged.updates).toEqual([ + { + toolCallId: "launch", + status: "completed", + content: [{ type: "content", content: { type: "text", text: "done" } }], + }, + ]); + expect(acknowledged.resultActions.get("launch")).toEqual({ kind: "omit" }); + }); + + it("does not infer task identity from prompt or description", () => { + const protocol = new ClaudeBackgroundTaskStateMachine(); + observeTool(protocol, "other", "Agent"); + observeTool(protocol, "background", "Task"); + + const acknowledged = acceptMessage( + protocol, + userMessage( + [ + { id: "other", content: "foreground report" }, + { id: "background", content: "opaque internal acknowledgement" }, + ], + { + isAsync: true, + status: "async_launched", + agentId: "task-a", + prompt: "background prompt", + description: "Background task", + } + ) + ); + const taskOnlyProgress = acceptMessage( + protocol, + systemMessage({ + subtype: "task_progress", + task_id: "task-a", + description: "working", + }) + ); + const started = acceptMessage( + protocol, + systemMessage({ + subtype: "task_started", + task_id: "task-a", + tool_use_id: "background", + }) + ); + + expect(acknowledged.resultActions).toEqual(new Map()); + expect(taskOnlyProgress.updates).toEqual([]); + expect(started.updates).toEqual([{ toolCallId: "background", status: "in_progress" }]); + }); + + it("uses an explicit task binding to select an acknowledgement from multiple launches", () => { + const protocol = new ClaudeBackgroundTaskStateMachine(); + observeTool(protocol, "launch-a", "Agent"); + observeTool(protocol, "launch-b", "Task"); + + const started = acceptMessage( + protocol, + systemMessage({ + subtype: "task_started", + task_id: "task-a", + tool_use_id: "launch-b", + }) + ); + + const acknowledged = acceptMessage( + protocol, + userMessage( + [ + { id: "launch-a", content: "first result" }, + { id: "launch-b", content: "second result" }, + ], + { + isAsync: true, + status: "async_launched", + agentId: "task-a", + } + ) + ); + + expect(started.updates).toEqual([{ toolCallId: "launch-b", status: "in_progress" }]); + expect(acknowledged.resultActions).toEqual(new Map([["launch-b", { kind: "omit" }]])); + }); + + it("preserves an earlier pending terminal report when a later patch has no output", () => { + const protocol = new ClaudeBackgroundTaskStateMachine(); + observeTool(protocol, "launch", "Agent"); + + acceptMessage( + protocol, + systemMessage({ + subtype: "task_notification", + task_id: "task-a", + status: "completed", + summary: "done", + }) + ); + acceptMessage( + protocol, + systemMessage({ + subtype: "task_updated", + task_id: "task-a", + patch: { status: "completed" }, + }) + ); + + const acknowledged = acceptMessage(protocol, launchAck("task-a", "launch")); + + expect(acknowledged.updates).toEqual([ + { + toolCallId: "launch", + status: "completed", + content: [{ type: "content", content: { type: "text", text: "done" } }], + }, + ]); + }); + + it("normalizes progress and never regresses a terminal launch", () => { + const protocol = new ClaudeBackgroundTaskStateMachine(); + observeTool(protocol, "launch", "Agent"); + + const progress = acceptMessage( + protocol, + systemMessage({ + subtype: "task_progress", + task_id: "task-a", + tool_use_id: "launch", + description: "Count notes", + last_tool_name: "Glob", + usage: { + tool_uses: 3, + duration_ms: 9851, + total_tokens: Number.POSITIVE_INFINITY, + }, + }) + ); + const completed = acceptMessage( + protocol, + systemMessage({ + subtype: "task_notification", + task_id: "task-a", + tool_use_id: "launch", + status: "completed", + summary: "done", + }) + ); + const lateProgress = acceptMessage( + protocol, + systemMessage({ + subtype: "task_progress", + task_id: "task-a", + tool_use_id: "launch", + }) + ); + const conflictingFailure = acceptMessage( + protocol, + systemMessage({ + subtype: "task_notification", + task_id: "task-a", + tool_use_id: "launch", + status: "failed", + summary: "late failure", + }) + ); + + expect(progress.updates).toEqual([ + { + toolCallId: "launch", + status: "in_progress", + progress: { + description: "Count notes", + toolName: "Glob", + toolUses: 3, + durationMs: 9851, + }, + }, + ]); + expect(completed.updates).toEqual([ + { + toolCallId: "launch", + status: "completed", + content: [{ type: "content", content: { type: "text", text: "done" } }], + }, + ]); + expect(lateProgress.updates).toEqual([]); + expect(conflictingFailure.updates).toEqual([]); + }); + + it("uses a prior task binding to suppress a batched asynchronous acknowledgement", () => { + const protocol = new ClaudeBackgroundTaskStateMachine(); + observeTool(protocol, "foreground", "Agent"); + observeTool(protocol, "background", "Task"); + acceptMessage( + protocol, + systemMessage({ + subtype: "task_started", + task_id: "task-a", + tool_use_id: "background", + }) + ); + + const acknowledged = acceptMessage( + protocol, + userMessage( + [ + { id: "foreground", content: "foreground report" }, + { id: "background", content: "opaque internal acknowledgement" }, + ], + { isAsync: true, status: "async_launched", agentId: "task-a" } + ) + ); + const progress = acceptMessage( + protocol, + systemMessage({ + subtype: "task_progress", + task_id: "task-a", + description: "Count notes", + }) + ); + + expect(acknowledged.resultActions).toEqual(new Map([["background", { kind: "omit" }]])); + expect(progress.updates).toEqual([ + { + toolCallId: "background", + status: "in_progress", + progress: { description: "Count notes" }, + }, + ]); + expect( + acceptMessage( + protocol, + systemMessage({ + subtype: "task_started", + task_id: "task-a", + tool_use_id: "foreground", + }) + ).updates + ).toEqual([]); + }); + + it("retains terminal identity until a delayed output frame completes the card", () => { + const protocol = new ClaudeBackgroundTaskStateMachine(); + observeTool(protocol, "launch", "Agent"); + acceptMessage( + protocol, + systemMessage({ + subtype: "task_started", + task_id: "task-a", + tool_use_id: "launch", + }) + ); + const terminalStatus = acceptMessage( + protocol, + systemMessage({ + subtype: "task_notification", + task_id: "task-a", + status: "completed", + }) + ); + + const terminalOutput = acceptMessage( + protocol, + systemMessage({ + subtype: "task_notification", + task_id: "task-a", + status: "completed", + summary: "late", + }) + ); + const afterCompletion = acceptMessage( + protocol, + systemMessage({ + subtype: "task_progress", + task_id: "task-a", + description: "too late", + }) + ); + observeTool(protocol, "replacement", "Task"); + const duplicateIdentity = acceptMessage( + protocol, + systemMessage({ + subtype: "task_started", + task_id: "task-a", + tool_use_id: "replacement", + }) + ); + + expect(terminalStatus.updates).toEqual([{ toolCallId: "launch", status: "completed" }]); + expect(terminalOutput.updates).toEqual([ + { + toolCallId: "launch", + status: "completed", + content: [{ type: "content", content: { type: "text", text: "late" } }], + }, + ]); + expect(afterCompletion.updates).toEqual([]); + expect(duplicateIdentity.updates).toEqual([]); + }); + + it("preserves the first terminal status when an ordinary result supplies late output", () => { + const protocol = new ClaudeBackgroundTaskStateMachine(); + observeTool(protocol, "launch", "Agent"); + acceptMessage(protocol, launchAck("task-a", "launch")); + acceptMessage( + protocol, + systemMessage({ + subtype: "task_notification", + task_id: "task-a", + status: "completed", + }) + ); + + const lateOutput = acceptMessage( + protocol, + userMessage([{ id: "launch", content: "late output", isError: true }]) + ); + + expect(lateOutput.resultActions.get("launch")).toEqual({ + kind: "preserve_status", + status: "completed", + }); + }); + }); + }); +}); diff --git a/src/agentMode/sdk/claudeTaskProtocol.ts b/src/agentMode/sdk/claudeTaskProtocol.ts new file mode 100644 index 00000000..59a9a056 --- /dev/null +++ b/src/agentMode/sdk/claudeTaskProtocol.ts @@ -0,0 +1,551 @@ +/** + * @fileoverview Owns Claude Agent/Task identity, lifecycle, and terminal output merging. + * + * One logical task has two identities whose carriers may arrive out of order: + * `L`, the Agent/Task launch tool-call ID, and `T`, Claude's task ID. The launch + * moves through awaiting identity, active, and terminal. Execution state and + * output availability remain separate, identified correlation lasts for the + * SDK session, and terminal status never regresses. + * Keeping those rules behind one session-owned interface prevents the translator + * from coordinating maps whose invariants can drift apart. + */ +import type { SDKMessage } from "@anthropic-ai/claude-agent-sdk"; +import type { + AgentToolProgress, + AgentToolStatus, + ToolCallContent, +} from "@/agentMode/session/types"; + +interface AwaitingTaskIdentity { + phase: "awaiting_identity"; + toolCallId: string; + candidateTaskId?: string; +} + +interface ActiveTaskLaunch { + phase: "active"; + toolCallId: string; + taskId: string; +} + +interface TerminalTaskLaunch { + phase: "terminal"; + toolCallId: string; + taskId?: string; + terminalStatus: TerminalTaskStatus; + outputAvailable: boolean; +} + +type IdentifiedTaskLaunch = ActiveTaskLaunch | (TerminalTaskLaunch & { taskId: string }); + +type TaskLaunch = AwaitingTaskIdentity | ActiveTaskLaunch | TerminalTaskLaunch; + +export type ClaudeTaskCarrier = + | { + kind: "tool_snapshot"; + toolCallId: string; + nativeToolName?: string; + } + | { kind: "sdk_message"; message: SDKMessage }; + +export interface ClaudeTaskToolUpdate { + toolCallId: string; + status?: AgentToolStatus; + content?: ToolCallContent[]; + progress?: AgentToolProgress; +} + +type TerminalTaskStatus = "completed" | "failed"; + +export type ClaudeTaskResultAction = + | { kind: "omit" } + | { kind: "preserve_status"; status: TerminalTaskStatus }; + +export interface ClaudeTaskDecision { + updates: readonly ClaudeTaskToolUpdate[]; + resultActions: ReadonlyMap; +} + +interface ClaudeToolResultBlock { + toolCallId: string; + status: TerminalTaskStatus; +} + +interface PendingTerminalFrame { + status: TerminalTaskStatus; + output?: string; + progress?: AgentToolProgress; +} + +type ClaudeTaskProtocolEvent = + | { + kind: "launch_observed"; + toolCallId: string; + } + | { + kind: "task_active"; + toolCallId?: string; + taskId: string; + progress?: AgentToolProgress; + } + | { + kind: "task_terminal"; + toolCallId?: string; + taskId: string; + status: TerminalTaskStatus; + output?: string; + progress?: AgentToolProgress; + } + | { + kind: "result_batch"; + results: readonly ClaudeToolResultBlock[]; + asyncLaunchTaskId?: string; + }; + +const EMPTY_TASK_UPDATES: readonly ClaudeTaskToolUpdate[] = Object.freeze([]); +const EMPTY_RESULT_ACTIONS: ReadonlyMap = new Map(); +const NO_TASK_DECISION: ClaudeTaskDecision = Object.freeze({ + updates: EMPTY_TASK_UPDATES, + resultActions: EMPTY_RESULT_ACTIONS, +}); + +/** + * Reconciles Claude Agent/Task carriers for one SDK session. + * + * Callers submit carrier snapshots without coordinating their order. The + * protocol owns foreground and background identity correlation across query + * boundaries plus monotonic execution state; callers only project its decisions + * to session events. + */ +export class ClaudeBackgroundTaskStateMachine { + private readonly launchesByToolCallId = new Map(); + private readonly launchToolCallIdByTaskId = new Map(); + // A fast task can settle with only its task id before the launch + // acknowledgement supplies the binding; the frame is parked here and + // replayed by `bindLaunch` the moment the identity arrives. + private readonly pendingTerminalsByTaskId = new Map(); + private readonly replayedUpdates: ClaudeTaskToolUpdate[] = []; + + /** + * Applies one carrier atomically and returns every resulting protocol decision. + * + * @param carrier A tool snapshot or SDK message from the owning session. + */ + accept(carrier: ClaudeTaskCarrier): ClaudeTaskDecision { + const protocolEvent = protocolEventFromCarrier(carrier); + if (!protocolEvent) return NO_TASK_DECISION; + const decision = this.transition(protocolEvent); + if (this.replayedUpdates.length === 0) return decision; + const replays = this.replayedUpdates.splice(0); + return { + updates: [...replays, ...decision.updates], + resultActions: decision.resultActions, + }; + } + + private transition(protocolEvent: ClaudeTaskProtocolEvent): ClaudeTaskDecision { + switch (protocolEvent.kind) { + case "launch_observed": + if (!this.launchesByToolCallId.has(protocolEvent.toolCallId)) { + this.launchesByToolCallId.set(protocolEvent.toolCallId, { + phase: "awaiting_identity", + toolCallId: protocolEvent.toolCallId, + }); + } + return NO_TASK_DECISION; + case "task_active": + return this.transitionActiveTask(protocolEvent); + case "task_terminal": + return this.transitionTerminalTask(protocolEvent); + case "result_batch": + return this.transitionResultBatch(protocolEvent); + } + } + + private transitionActiveTask( + protocolEvent: Extract + ): ClaudeTaskDecision { + const launch = this.resolveLaunch(protocolEvent.toolCallId, protocolEvent.taskId); + if (!launch || isTerminalLaunch(launch)) return NO_TASK_DECISION; + return taskDecision({ + toolCallId: launch.toolCallId, + status: "in_progress", + ...(protocolEvent.progress ? { progress: protocolEvent.progress } : {}), + }); + } + + private transitionTerminalTask( + protocolEvent: Extract + ): ClaudeTaskDecision { + const launch = this.resolveLaunch(protocolEvent.toolCallId, protocolEvent.taskId); + if (!launch) { + // Park only task-only frames: a frame carrying an explicit tool_use_id + // that still fails to resolve refers to a launch this session never + // observed (or a conflicting identity), and replaying it later would + // fabricate a card. + if (protocolEvent.toolCallId === undefined) { + const pending = this.pendingTerminalsByTaskId.get(protocolEvent.taskId); + this.pendingTerminalsByTaskId.set(protocolEvent.taskId, { + status: pending?.status ?? protocolEvent.status, + output: pending?.output ?? protocolEvent.output, + progress: + pending?.progress && protocolEvent.progress + ? { ...pending.progress, ...protocolEvent.progress } + : (pending?.progress ?? protocolEvent.progress), + }); + } + return NO_TASK_DECISION; + } + const update = this.mergeTerminal( + launch, + protocolEvent.status, + protocolEvent.output, + protocolEvent.progress + ); + return update ? taskDecision(update) : NO_TASK_DECISION; + } + + private transitionResultBatch( + protocolEvent: Extract + ): ClaudeTaskDecision { + const resultActions = new Map(); + const launchAckToolCallId = protocolEvent.asyncLaunchTaskId + ? this.launchAckOwner(protocolEvent.asyncLaunchTaskId, protocolEvent.results) + : undefined; + if (launchAckToolCallId) resultActions.set(launchAckToolCallId, { kind: "omit" }); + + const ambiguousTaskId = + protocolEvent.asyncLaunchTaskId && + !launchAckToolCallId && + !this.launchToolCallIdByTaskId.has(protocolEvent.asyncLaunchTaskId) && + protocolEvent.results.filter( + ({ toolCallId }) => this.launchesByToolCallId.get(toolCallId)?.phase === "awaiting_identity" + ).length > 1 + ? protocolEvent.asyncLaunchTaskId + : undefined; + + // Async acknowledgements stay active; every unambiguous ordinary result is + // terminal because the normal result pipeline already owns its final output. + for (const { toolCallId, status } of protocolEvent.results) { + const launch = this.launchesByToolCallId.get(toolCallId); + if (launch && toolCallId !== launchAckToolCallId) { + if (launch.phase === "awaiting_identity" && ambiguousTaskId) { + this.launchesByToolCallId.set(toolCallId, { + ...launch, + candidateTaskId: ambiguousTaskId, + }); + } else { + if (launch.phase === "terminal") { + resultActions.set(toolCallId, { + kind: "preserve_status", + status: launch.terminalStatus, + }); + } + this.markForegroundResult(launch, status); + } + } + } + + return resultActions.size === 0 + ? NO_TASK_DECISION + : { updates: EMPTY_TASK_UPDATES, resultActions }; + } + + private bindLaunch(toolCallId: string, taskId: string): IdentifiedTaskLaunch | undefined { + const launch = this.launchesByToolCallId.get(toolCallId); + if (!launch) return undefined; + if (launch.phase === "active" && launch.taskId !== taskId) return undefined; + if (launch.phase === "terminal" && launch.taskId && launch.taskId !== taskId) return undefined; + const existing = this.launchToolCallIdByTaskId.get(taskId); + if (existing && existing !== toolCallId) return undefined; + if (launch.phase === "awaiting_identity" && launch.candidateTaskId !== undefined) { + if (launch.candidateTaskId !== taskId) return undefined; + this.pruneAmbiguousCandidates(taskId, toolCallId); + } + let identified: IdentifiedTaskLaunch; + if (launch.phase === "awaiting_identity") { + identified = { phase: "active", toolCallId, taskId }; + } else if (launch.phase === "terminal") { + identified = { ...launch, taskId }; + } else { + identified = launch; + } + this.launchesByToolCallId.set(toolCallId, identified); + this.launchToolCallIdByTaskId.set(taskId, toolCallId); + const pending = this.pendingTerminalsByTaskId.get(taskId); + if (!pending) return identified; + this.pendingTerminalsByTaskId.delete(taskId); + const replay = this.mergeTerminal(identified, pending.status, pending.output, pending.progress); + if (replay) this.replayedUpdates.push(replay); + // mergeTerminal advanced the stored launch; return the settled record so + // the caller's phase checks see the terminal state. + return this.launchesByToolCallId.get(toolCallId) as IdentifiedTaskLaunch; + } + + private resolveLaunch( + toolCallId: string | undefined, + taskId: string + ): IdentifiedTaskLaunch | undefined { + const resolvedToolCallId = toolCallId ?? this.launchToolCallIdByTaskId.get(taskId); + return resolvedToolCallId ? this.bindLaunch(resolvedToolCallId, taskId) : undefined; + } + + private launchAckOwner( + taskId: string, + results: readonly ClaudeToolResultBlock[] + ): string | undefined { + const known = this.launchToolCallIdByTaskId.get(taskId); + if (known) { + return results.some(({ toolCallId }) => toolCallId === known) ? known : undefined; + } + // Prompt and description describe work rather than identity. An explicit + // task binding or one pending launch is required before ownership is safe. + const candidates = results.filter( + ({ toolCallId }) => this.launchesByToolCallId.get(toolCallId)?.phase === "awaiting_identity" + ); + if (candidates.length !== 1) return undefined; + return this.bindLaunch(candidates[0].toolCallId, taskId)?.toolCallId; + } + + private pruneAmbiguousCandidates(taskId: string, ownerToolCallId: string): void { + for (const [toolCallId, launch] of this.launchesByToolCallId) { + if ( + toolCallId !== ownerToolCallId && + launch.phase === "awaiting_identity" && + launch.candidateTaskId === taskId + ) { + this.launchesByToolCallId.delete(toolCallId); + } + } + } + + private markForegroundResult(launch: TaskLaunch, status: TerminalTaskStatus): void { + if (launch.phase === "terminal") { + if (!launch.outputAvailable) { + this.launchesByToolCallId.set(launch.toolCallId, { + ...launch, + outputAvailable: true, + }); + } + return; + } + this.launchesByToolCallId.set(launch.toolCallId, { + phase: "terminal", + toolCallId: launch.toolCallId, + ...(launch.phase === "active" ? { taskId: launch.taskId } : {}), + terminalStatus: status, + outputAvailable: true, + }); + } + + private mergeTerminal( + launch: IdentifiedTaskLaunch, + status: TerminalTaskStatus, + output?: string, + progress?: AgentToolProgress + ): ClaudeTaskToolUpdate | undefined { + if (launch.phase === "terminal") { + if (!output || launch.outputAvailable) return undefined; + this.launchesByToolCallId.set(launch.toolCallId, { + ...launch, + outputAvailable: true, + }); + return { + toolCallId: launch.toolCallId, + status: launch.terminalStatus, + content: textContent(output), + ...(progress ? { progress } : {}), + }; + } + + this.launchesByToolCallId.set(launch.toolCallId, { + phase: "terminal", + toolCallId: launch.toolCallId, + taskId: launch.taskId, + terminalStatus: status, + outputAvailable: output !== undefined, + }); + return { + toolCallId: launch.toolCallId, + status, + ...(output ? { content: textContent(output) } : {}), + ...(progress ? { progress } : {}), + }; + } +} + +function protocolEventFromCarrier(carrier: ClaudeTaskCarrier): ClaudeTaskProtocolEvent | undefined { + if (carrier.kind === "tool_snapshot") { + if (carrier.nativeToolName === "Agent" || carrier.nativeToolName === "Task") { + return { + kind: "launch_observed", + toolCallId: carrier.toolCallId, + }; + } + return undefined; + } + + const message = carrier.message; + if (message.type === "user") { + return { + kind: "result_batch", + results: toolResultBlocks(message), + asyncLaunchTaskId: readAsyncLaunchTaskId(message), + }; + } + if (message.type !== "system") return undefined; + + const frame = message as { + subtype?: string; + task_id?: string; + tool_use_id?: string; + status?: unknown; + summary?: unknown; + description?: unknown; + last_tool_name?: unknown; + usage?: unknown; + patch?: unknown; + }; + if (frame.subtype === "task_updated") { + // `task_updated` carries a partial TaskState patch and no tool_use_id. A + // subagent may report its terminal transition only here, so map terminal + // patch statuses through the state machine; non-terminal patches carry + // nothing the launch card renders. + const taskId = nonEmptyString(frame.task_id); + const patch = asRecord(frame.patch); + const status = mapTerminalTaskStatus(patch?.status); + if (!taskId || !status) return undefined; + return { + kind: "task_terminal", + taskId, + status, + output: nonEmptyString(patch?.error), + }; + } + if ( + frame.subtype !== "task_started" && + frame.subtype !== "task_progress" && + frame.subtype !== "task_notification" + ) { + return undefined; + } + const taskId = nonEmptyString(frame.task_id); + const toolCallId = nonEmptyString(frame.tool_use_id); + if (!taskId || (frame.tool_use_id !== undefined && !toolCallId)) return undefined; + const progress = readTaskProgress(frame); + if (frame.subtype !== "task_notification") { + return { + kind: "task_active", + toolCallId, + taskId, + ...(frame.subtype === "task_progress" && progress ? { progress } : {}), + }; + } + const status = mapTerminalTaskStatus(frame.status); + return status + ? { + kind: "task_terminal", + toolCallId, + taskId, + status, + output: nonEmptyString(frame.summary), + ...(progress ? { progress } : {}), + } + : { kind: "task_active", toolCallId, taskId, ...(progress ? { progress } : {}) }; +} + +function taskDecision(update: ClaudeTaskToolUpdate): ClaudeTaskDecision { + return { updates: [update], resultActions: EMPTY_RESULT_ACTIONS }; +} + +function isTerminalLaunch(launch: IdentifiedTaskLaunch): launch is TerminalTaskLaunch & { + taskId: string; +} { + return launch.phase === "terminal"; +} + +function asRecord(value: unknown): Record | undefined { + return typeof value === "object" && value !== null + ? (value as Record) + : undefined; +} + +function nonEmptyString(value: unknown): string | undefined { + return typeof value === "string" && value.trim().length > 0 ? value : undefined; +} + +function finiteNumber(value: unknown): number | undefined { + return typeof value === "number" && Number.isFinite(value) ? value : undefined; +} + +function toolResultBlocks(msg: SDKMessage): ClaudeToolResultBlock[] { + const content = asRecord((msg as { message?: unknown }).message)?.content; + if (!Array.isArray(content)) return []; + const results: ClaudeToolResultBlock[] = []; + for (const block of content) { + const record = asRecord(block); + if (record?.type !== "tool_result") continue; + const toolCallId = nonEmptyString(record.tool_use_id); + if (toolCallId) { + results.push({ + toolCallId, + status: record.is_error === true ? "failed" : "completed", + }); + } + } + return results; +} + +function readTaskProgress(msg: { + description?: unknown; + last_tool_name?: unknown; + usage?: unknown; +}): AgentToolProgress | undefined { + const usage = asRecord(msg.usage); + const description = nonEmptyString(msg.description); + const toolName = nonEmptyString(msg.last_tool_name); + const toolUses = finiteNumber(usage?.tool_uses); + const durationMs = finiteNumber(usage?.duration_ms); + const totalTokens = finiteNumber(usage?.total_tokens); + if ( + description === undefined && + toolName === undefined && + toolUses === undefined && + durationMs === undefined && + totalTokens === undefined + ) { + return undefined; + } + return { + ...(description ? { description } : {}), + ...(toolName ? { toolName } : {}), + ...(toolUses !== undefined ? { toolUses } : {}), + ...(durationMs !== undefined ? { durationMs } : {}), + ...(totalTokens !== undefined ? { totalTokens } : {}), + }; +} + +function textContent(text: string): ToolCallContent[] { + return [{ type: "content", content: { type: "text", text } }]; +} + +function readAsyncLaunchTaskId(msg: SDKMessage): string | undefined { + const result = asRecord((msg as { tool_use_result?: unknown }).tool_use_result); + if (!result) return undefined; + const isAsync = result.isAsync === true || nonEmptyString(result.status) === "async_launched"; + if (!isAsync) return undefined; + return nonEmptyString(result.agentId); +} + +function mapTerminalTaskStatus(status: unknown): TerminalTaskStatus | undefined { + switch (status) { + case "completed": + return "completed"; + case "failed": + case "stopped": + case "killed": + return "failed"; + default: + return undefined; + } +} diff --git a/src/agentMode/sdk/sdkMessageTranslator.test.ts b/src/agentMode/sdk/sdkMessageTranslator.test.ts index d0c04158..a6acb2a7 100644 --- a/src/agentMode/sdk/sdkMessageTranslator.test.ts +++ b/src/agentMode/sdk/sdkMessageTranslator.test.ts @@ -172,7 +172,6 @@ describe("translateSdkMessage", () => { name: "Tool", inputJsonAcc: "", lastParsedInput: {}, - emittedToolCall: false, }); translateSdkMessage(streamEvent({ type: "message_start", message: {} }), SESSION_ID, state); expect(state.toolUseBlocks.size).toBe(0); @@ -725,20 +724,591 @@ describe("translateSdkMessage", () => { ); expect(out).toEqual([]); }); -}); + describe("background subagent system frames", () => { + const UUID = "uuid-1" as `${string}-${string}-${string}-${string}-${string}`; -describe("mapStopReason", () => { - it("maps success → end_turn", () => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - expect(mapStopReason({ type: "result", subtype: "success" } as any)).toBe("end_turn"); - }); - it("maps error variants → cancelled", () => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - expect(mapStopReason({ type: "result", subtype: "error_during_execution" } as any)).toBe( - "cancelled" - ); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - expect(mapStopReason({ type: "result", subtype: "error_max_turns" } as any)).toBe("cancelled"); + function systemMessage(fields: Record): SDKMessage { + return { + type: "system", + uuid: UUID, + session_id: SESSION_ID, + ...fields, + } as unknown as SDKMessage; + } + + function launchAckResult(toolUseId: string, agentId = "abc123"): SDKMessage { + return { + type: "user", + tool_use_result: { isAsync: true, status: "async_launched", agentId }, + message: { + content: [ + { + type: "tool_result", + tool_use_id: toolUseId, + is_error: false, + content: [{ type: "text", text: "Async agent launched successfully." }], + }, + ], + }, + parent_tool_use_id: null, + session_id: SESSION_ID, + } as unknown as SDKMessage; + } + + function trackToolUse( + state: ReturnType, + id: string, + name: string, + input: Record = {} + ): void { + translateSdkMessage( + streamEvent({ + type: "content_block_start", + index: state.emittedToolUseIds.size, + content_block: { type: "tool_use", id, name, input }, + }), + SESSION_ID, + state + ); + } + + function trackStreamedToolUse( + state: ReturnType, + index: number, + id: string, + name: string, + input: Record + ): void { + translateSdkMessage( + streamEvent({ + type: "content_block_start", + index, + content_block: { type: "tool_use", id, name, input: {} }, + }), + SESSION_ID, + state + ); + translateSdkMessage( + streamEvent({ + type: "content_block_delta", + index, + delta: { type: "input_json_delta", partial_json: JSON.stringify(input) }, + }), + SESSION_ID, + state + ); + translateSdkMessage(streamEvent({ type: "content_block_stop", index }), SESSION_ID, state); + } + + // Task frames only apply once we've tracked the launch (agentId "abc123" → + // card "tu-launch"); seed that so the gate lets them through. + function seedLaunch(state: ReturnType): void { + trackToolUse(state, "tu-launch", "Agent"); + translateSdkMessage(launchAckResult("tu-launch"), SESSION_ID, state); + } + + it("binds task_started before the async acknowledgement arrives", () => { + const state = createTranslatorState(); + trackToolUse(state, "tu-launch", "Agent"); + + const started = translateSdkMessage( + systemMessage({ + subtype: "task_started", + task_id: "abc123", + tool_use_id: "tu-launch", + description: "Analyze vault", + }), + SESSION_ID, + state + ); + + expect(started[0].update).toMatchObject({ + toolCallId: "tu-launch", + status: "in_progress", + }); + expect(translateSdkMessage(launchAckResult("tu-launch"), SESSION_ID, state)).toEqual([]); + }); + + it("folds task_notification onto the launch card as its output + terminal status", () => { + const state = createTranslatorState(); + seedLaunch(state); + const out = translateSdkMessage( + systemMessage({ + subtype: "task_notification", + task_id: "abc123", + tool_use_id: "tu-launch", + status: "completed", + summary: "Most prominent category: AI/agents (16 notes).", + }), + SESSION_ID, + state + ); + expect(out).toEqual([ + { + sessionId: SESSION_ID, + update: { + sessionUpdate: "tool_call_update", + toolCallId: "tu-launch", + status: "completed", + content: [ + { + type: "content", + content: { type: "text", text: "Most prominent category: AI/agents (16 notes)." }, + }, + ], + }, + }, + ]); + }); + + it("maps a non-completed task_notification status to failed", () => { + const state = createTranslatorState(); + seedLaunch(state); + const out = translateSdkMessage( + systemMessage({ + subtype: "task_notification", + task_id: "abc123", + tool_use_id: "tu-launch", + status: "stopped", + summary: "", + }), + SESSION_ID, + state + ); + expect(out).toHaveLength(1); + expect(out[0].update).toMatchObject({ toolCallId: "tu-launch", status: "failed" }); + // Empty summary contributes no content field (nothing to render). + expect((out[0].update as { content?: unknown }).content).toBeUndefined(); + }); + + it.each([ + ["completed", "completed"], + ["stopped", "failed"], + ])("uses the shared terminal mapping for task_notification status %s", (status, expected) => { + const state = createTranslatorState(); + seedLaunch(state); + const out = translateSdkMessage( + systemMessage({ + subtype: "task_notification", + task_id: "abc123", + tool_use_id: "tu-launch", + status, + }), + SESSION_ID, + state + ); + expect(out[0].update).toMatchObject({ toolCallId: "tu-launch", status: expected }); + }); + + it("keeps task_started in progress and normalizes meaningful task_progress", () => { + const state = createTranslatorState(); + seedLaunch(state); + expect( + translateSdkMessage( + systemMessage({ + subtype: "task_started", + task_id: "abc123", + tool_use_id: "tu-launch", + description: "started", + }), + SESSION_ID, + state + )[0].update + ).toEqual({ + sessionUpdate: "tool_call_update", + toolCallId: "tu-launch", + status: "in_progress", + }); + expect( + translateSdkMessage( + systemMessage({ + subtype: "task_progress", + task_id: "abc123", + tool_use_id: "tu-launch", + description: "Count markdown files", + last_tool_name: "Read", + usage: { tool_uses: 3, duration_ms: 9851, total_tokens: 4210 }, + }), + SESSION_ID, + state + )[0].update + ).toEqual({ + sessionUpdate: "tool_call_update", + toolCallId: "tu-launch", + status: "in_progress", + progress: { + description: "Count markdown files", + toolName: "Read", + toolUses: 3, + durationMs: 9851, + totalTokens: 4210, + }, + }); + }); + + it("ignores a straggling task_progress after the notification settled the card", () => { + const state = createTranslatorState(); + seedLaunch(state); + translateSdkMessage( + systemMessage({ + subtype: "task_notification", + task_id: "abc123", + tool_use_id: "tu-launch", + status: "completed", + summary: "done", + }), + SESSION_ID, + state + ); + const out = translateSdkMessage( + systemMessage({ + subtype: "task_progress", + task_id: "abc123", + tool_use_id: "tu-launch", + description: "…", + }), + SESSION_ID, + state + ); + expect(out).toEqual([]); + }); + + it("ignores a task_notification that is not a tracked subagent launch (background Bash/Monitor)", () => { + const state = createTranslatorState(); + seedLaunch(state); // tracks agentId "abc123" → "tu-launch" + // A background Bash finishing: task_id/tool_use_id don't match the tracked + // launch, so it must NOT clobber that (or any) card. + expect( + translateSdkMessage( + systemMessage({ + subtype: "task_notification", + task_id: "bash-task", + tool_use_id: "tu-bash", + status: "completed", + summary: "done", + }), + SESSION_ID, + state + ) + ).toEqual([]); + // Right task_id but a different tool_use_id (stale/other card) is also ignored. + expect( + translateSdkMessage( + systemMessage({ + subtype: "task_notification", + task_id: "abc123", + tool_use_id: "tu-other", + status: "completed", + summary: "done", + }), + SESSION_ID, + state + ) + ).toEqual([]); + }); + + it("correlates task frames with no tool_use_id after acknowledgement", () => { + const state = createTranslatorState(); + seedLaunch(state); + + const progress = translateSdkMessage( + systemMessage({ + subtype: "task_progress", + task_id: "abc123", + description: "Count notes", + }), + SESSION_ID, + state + ); + const completed = translateSdkMessage( + systemMessage({ + subtype: "task_notification", + task_id: "abc123", + status: "completed", + summary: "done", + }), + SESSION_ID, + state + ); + + expect(progress[0].update).toMatchObject({ + toolCallId: "tu-launch", + status: "in_progress", + progress: { description: "Count notes" }, + }); + expect(completed[0].update).toMatchObject({ + toolCallId: "tu-launch", + status: "completed", + }); + }); + + it("keeps task identity across translator generations until terminal output arrives", () => { + const firstQuery = createTranslatorState(); + seedLaunch(firstQuery); + translateSdkMessage(resultMsg({}), SESSION_ID, firstQuery); + const nextQuery = createTranslatorState(firstQuery.claudeTasks, firstQuery.backgroundTasks); + + const completed = translateSdkMessage( + systemMessage({ + subtype: "task_notification", + task_id: "abc123", + status: "completed", + summary: "finished after the first query", + }), + SESSION_ID, + nextQuery + ); + const lateProgress = translateSdkMessage( + systemMessage({ + subtype: "task_progress", + task_id: "abc123", + description: "too late", + }), + SESSION_ID, + nextQuery + ); + + expect(completed[0].update).toMatchObject({ + toolCallId: "tu-launch", + status: "completed", + }); + expect(lateProgress).toEqual([]); + }); + + it("settles the launch when task_updated carries a terminal patch status", () => { + const state = createTranslatorState(); + seedLaunch(state); + + const settled = translateSdkMessage( + systemMessage({ + subtype: "task_updated", + task_id: "abc123", + patch: { status: "completed" }, + }), + SESSION_ID, + state + ); + + expect(settled[0].update).toMatchObject({ + sessionUpdate: "tool_call_update", + toolCallId: "tu-launch", + status: "completed", + }); + }); + + it("ignores task_updated frames whose patch is not terminal", () => { + const state = createTranslatorState(); + seedLaunch(state); + + expect( + translateSdkMessage( + systemMessage({ + subtype: "task_updated", + task_id: "abc123", + patch: { status: "running" }, + }), + SESSION_ID, + state + ) + ).toEqual([]); + }); + + it("suppresses the async-launch ack tool_result so the launch stays in_progress", () => { + const state = createTranslatorState(); + trackToolUse(state, "tu-launch", "Agent"); + expect(translateSdkMessage(launchAckResult("tu-launch"), SESSION_ID, state)).toEqual([]); + }); + + it("suppresses only the launch-ack block, still translating a batched sibling result", () => { + const state = createTranslatorState(); + trackToolUse(state, "tu-launch", "Agent"); + trackToolUse(state, "tu-read", "Read"); + const batched = { + type: "user", + tool_use_result: { isAsync: true, status: "async_launched", agentId: "abc123" }, + message: { + content: [ + { + type: "tool_result", + tool_use_id: "tu-launch", + content: [{ type: "text", text: "Async agent launched successfully." }], + }, + { type: "tool_result", tool_use_id: "tu-read", content: "file contents" }, + ], + }, + parent_tool_use_id: null, + session_id: SESSION_ID, + } as unknown as SDKMessage; + const out = translateSdkMessage(batched, SESSION_ID, state); + // Only the sibling Read is translated; the launch ack is suppressed. + expect(out).toHaveLength(1); + expect(out[0].update).toMatchObject({ toolCallId: "tu-read", status: "completed" }); + }); + + it("matches a batched launch ack to the Agent result when it is not the first block", () => { + const state = createTranslatorState(); + trackToolUse(state, "tu-read", "Read"); + trackToolUse(state, "tu-launch", "Agent"); + const batched = { + type: "user", + tool_use_result: { isAsync: true, status: "async_launched", agentId: "abc123" }, + message: { + content: [ + { type: "tool_result", tool_use_id: "tu-read", content: "file contents" }, + { + type: "tool_result", + tool_use_id: "tu-launch", + content: [{ type: "text", text: "Async agent launched successfully." }], + }, + ], + }, + parent_tool_use_id: null, + session_id: SESSION_ID, + } as unknown as SDKMessage; + + const out = translateSdkMessage(batched, SESSION_ID, state); + + expect(out).toHaveLength(1); + expect(out[0].update).toMatchObject({ toolCallId: "tu-read", status: "completed" }); + }); + + it("uses explicit task identity when launch inputs stream from empty snapshots", () => { + const state = createTranslatorState(); + trackStreamedToolUse(state, 0, "tu-foreground", "Agent", { + prompt: "foreground prompt", + }); + trackStreamedToolUse(state, 1, "tu-background", "Task", { + prompt: "background prompt", + }); + translateSdkMessage( + systemMessage({ + subtype: "task_started", + task_id: "abc123", + tool_use_id: "tu-background", + description: "Background task", + }), + SESSION_ID, + state + ); + const acknowledged = translateSdkMessage( + { + type: "user", + tool_use_result: { + isAsync: true, + status: "async_launched", + agentId: "abc123", + prompt: "metadata is not an identity key", + }, + message: { + content: [ + { type: "tool_result", tool_use_id: "tu-foreground", content: "done" }, + { + type: "tool_result", + tool_use_id: "tu-background", + content: "opaque internal acknowledgement", + }, + ], + }, + parent_tool_use_id: null, + session_id: SESSION_ID, + } as unknown as SDKMessage, + SESSION_ID, + state + ); + + const progress = translateSdkMessage( + systemMessage({ + subtype: "task_progress", + task_id: "abc123", + description: "Count notes", + }), + SESSION_ID, + state + ); + + expect(acknowledged).toEqual([ + { + sessionId: SESSION_ID, + update: { + sessionUpdate: "tool_call_update", + toolCallId: "tu-foreground", + status: "completed", + content: [{ type: "content", content: { type: "text", text: "done" } }], + }, + }, + ]); + expect(progress[0].update).toMatchObject({ + toolCallId: "tu-background", + status: "in_progress", + progress: { description: "Count notes" }, + }); + }); + + it("keeps the first terminal status when an ordinary result supplies late output", () => { + const state = createTranslatorState(); + trackToolUse(state, "tu-launch", "Agent"); + translateSdkMessage(launchAckResult("tu-launch"), SESSION_ID, state); + translateSdkMessage( + systemMessage({ + subtype: "task_notification", + task_id: "abc123", + status: "completed", + }), + SESSION_ID, + state + ); + + const lateOutput = translateSdkMessage( + { + type: "user", + message: { + content: [ + { + type: "tool_result", + tool_use_id: "tu-launch", + content: "late output", + is_error: true, + }, + ], + }, + parent_tool_use_id: null, + session_id: SESSION_ID, + } as unknown as SDKMessage, + SESSION_ID, + state + ); + + expect(lateOutput).toEqual([ + { + sessionId: SESSION_ID, + update: { + sessionUpdate: "tool_call_update", + toolCallId: "tu-launch", + status: "completed", + content: [{ type: "content", content: { type: "text", text: "late output" } }], + }, + }, + ]); + }); + + it("still completes a genuine (non-launch) tool_result", () => { + const state = createTranslatorState(); + const out = translateSdkMessage( + { + type: "user", + message: { + content: [ + { type: "tool_result", tool_use_id: "tu-real", content: "done", is_error: false }, + ], + }, + parent_tool_use_id: null, + session_id: SESSION_ID, + } as unknown as SDKMessage, + SESSION_ID, + state + ); + expect(out[0].update).toMatchObject({ toolCallId: "tu-real", status: "completed" }); + }); }); }); @@ -1049,3 +1619,18 @@ describe("translateSdkMessage — result → usage_update", () => { expect(update.usage.contextWindow).toBeUndefined(); }); }); + +describe("mapStopReason()", () => { + it("maps success → end_turn", () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect(mapStopReason({ type: "result", subtype: "success" } as any)).toBe("end_turn"); + }); + it("maps error variants → cancelled", () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect(mapStopReason({ type: "result", subtype: "error_during_execution" } as any)).toBe( + "cancelled" + ); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect(mapStopReason({ type: "result", subtype: "error_max_turns" } as any)).toBe("cancelled"); + }); +}); diff --git a/src/agentMode/sdk/sdkMessageTranslator.ts b/src/agentMode/sdk/sdkMessageTranslator.ts index 7f60d5e1..1972f4b1 100644 --- a/src/agentMode/sdk/sdkMessageTranslator.ts +++ b/src/agentMode/sdk/sdkMessageTranslator.ts @@ -22,12 +22,15 @@ import { type ClaudeTaskPlanState, } from "./claudeTodoPlan"; import { deriveToolKind, deriveToolTitle, vendorMetaFields } from "./toolMeta"; +import { ClaudeBackgroundTaskStateMachine, type ClaudeTaskToolUpdate } from "./claudeTaskProtocol"; + +/** Every SDK `system` frame — the `type: "system"` slice of the message union. */ +type SDKSystemLike = Extract; /** - * Mutable per-query translator state. One instance lives for the duration of - * a single `query()` call; reset whenever a new turn starts — EXCEPT - * `claudeTasks`, which the caller shares across a session's queries (Task ids - * created in one turn must resolve when a later turn updates them). + * Mutable translator state. One instance lives for a single `query()` call; + * stream parsing fields reset with each turn, while the Claude task owners are + * deliberately shared across queries in the same session. */ export interface TranslatorState { toolUseBlocks: Map< @@ -38,11 +41,12 @@ export interface TranslatorState { mcpServer?: string; inputJsonAcc: string; lastParsedInput: unknown; - emittedToolCall: boolean; } >; /** Tool-use ids already emitted in this turn — used to dedupe in the assistant-message fallback path. */ emittedToolUseIds: Set; + /** Session-lived owner of Claude background-task identity and lifecycle. */ + backgroundTasks: ClaudeBackgroundTaskStateMachine; /** Session-lived todo/Task accumulator (see claudeTodoPlan.ts). */ claudeTasks: ClaudeTaskPlanState; /** @@ -66,10 +70,19 @@ interface AssistantUsageSample { model: string; } -export function createTranslatorState(claudeTasks?: ClaudeTaskPlanState): TranslatorState { +/** + * Creates query-local parsing state backed by the owning session's Claude task state. + * @param claudeTasks - The task-plan state to preserve when translator generations share one conversation. + * @param backgroundTasks - The background-task state to preserve across queries in one session. + */ +export function createTranslatorState( + claudeTasks?: ClaudeTaskPlanState, + backgroundTasks?: ClaudeBackgroundTaskStateMachine +): TranslatorState { return { toolUseBlocks: new Map(), emittedToolUseIds: new Set(), + backgroundTasks: backgroundTasks ?? new ClaudeBackgroundTaskStateMachine(), claudeTasks: claudeTasks ?? createClaudeTaskPlanState(), }; } @@ -79,10 +92,10 @@ function event(sessionId: SessionId, update: SessionUpdate): SessionEvent { } /** - * Translate one SDK message to zero or more session-domain events. Returning - * an array (rather than firing a callback) keeps the function pure and - * trivially testable; the caller decides what to do with the events and when - * to terminate the prompt promise. + * Preserves the SDK boundary by turning vendor messages into session-domain events for backend-independent consumers. + * @param msg - The vendor message to translate. + * @param sessionId - The session that should receive the translated events. + * @param state - The cross-message correlation state for the active translation stream. */ export function translateSdkMessage( msg: SDKMessage, @@ -96,6 +109,8 @@ export function translateSdkMessage( return translateAssistantMessage(msg, sessionId, state); case "user": return translateUserMessage(msg, sessionId, state); + case "system": + return translateSystemMessage(msg, sessionId, state); case "result": return translateResultMessage(msg, sessionId, state); default: @@ -164,6 +179,19 @@ function modelTokens(m: SDKResultMessage["modelUsage"][string]): number { return m.inputTokens + m.outputTokens + m.cacheReadInputTokens + m.cacheCreationInputTokens; } +/** + * Delegates Claude's out-of-order task protocol to the background-task state machine and + * converts its normalized decision into the session event vocabulary. + */ +function translateSystemMessage( + msg: SDKSystemLike, + sessionId: SessionId, + state: TranslatorState +): SessionEvent[] { + const decision = state.backgroundTasks.accept({ kind: "sdk_message", message: msg }); + return taskUpdateEvents(sessionId, decision.updates); +} + export function mapStopReason(msg: SDKResultMessage): "end_turn" | "cancelled" | "refusal" { if (msg.subtype === "success") return "end_turn"; return "cancelled"; @@ -208,13 +236,13 @@ function translateStreamEvent( const block = sdkEvent.content_block; if (block.type === "tool_use") { const { tool: name, mcpServer } = resolveToolName(block.name); + observeBackgroundTaskLaunch(state, block.id, name, mcpServer); state.toolUseBlocks.set(sdkEvent.index, { id: block.id, name, mcpServer, inputJsonAcc: "", lastParsedInput: block.input ?? {}, - emittedToolCall: true, }); state.emittedToolUseIds.add(block.id); const out: SessionEvent[] = [ @@ -358,10 +386,11 @@ function translateAssistantMessage( for (const block of content) { const b = block as { type?: string; id?: string; name?: string; input?: unknown }; if (b.type !== "tool_use" || !b.id || !b.name) continue; + const { tool: name, mcpServer } = resolveToolName(b.name); + observeBackgroundTaskLaunch(state, b.id, name, mcpServer); if (state.emittedToolUseIds.has(b.id)) continue; state.emittedToolUseIds.add(b.id); out.push(event(sessionId, makeToolCallUpdate(b.id, b.name, b.input ?? {}, parentToolUseId))); - const { tool: name, mcpServer } = resolveToolName(b.name); out.push( ...todoPlanEvents(sessionId, state, b.id, name, mcpServer, parentToolUseId, b.input ?? {}) ); @@ -427,6 +456,8 @@ function translateUserMessage( ): SessionEvent[] { const content = (msg.message as { content?: unknown }).content; if (!Array.isArray(content)) return []; + const decision = state.backgroundTasks.accept({ kind: "sdk_message", message: msg }); + const out: SessionEvent[] = []; for (const block of content) { const b = block as { @@ -436,7 +467,16 @@ function translateUserMessage( is_error?: boolean; }; if (b.type !== "tool_result" || !b.tool_use_id) continue; - const status: AgentToolStatus = b.is_error ? "failed" : "completed"; + + const resultAction = decision.resultActions.get(b.tool_use_id); + if (resultAction?.kind === "omit") continue; + + let status: AgentToolStatus; + if (resultAction?.kind === "preserve_status") { + status = resultAction.status; + } else { + status = b.is_error ? "failed" : "completed"; + } const outputs = toolResultContent(b.content); out.push( event(sessionId, { @@ -457,6 +497,7 @@ function translateUserMessage( ); if (!b.is_error && planUpdate) out.push(event(sessionId, planUpdate)); } + out.push(...taskUpdateEvents(sessionId, decision.updates)); return out; } @@ -479,6 +520,28 @@ function makeToolCallUpdate( }; } +function observeBackgroundTaskLaunch( + state: TranslatorState, + toolUseId: string, + name: string, + mcpServer: string | undefined +): void { + state.backgroundTasks.accept({ + kind: "tool_snapshot", + toolCallId: toolUseId, + nativeToolName: mcpServer ? undefined : name, + }); +} + +function taskUpdateEvents( + sessionId: SessionId, + updates: readonly ClaudeTaskToolUpdate[] +): SessionEvent[] { + return updates.map((update) => + event(sessionId, { sessionUpdate: "tool_call_update", ...update }) + ); +} + function toolResultContent(content: unknown): ToolCallContent[] | undefined { if (typeof content === "string") { return [{ type: "content", content: { type: "text", text: content } }]; diff --git a/src/agentMode/session/AgentMessageStore.test.ts b/src/agentMode/session/AgentMessageStore.test.ts index b0dfa14e..5f6c90dd 100644 --- a/src/agentMode/session/AgentMessageStore.test.ts +++ b/src/agentMode/session/AgentMessageStore.test.ts @@ -130,6 +130,27 @@ describe("AgentMessageStore", () => { expect(store.upsertAgentPart(id, { ...part })).toBe(false); }); + it("notifies for progress changes but skips identical progress", () => { + const store = new AgentMessageStore(); + const id = store.addMessage(placeholder()); + const part: AgentMessagePart = { + kind: "tool_call", + id: "tc1", + title: "Agent", + status: "in_progress", + progress: { description: "Inspect vault", toolUses: 1, durationMs: 1000 }, + }; + + expect(store.upsertAgentPart(id, part)).toBe(true); + expect(store.upsertAgentPart(id, { ...part, progress: { ...part.progress } })).toBe(false); + expect( + store.upsertAgentPart(id, { + ...part, + progress: { ...part.progress, toolUses: 2 }, + }) + ).toBe(true); + }); + it("upsertAgentPart compares large repeated tool outputs without duplicating", () => { const store = new AgentMessageStore(); const id = store.addMessage(placeholder()); @@ -188,6 +209,28 @@ describe("AgentMessageStore", () => { expect(msg?.parts).toHaveLength(1); }); + it("findMessageIdWithToolCall returns the message owning a tool call, or undefined", () => { + const store = new AgentMessageStore(); + const first = store.addMessage(placeholder()); + const second = store.addMessage(placeholder()); + store.upsertAgentPart(first, { + kind: "tool_call", + id: "tc-old", + title: "Agent", + status: "in_progress", + }); + store.upsertAgentPart(second, { + kind: "tool_call", + id: "tc-new", + title: "Read README", + status: "completed", + }); + + expect(store.findMessageIdWithToolCall("tc-old")).toBe(first); + expect(store.findMessageIdWithToolCall("tc-new")).toBe(second); + expect(store.findMessageIdWithToolCall("tc-missing")).toBeUndefined(); + }); + it("markMessageError flags the message and appends formatted error text", () => { const store = new AgentMessageStore(); const id = store.addMessage({ diff --git a/src/agentMode/session/AgentMessageStore.ts b/src/agentMode/session/AgentMessageStore.ts index 7e4fdd60..82ad0212 100644 --- a/src/agentMode/session/AgentMessageStore.ts +++ b/src/agentMode/session/AgentMessageStore.ts @@ -86,6 +86,7 @@ function partsEqual(a: AgentMessagePart, b: AgentMessagePart): boolean { a.status === b.status && a.vendorToolName === b.vendorToolName && a.parentToolCallId === b.parentToolCallId && + toolProgressEqual(a.progress, b.progress) && boundedValueEqual(a.input, b.input) && locationsEqual(a.locations, b.locations) && toolOutputsEqual(a.output, b.output) @@ -93,6 +94,22 @@ function partsEqual(a: AgentMessagePart, b: AgentMessagePart): boolean { } } +/** Compare normalized progress without serializing the surrounding tool input/output. */ +function toolProgressEqual( + a: Extract["progress"], + b: Extract["progress"] +): boolean { + if (a === b) return true; + if (!a || !b) return a === b; + return ( + a.description === b.description && + a.toolName === b.toolName && + a.toolUses === b.toolUses && + a.durationMs === b.durationMs && + a.totalTokens === b.totalTokens + ); +} + /** * Compare plan entries without stringifying the whole part object. * @@ -346,6 +363,20 @@ export class AgentMessageStore { return true; } + /** + * Locate the message that already renders a tool call, searching newest + * first. Lets a late update for a long-running tool (e.g. a background + * subagent settling during a later turn) land on its original card instead + * of the current placeholder. Returns undefined when no message has it. + */ + findMessageIdWithToolCall(toolCallId: string): string | undefined { + for (let i = this.messages.length - 1; i >= 0; i--) { + const msg = this.messages[i]; + if (msg.parts?.some((p) => p.kind === "tool_call" && p.id === toolCallId)) return msg.id; + } + return undefined; + } + /** * Mark a message as an error and append the error text to its display body. * Used when a turn rejects mid-stream so the partial placeholder gets a diff --git a/src/agentMode/session/AgentSession.test.ts b/src/agentMode/session/AgentSession.test.ts index f4f36205..a9cb17e6 100644 --- a/src/agentMode/session/AgentSession.test.ts +++ b/src/agentMode/session/AgentSession.test.ts @@ -967,6 +967,190 @@ describe("AgentSession.sendPrompt", () => { await turn; }); + it("routes a tool_call_update for a prior turn's tool call to its original message", async () => { + const mock = makeMockBackend(); + let resolvePrompt: ((v: { stopReason: "end_turn" }) => void) | null = null; + mock.prompt.mockImplementation( + () => new Promise((resolve) => (resolvePrompt = resolve as typeof resolvePrompt)) + ); + const session = new AgentSession({ + backend: mock.asBackend, + backendSessionId: "acp-1", + internalId: "internal-1", + backendId: "claude-code", + }); + + const first = session.sendPrompt("launch a background agent"); + mock.emit({ + sessionId: "acp-1", + update: { + sessionUpdate: "tool_call", + toolCallId: "tc-agent", + title: "Agent", + status: "in_progress", + }, + }); + resolvePrompt!({ stopReason: "end_turn" }); + await first.turn; + + const second = session.sendPrompt("meanwhile"); + mock.emit({ + sessionId: "acp-1", + update: { + sessionUpdate: "tool_call_update", + toolCallId: "tc-agent", + status: "completed", + content: [{ type: "content", content: { type: "text", text: "agent report" } }], + }, + }); + + const ai = session.store.getDisplayMessages().filter((m) => m.sender === AI_SENDER); + // The first turn's launch card settles in place… + expect(ai[0]?.parts?.[0]).toMatchObject({ + kind: "tool_call", + id: "tc-agent", + status: "completed", + output: [{ type: "text", text: "agent report" }], + }); + // …and no duplicate card appears on the new turn's placeholder. + expect(ai[1]?.parts ?? []).toHaveLength(0); + + resolvePrompt!({ stopReason: "end_turn" }); + await second.turn; + + const laterTurn = session.store.getDisplayMessages().filter((m) => m.sender === AI_SENDER)[1]; + expect(laterTurn).toMatchObject({ message: "", turnStopReason: "end_turn" }); + expect(laterTurn?.isErrorMessage).not.toBe(true); + }); + + it("routes a prior turn's tool update after the current turn is cancelled", async () => { + jest.useFakeTimers(); + try { + const mock = makeMockBackend(); + const resolvePrompts: Array<(value: { stopReason: "end_turn" | "cancelled" }) => void> = []; + mock.prompt.mockImplementation( + () => + new Promise((resolve) => { + resolvePrompts.push(resolve); + }) + ); + const session = new AgentSession({ + backend: mock.asBackend, + backendSessionId: "acp-1", + internalId: "internal-1", + backendId: "claude-code", + }); + + const first = session.sendPrompt("launch a background agent"); + mock.emit({ + sessionId: "acp-1", + update: { + sessionUpdate: "tool_call", + toolCallId: "tc-agent", + title: "Agent", + status: "in_progress", + }, + }); + resolvePrompts[0]({ stopReason: "end_turn" }); + await first.turn; + + const second = session.sendPrompt("do something else"); + mock.emit({ + sessionId: "acp-1", + update: { + sessionUpdate: "tool_call", + toolCallId: "tc-cancelled", + title: "Current turn tool", + status: "in_progress", + }, + }); + await session.cancel(); + await expect(second.turn).resolves.toBe("cancelled"); + + mock.emit({ + sessionId: "acp-1", + update: { + sessionUpdate: "tool_call_update", + toolCallId: "tc-agent", + status: "completed", + content: [{ type: "content", content: { type: "text", text: "agent report" } }], + }, + }); + mock.emit({ + sessionId: "acp-1", + update: { + sessionUpdate: "tool_call_update", + toolCallId: "tc-cancelled", + status: "completed", + }, + }); + + const ai = session.store + .getDisplayMessages() + .filter((message) => message.sender === AI_SENDER); + expect(ai[0]?.parts?.[0]).toMatchObject({ + kind: "tool_call", + id: "tc-agent", + status: "completed", + output: [{ type: "text", text: "agent report" }], + }); + expect(ai[1]?.parts?.[0]).toMatchObject({ + kind: "tool_call", + id: "tc-cancelled", + status: "in_progress", + }); + + resolvePrompts[1]({ stopReason: "cancelled" }); + await jest.advanceTimersByTimeAsync(500); + } finally { + jest.useRealTimers(); + } + }); + + it("merges partial tool progress", async () => { + const mock = makeMockBackend(); + let resolvePrompt: ((v: { stopReason: "end_turn" }) => void) | null = null; + mock.prompt.mockImplementation( + () => new Promise((resolve) => (resolvePrompt = resolve as typeof resolvePrompt)) + ); + const session = new AgentSession({ + backend: mock.asBackend, + backendSessionId: "acp-1", + internalId: "internal-1", + backendId: "claude-code", + }); + const { turn } = session.sendPrompt("hi"); + + mock.emit({ + sessionId: "acp-1", + update: { + sessionUpdate: "tool_call", + toolCallId: "tc1", + title: "Agent", + status: "in_progress", + progress: { description: "Inspect vault", toolUses: 1 }, + }, + }); + mock.emit({ + sessionId: "acp-1", + update: { + sessionUpdate: "tool_call_update", + toolCallId: "tc1", + progress: { toolUses: 3, durationMs: 9851 }, + }, + }); + + const part = session.store.getDisplayMessages().find((message) => message.sender === AI_SENDER) + ?.parts?.[0]; + expect(part).toMatchObject({ + kind: "tool_call", + progress: { description: "Inspect vault", toolUses: 3, durationMs: 9851 }, + }); + + resolvePrompt!({ stopReason: "end_turn" }); + await turn; + }); + // Emit one completed tool call with `text` output and return the stored output. const storedToolOutput = async (text: string): Promise => { const mock = makeMockBackend(); diff --git a/src/agentMode/session/AgentSession.ts b/src/agentMode/session/AgentSession.ts index eb10913d..626cedc5 100644 --- a/src/agentMode/session/AgentSession.ts +++ b/src/agentMode/session/AgentSession.ts @@ -371,6 +371,10 @@ export class AgentSession { // keep the QA context. LIVE-ONLY — never persisted. private pendingFanoutContext: PendingFanoutContext[] = []; private placeholderId: string | null = null; + // A task update routed to an earlier turn still counts as activity consumed + // by the current backend prompt, even though it adds nothing to this turn's + // placeholder. + private currentTurnHadRoutedToolActivity = false; // ACP `messageId`s seen on this turn's content chunks. Used to re-route // trailing chunks that a backend flushes *after* the `session/prompt` result // (observed with opencode + fast DeepSeek models) to the right message. @@ -976,6 +980,7 @@ export class AgentSession { }; this.placeholderId = this.store.addMessage(placeholder); this.currentMessageIds = new Set(); + this.currentTurnHadRoutedToolActivity = false; this.notifyMessages(); // Backends without a title summarizer (codex, Claude Code) have no usable @@ -1161,6 +1166,7 @@ export class AgentSession { if ( placeholderId && resp.stopReason !== "cancelled" && + !this.currentTurnHadRoutedToolActivity && !this.store.hasAssistantActivity(placeholderId) ) { const message = buildEmptyTurnMessage(this.backendId, resp.stopReason); @@ -1676,6 +1682,21 @@ export class AgentSession { private handleSessionEvent(event: SessionEvent): void { const update = event.update; + const toolOwnerMessageId = + update.sessionUpdate === "tool_call_update" + ? this.store.findMessageIdWithToolCall(update.toolCallId) + : undefined; + const toolOwnerStopReason = toolOwnerMessageId + ? this.store.getMessage(toolOwnerMessageId)?.turnStopReason + : undefined; + // A completed non-cancelled message belongs to an earlier turn. Its + // background task may settle while a later prompt is being cancelled, so + // preserve that update while continuing to suppress the cancelled turn's + // own tool activity. + const isPriorToolUpdate = + toolOwnerMessageId !== undefined && + toolOwnerStopReason !== undefined && + toolOwnerStopReason !== "cancelled"; if (this.cancelledPromptDrain || this.abortController?.signal.aborted) { switch (update.sessionUpdate) { @@ -1688,13 +1709,19 @@ export class AgentSession { ); return; case "tool_call": - case "tool_call_update": case "plan": this.cancelledTurnActivity += 1; logWarn( `[AgentMode] dropping ${update.sessionUpdate} from cancelled turn ${this.internalId}` ); return; + case "tool_call_update": + if (isPriorToolUpdate) break; + this.cancelledTurnActivity += 1; + logWarn( + `[AgentMode] dropping ${update.sessionUpdate} from cancelled turn ${this.internalId}` + ); + return; } } @@ -1756,7 +1783,8 @@ export class AgentSession { } const placeholderId = this.placeholderId; - if (!placeholderId) { + const targetMessageId = isPriorToolUpdate ? toolOwnerMessageId : placeholderId; + if (!targetMessageId) { logWarn(`[AgentMode] dropping session/update — no placeholder for ${this.internalId}`); return; } @@ -1770,18 +1798,22 @@ export class AgentSession { }); if (exitPlan) { this.publishGatedPlan(update.toolCallId, exitPlan); - if (this.store.upsertAgentPart(placeholderId, toolCallToPart(update))) { + if (this.store.upsertAgentPart(targetMessageId, toolCallToPart(update))) { this.scheduleNotifyMessages(); } return; } - if (this.store.upsertAgentPart(placeholderId, toolCallToPart(update))) { + if (this.store.upsertAgentPart(targetMessageId, toolCallToPart(update))) { this.scheduleNotifyMessages(); } return; } case "tool_call_update": { - const existing = this.findToolCallPart(placeholderId, update.toolCallId); + // A background launch can settle during a later prompt; route the + // update to the message that owns the tool call so the original card + // settles instead of a duplicate appearing on the current turn. + if (targetMessageId !== placeholderId) this.currentTurnHadRoutedToolActivity = true; + const existing = this.findToolCallPart(targetMessageId, update.toolCallId); const merged = mergeToolCallUpdate(existing, update); if (merged.kind === "tool_call") { const exitPlan = tryReadExitPlanModeCall({ @@ -1793,13 +1825,13 @@ export class AgentSession { this.publishGatedPlan(merged.id, exitPlan); } } - if (this.store.upsertAgentPart(placeholderId, merged)) { + if (this.store.upsertAgentPart(targetMessageId, merged)) { this.scheduleNotifyMessages(); } return; } case "plan": { - if (this.store.upsertAgentPart(placeholderId, planToPart(update))) { + if (this.store.upsertAgentPart(targetMessageId, planToPart(update))) { this.scheduleNotifyMessages(); } return; @@ -2261,6 +2293,7 @@ function toolCallToPart( vendorToolName: call.vendorToolName, mcpServer: call.mcpServer, parentToolCallId: call.parentToolCallId, + progress: call.progress, }; } @@ -2322,6 +2355,7 @@ function mergeToolCallUpdate( vendorToolName: upd.vendorToolName ?? base.vendorToolName, mcpServer: upd.mcpServer ?? base.mcpServer, parentToolCallId: upd.parentToolCallId ?? base.parentToolCallId, + progress: upd.progress === undefined ? base.progress : { ...base.progress, ...upd.progress }, }; } diff --git a/src/agentMode/session/types.ts b/src/agentMode/session/types.ts index 85f5496e..e8506f39 100644 --- a/src/agentMode/session/types.ts +++ b/src/agentMode/session/types.ts @@ -424,6 +424,15 @@ export interface AgentTodoListEntry { status: AgentPlanEntry["status"]; } +/** Backend-neutral progress reported by a long-running tool call. */ +export interface AgentToolProgress { + description?: string; + toolName?: string; + toolUses?: number; + durationMs?: number; + totalTokens?: number; +} + /** * Initial / updated state for an in-flight tool call. Mirrors ACP `ToolCall` * (initial form) — emitted by backends in the `tool_call` `SessionUpdate`. @@ -446,6 +455,8 @@ export interface ToolCallSnapshot { mcpServer?: string; /** Parent tool-call id, for nested tools (e.g. Claude's Task subagents). */ parentToolCallId?: string; + /** Latest backend-neutral progress for a long-running tool call. */ + progress?: AgentToolProgress; /** True iff this tool call is the agent's plan-finalization signal. */ isPlanProposal?: boolean; } @@ -465,6 +476,7 @@ export interface ToolCallDelta { /** MCP server name; see `ToolCallSnapshot.mcpServer`. */ mcpServer?: string; parentToolCallId?: string; + progress?: AgentToolProgress; isPlanProposal?: boolean; } @@ -877,6 +889,8 @@ export type AgentMessagePart = * top-level. */ parentToolCallId?: string; + /** Latest normalized progress for this tool call. */ + progress?: AgentToolProgress; } | { kind: "thought"; diff --git a/src/agentMode/ui/AgentTrailView.test.tsx b/src/agentMode/ui/AgentTrailView.test.tsx index 985a01a6..1cdc11a4 100644 --- a/src/agentMode/ui/AgentTrailView.test.tsx +++ b/src/agentMode/ui/AgentTrailView.test.tsx @@ -131,4 +131,28 @@ describe("AgentTrail inline trail (no collapse)", () => { // The research tool card renders inline (not folded behind a toggle). expect(screen.getByText("Search vault")).toBeTruthy(); }); + + it("shows progress on a childless background subagent card", () => { + renderTrail({ + parts: [ + { + kind: "tool_call", + id: "launch", + title: "Agent", + vendorToolName: "Agent", + status: "in_progress", + input: { subagent_type: "Explore", description: "Analyze notes" }, + progress: { + description: "Running Count markdown files", + toolUses: 3, + durationMs: 9851, + }, + }, + ], + isStreaming: true, + turnStopReason: undefined, + }); + + expect(screen.getByText("Running Count markdown files · 3 tools · 9s")).toBeTruthy(); + }); }); diff --git a/src/agentMode/ui/SubAgentCard.tsx b/src/agentMode/ui/SubAgentCard.tsx index a73dfaa4..53e8a58d 100644 --- a/src/agentMode/ui/SubAgentCard.tsx +++ b/src/agentMode/ui/SubAgentCard.tsx @@ -20,6 +20,14 @@ interface SubAgentCardProps { renderNode: (node: RenderNode, key: string | number) => React.ReactNode; } +/** + * Keeps delegated work attached to its launch so the prompt, progress, and report remain one traceable unit. + * @param parent - The tool call that launched the delegated work. + * @param childNodes - The nested activity produced by the delegated work. + * @param truncated - Whether omitted activity should be disclosed to the user. + * @param app - The Obsidian application used to render note-aware content. + * @param renderNode - The renderer for nested agent-trail nodes. + */ export const SubAgentCard: React.FC = ({ parent, childNodes, @@ -32,6 +40,7 @@ export const SubAgentCard: React.FC = ({ const summary = lookupToolSummary(parent); const Icon = summary.icon; const line = summary.collapsedLine(parent); + const outcome = summary.outcome(parent); const childCounts = countChildren(childNodes); const inputPrompt = extractSubAgentInputPrompt(parent); const returnText = extractSubAgentReturnText(parent); @@ -52,9 +61,12 @@ export const SubAgentCard: React.FC = ({ )} + {outcome ?
{outcome}
: null} {open ? (
-
{describeCounts(childCounts, truncated)}
+ {childCounts.tools > 0 || childCounts.reasoning > 0 || truncated ? ( +
{describeCounts(childCounts, truncated)}
+ ) : null} {inputPrompt ? (
{ } }); + it("groups a background (childless) sub-agent launch as a subagent node", () => { + // A launch can still be childless when no complete nested frame arrived; + // the card remains a group so the final report has a home. + const parts = [ + tool("launch", { + vendorToolName: "Agent", + input: { subagent_type: "Explore", description: "Analyze notes" }, + output: [{ type: "text", text: "Most prominent: AI/agents (16)." }], + }), + ]; + const tree = buildAgentTrail(parts); + expect(tree).toHaveLength(1); + expect(tree[0].type).toBe("subagent"); + if (tree[0].type === "subagent") { + expect(tree[0].parent.id).toBe("launch"); + expect(tree[0].children).toHaveLength(0); + } + }); + + it.each(["Agent", "Task"])( + "renders an MCP tool named %s as an action rather than a subagent", + (vendorToolName) => { + const tree = buildAgentTrail([ + tool("mcp-tool", { vendorToolName, mcpServer: "srv", toolKind: "think" }), + ]); + expect(tree[0].type).toBe("action"); + } + ); + + it("recognizes an opencode task tool (subagent_type input) as a subagent group", () => { + const parts = [tool("t", { input: { subagent_type: "general" } })]; + const tree = buildAgentTrail(parts); + expect(tree[0].type).toBe("subagent"); + }); + + it.each([ + ["a named vendor tool", { vendorToolName: "RunAnalysis" }], + ["an anonymous MCP tool", { mcpServer: "analysis-server", toolKind: "other" }], + ["a typed native tool", { toolKind: "execute" }], + ] as const)( + "does not classify %s with a subagent_type parameter as a subagent", + (_label, identity) => { + const tree = buildAgentTrail([ + tool("ordinary", { ...identity, input: { subagent_type: "worker" } }), + ]); + expect(tree[0].type).toBe("action"); + } + ); + it("a sub-agent breaks the run; same-tool peers around it compact independently", () => { const parts = [ tool("e1", { vendorToolName: "Edit" }), diff --git a/src/agentMode/ui/agentTrail.ts b/src/agentMode/ui/agentTrail.ts index a7f51111..2606e016 100644 --- a/src/agentMode/ui/agentTrail.ts +++ b/src/agentMode/ui/agentTrail.ts @@ -61,15 +61,29 @@ export function toolKeyFor(part: ToolCallPart): string { return part.mcpServer ? `mcp:${part.mcpServer}:${base}` : base; } -/** - * `ToolSearch` is Claude Code's deferred-tool schema loader — invoked - * before every `ExitPlanMode` to fetch its schema. Hiding it removes - * meaningless "tool calls" cards at the end of plan mode. - */ +/** Claude Code's deferred-tool schema loader has no standalone user meaning. */ function isHiddenTool(part: AgentMessagePart): boolean { return part.kind === "tool_call" && part.vendorToolName === "ToolSearch"; } +/** + * A sub-agent invocation (Claude's `Agent`/`Task`, or opencode's anonymous + * `task` tool, which has no vendor/MCP provenance, maps to missing/other kind, + * and carries a `subagent_type` input). Background Claude subagents do not emit + * partial stream events, but current SDK versions do forward their complete + * nested assistant/user frames. A childless launch still renders as a group so + * its final report has a home. + */ +function isSubAgentLaunch(part: ToolCallPart): boolean { + if (!part.mcpServer && (part.vendorToolName === "Agent" || part.vendorToolName === "Task")) { + return true; + } + if (part.vendorToolName || part.mcpServer) return false; + if (part.toolKind && part.toolKind !== "other") return false; + const input = part.input as { subagent_type?: unknown } | null | undefined; + return typeof input?.subagent_type === "string"; +} + /** * Fold a flat `AgentMessagePart[]` into a render tree. Compaction folds * runs of `N >= 2` consecutive same-`toolKey` peers into one aggregate @@ -155,11 +169,14 @@ function foldNodes( } // tool_call const children = childrenByParent.get(p.id); - if (children && children.length > 0) { + if ((children && children.length > 0) || isSubAgentLaunch(p)) { // Sub-agent: flush any pending compaction first (sub-agent boundary - // breaks compaction), then emit the subagent node. + // breaks compaction), then emit the subagent node. A background launch + // has no streamed children but still groups so its report renders inside. const childNodes = - depth + 1 >= maxDepth ? [] : foldNodes(children, childrenByParent, maxDepth, depth + 1); + depth + 1 >= maxDepth + ? [] + : foldNodes(children ?? [], childrenByParent, maxDepth, depth + 1); out.push({ type: "subagent", parent: p, diff --git a/src/agentMode/ui/toolSummaries.test.ts b/src/agentMode/ui/toolSummaries.test.ts index f34e9150..6ab423f7 100644 --- a/src/agentMode/ui/toolSummaries.test.ts +++ b/src/agentMode/ui/toolSummaries.test.ts @@ -196,6 +196,28 @@ describe("lookupToolSummary", () => { expect(lookupToolSummary(t).collapsedLine(t, CTX)).toBe("Tool call"); }); + it("summarizes live subagent progress and omits stale descriptions after completion", () => { + const running = tool({ + vendorToolName: "Agent", + status: "in_progress", + progress: { + description: "Running Count markdown files and subfolders", + toolUses: 3, + durationMs: 9851, + }, + }); + const done = tool({ + ...running, + status: "completed", + progress: { ...running.progress, toolUses: 19, durationMs: 66_000 }, + }); + + expect(lookupToolSummary(running).outcome(running)).toBe( + "Running Count markdown files and subfolders · 3 tools · 9s" + ); + expect(lookupToolSummary(done).outcome(done)).toBe("19 tools · 1m 6s"); + }); + it("summarizes AskUserQuestion with the question header", () => { const t = tool({ vendorToolName: "AskUserQuestion", diff --git a/src/agentMode/ui/toolSummaries.ts b/src/agentMode/ui/toolSummaries.ts index 452b2a99..3ac36acd 100644 --- a/src/agentMode/ui/toolSummaries.ts +++ b/src/agentMode/ui/toolSummaries.ts @@ -2,6 +2,7 @@ import type { LucideIcon } from "lucide-react"; import { Bot, MessageCircleQuestion } from "lucide-react"; import { pickToolIcon } from "@/agentMode/ui/toolIcons"; import type { ToolCallPart } from "@/agentMode/ui/agentTrail"; +import { formatDuration } from "@/lib/duration"; import { isAbsolutePath, toVaultRelative } from "@/utils/vaultPath"; /** @@ -402,7 +403,21 @@ const TASK_SUMMARY: ToolSummary = { : targetFromTitle(p); return agent ? `${agent} · "${desc}"` : `Sub-agent · "${desc}"`; }, - outcome: () => null, + outcome: (p) => { + const progress = p.progress; + if (!progress) return null; + const bits: string[] = []; + // The SDK's task_progress descriptions are already verb-prefixed status + // lines ("Running X", "Reading Y") — don't stack another verb on top. + if ((p.status === "pending" || p.status === "in_progress") && progress.description?.trim()) { + bits.push(progress.description.trim()); + } + if (progress.toolUses !== undefined) { + bits.push(pluralize(progress.toolUses, "tool")); + } + if (progress.durationMs !== undefined) bits.push(formatDuration(progress.durationMs)); + return bits.length > 0 ? bits.join(" · ") : null; + }, aggregate: (parts) => ({ line: `Ran ${pluralize(parts.length, "sub-agent")}${statusSuffix(parts)}`, outcome: "", @@ -585,13 +600,8 @@ export function extractSubAgentInputPrompt(part: ToolCallPart): string | null { } /** - * Extract the sub-agent return value from a Task / sub-agent tool call's - * output. Strips `` markers (opencode wraps - * the result this way) and returns the inner text. Returns null when - * the part has no text output, or when the output is identical to the - * input prompt — Claude Code initially echoes the prompt as the Agent - * tool's `content` before the sub-agent has produced anything, and that - * echoed prompt would otherwise render in the "response" slot. + * Selects the user-visible delegated-work report without repeating an echoed prompt. + * @param part - The subagent tool call whose displayable report is needed. */ export function extractSubAgentReturnText(part: ToolCallPart): string | null { if (!part.output) return null; diff --git a/src/components/chat-components/RelevantNotes.test.tsx b/src/components/chat-components/RelevantNotes.test.tsx index 09d2cec5..a91475d4 100644 --- a/src/components/chat-components/RelevantNotes.test.tsx +++ b/src/components/chat-components/RelevantNotes.test.tsx @@ -39,6 +39,7 @@ jest.mock("@/hooks/useNoteDrag", () => ({ })); jest.mock("@/miyo/miyoUtils", () => ({ + getSearchBackend: () => "keyword", shouldUseMiyo: () => false, }));