mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-14 13:22:37 +00:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4c81db8bca | ||
|
|
3a51db2db2 | ||
|
|
08f376f501 |
@@ -431,6 +431,142 @@ describe("background Claude subagents", () => {
|
||||
type: "sub_agent",
|
||||
subAgentType: "general-purpose",
|
||||
description: "Count files here",
|
||||
log: "[Bash] ls -1",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("keeps one logged Task card when the child streams on after the parent's turn ends", async () => {
|
||||
// Claude Code usually ends the launching turn before a background child finishes, so the
|
||||
// child's later frames land in the next turn.
|
||||
queryFactory.mockImplementation(() =>
|
||||
buildQueryMock([
|
||||
{ type: "system", subtype: "init", session_id: "bg-session", permissionMode: "default" },
|
||||
{
|
||||
type: "assistant",
|
||||
message: {
|
||||
model: "claude-haiku-4-5",
|
||||
content: [
|
||||
{
|
||||
type: "tool_use",
|
||||
id: "toolu_background",
|
||||
name: "Agent",
|
||||
input: {
|
||||
subagent_type: "general-purpose",
|
||||
description: "Count files here",
|
||||
prompt: "Run ls and reply with the number of entries.",
|
||||
run_in_background: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "system",
|
||||
subtype: "task_started",
|
||||
task_id: "bg-task",
|
||||
tool_use_id: "toolu_background",
|
||||
task_type: "local_agent",
|
||||
subagent_type: "general-purpose",
|
||||
description: "Count files here",
|
||||
prompt: "Run ls and reply with the number of entries.",
|
||||
},
|
||||
{
|
||||
type: "user",
|
||||
message: {
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "tool_result",
|
||||
tool_use_id: "toolu_background",
|
||||
content: "Async agent launched successfully.",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "assistant",
|
||||
parent_tool_use_id: "toolu_background",
|
||||
message: {
|
||||
model: "claude-haiku-4-5",
|
||||
content: [
|
||||
{
|
||||
type: "tool_use",
|
||||
id: "toolu_ls",
|
||||
name: "Bash",
|
||||
input: { command: "ls -1", description: "List files" },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{ type: "result", subtype: "success", usage: {}, total_cost_usd: 0 },
|
||||
{
|
||||
type: "user",
|
||||
parent_tool_use_id: "toolu_background",
|
||||
message: {
|
||||
role: "user",
|
||||
content: [{ type: "tool_result", tool_use_id: "toolu_ls", content: "a\nb\nc" }],
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "assistant",
|
||||
parent_tool_use_id: "toolu_background",
|
||||
message: { model: "claude-haiku-4-5", content: [{ type: "text", text: "3" }] },
|
||||
},
|
||||
{
|
||||
type: "system",
|
||||
subtype: "task_updated",
|
||||
task_id: "bg-task",
|
||||
patch: { status: "completed" },
|
||||
},
|
||||
{
|
||||
type: "system",
|
||||
subtype: "task_notification",
|
||||
task_id: "bg-task",
|
||||
tool_use_id: "toolu_background",
|
||||
status: "completed",
|
||||
},
|
||||
{ type: "result", subtype: "success", usage: {}, total_cost_usd: 0 },
|
||||
]),
|
||||
);
|
||||
const session = await new ClaudeAgentClient({
|
||||
logger: createTestLogger(),
|
||||
queryFactory,
|
||||
resolveBinary: async () => "/test/claude/bin",
|
||||
}).createSession({ provider: "claude", cwd: process.cwd() });
|
||||
const rows: AgentTimelineRow[] = [];
|
||||
let currentTurnId: string | undefined;
|
||||
let completedTurns = 0;
|
||||
const bothTurnsCompleted = new Promise<void>((resolve) => {
|
||||
session.subscribe((event) => {
|
||||
if (event.type === "turn_started") currentTurnId = event.turnId;
|
||||
if (event.type === "timeline") {
|
||||
rows.push({
|
||||
seq: rows.length + 1,
|
||||
timestamp: new Date(Date.UTC(2026, 9, 4, 0, 0, rows.length)).toISOString(),
|
||||
item: event.item,
|
||||
...(currentTurnId ? { turnId: currentTurnId } : {}),
|
||||
});
|
||||
}
|
||||
if (event.type === "turn_completed" && ++completedTurns === 2) resolve();
|
||||
});
|
||||
});
|
||||
await session.startTurn("delegate work");
|
||||
await bothTurnsCompleted;
|
||||
await session.close();
|
||||
|
||||
const cards = projectTimelineRows({ rows, mode: "projected" })
|
||||
.map((entry) => entry.item)
|
||||
.filter((item) => item.type === "tool_call" && item.callId === "toolu_background");
|
||||
|
||||
expect(cards).toHaveLength(1);
|
||||
expect(cards[0]).toMatchObject({
|
||||
status: "completed",
|
||||
detail: {
|
||||
type: "sub_agent",
|
||||
subAgentType: "general-purpose",
|
||||
description: "Count files here",
|
||||
log: "[Bash] ls -1",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2094,18 +2094,37 @@ class ClaudeAgentSession implements AgentSession {
|
||||
getToolInput: (toolUseId) => this.toolUseCache.get(toolUseId)?.input ?? null,
|
||||
readWorkflowResult: readClaudeWorkflowResultFile,
|
||||
});
|
||||
/**
|
||||
* Subagent tool calls Claude has already answered while the child still runs, with the input
|
||||
* they were launched with and the turn that answered them. A background child is answered at
|
||||
* launch and streams its frames afterwards.
|
||||
*/
|
||||
private readonly settledSubagentToolCalls = new Map<
|
||||
string,
|
||||
{
|
||||
item: Extract<AgentTimelineItem, { type: "tool_call" }>;
|
||||
input: AgentMetadata | null;
|
||||
turnId: string | null;
|
||||
}
|
||||
>();
|
||||
private readonly sidechainTracker = new ClaudeSidechainTracker({
|
||||
getToolInput: (toolUseId) => this.toolUseCache.get(toolUseId)?.input ?? null,
|
||||
getToolInput: (toolUseId) =>
|
||||
this.toolUseCache.get(toolUseId)?.input ??
|
||||
this.settledSubagentToolCallInCurrentTurn(toolUseId)?.input ??
|
||||
null,
|
||||
// Releases that predate the task protocol announce nothing, so the tracker keeps deriving
|
||||
// identity and status from frames for them. Detecting the capability beats comparing version
|
||||
// strings: it reacts to what this session actually does.
|
||||
isDescriptorOwnedElsewhere: () => this.taskProtocolSource.isActive,
|
||||
// The parent's tool call owns its card once its result arrives. A background child streams
|
||||
// its frames after Claude has already answered the call, and re-emitting the card from them
|
||||
// would replace the settled card with an unlabeled, running one.
|
||||
// A background child streams its frames after Claude has already answered the call, so its
|
||||
// card is rebuilt from the settled call: a running one would replace the settled, labeled card.
|
||||
// A card belongs to the turn that answered the call, so frames from a later turn leave it be.
|
||||
needsSyntheticParentToolCard: (toolUseId) =>
|
||||
this.toolUseCache.has(toolUseId) &&
|
||||
(this.toolUseCache.has(toolUseId) ||
|
||||
this.settledSubagentToolCallInCurrentTurn(toolUseId) !== undefined) &&
|
||||
this.taskProtocolSource.needsSyntheticParentToolCard(toolUseId),
|
||||
getSettledParentToolCall: (toolUseId) =>
|
||||
this.settledSubagentToolCallInCurrentTurn(toolUseId)?.item,
|
||||
});
|
||||
private persistedHistory: PersistedTimelineEntry[] = [];
|
||||
private persistedProviderSubagentEvents: Extract<
|
||||
@@ -2711,6 +2730,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
this.cancelCurrentTurn = null;
|
||||
this.turnState = "idle";
|
||||
this.sidechainTracker.clear();
|
||||
this.settledSubagentToolCalls.clear();
|
||||
this.taskProtocolSource.reset();
|
||||
this.input?.end();
|
||||
this.query?.close?.();
|
||||
@@ -4133,6 +4153,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
for (const event of foldSubagentObservations(subagentObservations)) {
|
||||
events.push({ type: "provider_subagent", provider: "claude", event });
|
||||
}
|
||||
this.releaseFinishedSubagentToolCalls(subagentObservations);
|
||||
for (const observation of subagentObservations) {
|
||||
if (observation.kind !== "declared") continue;
|
||||
if (!this.taskProtocolSource.needsSyntheticParentToolCard(observation.id)) continue;
|
||||
@@ -5308,28 +5329,22 @@ class ClaudeAgentSession implements AgentSession {
|
||||
const { images, text } = splitClaudeToolResultImages(block.content);
|
||||
const output = this.buildToolOutput(text, block, entry);
|
||||
|
||||
if (block.is_error) {
|
||||
this.pushToolCall(
|
||||
mapClaudeFailedToolCall({
|
||||
const settledToolCall = block.is_error
|
||||
? mapClaudeFailedToolCall({
|
||||
name: toolName,
|
||||
callId,
|
||||
input: entry?.input ?? null,
|
||||
output: output ?? null,
|
||||
error: { ...block, content: text },
|
||||
}),
|
||||
items,
|
||||
);
|
||||
} else {
|
||||
this.pushToolCall(
|
||||
mapClaudeCompletedToolCall({
|
||||
})
|
||||
: mapClaudeCompletedToolCall({
|
||||
name: toolName,
|
||||
callId,
|
||||
input: entry?.input ?? null,
|
||||
output: output ?? null,
|
||||
}),
|
||||
items,
|
||||
);
|
||||
}
|
||||
});
|
||||
this.pushToolCall(settledToolCall, items);
|
||||
this.rememberSettledSubagentToolCall(settledToolCall, entry);
|
||||
|
||||
for (const image of images) {
|
||||
const imageItem = renderProviderImageOutputAsAssistantMarkdown(image, {
|
||||
@@ -5345,6 +5360,34 @@ class ClaudeAgentSession implements AgentSession {
|
||||
}
|
||||
}
|
||||
|
||||
private rememberSettledSubagentToolCall(
|
||||
item: Extract<AgentTimelineItem, { type: "tool_call" }> | null,
|
||||
entry: ToolUseCacheEntry | undefined,
|
||||
): void {
|
||||
const subagentId = item ? this.taskProtocolSource.resolveSubagentId(item.callId) : undefined;
|
||||
if (!item || !subagentId) return;
|
||||
this.settledSubagentToolCalls.set(subagentId, {
|
||||
item,
|
||||
input: entry?.input ?? null,
|
||||
turnId: this.activeForegroundTurnId ?? this.autonomousTurn?.id ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
private settledSubagentToolCallInCurrentTurn(subagentId: string) {
|
||||
const settled = this.settledSubagentToolCalls.get(subagentId);
|
||||
const currentTurnId = this.activeForegroundTurnId ?? this.autonomousTurn?.id ?? null;
|
||||
return settled && settled.turnId === currentTurnId ? settled : undefined;
|
||||
}
|
||||
|
||||
/** A finished child streams no more frames, so its settled call has nothing left to update. */
|
||||
private releaseFinishedSubagentToolCalls(observations: SubagentObservation[]): void {
|
||||
for (const observation of observations) {
|
||||
if (observation.kind === "status" && observation.status !== "running") {
|
||||
this.settledSubagentToolCalls.delete(observation.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private buildToolOutput(
|
||||
content: unknown,
|
||||
block: ClaudeContentChunk,
|
||||
|
||||
@@ -14,6 +14,8 @@ interface ClaudeContentChunk {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
type ClaudeToolCallItem = Extract<AgentTimelineItem, { type: "tool_call" }>;
|
||||
|
||||
interface SubAgentActionEntry {
|
||||
index: number;
|
||||
toolName: string;
|
||||
@@ -60,6 +62,7 @@ export class ClaudeSidechainTracker {
|
||||
private readonly getToolInput: (toolUseId: string) => AgentMetadata | null | undefined;
|
||||
private readonly isDescriptorOwnedElsewhere: () => boolean;
|
||||
private readonly needsSyntheticParentToolCard: (toolUseId: string) => boolean;
|
||||
private readonly getSettledParentToolCall: (toolUseId: string) => ClaudeToolCallItem | undefined;
|
||||
|
||||
constructor(input: {
|
||||
getToolInput: (toolUseId: string) => AgentMetadata | null | undefined;
|
||||
@@ -70,10 +73,17 @@ export class ClaudeSidechainTracker {
|
||||
*/
|
||||
isDescriptorOwnedElsewhere?: () => boolean;
|
||||
needsSyntheticParentToolCard?: (toolUseId: string) => boolean;
|
||||
/**
|
||||
* The parent's tool call once Claude has answered it. A background child streams its frames
|
||||
* after that answer, so its card keeps the settled status and output and gains the child's
|
||||
* action log.
|
||||
*/
|
||||
getSettledParentToolCall?: (toolUseId: string) => ClaudeToolCallItem | undefined;
|
||||
}) {
|
||||
this.getToolInput = input.getToolInput;
|
||||
this.isDescriptorOwnedElsewhere = input.isDescriptorOwnedElsewhere ?? (() => false);
|
||||
this.needsSyntheticParentToolCard = input.needsSyntheticParentToolCard ?? (() => true);
|
||||
this.getSettledParentToolCall = input.getSettledParentToolCall ?? (() => undefined);
|
||||
}
|
||||
|
||||
handleMessage(message: SDKMessage, parentToolUseId: string): AgentStreamEvent[] {
|
||||
@@ -115,17 +125,19 @@ export class ClaudeSidechainTracker {
|
||||
return [];
|
||||
}
|
||||
|
||||
const toolCall = mapClaudeRunningToolCall({
|
||||
name: "Task",
|
||||
callId: parentToolUseId,
|
||||
input: null,
|
||||
output: null,
|
||||
});
|
||||
const toolCall =
|
||||
this.getSettledParentToolCall(parentToolUseId) ??
|
||||
mapClaudeRunningToolCall({
|
||||
name: "Task",
|
||||
callId: parentToolUseId,
|
||||
input: null,
|
||||
output: null,
|
||||
});
|
||||
if (!toolCall) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const detail: Extract<AgentTimelineItem, { type: "tool_call" }>["detail"] = {
|
||||
const detail: ClaudeToolCallItem["detail"] = {
|
||||
type: "sub_agent",
|
||||
...(state.subAgentType ? { subAgentType: state.subAgentType } : {}),
|
||||
...(state.description ? { description: state.description } : {}),
|
||||
|
||||
Reference in New Issue
Block a user