mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-14 13:22:37 +00:00
fix(claude): keep long-running tool calls out of the subagents track (#6308)
Claude Code sends a tool_progress heartbeat every 30 s while a tool is still running, with that tool's own id as parent_tool_use_id. The Claude adapter routed any frame with a parent id to the sidechain tracker, so a main-thread call that ran past 30 s (MCP, WebFetch, WebSearch) became a "Claude subagent" row with a child action per heartbeat that never completes, and its card was relabeled Task. A heartbeat now names no sidechain, so it never reaches subagent routing. Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
This commit is contained in:
@@ -587,4 +587,75 @@ describe("ClaudeAgentSession sub-agent sidechain updates", () => {
|
||||
expect(latest.detail.log).toContain("[Read] file-6.md");
|
||||
expect(latest.detail.log).toContain("[Read] file-205.md");
|
||||
});
|
||||
|
||||
test("keeps a long main-thread tool call out of the subagents track", async () => {
|
||||
// Claude Code sends a heartbeat every 30 s while a tool runs, with the tool's own id as
|
||||
// parent_tool_use_id. Frames captured from Claude Code 2.1.291 for a 40 s MCP call.
|
||||
queryFactory.mockImplementation(() =>
|
||||
buildQueryMock([
|
||||
{
|
||||
type: "system",
|
||||
subtype: "init",
|
||||
session_id: "heartbeat-session",
|
||||
permissionMode: "bypassPermissions",
|
||||
model: "opus",
|
||||
},
|
||||
{
|
||||
type: "assistant",
|
||||
parent_tool_use_id: null,
|
||||
message: {
|
||||
id: "msg-heartbeat-1",
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "tool_use", id: "toolu_slow", name: "mcp__slow__slow_check", input: {} },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "tool_progress",
|
||||
tool_use_id: "toolu_slow-heartbeat-0",
|
||||
tool_name: "mcp__slow__slow_check",
|
||||
parent_tool_use_id: "toolu_slow",
|
||||
elapsed_time_seconds: 30,
|
||||
heartbeat: true,
|
||||
},
|
||||
{
|
||||
type: "user",
|
||||
parent_tool_use_id: null,
|
||||
message: {
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "tool_result",
|
||||
tool_use_id: "toolu_slow",
|
||||
content: [{ type: "text", text: "SLOW_CHECK_OK" }],
|
||||
is_error: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "result",
|
||||
subtype: "success",
|
||||
usage: { input_tokens: 1, cache_read_input_tokens: 0, output_tokens: 1 },
|
||||
total_cost_usd: 0,
|
||||
},
|
||||
]),
|
||||
);
|
||||
const session = await new ClaudeAgentClient({
|
||||
logger,
|
||||
queryFactory,
|
||||
resolveBinary: async () => "/test/claude/bin",
|
||||
}).createSession({ provider: "claude", cwd: process.cwd() });
|
||||
|
||||
const events = await collectUntilTerminal(streamSession(session, "run the slow check"));
|
||||
await session.close();
|
||||
|
||||
expect(events.filter((event) => event.type === "provider_subagent")).toEqual([]);
|
||||
const toolCallNames = events.flatMap((event) =>
|
||||
event.type === "timeline" && event.item.type === "tool_call" ? [event.item.name] : [],
|
||||
);
|
||||
expect(toolCallNames).toContain("mcp__slow__slow_check");
|
||||
expect(toolCallNames).not.toContain("Task");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1916,6 +1916,11 @@ function readClaudeParentToolUseId(message: SDKMessage): string | null {
|
||||
if (!("parent_tool_use_id" in message)) {
|
||||
return null;
|
||||
}
|
||||
// Claude Code's liveness tick for a tool still running after 30 s names that tool as its
|
||||
// parent, so a long main-thread call would otherwise read as a subagent.
|
||||
if (message.type === "tool_progress" && message.heartbeat === true) {
|
||||
return null;
|
||||
}
|
||||
const parentToolUseId = (message as { parent_tool_use_id?: unknown }).parent_tool_use_id;
|
||||
return typeof parentToolUseId === "string" && parentToolUseId.length > 0 ? parentToolUseId : null;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user