mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-30 13:13:25 +00:00
fix(server): settle child compactions and keep sub-agent cards stable
- Codex: a child compaction shown as loading now completes on the child's thread/compacted or turn end, once. - Claude: a streamed child tool call's empty start input is a placeholder, so the assistant message's input reaches its card. - OMP: the task card follows only its first child, so later children no longer re-send an unchanged card.
This commit is contained in:
@@ -38,6 +38,46 @@ describe("ClaudeSidechainTracker", () => {
|
||||
expect(childCallIds(next)).toEqual(["next"]);
|
||||
});
|
||||
|
||||
it("shows a streamed child tool call's input once the assistant message brings it", () => {
|
||||
const tracker = new ClaudeSidechainTracker({ getToolInput: () => null });
|
||||
const frames = [
|
||||
{
|
||||
type: "stream_event",
|
||||
parent_tool_use_id: "task-1",
|
||||
event: {
|
||||
type: "content_block_start",
|
||||
index: 0,
|
||||
content_block: { type: "tool_use", id: "bash-1", name: "Bash", input: {} },
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "assistant",
|
||||
parent_tool_use_id: "task-1",
|
||||
message: {
|
||||
content: [
|
||||
{ type: "tool_use", id: "bash-1", name: "Bash", input: { command: "echo child" } },
|
||||
],
|
||||
},
|
||||
},
|
||||
] as unknown as SDKMessage[];
|
||||
|
||||
const childCards = frames
|
||||
.flatMap((frame) => tracker.handleMessage(frame, "task-1"))
|
||||
.flatMap((event) =>
|
||||
event.type === "provider_subagent" &&
|
||||
event.event.type === "timeline" &&
|
||||
event.event.item.type === "tool_call"
|
||||
? [event.event.item]
|
||||
: [],
|
||||
);
|
||||
|
||||
expect(childCards.at(-1)).toMatchObject({
|
||||
callId: "bash-1",
|
||||
status: "running",
|
||||
detail: { type: "shell", command: "echo child" },
|
||||
});
|
||||
});
|
||||
|
||||
it("uses Claude's native agent name for the provider subagent title", () => {
|
||||
const tracker = new ClaudeSidechainTracker({
|
||||
getToolInput: () => ({
|
||||
|
||||
@@ -402,13 +402,12 @@ export class ClaudeSidechainTracker {
|
||||
const key =
|
||||
readTrimmedString(block.id) ??
|
||||
`stream:${block.name}:${typeof event.index === "number" ? event.index : 0}`;
|
||||
return [
|
||||
{
|
||||
key,
|
||||
toolName: block.name,
|
||||
input: block.input ?? null,
|
||||
},
|
||||
];
|
||||
// A streamed call starts with `{}` until its input arrives, so an empty input is not final.
|
||||
const input =
|
||||
block.input && typeof block.input === "object" && Object.keys(block.input).length > 0
|
||||
? block.input
|
||||
: null;
|
||||
return [{ key, toolName: block.name, input }];
|
||||
}
|
||||
|
||||
private extractSubAgentActionCandidates(message: SDKMessage): SubAgentActionCandidate[] {
|
||||
|
||||
@@ -4872,6 +4872,54 @@ describe("Codex app-server provider", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
test("settles a child compaction once when the item completion is missing or duplicated", async () => {
|
||||
const session = createSession();
|
||||
const events: AgentStreamEvent[] = [];
|
||||
session.subscribe((event) => events.push(event));
|
||||
|
||||
for (const child of ["compacted-child", "turn-ended-child"]) {
|
||||
await asInternals(session).handleNotification("item/completed", {
|
||||
threadId: "test-thread",
|
||||
item: {
|
||||
type: "subAgentActivity",
|
||||
id: `spawn-${child}`,
|
||||
kind: "started",
|
||||
agentThreadId: child,
|
||||
agentPath: `/root/${child}`,
|
||||
},
|
||||
});
|
||||
await asInternals(session).handleNotification("item/started", {
|
||||
threadId: child,
|
||||
item: { type: "contextCompaction", id: `${child}-compaction` },
|
||||
});
|
||||
}
|
||||
await asInternals(session).handleNotification("thread/compacted", {
|
||||
threadId: "compacted-child",
|
||||
});
|
||||
await asInternals(session).handleNotification("item/completed", {
|
||||
threadId: "compacted-child",
|
||||
item: { type: "contextCompaction", id: "compacted-child-compaction" },
|
||||
});
|
||||
await asInternals(session).handleNotification("turn/completed", {
|
||||
threadId: "turn-ended-child",
|
||||
turn: { status: "completed" },
|
||||
});
|
||||
|
||||
const compactions = events.flatMap((event) =>
|
||||
event.type === "provider_subagent" && event.event.type === "timeline"
|
||||
? [
|
||||
`${event.event.id} ${event.event.item.type === "compaction" ? event.event.item.status : ""}`,
|
||||
]
|
||||
: [],
|
||||
);
|
||||
expect(compactions).toEqual([
|
||||
"compacted-child loading",
|
||||
"turn-ended-child loading",
|
||||
"compacted-child completed",
|
||||
"turn-ended-child completed",
|
||||
]);
|
||||
});
|
||||
|
||||
test("keeps the parent sub-agent running when a child command fails during the child turn", async () => {
|
||||
const session = createSession();
|
||||
const events: AgentStreamEvent[] = [];
|
||||
|
||||
@@ -3485,6 +3485,9 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
private compactionTriggerByItemId = new Map<string, "auto" | "manual">();
|
||||
private pendingRootCompactionItemIds = new Set<string>();
|
||||
private pendingAnonymousRootCompactions = 0;
|
||||
// Child threads with a compaction shown as loading. Some Codex builds settle it only through
|
||||
// `thread/compacted` or the end of the child's turn.
|
||||
private pendingChildCompactionThreadIds = new Set<string>();
|
||||
// Codex can report one completed compaction through both channels:
|
||||
// `thread/compacted` and a completed `contextCompaction` item.
|
||||
private unpairedCompactionNotificationCompletions = 0;
|
||||
@@ -5514,6 +5517,9 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
case "patch_apply_completed":
|
||||
this.handlePatchApplyCompletedNotification(parsed, callId);
|
||||
return;
|
||||
case "context_compacted":
|
||||
this.settleChildCompaction(parsed.threadId);
|
||||
return;
|
||||
default:
|
||||
// Aggregate child telemetry is redundant and must not leak into the
|
||||
// root timeline. Concrete legacy tools are projected above for Codex
|
||||
@@ -5797,7 +5803,13 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
): boolean {
|
||||
if (
|
||||
childSubAgentCallId &&
|
||||
this.handleSubAgentContextCompactionItem(childSubAgentCallId, parsed.item, "completed")
|
||||
parsed.threadId &&
|
||||
this.handleSubAgentContextCompactionItem(
|
||||
childSubAgentCallId,
|
||||
parsed.threadId,
|
||||
parsed.item,
|
||||
"completed",
|
||||
)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
@@ -5989,19 +6001,29 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
|
||||
private handleSubAgentContextCompactionItem(
|
||||
callId: string,
|
||||
childThreadId: string,
|
||||
item: { id?: string; type?: string; [key: string]: unknown },
|
||||
status: "loading" | "completed",
|
||||
): boolean {
|
||||
if (!this.isContextCompactionItem(item)) {
|
||||
return false;
|
||||
}
|
||||
for (const childThreadId of this.subAgentCallsByCallId.get(callId)?.childThreadIds ?? []) {
|
||||
if (status === "loading") {
|
||||
this.pendingChildCompactionThreadIds.add(childThreadId);
|
||||
this.emitProviderSubagentTimeline(childThreadId, { type: "compaction", status });
|
||||
} else {
|
||||
this.settleChildCompaction(childThreadId);
|
||||
}
|
||||
this.emitSubAgentActivityUpdate(callId);
|
||||
return true;
|
||||
}
|
||||
|
||||
private settleChildCompaction(childThreadId: string): void {
|
||||
if (this.pendingChildCompactionThreadIds.delete(childThreadId)) {
|
||||
this.emitProviderSubagentTimeline(childThreadId, { type: "compaction", status: "completed" });
|
||||
}
|
||||
}
|
||||
|
||||
private shouldSkipCompletedThreadItem(
|
||||
timelineItem: AgentTimelineItem,
|
||||
normalizedItemType: string | undefined,
|
||||
@@ -6132,6 +6154,7 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
): void {
|
||||
const subAgentCallId = this.getSubAgentCallIdForThread(parsed.threadId);
|
||||
if (subAgentCallId) {
|
||||
if (parsed.threadId) this.settleChildCompaction(parsed.threadId);
|
||||
let status: ToolCallTimelineItem["status"] = "completed";
|
||||
if (parsed.status === "failed") {
|
||||
status = "failed";
|
||||
@@ -6718,7 +6741,13 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
const childSubAgentCallId = this.getSubAgentCallIdForThread(parsed.threadId);
|
||||
if (
|
||||
childSubAgentCallId &&
|
||||
this.handleSubAgentContextCompactionItem(childSubAgentCallId, parsed.item, "loading")
|
||||
parsed.threadId &&
|
||||
this.handleSubAgentContextCompactionItem(
|
||||
childSubAgentCallId,
|
||||
parsed.threadId,
|
||||
parsed.item,
|
||||
"loading",
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -112,4 +112,31 @@ describe("OmpSubagentCardTracker", () => {
|
||||
log: "",
|
||||
});
|
||||
});
|
||||
test("leaves the card alone when a later child of the same task starts", () => {
|
||||
const emitted: string[] = [];
|
||||
const emit = (toolCallId: string) => {
|
||||
emitted.push(toolCallId);
|
||||
return true;
|
||||
};
|
||||
const tracker = new OmpSubagentCardTracker();
|
||||
tracker.handleLifecycle(LIFECYCLE, emit);
|
||||
tracker.handleLifecycle(
|
||||
{
|
||||
...LIFECYCLE,
|
||||
id: "Second",
|
||||
index: 1,
|
||||
description: "Second child",
|
||||
sessionFile: "/s.jsonl",
|
||||
},
|
||||
emit,
|
||||
);
|
||||
|
||||
expect(emitted).toEqual([PARENT_TOOL_CALL_ID]);
|
||||
expect(tracker.detailFor(PARENT_TOOL_CALL_ID, { type: "sub_agent", log: "" })).toEqual({
|
||||
type: "sub_agent",
|
||||
description: "Run echo in subagent",
|
||||
childSessionId: SESSION_FILE,
|
||||
log: "",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,10 +9,11 @@ interface OmpSubagentCardChild {
|
||||
|
||||
/**
|
||||
* Fills a task card's description and child session from OMP's sub-agent events. The card is a
|
||||
* launch marker; the child's activity belongs to the sub-agent's own timeline.
|
||||
* launch marker that names the task's first child; the children's activity belongs to their own
|
||||
* timelines.
|
||||
*/
|
||||
export class OmpSubagentCardTracker {
|
||||
private readonly children = new Map<string, Map<number, OmpSubagentCardChild>>();
|
||||
private readonly firstChildren = new Map<string, OmpSubagentCardChild>();
|
||||
|
||||
handleLifecycle(
|
||||
payload: OmpSubagentLifecyclePayload,
|
||||
@@ -48,10 +49,7 @@ export class OmpSubagentCardTracker {
|
||||
if (baseDetail.type !== "sub_agent") {
|
||||
return baseDetail;
|
||||
}
|
||||
const children = this.children.get(toolCallId);
|
||||
const first = children
|
||||
? [...children.values()].sort((left, right) => left.index - right.index)[0]
|
||||
: undefined;
|
||||
const first = this.firstChildren.get(toolCallId);
|
||||
if (!first) {
|
||||
return baseDetail;
|
||||
}
|
||||
@@ -65,11 +63,11 @@ export class OmpSubagentCardTracker {
|
||||
}
|
||||
|
||||
delete(toolCallId: string): void {
|
||||
this.children.delete(toolCallId);
|
||||
this.firstChildren.delete(toolCallId);
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.children.clear();
|
||||
this.firstChildren.clear();
|
||||
}
|
||||
|
||||
private update(
|
||||
@@ -81,9 +79,11 @@ export class OmpSubagentCardTracker {
|
||||
if (!toolCallId) {
|
||||
return;
|
||||
}
|
||||
const children = this.children.get(toolCallId) ?? new Map<number, OmpSubagentCardChild>();
|
||||
this.children.set(toolCallId, children);
|
||||
const existing = children.get(input.index);
|
||||
const first = this.firstChildren.get(toolCallId);
|
||||
if (first && input.index > first.index) {
|
||||
return;
|
||||
}
|
||||
const existing = first?.index === input.index ? first : undefined;
|
||||
const next: OmpSubagentCardChild = {
|
||||
index: input.index,
|
||||
description: readTrimmedString(input.description) ?? existing?.description,
|
||||
@@ -96,7 +96,7 @@ export class OmpSubagentCardTracker {
|
||||
) {
|
||||
return;
|
||||
}
|
||||
children.set(input.index, next);
|
||||
this.firstChildren.set(toolCallId, next);
|
||||
emitToolCall(toolCallId);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user