diff --git a/src/tests/integration/web-live-state-contract.test.ts b/src/tests/integration/web-live-state-contract.test.ts index 2af24bcc6..bed3b44c2 100644 --- a/src/tests/integration/web-live-state-contract.test.ts +++ b/src/tests/integration/web-live-state-contract.test.ts @@ -397,10 +397,11 @@ test("/api/session/events exposes explicit live_state_invalidation events for ag harness.emit({ type: "auto_retry_end", success: false, attempt: 1, finalError: "still failing" }); harness.emit({ type: "auto_compaction_start", reason: "threshold" }); harness.emit({ type: "auto_compaction_end", result: undefined, aborted: false, willRetry: false }); + harness.emit({ type: "turn_end" }); const events = await readSseEventsUntil( response, - (seen) => seen.filter((event) => event.type === "live_state_invalidation").length >= 5, + (seen) => seen.filter((event) => event.type === "live_state_invalidation").length >= 6, ); const invalidations = events.filter((event) => event.type === "live_state_invalidation"); @@ -416,6 +417,7 @@ test("/api/session/events exposes explicit live_state_invalidation events for ag { reason: "auto_retry_end", source: "bridge_event", workspaceIndexCacheInvalidated: false }, { reason: "auto_compaction_start", source: "bridge_event", workspaceIndexCacheInvalidated: false }, { reason: "auto_compaction_end", source: "bridge_event", workspaceIndexCacheInvalidated: false }, + { reason: "turn_end", source: "bridge_event", workspaceIndexCacheInvalidated: true }, ], "live_state_invalidation reasons/sources should stay inspectable on /api/session/events", ); @@ -424,6 +426,7 @@ test("/api/session/events exposes explicit live_state_invalidation events for ag assert.deepEqual(invalidations[2].domains, ["auto", "recovery"]); assert.deepEqual(invalidations[3].domains, ["auto", "recovery"]); assert.deepEqual(invalidations[4].domains, ["auto", "recovery"]); + assert.deepEqual(invalidations[5].domains, ["workspace"]); controller.abort(); await waitForMicrotasks(); @@ -585,3 +588,79 @@ test("workspace cache only busts on real boundaries and session mutations emit t unsubscribe(); }); + +test("turn_end events invalidate workspace so milestones list reflects current state (issue #2706)", async (t) => { + const fixture = makeWorkspaceFixture(); + const sessionPath = createSessionFile( + fixture.projectCwd, + fixture.sessionsDir, + "sess-turn", + "Turn Session", + "2026-03-15T03:32:00.000Z", + ); + let workspaceIndexCalls = 0; + + const harness = createHarness((command, current) => { + if (command.type === "get_state") { + current.emit({ + id: command.id, + type: "response", + command: "get_state", + success: true, + data: fakeSessionState("sess-turn", sessionPath), + }); + return; + } + + assert.fail(`unexpected command: ${command.type}`); + }); + + setupBridge(harness, fixture, { + indexWorkspace: async () => { + workspaceIndexCalls += 1; + return fakeWorkspaceIndex(); + }, + }); + + t.after(async () => { + await bridge.resetBridgeServiceForTests(); + onboarding.resetOnboardingServiceForTests(); + fixture.cleanup(); + }); + + const service = bridge.getProjectBridgeService(); + await service.ensureStarted(); + const seenEvents: any[] = []; + const unsubscribe = service.subscribe((event) => { + seenEvents.push(event); + }); + + // Load workspace once to prime cache + await bridge.collectBootPayload(); + assert.equal(workspaceIndexCalls, 1, "initial boot should call indexWorkspace once"); + + // Emit turn_end — this should invalidate the workspace cache so the + // milestones list picks up state changes that occurred during the turn. + harness.emit({ type: "turn_end" }); + await waitForMicrotasks(); + + // Verify a live_state_invalidation was emitted for turn_end + const invalidations = seenEvents.filter((event) => event.type === "live_state_invalidation"); + const turnEndInvalidation = invalidations.find((event) => event.reason === "turn_end"); + assert.ok(turnEndInvalidation, "turn_end should emit a live_state_invalidation event"); + assert.ok( + turnEndInvalidation.domains.includes("workspace"), + "turn_end invalidation should include the workspace domain", + ); + assert.equal( + turnEndInvalidation.workspaceIndexCacheInvalidated, + true, + "turn_end should invalidate the workspace index cache", + ); + + // Verify workspace cache was actually busted + await bridge.collectBootPayload(); + assert.equal(workspaceIndexCalls, 2, "turn_end should bust the workspace index cache so the next fetch re-indexes"); + + unsubscribe(); +}); diff --git a/src/web/bridge-service.ts b/src/web/bridge-service.ts index f1faac3aa..6f5ed530a 100644 --- a/src/web/bridge-service.ts +++ b/src/web/bridge-service.ts @@ -659,6 +659,7 @@ export type BridgeLiveStateDomain = "auto" | "workspace" | "recovery" | "resumab export type BridgeLiveStateInvalidationSource = "bridge_event" | "rpc_command" | "session_manage"; export type BridgeLiveStateInvalidationReason = | "agent_end" + | "turn_end" | "auto_retry_start" | "auto_retry_end" | "auto_compaction_start" @@ -1251,6 +1252,13 @@ function createLiveStateInvalidationFromBridgeEvent( domains: ["auto", "workspace", "recovery"], workspaceIndexCacheInvalidated: true, }; + case "turn_end": + return { + reason: "turn_end", + source: "bridge_event", + domains: ["workspace"], + workspaceIndexCacheInvalidated: true, + }; case "auto_retry_start": return { reason: "auto_retry_start", @@ -1771,6 +1779,7 @@ export class BridgeService { const eventType = (event as { type?: string }).type; if ( eventType === "agent_end" || + eventType === "turn_end" || eventType === "auto_retry_start" || eventType === "auto_retry_end" || eventType === "auto_compaction_start" || diff --git a/web/lib/gsd-workspace-store.tsx b/web/lib/gsd-workspace-store.tsx index a34d91cf1..123b914f8 100644 --- a/web/lib/gsd-workspace-store.tsx +++ b/web/lib/gsd-workspace-store.tsx @@ -349,6 +349,7 @@ export type LiveStateInvalidationDomain = "auto" | "workspace" | "recovery" | "r export type LiveStateInvalidationSource = "bridge_event" | "rpc_command" | "session_manage" export type LiveStateInvalidationReason = | "agent_end" + | "turn_end" | "auto_retry_start" | "auto_retry_end" | "auto_compaction_start"