diff --git a/packages/client-runtime/src/state/threadShell.test.ts b/packages/client-runtime/src/state/threadShell.test.ts index fda1054064f78..312a24ce9edbb 100644 --- a/packages/client-runtime/src/state/threadShell.test.ts +++ b/packages/client-runtime/src/state/threadShell.test.ts @@ -1,5 +1,6 @@ import { EnvironmentId, + MessageId, ProjectId, RunId, RuntimeRequestId, @@ -108,6 +109,118 @@ describe("v2 thread shell lists", () => { registry.dispose(); }); + it("keeps navigation and its rows through streaming ticks while the live shell follows", () => { + const { registry, threads, snapshotAtom } = makeHarness(); + const runId = RunId.make("navigation-run"); + const running: OrchestrationV2ThreadShell = { + ...v2ThreadShell, + latestRunId: runId, + activeRunId: runId, + status: "running", + itemCount: 1, + visibleItemCount: 1, + }; + const idle = { ...v2ThreadShell, id: ThreadId.make("idle") }; + const setRunning = (thread: OrchestrationV2ThreadShell, sequence: number) => + registry.set(snapshotAtom(environmentId), { + ...v2ShellSnapshot, + snapshotSequence: sequence, + threads: [thread, idle], + }); + setRunning(running, 1); + const ref = { environmentId, threadId: running.id }; + const dispose = registry.mount(threads.navigationThreadShellsAtom); + try { + const before = registry.get(threads.navigationThreadShellsAtom); + const [runningRow, idleRow] = before; + for (let tick = 1; tick <= 3; tick++) { + const updatedAt = DateTime.add(v2Now, { seconds: tick }); + setRunning( + { + ...running, + updatedAt, + itemCount: tick + 1, + visibleItemCount: tick + 1, + latestVisibleMessage: { + id: MessageId.make("streaming-message"), + role: "assistant", + text: `Chunk ${tick}`, + updatedAt, + }, + }, + tick + 1, + ); + expect(registry.get(threads.navigationThreadShellsAtom)).toBe(before); + } + expect(registry.get(threads.threadShellAtom(ref))?.itemCount).toBe(4); + expect(registry.get(threads.threadShellAtom(ref))?.source.latestVisibleMessage?.text).toBe( + "Chunk 3", + ); + + setRunning({ ...running, title: "Renamed" }, 5); + const renamed = registry.get(threads.navigationThreadShellsAtom); + expect(renamed).not.toBe(before); + expect(renamed[0]).not.toBe(runningRow); + expect(renamed[0]?.title).toBe("Renamed"); + expect(renamed[1]).toBe(idleRow); + } finally { + dispose(); + registry.dispose(); + } + }); + + it("rebuilds navigation for running-thread changes a list shows", () => { + const { registry, threads, snapshotAtom } = makeHarness(); + const runId = RunId.make("navigation-run"); + const running: OrchestrationV2ThreadShell = { + ...v2ThreadShell, + latestRunId: runId, + activeRunId: runId, + status: "running", + itemCount: 1, + visibleItemCount: 1, + }; + const completed = { ...running, activeRunId: null, status: "completed" as const }; + const changes: ReadonlyArray = [ + { ...running, itemCount: 0, visibleItemCount: 0 }, + { ...running, status: "waiting" }, + { + ...running, + pendingRuntimeRequest: { + id: RuntimeRequestId.make("navigation-approval"), + kind: "command", + createdAt: v2Now, + }, + }, + completed, + { ...completed, updatedAt: DateTime.add(v2Now, { seconds: 1 }) }, + ]; + const dispose = registry.mount(threads.navigationThreadShellsAtom); + try { + let sequence = 0; + for (const [index, change] of changes.entries()) { + const from = index === changes.length - 1 ? completed : running; + registry.set(snapshotAtom(environmentId), { + ...v2ShellSnapshot, + snapshotSequence: ++sequence, + threads: [from], + }); + const before = registry.get(threads.navigationThreadShellsAtom); + registry.set(snapshotAtom(environmentId), { + ...v2ShellSnapshot, + snapshotSequence: ++sequence, + threads: [change], + }); + const after = registry.get(threads.navigationThreadShellsAtom); + expect(after).not.toBe(before); + expect(after[0]?.source).toBe(change); + } + } finally { + dispose(); + registry.dispose(); + } + }); + it("keeps a live subagent question inside the parent without flagging its thread", () => { const { registry, threads, snapshotAtom } = makeHarness(); const root = v2ThreadShell; diff --git a/packages/client-runtime/src/state/threadShell.ts b/packages/client-runtime/src/state/threadShell.ts index 15718da1eca65..59cc808c6ea5a 100644 --- a/packages/client-runtime/src/state/threadShell.ts +++ b/packages/client-runtime/src/state/threadShell.ts @@ -559,7 +559,14 @@ export function createEnvironmentThreadShellAtoms(input: { ) { continue; } - next.push(scopedThread(environmentId, thread, subagentRequests)); + const shell = scopedThread(environmentId, thread, subagentRequests); + const previous = previousNavigationShells[next.length]; + // Token ticks must not rebuild navigation; threadShellAtom still supplies the live values. + next.push( + previous !== undefined && previous !== shell && sameApartFromStreaming(previous, shell) + ? previous + : shell, + ); } } if (arrayElementsEqual(previousNavigationShells, next)) return previousNavigationShells;