From f6ec93bb68a50f52e80a4af954c2232bc9e6ae28 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 15 Jul 2026 00:04:06 +0200 Subject: [PATCH] fix(subagents): preserve restored hidden tabs safely --- .../app/src/subagents/provider-store.test.ts | 41 ++++++++++++++++++ packages/app/src/subagents/provider-store.ts | 13 ++++-- packages/client/src/daemon-client.test.ts | 2 + packages/client/src/daemon-client.ts | 1 + packages/protocol/src/client-capabilities.ts | 4 ++ .../src/messages.provider-subagents.test.ts | 36 ++++++++++++++++ packages/protocol/src/messages.ts | 3 ++ .../src/server/agent/agent-manager.test.ts | 42 ++++++++++++++++++- .../server/src/server/agent/agent-manager.ts | 5 +++ packages/server/src/server/session.ts | 11 +++++ 10 files changed, 153 insertions(+), 5 deletions(-) diff --git a/packages/app/src/subagents/provider-store.test.ts b/packages/app/src/subagents/provider-store.test.ts index dd745d19fe..4ffd724f30 100644 --- a/packages/app/src/subagents/provider-store.test.ts +++ b/packages/app/src/subagents/provider-store.test.ts @@ -174,6 +174,47 @@ describe("provider subagent client store", () => { expect(state.timelines.get(key)?.tail).toEqual([ expect.objectContaining({ kind: "assistant_message", text: "Finished output." }), ]); + + store.replaceList(SERVER_ID, PARENT_ID, [state.descriptors.get(key)!]); + expect(useProviderSubagentStore.getState().hiddenFromTrack.has(key)).toBe(true); + }); + + test("restores a hidden descriptor from a provider timeline response", () => { + const store = useProviderSubagentStore.getState(); + store.replaceTimeline(SERVER_ID, { + requestId: "restored-tab", + parentAgentId: PARENT_ID, + subagentId: SUBAGENT_ID, + provider: "codex", + 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:02:00.000Z", + toolCallId: "call-1", + }, + hiddenFromTrack: true, + direction: "tail", + epoch: "epoch-1", + reset: false, + staleCursor: false, + gap: false, + window: { minSeq: 0, maxSeq: 0, nextSeq: 1 }, + hasOlder: false, + hasNewer: false, + rows: [], + error: null, + }); + + const key = providerSubagentKey(SERVER_ID, PARENT_ID, SUBAGENT_ID); + const state = useProviderSubagentStore.getState(); + expect(state.descriptors.get(key)?.title).toBe("Finished child"); + expect(state.hiddenFromTrack.has(key)).toBe(true); + expect(state.timelines.get(key)?.head).toEqual([]); }); test("applies terminal list status to a timeline received before its descriptor", () => { diff --git a/packages/app/src/subagents/provider-store.ts b/packages/app/src/subagents/provider-store.ts index 3dc3f5c5cb..563fc60b47 100644 --- a/packages/app/src/subagents/provider-store.ts +++ b/packages/app/src/subagents/provider-store.ts @@ -207,7 +207,6 @@ export const useProviderSubagentStore = create((set) => ( for (const subagent of subagents) { const key = providerSubagentKey(serverId, parentAgentId, subagent.id); descriptors.set(key, subagent); - hiddenFromTrack.delete(key); } const retainedKeys = new Set(descriptors.keys()); const timelines = new Map( @@ -311,10 +310,18 @@ export const useProviderSubagentStore = create((set) => ( const key = providerSubagentKey(serverId, payload.parentAgentId, payload.subagentId); const existing = state.timelines.get(key); const rows = buildTimelineResponseRows(existing, payload, provider); - const descriptor = state.descriptors.get(key); + const descriptors = new Map(state.descriptors); + if (payload.subagent) { + descriptors.set(key, payload.subagent); + } + const hiddenFromTrack = new Set(state.hiddenFromTrack); + if (payload.hiddenFromTrack) { + hiddenFromTrack.add(key); + } + const descriptor = payload.subagent ?? state.descriptors.get(key); const timelines = new Map(state.timelines); timelines.set(key, buildTimelineState(rows, payload.epoch, descriptor, payload.hasOlder)); - return { timelines }; + return { descriptors, timelines, hiddenFromTrack }; }); }, })); diff --git a/packages/client/src/daemon-client.test.ts b/packages/client/src/daemon-client.test.ts index a636fa4d5a..c9e0eb076e 100644 --- a/packages/client/src/daemon-client.test.ts +++ b/packages/client/src/daemon-client.test.ts @@ -185,6 +185,7 @@ test("does not infer browser automation capabilities from Electron runtime", asy }) .parse(JSON.parse(assertStr(mock.sent[0]))); expect(hello.capabilities[CLIENT_CAPS.browserHost]).toBeUndefined(); + expect(hello.capabilities[CLIENT_CAPS.providerSubagentRetainTimeline]).toBe(true); }); test("advertises consumer-provided browser automation capabilities", async () => { @@ -555,6 +556,7 @@ test("advertises client capabilities in hello", async () => { protocolVersion: 1, capabilities: { custom_mode_icons: true, + provider_subagent_retain_timeline: true, provider_subagents: true, reasoning_merge_enum: true, terminal_reflowable_snapshot: true, diff --git a/packages/client/src/daemon-client.ts b/packages/client/src/daemon-client.ts index f08c90a844..d4f8fce7ad 100644 --- a/packages/client/src/daemon-client.ts +++ b/packages/client/src/daemon-client.ts @@ -4831,6 +4831,7 @@ export class DaemonClient { [CLIENT_CAPS.reasoningMergeEnum]: true, [CLIENT_CAPS.terminalReflowableSnapshot]: true, [CLIENT_CAPS.providerSubagents]: true, + [CLIENT_CAPS.providerSubagentRetainTimeline]: true, ...this.config.capabilities, }, ...(this.config.appVersion ? { appVersion: this.config.appVersion } : {}), diff --git a/packages/protocol/src/client-capabilities.ts b/packages/protocol/src/client-capabilities.ts index 63e01a5e45..040182695c 100644 --- a/packages/protocol/src/client-capabilities.ts +++ b/packages/protocol/src/client-capabilities.ts @@ -14,6 +14,10 @@ export const CLIENT_CAPS = { // COMPAT(providerSubagents): added in v0.1.107. The daemon emits provider-owned // child descriptors and timelines only to clients that understand the new messages. providerSubagents: "provider_subagents", + // COMPAT(providerSubagentRetainTimeline): added in v0.1.108. Retained-remove + // updates are withheld from older clients because they interpret every remove + // as destructive and blank already-open provider child tabs. + providerSubagentRetainTimeline: "provider_subagent_retain_timeline", browserHost: "browser_host", } as const; diff --git a/packages/protocol/src/messages.provider-subagents.test.ts b/packages/protocol/src/messages.provider-subagents.test.ts index 8a18f338e7..7069287cfa 100644 --- a/packages/protocol/src/messages.provider-subagents.test.ts +++ b/packages/protocol/src/messages.provider-subagents.test.ts @@ -115,4 +115,40 @@ describe("provider subagent protocol", () => { }), ).toMatchObject({ payload: { kind: "remove", retainTimeline: true } }); }); + + test("accepts a retained descriptor with provider timeline history", () => { + expect( + SessionOutboundMessageSchema.parse({ + type: "agent.provider_subagents.timeline.get.response", + payload: { + requestId: "request-1", + parentAgentId: "parent-1", + subagentId: "child-1", + provider: "codex", + subagent: { + id: "child-1", + parentAgentId: "parent-1", + provider: "codex", + title: "Finished child", + description: null, + status: "completed", + createdAt: "2026-07-12T10:00:00.000Z", + updatedAt: "2026-07-12T10:01:00.000Z", + toolCallId: null, + }, + hiddenFromTrack: true, + direction: "tail", + epoch: "epoch-1", + reset: false, + staleCursor: false, + gap: false, + window: { minSeq: 0, maxSeq: 0, nextSeq: 1 }, + hasOlder: false, + hasNewer: false, + rows: [], + error: null, + }, + }), + ).toMatchObject({ payload: { hiddenFromTrack: true, subagent: { id: "child-1" } } }); + }); }); diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index 00b77641bb..207d7d8d47 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -3093,6 +3093,8 @@ export const ProviderSubagentTimelineResponseMessageSchema = z.object({ parentAgentId: z.string(), subagentId: z.string(), provider: AgentProviderSchema.nullable(), + subagent: ProviderSubagentDescriptorPayloadSchema.optional(), + hiddenFromTrack: z.boolean().optional(), direction: z.enum(["tail", "before", "after"]), epoch: z.string(), reset: z.boolean(), @@ -4877,6 +4879,7 @@ export const WSHelloMessageSchema = z.object({ [CLIENT_CAPS.customModeIcons]: z.boolean().optional(), [CLIENT_CAPS.terminalReflowableSnapshot]: z.boolean().optional(), [CLIENT_CAPS.providerSubagents]: z.boolean().optional(), + [CLIENT_CAPS.providerSubagentRetainTimeline]: z.boolean().optional(), [CLIENT_CAPS.browserHost]: BrowserAutomationHostCapabilitySchema.optional(), }) .passthrough() diff --git a/packages/server/src/server/agent/agent-manager.test.ts b/packages/server/src/server/agent/agent-manager.test.ts index 0b802fe6c0..7e3930e029 100644 --- a/packages/server/src/server/agent/agent-manager.test.ts +++ b/packages/server/src/server/agent/agent-manager.test.ts @@ -2848,12 +2848,36 @@ test("archiveFinishedSubagents archives managed children and keeps dismissed pro }; } } + class PendingStartSession extends TestAgentSession { + private readonly startEntered = deferred(); + private readonly startAllowed = deferred(); + + override async startTurn(): Promise<{ turnId: string }> { + this.startEntered.resolve(); + await this.startAllowed.promise; + return super.startTurn(); + } + + waitForStart(): Promise { + return this.startEntered.promise; + } + + finishStart(): void { + this.startAllowed.resolve(); + } + } class SubagentsClient extends TestAgentClient { readonly sessions: TestAgentSession[] = []; override async createSession(config: AgentSessionConfig): Promise { - const session = - this.sessions.length === 0 ? new ParentSession(config) : new TestAgentSession(config); + let session: TestAgentSession; + if (this.sessions.length === 0) { + session = new ParentSession(config); + } else if (this.sessions.length === 5) { + session = new PendingStartSession(config); + } else { + session = new TestAgentSession(config); + } this.sessions.push(session); return session; } @@ -2899,6 +2923,15 @@ test("archiveFinishedSubagents archives managed children and keeps dismissed pro undefined, { labels: { [PARENT_AGENT_ID_LABEL]: parent.id }, workspaceId: undefined }, ); + const managedWithPendingRun = await manager.createAgent( + { provider: "codex", cwd: workdir }, + undefined, + { labels: { [PARENT_AGENT_ID_LABEL]: parent.id }, workspaceId: undefined }, + ); + const pendingSession = client.sessions[5] as PendingStartSession; + const pendingStream = manager.streamAgent(managedWithPendingRun.id, "starting now"); + const firstPendingEvent = pendingStream.next(); + await pendingSession.waitForStart(); client.sessions[3]?.pushEvent({ type: "turn_started", provider: "codex", @@ -2931,6 +2964,7 @@ test("archiveFinishedSubagents archives managed children and keeps dismissed pro expectArchivedAgentRecord(await storage.get(managedFinished.id), "closed"); expect((await storage.get(managedWithRunningDescendant.id))?.archivedAt).toBeFalsy(); expect((await storage.get(managedWithRunningProviderDescendant.id))?.archivedAt).toBeFalsy(); + expect((await storage.get(managedWithPendingRun.id))?.archivedAt).toBeFalsy(); expect(manager.getAgent(runningGrandchild.id)?.lifecycle).toBe("running"); expect(manager.listProviderSubagents(parent.id).map((subagent) => subagent.id)).toEqual([ "native-running", @@ -2980,6 +3014,10 @@ test("archiveFinishedSubagents archives managed children and keeps dismissed pro id: "native-finished", status: "completed", }); + + pendingSession.finishStart(); + await firstPendingEvent; + await pendingStream.return(undefined); }); test("force provider hydration removes children absent from current history", async () => { diff --git a/packages/server/src/server/agent/agent-manager.ts b/packages/server/src/server/agent/agent-manager.ts index 3d076e1ddb..747c4c6b98 100644 --- a/packages/server/src/server/agent/agent-manager.ts +++ b/packages/server/src/server/agent/agent-manager.ts @@ -985,6 +985,7 @@ export class AgentManager { const hasBusyDescendant = (agentId: string, visited = new Set()): boolean => { if (visited.has(agentId)) return true; visited.add(agentId); + if (this.foregroundRuns.hasPendingRun(agentId)) return true; if (this.providerSubagents.list(agentId).some((subagent) => subagent.status === "running")) { return true; } @@ -1048,6 +1049,10 @@ export class AgentManager { return this.providerSubagents.get(parentAgentId, subagentId); } + isProviderSubagentDismissed(parentAgentId: string, subagentId: string): boolean { + return this.requirePublicAgent(parentAgentId).dismissedProviderSubagentIds.has(subagentId); + } + fetchProviderSubagentTimeline( parentAgentId: string, subagentId: string, diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index b7d48f9156..8fce3d21f5 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -1186,6 +1186,12 @@ export class Session { }, }); } else { + if ( + update.retainTimeline && + !this.supports(CLIENT_CAPS.providerSubagentRetainTimeline) + ) { + return; + } this.emit({ type: "agent.provider_subagents.update", payload: { @@ -5504,6 +5510,11 @@ export class Session { parentAgentId: msg.parentAgentId, subagentId: msg.subagentId, provider: descriptor.provider, + subagent: descriptor, + hiddenFromTrack: this.agentManager.isProviderSubagentDismissed( + msg.parentAgentId, + msg.subagentId, + ), direction, epoch: timeline.epoch, reset: timeline.reset,