fix(subagents): separate hidden rows from retained history

This commit is contained in:
Mohamed Boudra
2026-07-15 00:10:31 +02:00
parent 12018bf87b
commit 6e50c22956
6 changed files with 98 additions and 13 deletions
@@ -6,7 +6,11 @@ const PARENT_ID = "parent-1";
const SUBAGENT_ID = "child-1";
afterEach(() => {
useProviderSubagentStore.setState({ descriptors: new Map(), timelines: new Map() });
useProviderSubagentStore.setState({
descriptors: new Map(),
timelines: new Map(),
hiddenFromTrack: new Set(),
});
});
describe("provider subagent client store", () => {
@@ -165,7 +169,8 @@ describe("provider subagent client store", () => {
const state = useProviderSubagentStore.getState();
const key = providerSubagentKey(SERVER_ID, PARENT_ID, SUBAGENT_ID);
expect(state.descriptors.has(key)).toBe(false);
expect(state.descriptors.get(key)?.title).toBe("Finished child");
expect(state.hiddenFromTrack.has(key)).toBe(true);
expect(state.timelines.get(key)?.tail).toEqual([
expect.objectContaining({ kind: "assistant_message", text: "Finished output." }),
]);
+20 -7
View File
@@ -32,6 +32,7 @@ export interface ProviderSubagentTimelineState {
interface ProviderSubagentState {
descriptors: Map<string, ProviderSubagentDescriptorPayload>;
timelines: Map<string, ProviderSubagentTimelineState>;
hiddenFromTrack: Set<string>;
replaceList(
serverId: string,
parentAgentId: string,
@@ -193,14 +194,20 @@ function buildTimelineResponseRows(
export const useProviderSubagentStore = create<ProviderSubagentState>((set) => ({
descriptors: new Map(),
timelines: new Map(),
hiddenFromTrack: new Set(),
replaceList(serverId, parentAgentId, subagents) {
set((state) => {
const prefix = parentPrefix(serverId, parentAgentId);
const descriptors = new Map(
[...state.descriptors].filter(([key]) => !key.startsWith(prefix)),
[...state.descriptors].filter(
([key]) => !key.startsWith(prefix) || state.hiddenFromTrack.has(key),
),
);
const hiddenFromTrack = new Set(state.hiddenFromTrack);
for (const subagent of subagents) {
descriptors.set(providerSubagentKey(serverId, parentAgentId, subagent.id), subagent);
const key = providerSubagentKey(serverId, parentAgentId, subagent.id);
descriptors.set(key, subagent);
hiddenFromTrack.delete(key);
}
const retainedKeys = new Set(descriptors.keys());
const timelines = new Map(
@@ -217,7 +224,7 @@ export const useProviderSubagentStore = create<ProviderSubagentState>((set) => (
);
}
}
return { descriptors, timelines };
return { descriptors, timelines, hiddenFromTrack };
});
},
applyUpdate(serverId, payload) {
@@ -229,8 +236,10 @@ export const useProviderSubagentStore = create<ProviderSubagentState>((set) => (
payload.subagent.id,
);
const descriptors = new Map(state.descriptors);
const hiddenFromTrack = new Set(state.hiddenFromTrack);
const previous = descriptors.get(key);
descriptors.set(key, payload.subagent);
hiddenFromTrack.delete(key);
let timelines = state.timelines;
const current = state.timelines.get(key);
if (current && previous?.status !== payload.subagent.status) {
@@ -240,18 +249,22 @@ export const useProviderSubagentStore = create<ProviderSubagentState>((set) => (
buildTimelineState(current.rows, current.epoch, payload.subagent, current.hasOlder),
);
}
return { descriptors, timelines };
return { descriptors, timelines, hiddenFromTrack };
}
if (payload.kind === "remove") {
const key = providerSubagentKey(serverId, payload.parentAgentId, payload.subagentId);
const descriptors = new Map(state.descriptors);
descriptors.delete(key);
if (payload.retainTimeline) {
return { descriptors, timelines: state.timelines };
const hiddenFromTrack = new Set(state.hiddenFromTrack);
hiddenFromTrack.add(key);
return { descriptors, timelines: state.timelines, hiddenFromTrack };
}
descriptors.delete(key);
const timelines = new Map(state.timelines);
const hiddenFromTrack = new Set(state.hiddenFromTrack);
hiddenFromTrack.delete(key);
timelines.delete(key);
return { descriptors, timelines };
return { descriptors, timelines, hiddenFromTrack };
}
const key = providerSubagentKey(serverId, payload.parentAgentId, payload.subagentId);
const existing = state.timelines.get(key);
+38 -1
View File
@@ -59,7 +59,11 @@ function setAgents(agents: Agent[]): void {
afterEach(() => {
useSessionStore.getState().clearSession(SERVER_ID);
useProviderSubagentStore.setState({ descriptors: new Map(), timelines: new Map() });
useProviderSubagentStore.setState({
descriptors: new Map(),
timelines: new Map(),
hiddenFromTrack: new Set(),
});
});
describe("selectSubagentsForParent", () => {
@@ -90,6 +94,39 @@ describe("selectSubagentsForParent", () => {
).toEqual(["provider-child"]);
});
it("hides dismissed provider children from the track while retaining their descriptor", () => {
const store = useProviderSubagentStore.getState();
store.applyUpdate(SERVER_ID, {
kind: "upsert",
subagent: {
id: "provider-child",
parentAgentId: "parent-a",
provider: "codex",
title: "Provider child",
description: null,
status: "completed",
createdAt: "2026-03-08T10:01:00.000Z",
updatedAt: "2026-03-08T10:02:00.000Z",
toolCallId: "call-1",
},
});
store.applyUpdate(SERVER_ID, {
kind: "remove",
parentAgentId: "parent-a",
subagentId: "provider-child",
retainTimeline: true,
});
expect(
selectProviderSubagentsForParent(
useProviderSubagentStore.getState(),
{ serverId: SERVER_ID, parentAgentId: "parent-a" },
true,
),
).toEqual([]);
expect(useProviderSubagentStore.getState().descriptors.size).toBe(1);
});
it("returns only non-archived children for the requested parent", () => {
setAgents([
makeAgent({ id: "parent-a" }),
+1 -1
View File
@@ -91,7 +91,7 @@ export function selectProviderSubagentsForParent(
const rows: ProviderSubagentRow[] = [];
const prefix = `${params.serverId}\0${params.parentAgentId}\0`;
for (const [key, subagent] of state.descriptors) {
if (!key.startsWith(prefix)) continue;
if (!key.startsWith(prefix) || state.hiddenFromTrack.has(key)) continue;
rows.push({
kind: "provider",
id: subagent.id,
@@ -2857,6 +2857,15 @@ test("archiveFinishedSubagents archives managed children and keeps dismissed pro
this.sessions.push(session);
return session;
}
override async resumeSession(
_handle: AgentPersistenceHandle,
config?: Partial<AgentSessionConfig>,
): Promise<AgentSession> {
const session = new ParentSession({ provider: "codex", cwd: config?.cwd ?? workdir });
this.sessions.push(session);
return session;
}
}
const client = new SubagentsClient();
const manager = new AgentManager({
@@ -2951,6 +2960,26 @@ test("archiveFinishedSubagents archives managed children and keeps dismissed pro
status: "completed",
});
expect(() => manager.fetchProviderSubagentTimeline(parent.id, "native-finished")).not.toThrow();
const reloadedManager = new AgentManager({
clients: { codex: new SubagentsClient() },
registry: storage,
logger,
});
await reloadedManager.resumeAgentFromPersistence(
{ provider: "codex", sessionId: "persisted-parent" },
{ cwd: workdir },
parent.id,
{ dismissedProviderSubagentIds: ["native-finished", "native-failed"] },
);
await reloadedManager.hydrateTimelineFromProvider(parent.id);
expect(reloadedManager.listProviderSubagents(parent.id).map((subagent) => subagent.id)).toEqual([
"native-running",
]);
expect(reloadedManager.getProviderSubagent(parent.id, "native-finished")).toMatchObject({
id: "native-finished",
status: "completed",
});
});
test("force provider hydration removes children absent from current history", async () => {
@@ -3165,8 +3165,9 @@ export class AgentManager {
try {
for await (const event of agent.session.streamHistory()) {
if (event.type === "provider_subagent") {
const update = this.applyProviderSubagentEvent(agent, event);
if (broadcast && update) {
const dismissed = agent.dismissedProviderSubagentIds.has(event.event.id);
const update = this.applyProviderSubagentEvent(agent, event, { includeDismissed: true });
if (broadcast && update && !dismissed) {
this.dispatch({ type: "provider_subagent", event: update });
}
continue;