mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-14 13:22:37 +00:00
Show a Claude subagent's auto-mode report as markdown instead of a raw SubagentHandback card (#6248)
* Show a Claude subagent's handback report as its final message Since Claude Code 2.1.271, a subagent in auto mode delivers its report through a SubagentHandback tool call instead of its final text. Paseo showed that call as a generic tool card with the report as escaped JSON. Live sidechain frames and replayed history now map the call to the subagent's final assistant message and hide its acknowledgement. Closes #6241 * Keep a handback report's leading whitespace and hide its acknowledgement by call id --------- Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
This commit is contained in:
@@ -712,4 +712,56 @@ describe("ClaudeAgentSession persisted subagent replay", () => {
|
||||
expect(timeline.length).toBeGreaterThan(0);
|
||||
expect(timeline[0]).toMatchObject({ id: TOOL_USE_ID });
|
||||
});
|
||||
|
||||
test("replays a SubagentHandback report as the subagent's final message", async () => {
|
||||
const handbackId = "toolu_handback";
|
||||
const report = "## Verdict\n\n- **Coherent**";
|
||||
writeSession({
|
||||
parentLines: [taskToolUse(), taskToolResult()],
|
||||
meta: JSON.stringify({ toolUseId: TOOL_USE_ID }),
|
||||
sidechainLines: [
|
||||
JSON.stringify({
|
||||
type: "assistant",
|
||||
isSidechain: true,
|
||||
agentId: AGENT_ID,
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "tool_use",
|
||||
id: handbackId,
|
||||
name: "SubagentHandback",
|
||||
input: { message: report },
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
JSON.stringify({
|
||||
type: "user",
|
||||
isSidechain: true,
|
||||
agentId: AGENT_ID,
|
||||
message: {
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "tool_result",
|
||||
tool_use_id: handbackId,
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: '{"success":true,"message":"Report delivered to your caller."}',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const items = (await replayDescriptors()).flatMap((event) =>
|
||||
event.event.type === "timeline" ? [event.event.item] : [],
|
||||
);
|
||||
expect(items).toEqual([{ type: "assistant_message", text: report }]);
|
||||
});
|
||||
});
|
||||
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import pino from "pino";
|
||||
import { expect, test } from "vitest";
|
||||
|
||||
import type { AgentStreamEvent, AgentTimelineItem } from "../../agent-sdk-types.js";
|
||||
import { collectSessionTurnEvents } from "../test-utils/session-stream-adapter.js";
|
||||
import { ClaudeAgentClient } from "./agent.js";
|
||||
|
||||
const ROOT_PROMPT = `Use Claude Code's native Agent tool exactly once, never Paseo tools, with this complete task:
|
||||
|
||||
Do not use any tools. Reply with exactly this markdown report and nothing else:
|
||||
## HANDBACK_HEADING
|
||||
- **HANDBACK_BOLD** item
|
||||
|
||||
Then reply exactly ROOT_DONE.`;
|
||||
|
||||
function subagentItems(events: AgentStreamEvent[]): AgentTimelineItem[] {
|
||||
return events.flatMap((event) =>
|
||||
event.type === "provider_subagent" && event.event.type === "timeline" ? [event.event.item] : [],
|
||||
);
|
||||
}
|
||||
|
||||
function expectHandbackAsReport(items: AgentTimelineItem[]): void {
|
||||
expect(items.filter((item) => item.type === "tool_call")).toEqual([]);
|
||||
expect(
|
||||
items.flatMap((item) => (item.type === "assistant_message" ? [item.text] : [])).join(""),
|
||||
).toContain("## HANDBACK_HEADING\n- **HANDBACK_BOLD** item");
|
||||
}
|
||||
|
||||
test("an auto-mode Claude subagent's handback report shows as its final message", async () => {
|
||||
const cwd = mkdtempSync(path.join(tmpdir(), "paseo-claude-subagent-handback-"));
|
||||
const client = new ClaudeAgentClient({ logger: pino({ level: "warn" }) });
|
||||
const session = await client.createSession({ provider: "claude", cwd, modeId: "auto" });
|
||||
try {
|
||||
const events = await collectSessionTurnEvents(session, ROOT_PROMPT);
|
||||
expectHandbackAsReport(subagentItems(events));
|
||||
|
||||
const persistence = session.describePersistence();
|
||||
expect(persistence).not.toBeNull();
|
||||
await session.close();
|
||||
const restored = await client.resumeSession(persistence!, { cwd });
|
||||
try {
|
||||
const replayed: AgentStreamEvent[] = [];
|
||||
for await (const event of restored.streamHistory()) replayed.push(event);
|
||||
expectHandbackAsReport(subagentItems(replayed));
|
||||
} finally {
|
||||
await restored.close();
|
||||
}
|
||||
} finally {
|
||||
await session.close();
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}
|
||||
}, 240_000);
|
||||
@@ -44,6 +44,7 @@ import {
|
||||
} from "./model-manifest.js";
|
||||
import { parsePartialJsonObject } from "./partial-json.js";
|
||||
import { ClaudeSidechainTracker } from "./sidechain-tracker.js";
|
||||
import { readClaudeSubagentHandback } from "./subagent-handback.js";
|
||||
import { ClaudeTaskState } from "./task-state.js";
|
||||
import {
|
||||
ClaudeTaskProtocolSource,
|
||||
@@ -2084,6 +2085,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
private toolUseCache = new Map<string, ToolUseCacheEntry>();
|
||||
private toolUseIndexToId = new Map<number, string>();
|
||||
private toolUseInputBuffers = new Map<string, string>();
|
||||
private subagentHandbackIds = new Set<string>();
|
||||
private pendingPermissions = new Map<string, PendingPermission>();
|
||||
private activeForegroundTurnId: string | null = null;
|
||||
private autonomousTurn: AutonomousTurnState | null = null;
|
||||
@@ -5184,7 +5186,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
// User SDK entries can arrive as multiple text blocks, but Paseo treats them as one message.
|
||||
const userTextParts: string[] = [];
|
||||
for (const block of content) {
|
||||
if (!isClaudeContentChunk(block)) {
|
||||
if (!isClaudeContentChunk(block) || this.mapSubagentHandbackBlock(block, items)) {
|
||||
continue;
|
||||
}
|
||||
this.mapBlockToTimeline(block, {
|
||||
@@ -5272,6 +5274,24 @@ class ClaudeAgentSession implements AgentSession {
|
||||
}
|
||||
}
|
||||
|
||||
// A subagent's handback call is its final message, and the call's result is only an
|
||||
// acknowledgement, so neither becomes a tool call.
|
||||
private mapSubagentHandbackBlock(block: ClaudeContentChunk, items: AgentTimelineItem[]): boolean {
|
||||
if (typeof block.tool_use_id === "string" && this.subagentHandbackIds.has(block.tool_use_id)) {
|
||||
this.subagentHandbackIds.delete(block.tool_use_id);
|
||||
return true;
|
||||
}
|
||||
const handback = readClaudeSubagentHandback(block);
|
||||
if (!handback) {
|
||||
return false;
|
||||
}
|
||||
this.subagentHandbackIds.add(handback.callId);
|
||||
if (handback.report) {
|
||||
items.push({ type: "assistant_message", text: handback.report });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private handleToolUseStart(block: ClaudeContentChunk, items: AgentTimelineItem[]): void {
|
||||
const entry = this.upsertToolUseEntry(block);
|
||||
if (!entry) {
|
||||
|
||||
@@ -35,4 +35,64 @@ describe("ClaudeSidechainTracker", () => {
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("shows a SubagentHandback report as the subagent's final message", () => {
|
||||
const tracker = new ClaudeSidechainTracker({ getToolInput: () => null });
|
||||
const report = " indented code\n\n## Verdict\n\n- **Coherent**";
|
||||
const frames = [
|
||||
{
|
||||
type: "stream_event",
|
||||
parent_tool_use_id: "task-1",
|
||||
event: {
|
||||
type: "content_block_start",
|
||||
index: 0,
|
||||
content_block: {
|
||||
type: "tool_use",
|
||||
id: "handback-1",
|
||||
name: "SubagentHandback",
|
||||
input: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "assistant",
|
||||
parent_tool_use_id: "task-1",
|
||||
message: {
|
||||
id: "msg-1",
|
||||
content: [
|
||||
{
|
||||
type: "tool_use",
|
||||
id: "handback-1",
|
||||
name: "SubagentHandback",
|
||||
input: { message: report },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "user",
|
||||
parent_tool_use_id: "task-1",
|
||||
message: {
|
||||
content: [
|
||||
{
|
||||
type: "tool_result",
|
||||
tool_use_id: "handback-1",
|
||||
tool_name: "SubagentHandback",
|
||||
content: [{ type: "text", text: '{"success":true}' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const childItems = frames
|
||||
.flatMap((frame) => tracker.handleMessage(frame as unknown as SDKMessage, "task-1"))
|
||||
.flatMap((event) =>
|
||||
event.type === "provider_subagent" && event.event.type === "timeline"
|
||||
? [event.event.item]
|
||||
: [],
|
||||
);
|
||||
|
||||
expect(childItems).toEqual([{ type: "assistant_message", text: report, messageId: "msg-1" }]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,6 +6,10 @@ import {
|
||||
mapClaudeRunningToolCall,
|
||||
} from "./tool-call-mapper.js";
|
||||
import { buildToolCallDisplayModel } from "@getpaseo/protocol/tool-call-display";
|
||||
import {
|
||||
isClaudeSubagentHandbackToolName,
|
||||
readClaudeSubagentHandback,
|
||||
} from "./subagent-handback.js";
|
||||
|
||||
import type { AgentMetadata, AgentStreamEvent, AgentTimelineItem } from "../../agent-sdk-types.js";
|
||||
|
||||
@@ -91,7 +95,7 @@ export class ClaudeSidechainTracker {
|
||||
const contextUpdated = this.updateSubAgentContextFromTaskInput(state, parentToolUseId);
|
||||
const actionCandidates = this.extractSubAgentActionCandidates(message);
|
||||
const childTimelineItems = [
|
||||
...this.extractSubAgentTimelineItems(message),
|
||||
...this.extractSubAgentTimelineItems(message, state),
|
||||
...this.extractSubAgentToolResults(message, state),
|
||||
];
|
||||
let actionUpdated = false;
|
||||
@@ -233,7 +237,10 @@ export class ClaudeSidechainTracker {
|
||||
this.activeSidechains.clear();
|
||||
}
|
||||
|
||||
private extractSubAgentTimelineItems(message: SDKMessage): AgentTimelineItem[] {
|
||||
private extractSubAgentTimelineItems(
|
||||
message: SDKMessage,
|
||||
state: SubAgentActivityState,
|
||||
): AgentTimelineItem[] {
|
||||
if (message.type !== "assistant" || !Array.isArray(message.message?.content)) {
|
||||
return [];
|
||||
}
|
||||
@@ -253,6 +260,18 @@ export class ClaudeSidechainTracker {
|
||||
} else if (block.type === "thinking") {
|
||||
const text = readTrimmedString(block.thinking);
|
||||
if (text) items.push({ type: "reasoning", text });
|
||||
} else {
|
||||
const handback = readClaudeSubagentHandback(block);
|
||||
if (!handback) continue;
|
||||
// The handback's result is only an acknowledgement, never a tool card.
|
||||
state.completedActionKeys.add(handback.callId);
|
||||
if (handback.report) {
|
||||
items.push({
|
||||
type: "assistant_message",
|
||||
text: handback.report,
|
||||
...(messageId ? { messageId } : {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return items;
|
||||
@@ -391,6 +410,12 @@ export class ClaudeSidechainTracker {
|
||||
}
|
||||
|
||||
private extractSubAgentActionCandidates(message: SDKMessage): SubAgentActionCandidate[] {
|
||||
return this.extractToolActionCandidates(message).filter(
|
||||
(action) => !isClaudeSubagentHandbackToolName(action.toolName),
|
||||
);
|
||||
}
|
||||
|
||||
private extractToolActionCandidates(message: SDKMessage): SubAgentActionCandidate[] {
|
||||
if (message.type === "assistant") {
|
||||
return this.extractAssistantMessageActions(message);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* Since Claude Code 2.1.271, a subagent in auto mode delivers its report to its caller through a
|
||||
* `SubagentHandback` tool call instead of its final text. The report is the subagent's final
|
||||
* message, so Paseo shows it as one and hides the call and its acknowledgement.
|
||||
*/
|
||||
const SUBAGENT_HANDBACK_TOOL_NAME = "SubagentHandback";
|
||||
|
||||
export interface ClaudeSubagentHandback {
|
||||
callId: string;
|
||||
/** Null while the call is still streaming its input. */
|
||||
report: string | null;
|
||||
}
|
||||
|
||||
export function isClaudeSubagentHandbackToolName(name: unknown): boolean {
|
||||
return name === SUBAGENT_HANDBACK_TOOL_NAME;
|
||||
}
|
||||
|
||||
export function readClaudeSubagentHandback(block: {
|
||||
type: string;
|
||||
[key: string]: unknown;
|
||||
}): ClaudeSubagentHandback | null {
|
||||
if (block.type !== "tool_use" || !isClaudeSubagentHandbackToolName(block.name)) return null;
|
||||
if (typeof block.id !== "string" || block.id.length === 0) return null;
|
||||
const input = block.input as { message?: unknown } | null | undefined;
|
||||
const report = typeof input?.message === "string" ? input.message : "";
|
||||
return { callId: block.id, report: report.trim().length > 0 ? report : null };
|
||||
}
|
||||
Reference in New Issue
Block a user