mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-06 16:34:36 +00:00
fix(subagents): preserve restored hidden tabs safely
This commit is contained in:
@@ -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", () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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" } } });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user