fix(subagents): retain dismissed timelines and trim paths

This commit is contained in:
Mohamed Boudra
2026-07-15 00:10:31 +02:00
parent 5d2b18fa86
commit d39127e2a6
10 changed files with 113 additions and 10 deletions
@@ -129,6 +129,48 @@ describe("provider subagent client store", () => {
).toBe(false);
});
test("keeps an open timeline when a child is removed only from the subagent track", () => {
const store = useProviderSubagentStore.getState();
store.applyUpdate(SERVER_ID, {
kind: "upsert",
subagent: {
id: SUBAGENT_ID,
parentAgentId: PARENT_ID,
provider: "codex",
title: "Finished child",
description: null,
status: "completed",
createdAt: "2026-07-12T10:00:00.000Z",
updatedAt: "2026-07-12T10:00:02.000Z",
toolCallId: "call-1",
},
});
store.applyUpdate(SERVER_ID, {
kind: "timeline",
parentAgentId: PARENT_ID,
subagentId: SUBAGENT_ID,
provider: "codex",
epoch: "epoch-1",
seq: 1,
timestamp: "2026-07-12T10:00:01.000Z",
item: { type: "assistant_message", text: "Finished output." },
});
store.applyUpdate(SERVER_ID, {
kind: "remove",
parentAgentId: PARENT_ID,
subagentId: SUBAGENT_ID,
retainTimeline: true,
});
const state = useProviderSubagentStore.getState();
const key = providerSubagentKey(SERVER_ID, PARENT_ID, SUBAGENT_ID);
expect(state.descriptors.has(key)).toBe(false);
expect(state.timelines.get(key)?.tail).toEqual([
expect.objectContaining({ kind: "assistant_message", text: "Finished output." }),
]);
});
test("applies terminal list status to a timeline received before its descriptor", () => {
const store = useProviderSubagentStore.getState();
store.applyUpdate(SERVER_ID, {
+4 -1
View File
@@ -245,8 +245,11 @@ export const useProviderSubagentStore = create<ProviderSubagentState>((set) => (
if (payload.kind === "remove") {
const key = providerSubagentKey(serverId, payload.parentAgentId, payload.subagentId);
const descriptors = new Map(state.descriptors);
const timelines = new Map(state.timelines);
descriptors.delete(key);
if (payload.retainTimeline) {
return { descriptors, timelines: state.timelines };
}
const timelines = new Map(state.timelines);
timelines.delete(key);
return { descriptors, timelines };
}
@@ -101,4 +101,18 @@ describe("provider subagent protocol", () => {
},
});
});
test("accepts a remove update that preserves an open provider timeline", () => {
expect(
SessionOutboundMessageSchema.parse({
type: "agent.provider_subagents.update",
payload: {
kind: "remove",
parentAgentId: "parent-1",
subagentId: "child-1",
retainTimeline: true,
},
}),
).toMatchObject({ payload: { kind: "remove", retainTimeline: true } });
});
});
+1
View File
@@ -3137,6 +3137,7 @@ export const ProviderSubagentUpdateMessageSchema = z.object({
kind: z.literal("remove"),
parentAgentId: z.string(),
subagentId: z.string(),
retainTimeline: z.boolean().optional(),
}),
]),
});
@@ -2907,11 +2907,21 @@ test("archiveFinishedSubagents archives managed children and keeps dismissed pro
expect(manager.listProviderSubagents(parent.id).map((subagent) => subagent.id)).toEqual([
"native-running",
]);
expect(manager.getProviderSubagent(parent.id, "native-finished")).toMatchObject({
id: "native-finished",
status: "completed",
});
expect(() => manager.fetchProviderSubagentTimeline(parent.id, "native-finished")).not.toThrow();
expect((await storage.get(parent.id))?.dismissedProviderSubagentIds).toEqual([
"native-finished",
"native-failed",
]);
await expect(manager.archiveFinishedSubagents(parent.id)).resolves.toEqual({
archivedAgentIds: [],
dismissedProviderSubagentIds: [],
});
await manager.hydrateTimelineFromProvider(parent.id, { force: true });
expect(manager.listProviderSubagents(parent.id).map((subagent) => subagent.id)).toEqual([
@@ -960,8 +960,10 @@ export class AgentManager {
}
listProviderSubagents(parentAgentId: string): ProviderSubagentDescriptor[] {
this.requirePublicAgent(parentAgentId);
return this.providerSubagents.list(parentAgentId);
const parent = this.requirePublicAgent(parentAgentId);
return this.providerSubagents
.list(parentAgentId)
.filter((subagent) => !parent.dismissedProviderSubagentIds.has(subagent.id));
}
async archiveFinishedSubagents(parentAgentId: string): Promise<{
@@ -1013,14 +1015,19 @@ export class AgentManager {
const dismissedProviderSubagentIds = this.providerSubagents
.list(parentAgentId)
.filter((subagent) => subagent.status !== "running")
.filter(
(subagent) =>
subagent.status !== "running" && !parent.dismissedProviderSubagentIds.has(subagent.id),
)
.map((subagent) => subagent.id);
for (const subagentId of dismissedProviderSubagentIds) {
parent.dismissedProviderSubagentIds.add(subagentId);
const event = this.providerSubagents.apply(parentAgentId, parent.provider, {
const event: ProviderSubagentStoreEvent = {
type: "remove",
id: subagentId,
});
parentAgentId,
subagentId,
retainTimeline: true,
};
this.dispatch({ type: "provider_subagent", event });
}
if (dismissedProviderSubagentIds.length > 0) {
@@ -52,7 +52,12 @@ export type ProviderSubagentStoreEvent =
row: AgentTimelineRow;
epoch: string;
}
| { type: "remove"; parentAgentId: string; subagentId: string };
| {
type: "remove";
parentAgentId: string;
subagentId: string;
retainTimeline?: boolean;
};
function storeKey(parentAgentId: string, subagentId: string): string {
return `${parentAgentId}\0${subagentId}`;
@@ -285,6 +285,24 @@ describe("codex tool-call mapper", () => {
});
});
it("uses only the final segment of a Windows subAgentActivity path", () => {
const item = mapCodexToolCallFromThreadItem({
type: "subAgentActivity",
id: "activity-windows-path",
kind: "started",
agentThreadId: "child-thread-windows-path",
agentPath: "C:\\Users\\dev\\agents\\investigator",
});
expect(item).toMatchObject({
detail: {
type: "sub_agent",
subAgentType: "investigator",
description: "C:\\Users\\dev\\agents\\investigator",
},
});
});
it("does not fail a collabAgentToolCall from child error state alone", () => {
const item = mapCodexToolCallFromThreadItem({
type: "collabAgentToolCall",
@@ -998,8 +998,10 @@ function mapSubAgentActivityItem(
nativeName = "";
} else if (nativeName.startsWith("/root/")) {
nativeName = nativeName.slice("/root/".length);
} else if (nativeName.includes("/")) {
nativeName = nativeName.slice(nativeName.lastIndexOf("/") + 1);
} else if (/[\\/]/.test(nativeName)) {
nativeName = nativeName.slice(
Math.max(nativeName.lastIndexOf("/"), nativeName.lastIndexOf("\\")) + 1,
);
}
nativeName ||= "Sub-agent";
return {
+1
View File
@@ -1192,6 +1192,7 @@ export class Session {
kind: "remove",
parentAgentId: update.parentAgentId,
subagentId: update.subagentId,
...(update.retainTimeline ? { retainTimeline: true } : {}),
},
});
}