Merge branch 'main' into update-zh-i18n-upstream-pr

This commit is contained in:
Dillon
2026-10-05 16:15:32 +08:00
committed by GitHub
2 changed files with 111 additions and 12 deletions
@@ -634,6 +634,72 @@ describe("ClaudeAgentSession persisted subagent replay", () => {
expect(events[0]).toMatchObject({ id: TOOL_USE_ID });
});
test.each(["Agent", "Task"] as const)(
"labels a background %s call's card with its type and task after a restart",
async (toolName) => {
writeSession({
parentLines: [
parentEntry([
{
type: "tool_use",
id: TOOL_USE_ID,
name: toolName,
input: {
subagent_type: "general-purpose",
description: "Count files here",
prompt: "Run ls and reply with the number of entries.",
},
},
]),
JSON.stringify({
type: "user",
sessionId: "replay-session",
timestamp: "2026-07-26T06:27:48.000Z",
message: {
role: "user",
content: [
{
type: "tool_result",
tool_use_id: TOOL_USE_ID,
content: [
{
type: "text",
text: `Async agent launched successfully.\nagentId: ${AGENT_ID}`,
},
],
},
],
},
toolUseResult: { isAsync: true, status: "async_launched", agentId: AGENT_ID },
}),
],
meta: JSON.stringify({
agentType: "general-purpose",
description: "Count files here",
toolUseId: TOOL_USE_ID,
spawnDepth: 1,
requestShape: "background",
}),
});
const cards = (await replayEvents()).flatMap((event) =>
event.type === "timeline" &&
event.item.type === "tool_call" &&
event.item.callId === TOOL_USE_ID
? [event.item]
: [],
);
expect(cards.at(-1)).toMatchObject({
status: "completed",
detail: {
type: "sub_agent",
subAgentType: "general-purpose",
description: "Count files here",
},
});
},
);
test("replays the subagent's own transcript onto its timeline", async () => {
writeSession({
parentLines: [taskToolUse(), taskToolResult()],
@@ -130,6 +130,7 @@ import {
type ProviderCatalog,
type ProviderRefreshContext,
type ResolveAgentDefaultModeInput,
type ToolCallDetail,
} from "../../agent-sdk-types.js";
import { importSessionFromPersistence } from "../../provider-session-import.js";
import { runProviderRefreshActivity } from "../../provider-refresh-deadline.js";
@@ -295,6 +296,13 @@ interface AsyncMessageInput<T> {
interface ClaudeReplayOwnership {
restoredIds: ReadonlySet<string>;
toolOwners: ReadonlyMap<string, string>;
/** The parent's Task calls, keyed by tool-call id, so their cards replay labeled as they were live. */
subagentToolCalls: ReadonlyMap<string, ClaudeSubagentCardFacts>;
}
interface ClaudeSubagentCardFacts {
title?: string;
description?: string;
}
interface PersistedTimelineEntry {
@@ -4249,16 +4257,7 @@ class ClaudeAgentSession implements AgentSession {
return {
type: "timeline",
provider: "claude",
item: {
...toolCall,
detail: {
type: "sub_agent",
...(declaration.title ? { subAgentType: declaration.title } : {}),
...(declaration.description ? { description: declaration.description } : {}),
log: "",
actions: [],
},
},
item: { ...toolCall, detail: buildClaudeSubagentCardDetail(declaration) },
};
}
@@ -4977,6 +4976,7 @@ class ClaudeAgentSession implements AgentSession {
const sidechainEntries = [parentContent, ...sidechains.contents]
.flatMap(parseClaudeHistoryRecords)
.filter((entry) => entry.isSidechain === true && typeof entry.agentId === "string");
const parentFacts = readClaudeReplayParentFacts(parentEntries);
// Replay produces the same observations the live task protocol produces, then folds them
// with the same function, so identity and status are derived once for both paths.
@@ -4987,7 +4987,7 @@ class ClaudeAgentSession implements AgentSession {
entries,
parentFacts: readClaudeReplayParentFacts(entries as ClaudeHistoryEntry[]),
})),
parent: readClaudeReplayParentFacts(parentEntries),
parent: parentFacts,
convertEntry: (entry) => this.convertHistoryEntry(entry as ClaudeHistoryEntry),
});
const observations = [
@@ -5016,6 +5016,7 @@ class ClaudeAgentSession implements AgentSession {
const replay = {
restoredIds: restoredProviderSubagentIds,
toolOwners: subagentReplay.toolOwners,
subagentToolCalls: parentFacts.toolCalls,
};
if (observations.length === 0) return replay;
@@ -5082,7 +5083,12 @@ class ClaudeAgentSession implements AgentSession {
return;
}
const taskSnapshot = this.taskState.observe(entry);
const items = [...(taskSnapshot ? [taskSnapshot] : []), ...this.convertHistoryEntry(entry)];
const items = [
...(taskSnapshot ? [taskSnapshot] : []),
...this.convertHistoryEntry(entry).map((item) =>
labelReplayedSubagentCard(item, replay.subagentToolCalls),
),
];
const isVisibleUserEntry =
entry.type === "user" &&
typeof entry.uuid === "string" &&
@@ -5832,6 +5838,33 @@ function parseClaudeHistoryRecords(content: string): ClaudeHistoryEntry[] {
return entries;
}
/** The parent's card for a subagent, labeled with its type and task. */
function buildClaudeSubagentCardDetail(
facts: ClaudeSubagentCardFacts,
): Extract<ToolCallDetail, { type: "sub_agent" }> {
return {
type: "sub_agent",
...(facts.title ? { subAgentType: facts.title } : {}),
...(facts.description ? { description: facts.description } : {}),
log: "",
actions: [],
};
}
/**
* Live, a Task call's card is built from the task protocol's declaration. Replay has no task
* protocol, so the generic tool mapper leaves the card unlabeled; label it from the same facts.
*/
function labelReplayedSubagentCard(
item: AgentTimelineItem,
subagentToolCalls: ReadonlyMap<string, ClaudeSubagentCardFacts>,
): AgentTimelineItem {
if (item.type !== "tool_call") return item;
const facts = subagentToolCalls.get(item.callId);
if (!facts) return item;
return { ...item, detail: buildClaudeSubagentCardDetail(facts) };
}
/**
* Collect what the parent transcript knows about its Task calls, in the shape the shared replay
* source consumes. The agentId scrape is kept only as a fallback for sessions recorded before