fix(subagents): preserve restored hidden tabs safely

This commit is contained in:
Mohamed Boudra
2026-07-15 00:10:31 +02:00
parent 6e50c22956
commit f6ec93bb68
10 changed files with 153 additions and 5 deletions
@@ -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", () => {
+10 -3
View File
@@ -207,7 +207,6 @@ export const useProviderSubagentStore = create<ProviderSubagentState>((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<ProviderSubagentState>((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 };
});
},
}));
@@ -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,
+1
View File
@@ -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 } : {}),
@@ -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;
@@ -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" } } });
});
});
+3
View File
@@ -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()
@@ -2848,12 +2848,36 @@ test("archiveFinishedSubagents archives managed children and keeps dismissed pro
};
}
}
class PendingStartSession extends TestAgentSession {
private readonly startEntered = deferred<void>();
private readonly startAllowed = deferred<void>();
override async startTurn(): Promise<{ turnId: string }> {
this.startEntered.resolve();
await this.startAllowed.promise;
return super.startTurn();
}
waitForStart(): Promise<void> {
return this.startEntered.promise;
}
finishStart(): void {
this.startAllowed.resolve();
}
}
class SubagentsClient extends TestAgentClient {
readonly sessions: TestAgentSession[] = [];
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
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 () => {
@@ -985,6 +985,7 @@ export class AgentManager {
const hasBusyDescendant = (agentId: string, visited = new Set<string>()): 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,
+11
View File
@@ -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,