mirror of
https://github.com/logancyang/obsidian-copilot.git
synced 2026-07-22 07:50:24 +00:00
feat(agent-mode): project background task lifecycle onto launch cards (#2686)
* feat(agent-mode): project background task lifecycle onto launch cards
* fix(agent-mode): tighten subagent UI detection
* fix(agent-mode): keep MCP Agent tools as actions
* fix(agent-mode): drop duplicated Running prefix on task progress
The SDK's task_progress descriptions are already verb-prefixed status
lines ("Running X", "Reading Y"), so prepending Running in
TASK_SUMMARY.outcome rendered "Running Running …" on launch cards.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(agent-mode): preserve background task correlation
* fix(agent-mode): correlate batched task acknowledgements
Result batches previously discarded the acknowledgement payload and retired only launches awaiting identity. Preserve result blocks long enough to bind the exact async acknowledgement, and retire every other completed launch so late task frames cannot revive foreground cards.
* fix(agent-mode): retain terminal task identity
* fix(agent-mode): harden task lifecycle against SDK frame variants
Address Codex review on #2686:
- translate terminal task_updated patches (completed/failed/killed) so a
subagent that only reports its end state there still settles its card
- match the async-launch acknowledgement by its stable leading sentence;
newer CLIs append metadata warnings the old full-string regex rejected
- park task-only terminal frames that arrive before the launch
acknowledgement and replay them when the identity binds
- route tool_call_update events to the message that owns the tool call so
a background launch settling in a later turn updates its original card
instead of duplicating on the current placeholder
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(agent-mode): use structured async task signals
* fix(agent-mode): correlate async tasks by identity
* fix(agent-mode): preserve prior task updates on cancel
* test(relevant-notes): mock getSearchBackend in RelevantNotes test
The component switched from shouldUseMiyo to getSearchBackend, but the
test's miyoUtils mock still only exported the old name. Calling the
undefined export threw inside useHasIndex's try block, so the catch set
hasIndex to false and the component rendered the "No semantic index yet"
empty state instead of the result row.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
d8dfaa6c6b
commit
79e3c22b4f
18 changed files with 2438 additions and 52 deletions
|
|
@ -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()])
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
672
src/agentMode/sdk/claudeTaskProtocol.test.ts
Normal file
672
src/agentMode/sdk/claudeTaskProtocol.test.ts
Normal file
|
|
@ -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<string, unknown>
|
||||
): 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<string, unknown>): 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",
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
551
src/agentMode/sdk/claudeTaskProtocol.ts
Normal file
551
src/agentMode/sdk/claudeTaskProtocol.ts
Normal file
|
|
@ -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<string, ClaudeTaskResultAction>;
|
||||
}
|
||||
|
||||
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<string, ClaudeTaskResultAction> = 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<string, TaskLaunch>();
|
||||
private readonly launchToolCallIdByTaskId = new Map<string, string>();
|
||||
// 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<string, PendingTerminalFrame>();
|
||||
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<ClaudeTaskProtocolEvent, { kind: "task_active" }>
|
||||
): 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<ClaudeTaskProtocolEvent, { kind: "task_terminal" }>
|
||||
): 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<ClaudeTaskProtocolEvent, { kind: "result_batch" }>
|
||||
): ClaudeTaskDecision {
|
||||
const resultActions = new Map<string, ClaudeTaskResultAction>();
|
||||
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<string, unknown> | undefined {
|
||||
return typeof value === "object" && value !== null
|
||||
? (value as Record<string, unknown>)
|
||||
: 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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<string, unknown>): 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<typeof createTranslatorState>,
|
||||
id: string,
|
||||
name: string,
|
||||
input: Record<string, unknown> = {}
|
||||
): 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<typeof createTranslatorState>,
|
||||
index: number,
|
||||
id: string,
|
||||
name: string,
|
||||
input: Record<string, unknown>
|
||||
): 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<typeof createTranslatorState>): 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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<SDKMessage, { type: "system" }>;
|
||||
|
||||
/**
|
||||
* 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<string>;
|
||||
/** 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 } }];
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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<AgentMessagePart, { kind: "tool_call" }>["progress"],
|
||||
b: Extract<AgentMessagePart, { kind: "tool_call" }>["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
|
||||
|
|
|
|||
|
|
@ -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<AgentToolCallOutput | undefined> => {
|
||||
const mock = makeMockBackend();
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<SubAgentCardProps> = ({
|
||||
parent,
|
||||
childNodes,
|
||||
|
|
@ -32,6 +40,7 @@ export const SubAgentCard: React.FC<SubAgentCardProps> = ({
|
|||
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<SubAgentCardProps> = ({
|
|||
<ChevronRight className="tw-size-3 tw-text-muted" />
|
||||
)}
|
||||
</div>
|
||||
{outcome ? <div className="tw-pl-5 tw-text-xs tw-text-muted">{outcome}</div> : null}
|
||||
{open ? (
|
||||
<div className="tw-mt-2 tw-flex tw-flex-col tw-gap-1 tw-border-l tw-border-border tw-pl-3">
|
||||
<div className="tw-text-xs tw-text-muted">{describeCounts(childCounts, truncated)}</div>
|
||||
{childCounts.tools > 0 || childCounts.reasoning > 0 || truncated ? (
|
||||
<div className="tw-text-xs tw-text-muted">{describeCounts(childCounts, truncated)}</div>
|
||||
) : null}
|
||||
{inputPrompt ? (
|
||||
<div className="tw-my-1">
|
||||
<div
|
||||
|
|
|
|||
|
|
@ -158,6 +158,55 @@ describe("buildAgentTrail", () => {
|
|||
}
|
||||
});
|
||||
|
||||
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" }),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 `<task_result>…</task_result>` 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;
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ jest.mock("@/hooks/useNoteDrag", () => ({
|
|||
}));
|
||||
|
||||
jest.mock("@/miyo/miyoUtils", () => ({
|
||||
getSearchBackend: () => "keyword",
|
||||
shouldUseMiyo: () => false,
|
||||
}));
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue