From b6bfdedf58512dc12e8082501291c16bda4d5b09 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sat, 29 Aug 2026 22:07:26 +0200 Subject: [PATCH] Fix archived agent navigation and recovery (#4033) * fix(app): preserve archived agent navigation Make explicit archived-agent opens one atomic layout command and defer them until persisted layout hydration. Use the provider's numeric active-session sentinel so recovery can complete. * fix(app): preserve timeline resume during hydration Paint persisted canonical rows with the current live head and retain their resume cursor. This prevents tail replay without dropping stream updates that arrive while replica storage loads. * fix(app): serialize replica reads with cache writes --- .../e2e/browser/agent-message-rewind.spec.ts | 4 +- packages/app/e2e/browser/archive-tab.spec.ts | 4 + .../src/runtime/replica-cache/index.test.ts | 15 +++ .../app/src/runtime/replica-cache/index.ts | 12 ++- .../index.ts | 3 +- .../navigation.test.ts | 33 ++++++- .../navigation.ts | 8 +- .../src/stores/workspace-layout-store.test.ts | 80 ++++++++++++--- .../app/src/stores/workspace-layout-store.ts | 58 ++++------- packages/app/src/timeline/replica.test.ts | 97 +++++++++++++++++++ .../app/src/timeline/viewed-timeline-sync.ts | 39 ++++++-- .../app/src/utils/prepare-workspace-tab.ts | 14 +-- .../src/utils/workspace-navigation.test.ts | 44 ++++++--- .../app/src/utils/workspace-navigation.ts | 2 +- .../agent/providers/opencode-agent.test.ts | 2 +- .../server/agent/providers/opencode-agent.ts | 16 +-- 16 files changed, 320 insertions(+), 111 deletions(-) diff --git a/packages/app/e2e/browser/agent-message-rewind.spec.ts b/packages/app/e2e/browser/agent-message-rewind.spec.ts index 990c922c30..4875cc31c1 100644 --- a/packages/app/e2e/browser/agent-message-rewind.spec.ts +++ b/packages/app/e2e/browser/agent-message-rewind.spec.ts @@ -23,7 +23,7 @@ async function completeSubmittedTurn( const finish = await agent.client.waitForFinish(agent.agentId, 30_000); expect(finish.status).toBe("idle"); - await expect(page.getByText("(end of synthetic stream)", { exact: true })).toBeVisible(); + await expect(page.getByText("(end of synthetic stream)", { exact: true }).last()).toBeVisible(); await expect(userMessage).toHaveAttribute("aria-busy", "false"); return userMessage; } @@ -40,7 +40,7 @@ async function expectTurnCompletesNormally( ): Promise { const finish = await agent.client.waitForFinish(agent.agentId, 30_000); expect(finish.status).toBe("idle"); - await expect(page.getByText("(end of synthetic stream)", { exact: true })).toBeVisible(); + await expect(page.getByText("(end of synthetic stream)", { exact: true }).last()).toBeVisible(); await expectAgentIdle(page); } diff --git a/packages/app/e2e/browser/archive-tab.spec.ts b/packages/app/e2e/browser/archive-tab.spec.ts index e250489a9b..6ee80ddf1e 100644 --- a/packages/app/e2e/browser/archive-tab.spec.ts +++ b/packages/app/e2e/browser/archive-tab.spec.ts @@ -10,6 +10,7 @@ import { archiveAgentFromSessions, clickSessionRow, createIdleAgent, + expectArchivedAgentFocused, expectSessionRowArchived, expectSessionRowVisible, expectWorkspaceArchiveOutcome, @@ -21,6 +22,7 @@ import { resetSeededPageState, reloadWorkspace, } from "../support/helpers/archive-tab"; +import { expectAgentTabActive } from "../support/helpers/launcher"; test.describe("Archive tab reconciliation", () => { let client: Awaited>; @@ -144,6 +146,8 @@ test.describe("Archive tab reconciliation", () => { await clickSessionRow(page, archived.title); + await expectArchivedAgentFocused(page, archived.id); + await expectAgentTabActive(page, archived.id); expect(await fetchAgentArchivedAt(client, archived.id)).toBe(archivedAt); await expect(page).toHaveURL(buildHostWorkspaceRoute(getServerId(), archived.workspaceId), { diff --git a/packages/app/src/runtime/replica-cache/index.test.ts b/packages/app/src/runtime/replica-cache/index.test.ts index 252788bc2b..752ccf36f3 100644 --- a/packages/app/src/runtime/replica-cache/index.test.ts +++ b/packages/app/src/runtime/replica-cache/index.test.ts @@ -508,6 +508,21 @@ describe("ReplicaCache", () => { ]); }); + it("retries a timeline read invalidated by a concurrent directory commit", async () => { + const storage = new MemoryStorage(); + const cache = createCache(storage); + cache.commitTimeline(SERVER_ID, "agent-1", timeline("Persisted timeline")); + await cache.flush(); + storage.onRead = () => { + storage.onRead = null; + cache.commitDirectory(SERVER_ID, directory()); + }; + + expect((await cache.readTimeline(SERVER_ID, "agent-1"))?.items).toEqual([ + timelineItem("Persisted timeline"), + ]); + }); + it("rebuilds every directory row before restoring its checkpoint after eviction", async () => { const storage = new MemoryStorage(); const cache = new ReplicaCache(storage, { ...noLegacyCleanup, maxBytes: 2_500 }); diff --git a/packages/app/src/runtime/replica-cache/index.ts b/packages/app/src/runtime/replica-cache/index.ts index 3a678186d8..40cdc4c691 100644 --- a/packages/app/src/runtime/replica-cache/index.ts +++ b/packages/app/src/runtime/replica-cache/index.ts @@ -898,13 +898,15 @@ export class ReplicaCache { ids?: readonly string[], ): Promise { if (!this.activeServerIds.has(serverId)) return []; - const revision = this.hostRevisions.get(serverId) ?? 0; try { await this.prepareStore(); - await this.flush(); - if (!this.canReadHostRevision(serverId, revision)) return []; - const rows = await this.rowStore.read(serverId, kinds, ids); - return this.canReadHostRevision(serverId, revision) ? rows : []; + while (this.activeServerIds.has(serverId)) { + await this.flush(); + const revision = this.hostRevisions.get(serverId) ?? 0; + const rows = await this.rowStore.read(serverId, kinds, ids); + if (this.canReadHostRevision(serverId, revision)) return rows; + } + return []; } catch { return []; } diff --git a/packages/app/src/stores/navigation-active-workspace-store/index.ts b/packages/app/src/stores/navigation-active-workspace-store/index.ts index 7c28c5b748..be001c5f2d 100644 --- a/packages/app/src/stores/navigation-active-workspace-store/index.ts +++ b/packages/app/src/stores/navigation-active-workspace-store/index.ts @@ -37,9 +37,8 @@ function navigateDeps(): NavigateToWorkspaceDeps { getSessionWorkspaces: (serverId) => useSessionStore.getState().sessions[serverId]?.workspaces, getSessionAgents: (serverId) => useSessionStore.getState().sessions[serverId]?.agents.values() ?? [], + isWorkspaceLayoutHydrated: () => useWorkspaceLayoutStore.persist.hasHydrated(), openTab: (input) => useWorkspaceLayoutStore.getState().openTab(input), - pinAgent: (workspaceKey, agentId) => - useWorkspaceLayoutStore.getState().pinAgent(workspaceKey, agentId), rememberLastWorkspace: (selection) => lastWorkspaceSelectionStore.remember(selection), navigateToRoute: (route) => { navigateToHostWorkspaceRoute(route); diff --git a/packages/app/src/stores/navigation-active-workspace-store/navigation.test.ts b/packages/app/src/stores/navigation-active-workspace-store/navigation.test.ts index 8db0e52246..3902ba116c 100644 --- a/packages/app/src/stores/navigation-active-workspace-store/navigation.test.ts +++ b/packages/app/src/stores/navigation-active-workspace-store/navigation.test.ts @@ -14,6 +14,7 @@ import type { Agent, WorkspaceDescriptor } from "@/stores/session-store"; interface RecordedTab { workspaceKey: string; target: WorkspaceTabTarget; + pin: boolean; } function createFakeDeps(overrides: Partial = {}) { @@ -23,11 +24,11 @@ function createFakeDeps(overrides: Partial = {}) { const deps: NavigateToWorkspaceDeps = { getSessionWorkspaces: () => null, getSessionAgents: () => [] as Agent[], - openTab: ({ workspaceKey, target }) => { - openedTabs.push({ workspaceKey, target }); + isWorkspaceLayoutHydrated: () => true, + openTab: ({ workspaceKey, target, pin = false }) => { + openedTabs.push({ workspaceKey, target, pin }); return target.kind === "agent" ? target.agentId : null; }, - pinAgent: () => undefined, rememberLastWorkspace: (selection) => remembered.push(selection), navigateToRoute: (route) => navigations.push(route), ...overrides, @@ -97,6 +98,7 @@ describe("workspace navigation", () => { { workspaceKey: "server-1:workspace-a", target: { kind: "agent", agentId: "agent-1" }, + pin: false, }, ]); }); @@ -131,6 +133,7 @@ describe("workspace navigation", () => { { workspaceKey: "server-1:workspace-a", target: { kind: "draft", draftId: "draft-1" }, + pin: false, }, ]); }); @@ -153,6 +156,30 @@ describe("workspace navigation", () => { expect(navigations).toEqual(["/h/server-1/workspace/workspace-a?open=agent%3Aagent-1"]); }); + it("defers an agent tab until persisted workspace layout has hydrated", () => { + const workspace = { + id: "workspace-a", + workspaceDirectory: "/repo/workspace-a", + } as WorkspaceDescriptor; + const { deps, navigations, openedTabs } = createFakeDeps({ + getSessionWorkspaces: () => new Map([[workspace.id, workspace]]), + isWorkspaceLayoutHydrated: () => false, + }); + + navigateToWorkspace( + { + serverId: "server-1", + workspaceId: "workspace-a", + target: { kind: "agent", agentId: "agent-1" }, + pin: true, + }, + deps, + ); + + expect(openedTabs).toEqual([]); + expect(navigations).toEqual(["/h/server-1/workspace/workspace-a?open=agent%3Aagent-1"]); + }); + it("reads the active workspace from the current route", () => { const selection = parseActiveWorkspaceSelection({ pathname: "/h/server-1/workspace/workspace-a", diff --git a/packages/app/src/stores/navigation-active-workspace-store/navigation.ts b/packages/app/src/stores/navigation-active-workspace-store/navigation.ts index aea1cd69e5..f8bb8ccf9f 100644 --- a/packages/app/src/stores/navigation-active-workspace-store/navigation.ts +++ b/packages/app/src/stores/navigation-active-workspace-store/navigation.ts @@ -34,6 +34,7 @@ export interface NavigateToWorkspaceInput { export interface NavigateToWorkspaceDeps extends PrepareWorkspaceTabDeps { getSessionWorkspaces: (serverId: string) => Map | null | undefined; getSessionAgents: (serverId: string) => Iterable; + isWorkspaceLayoutHydrated: () => boolean; rememberLastWorkspace: (selection: ActiveWorkspaceSelection) => void; navigateToRoute: (route: string) => void; } @@ -90,8 +91,11 @@ export function navigateToWorkspace( workspaces, workspaceId: input.workspaceId, }); + const shouldDeferAgentOpen = Boolean( + input.target?.kind === "agent" && (!resolvedWorkspaceId || !deps.isWorkspaceLayoutHydrated()), + ); if (input.target) { - if (resolvedWorkspaceId || input.target.kind !== "agent") { + if (!shouldDeferAgentOpen) { prepareWorkspaceTab({ ...input, target: input.target }, deps); } } else { @@ -111,7 +115,7 @@ export function navigateToWorkspace( } const route = - input.target?.kind === "agent" && !resolvedWorkspaceId + input.target?.kind === "agent" && shouldDeferAgentOpen ? buildHostWorkspaceOpenRoute( input.serverId, input.workspaceId, diff --git a/packages/app/src/stores/workspace-layout-store.test.ts b/packages/app/src/stores/workspace-layout-store.test.ts index 12421cf7f5..e53f9c1911 100644 --- a/packages/app/src/stores/workspace-layout-store.test.ts +++ b/packages/app/src/stores/workspace-layout-store.test.ts @@ -3092,7 +3092,7 @@ describe("workspace-layout-store actions", () => { expect(findPaneById(layout.root, "explorer")?.hidden).toBe(true); }); - it("keeps pinned archived agents in memory per workspace without persisting them", () => { + it("keeps explicitly opened pinned agents in memory per workspace without persisting them", () => { const workspaceKey = createWorkspaceKey(); const otherWorkspaceKey = buildWorkspaceTabPersistenceKey({ serverId: SERVER_ID, @@ -3102,9 +3102,18 @@ describe("workspace-layout-store actions", () => { expect(otherWorkspaceKey).toBeTruthy(); const store = workspaceLayoutStore.getState(); - store.pinAgent(workspaceKey, "agent-1"); - store.pinAgent(workspaceKey, "agent-1"); - store.pinAgent(otherWorkspaceKey as string, "agent-2"); + store.openTab({ + workspaceKey, + target: { kind: "agent", agentId: "agent-1" }, + intent: "reveal", + pin: true, + }); + store.openTab({ + workspaceKey: otherWorkspaceKey as string, + target: { kind: "agent", agentId: "agent-2" }, + intent: "reveal", + pin: true, + }); let state = workspaceLayoutStore.getState(); expect(Array.from(state.pinnedAgentIdsByWorkspace[workspaceKey] ?? [])).toEqual(["agent-1"]); @@ -3122,13 +3131,7 @@ describe("workspace-layout-store actions", () => { const partialize = workspaceLayoutStore.persist.getOptions().partialize; expect(partialize).toBeTypeOf("function"); - expect(partialize?.(state)).toEqual({ - layoutByWorkspace: {}, - splitSizesByWorkspace: {}, - explorerSidebarWidthByWorkspace: {}, - explorerPaneIdByWorkspace: {}, - sidePaneIdByWorkspace: {}, - }); + expect(partialize?.(state)).not.toHaveProperty("pinnedAgentIdsByWorkspace"); }); it("keeps hidden agent intents in memory per workspace without persisting them", () => { @@ -3653,14 +3656,19 @@ describe("workspace-layout-store actions", () => { ]); }); - it("pinning an agent clears hidden intent", () => { + it("opening a pinned agent clears hidden intent", () => { const workspaceKey = createWorkspaceKey(); const store = workspaceLayoutStore.getState(); store.hideAgent(workspaceKey, "agent-1"); expect(workspaceLayoutStore.getState().hiddenAgentIdsByWorkspace[workspaceKey]).toBeDefined(); - store.pinAgent(workspaceKey, "agent-1"); + store.openTab({ + workspaceKey, + target: { kind: "agent", agentId: "agent-1" }, + intent: "reveal", + pin: true, + }); const state = workspaceLayoutStore.getState(); expect(state.hiddenAgentIdsByWorkspace[workspaceKey]).toBeUndefined(); @@ -3675,8 +3683,8 @@ describe("workspace-layout-store actions", () => { workspaceKey: workspaceKey, target: { kind: "agent", agentId: "archived-agent" }, intent: "reveal", + pin: true, }); - store.pinAgent(workspaceKey, "archived-agent"); store.reconcileTabs(workspaceKey, { agentsHydrated: true, terminalsHydrated: true, @@ -3730,8 +3738,8 @@ describe("workspace-layout-store actions", () => { workspaceKey: workspaceKey, target: { kind: "agent", agentId: "archived-agent" }, intent: "reveal", + pin: true, }); - store.pinAgent(workspaceKey, "archived-agent"); store.reconcileTabs(workspaceKey, { agentsHydrated: true, terminalsHydrated: true, @@ -3749,6 +3757,48 @@ describe("workspace-layout-store actions", () => { ).toEqual(["agent_archived-agent"]); }); + it("atomically reveals, focuses, and pins an archived agent against reconciliation", () => { + const workspaceKey = createWorkspaceKey(); + const store = workspaceLayoutStore.getState(); + store.openTab({ + workspaceKey, + target: { kind: "agent", agentId: "first-agent" }, + intent: "reveal", + }); + store.openTab({ + workspaceKey, + target: { kind: "agent", agentId: "second-agent" }, + intent: "reveal", + }); + store.hideAgent(workspaceKey, "archived-agent"); + + const archivedTabId = store.openTab({ + workspaceKey, + target: { kind: "agent", agentId: "archived-agent" }, + intent: "reveal", + pin: true, + }); + store.reconcileTabs(workspaceKey, { + agentsHydrated: true, + terminalsHydrated: true, + activeAgentIds: ["first-agent", "second-agent"], + autoOpenAgentIds: ["first-agent", "second-agent"], + knownAgentIds: ["first-agent", "second-agent"], + standaloneTerminalIds: [], + }); + + const state = workspaceLayoutStore.getState(); + const layout = state.layoutByWorkspace[workspaceKey]; + expect(contentTabs(state.getWorkspaceTabs(workspaceKey)).map((tab) => tab.tabId)).toContain( + archivedTabId, + ); + expect(findPaneById(layout.root, layout.focusedPaneId)?.focusedTabId).toBe(archivedTabId); + expect(Array.from(state.pinnedAgentIdsByWorkspace[workspaceKey] ?? [])).toContain( + "archived-agent", + ); + expect(state.hiddenAgentIdsByWorkspace[workspaceKey]).toBeUndefined(); + }); + it("retargeting a tab to an agent clears hidden intent", () => { const workspaceKey = createWorkspaceKey(); const store = workspaceLayoutStore.getState(); diff --git a/packages/app/src/stores/workspace-layout-store.ts b/packages/app/src/stores/workspace-layout-store.ts index 6f72ea0850..974b8c6964 100644 --- a/packages/app/src/stores/workspace-layout-store.ts +++ b/packages/app/src/stores/workspace-layout-store.ts @@ -95,6 +95,8 @@ export interface OpenWorkspaceTabInput { workspaceKey: string; target: WorkspaceTabTarget; intent: WorkspaceTabOpenIntent; + /** Keeps an explicitly opened agent visible even when it is archived. */ + pin?: boolean; placement?: WorkspaceTabPlacement; parentTabId?: string; state?: JsonValue; @@ -159,7 +161,6 @@ interface WorkspaceLayoutStore { resizeSplit: (workspaceKey: string, groupId: string, sizes: number[]) => void; resizeExplorerSidebar: (workspaceKey: string, width: number) => void; reorderTabsInPane: (workspaceKey: string, paneId: string, tabIds: string[]) => void; - pinAgent: (workspaceKey: string, agentId: string) => void; unpinAgent: (workspaceKey: string, agentId: string) => void; hideAgent: (workspaceKey: string, agentId: string) => void; unhideAgent: (workspaceKey: string, agentId: string) => void; @@ -811,6 +812,7 @@ export function createWorkspaceLayoutStore( placement.explorerSidebarPaneId, placement.layout.focusedPaneId, ); + const shouldPinAgent = input.pin === true && normalizedTarget.kind === "agent"; set((state) => ({ ...withoutFocusRestoration(state, normalizedWorkspaceKey), hiddenAgentIdsByWorkspace: @@ -821,6 +823,20 @@ export function createWorkspaceLayoutStore( normalizedWorkspaceKey, normalizedTarget.agentId, ), + pinnedAgentIdsByWorkspace: shouldPinAgent + ? addAgentIdToWorkspaceSet( + state.pinnedAgentIdsByWorkspace, + normalizedWorkspaceKey, + normalizedTarget.agentId, + ) + : state.pinnedAgentIdsByWorkspace, + pendingAgentIdsByWorkspace: shouldPinAgent + ? addAgentIdToWorkspaceSet( + state.pendingAgentIdsByWorkspace, + normalizedWorkspaceKey, + normalizedTarget.agentId, + ) + : state.pendingAgentIdsByWorkspace, layoutByWorkspace: { ...state.layoutByWorkspace, [normalizedWorkspaceKey]: input.parentTabId @@ -1639,46 +1655,6 @@ export function createWorkspaceLayoutStore( }; }); }, - pinAgent: (workspaceKey, agentId) => { - const normalizedWorkspaceKey = trimNonEmpty(workspaceKey); - const normalizedAgentId = trimNonEmpty(agentId); - if (!normalizedWorkspaceKey || !normalizedAgentId) { - return; - } - - set((state) => { - const currentPinnedAgentIds = - state.pinnedAgentIdsByWorkspace[normalizedWorkspaceKey] ?? null; - const currentPendingAgentIds = - state.pendingAgentIdsByWorkspace[normalizedWorkspaceKey] ?? null; - if ( - currentPinnedAgentIds?.has(normalizedAgentId) && - currentPendingAgentIds?.has(normalizedAgentId) - ) { - return state; - } - - const nextPinnedAgentIds = new Set(currentPinnedAgentIds ?? []); - nextPinnedAgentIds.add(normalizedAgentId); - - return { - hiddenAgentIdsByWorkspace: removeAgentIdFromWorkspaceSet( - state.hiddenAgentIdsByWorkspace, - normalizedWorkspaceKey, - normalizedAgentId, - ), - pinnedAgentIdsByWorkspace: { - ...state.pinnedAgentIdsByWorkspace, - [normalizedWorkspaceKey]: nextPinnedAgentIds, - }, - pendingAgentIdsByWorkspace: addAgentIdToWorkspaceSet( - state.pendingAgentIdsByWorkspace, - normalizedWorkspaceKey, - normalizedAgentId, - ), - }; - }); - }, unpinAgent: (workspaceKey, agentId) => { const normalizedWorkspaceKey = trimNonEmpty(workspaceKey); const normalizedAgentId = trimNonEmpty(agentId); diff --git a/packages/app/src/timeline/replica.test.ts b/packages/app/src/timeline/replica.test.ts index 63f52f5668..72cd01772e 100644 --- a/packages/app/src/timeline/replica.test.ts +++ b/packages/app/src/timeline/replica.test.ts @@ -138,6 +138,103 @@ describe("viewed timeline persistence", () => { owner.dispose(); }); + it("paints cached rows without replacing a live head that arrives during preparation", async () => { + useSessionStore.getState().initializeSession(SERVER_ID, null); + let release!: (value: CachedTimeline) => void; + const read = new Promise((resolve) => { + release = resolve; + }); + const replica = createTimelineReplica({ + serverId: SERVER_ID, + storage: { + readTimeline: () => read, + commitTimeline: () => undefined, + }, + prepareAgent: async () => undefined, + }); + + const preparation = replica.prepare(AGENT_ID); + useSessionStore.getState().setAgentStreamState(SERVER_ID, AGENT_ID, { + head: [item("live", "live", 5)], + }); + release(cachedTimeline()); + await preparation; + + expect(replica.readCursor(AGENT_ID)).toEqual({ epoch: "epoch-1", endSeq: 4 }); + expect( + selectAgentTimelineState(useSessionStore.getState().sessions[SERVER_ID], AGENT_ID), + ).toEqual({ status: "painted", items: cachedTimeline().items }); + expect(useSessionStore.getState().sessions[SERVER_ID]?.agentStreamHead.get(AGENT_ID)).toEqual([ + item("live", "live", 5), + ]); + }); + + it("reconciles a live head that overlaps the cached canonical timeline", async () => { + useSessionStore.getState().initializeSession(SERVER_ID, null); + let release!: (value: CachedTimeline) => void; + const read = new Promise((resolve) => { + release = resolve; + }); + const replica = createTimelineReplica({ + serverId: SERVER_ID, + storage: { + readTimeline: () => read, + commitTimeline: () => undefined, + }, + prepareAgent: async () => undefined, + }); + + const preparation = replica.prepare(AGENT_ID); + useSessionStore.getState().setAgentStreamState(SERVER_ID, AGENT_ID, { + head: [item("cached", "cached", 4), item("live", "live", 5)], + }); + release(cachedTimeline()); + await preparation; + + const session = useSessionStore.getState().sessions[SERVER_ID]; + expect([ + ...(session?.agentStreamTail.get(AGENT_ID) ?? []), + ...(session?.agentStreamHead.get(AGENT_ID) ?? []), + ]).toEqual([item("cached", "cached", 4), item("live", "live", 5)]); + expect(replica.readCursor(AGENT_ID)).toEqual({ epoch: "epoch-1", endSeq: 4 }); + }); + + it("reconciles cached rows with a non-authoritative timeline painted during preparation", async () => { + useSessionStore.getState().initializeSession(SERVER_ID, null); + let release!: (value: CachedTimeline) => void; + const read = new Promise((resolve) => { + release = resolve; + }); + const replica = createTimelineReplica({ + serverId: SERVER_ID, + storage: { + readTimeline: () => read, + commitTimeline: () => undefined, + }, + prepareAgent: async () => undefined, + }); + + const preparation = replica.prepare(AGENT_ID); + useSessionStore.getState().applyAgentTimelineResponseState(SERVER_ID, AGENT_ID, { + items: [item("live", "live", 5)], + head: [], + range: null, + older: "none", + newer: false, + synchronized: false, + acknowledgedClientMessageIds: [], + }); + release(cachedTimeline()); + await preparation; + + const session = useSessionStore.getState().sessions[SERVER_ID]; + expect([ + ...(session?.agentStreamTail.get(AGENT_ID) ?? []), + ...(session?.agentStreamHead.get(AGENT_ID) ?? []), + ]).toEqual([item("cached", "cached", 4), item("live", "live", 5)]); + expect(replica.readCursor(AGENT_ID)).toEqual({ epoch: "epoch-1", endSeq: 4 }); + }); + it("persists accepted live stream commits through the owner", () => { useSessionStore.getState().initializeSession(SERVER_ID, null); applySynced(AGENT_ID, 8); diff --git a/packages/app/src/timeline/viewed-timeline-sync.ts b/packages/app/src/timeline/viewed-timeline-sync.ts index 386572f12b..e1167c524a 100644 --- a/packages/app/src/timeline/viewed-timeline-sync.ts +++ b/packages/app/src/timeline/viewed-timeline-sync.ts @@ -27,6 +27,7 @@ import { } from "./session-stream-reducers"; import { isTimelineResumeSnapshotAuthoritative } from "./timeline-sync-plan"; import { createInstalledTimelineTransform, type TimelineItemTransform } from "@/plugins/timeline"; +import { replaceWithCanonicalStream } from "@/types/stream"; const PLUGIN_TIMELINE_REPROJECTION_DELAY_MS = 50; @@ -50,21 +51,41 @@ async function prepareCachedTimeline(input: { if (!stored) return undefined; const session = useSessionStore.getState().sessions[input.serverId]; const currentTimeline = selectAgentTimelineState(session, input.agentId); - if (session?.agentStreamHead.get(input.agentId) !== beforeHead) return undefined; - if (beforeTimeline.status === "painted") { - return currentTimeline.status === "painted" && currentTimeline.items === beforeTimeline.items - ? stored - : undefined; + const currentHead = session?.agentStreamHead.get(input.agentId); + if (currentTimeline.status === "synced") return undefined; + if (!stored.range) { + if (currentHead !== beforeHead) return undefined; + if (beforeTimeline.status === "painted") { + return currentTimeline.status === "painted" && currentTimeline.items === beforeTimeline.items + ? stored + : undefined; + } + if (currentTimeline.status !== "cold") return undefined; } - if (currentTimeline.status !== "cold") return undefined; + const liveItems = + currentTimeline.status === "painted" + ? [...currentTimeline.items, ...(currentHead ?? [])] + : (currentHead ?? []); + const replacement = stored.range + ? replaceWithCanonicalStream({ + canonical: stored.items, + previousTail: [], + previousHead: liveItems, + sendingClientMessageIds: getSendingClientMessageIds( + session?.messageSubmissions.get(input.agentId), + ), + preserveContinuity: true, + canonicalCoverage: stored.range, + }) + : { tail: stored.items, head: liveItems, acknowledgedClientMessageIds: [] }; useSessionStore.getState().applyAgentTimelineResponseState(input.serverId, input.agentId, { - items: stored.items, - head: [], + items: replacement.tail, + head: replacement.head, range: stored.range, older: stored.hasOlder ? "available" : "none", newer: false, synchronized: false, - acknowledgedClientMessageIds: [], + acknowledgedClientMessageIds: replacement.acknowledgedClientMessageIds, }); return stored; } diff --git a/packages/app/src/utils/prepare-workspace-tab.ts b/packages/app/src/utils/prepare-workspace-tab.ts index 57a7448f33..5d05bff60c 100644 --- a/packages/app/src/utils/prepare-workspace-tab.ts +++ b/packages/app/src/utils/prepare-workspace-tab.ts @@ -15,9 +15,9 @@ export interface PrepareWorkspaceTabDeps { workspaceKey: string; target: WorkspaceTabTarget; intent: "reveal"; + pin?: boolean; placement?: WorkspaceTabPlacement; }) => string | null; - pinAgent: (workspaceKey: string, agentId: string) => void; } function getPreparedTarget(target: WorkspaceTabTarget): WorkspaceTabTarget { @@ -38,9 +38,11 @@ export function prepareWorkspaceTab( workspaceId: input.workspaceId, }) ?? ""; - deps.openTab({ workspaceKey: key, target, intent: "reveal", placement: input.placement }); - - if (input.pin && target.kind === "agent") { - deps.pinAgent(key, target.agentId); - } + deps.openTab({ + workspaceKey: key, + target, + intent: "reveal", + pin: input.pin === true && target.kind === "agent", + placement: input.placement, + }); } diff --git a/packages/app/src/utils/workspace-navigation.test.ts b/packages/app/src/utils/workspace-navigation.test.ts index cbad3e56f5..efb1f496b2 100644 --- a/packages/app/src/utils/workspace-navigation.test.ts +++ b/packages/app/src/utils/workspace-navigation.test.ts @@ -9,32 +9,25 @@ const AGENT_ID = "agent-1"; interface RecordedOpenedTab { key: string; target: WorkspaceTabTarget; -} - -interface RecordedPin { - key: string; - agentId: string; + pin: boolean; } function createFakeLayout() { const openedTabs: RecordedOpenedTab[] = []; - const pinnedAgents: RecordedPin[] = []; return { openedTabs, - pinnedAgents, openTab: ({ workspaceKey: key, target, + pin = false, }: { workspaceKey: string; target: WorkspaceTabTarget; + pin?: boolean; }) => { - openedTabs.push({ key, target }); + openedTabs.push({ key, target, pin }); return target.kind === "agent" ? target.agentId : null; }, - pinAgent: (key: string, agentId: string) => { - pinnedAgents.push({ key, agentId }); - }, }; } @@ -52,8 +45,33 @@ describe("prepareWorkspaceTab", () => { ); expect(layout.openedTabs).toEqual([ - { key: "server-1:/repo/worktree", target: { kind: "agent", agentId: AGENT_ID } }, + { + key: "server-1:/repo/worktree", + target: { kind: "agent", agentId: AGENT_ID }, + pin: false, + }, + ]); + }); + + it("requests pinned visibility in the same command that reveals an agent", () => { + const layout = createFakeLayout(); + + prepareWorkspaceTab( + { + serverId: SERVER_ID, + workspaceId: WORKSPACE_ID, + target: { kind: "agent", agentId: AGENT_ID }, + pin: true, + }, + layout, + ); + + expect(layout.openedTabs).toEqual([ + { + key: "server-1:/repo/worktree", + target: { kind: "agent", agentId: AGENT_ID }, + pin: true, + }, ]); - expect(layout.pinnedAgents).toEqual([]); }); }); diff --git a/packages/app/src/utils/workspace-navigation.ts b/packages/app/src/utils/workspace-navigation.ts index 1b1cc91657..727ce24bb3 100644 --- a/packages/app/src/utils/workspace-navigation.ts +++ b/packages/app/src/utils/workspace-navigation.ts @@ -14,9 +14,9 @@ function layoutStoreDeps() { workspaceKey: string; target: WorkspaceTabTarget; intent: "reveal"; + pin?: boolean; placement?: import("@/stores/workspace-layout-actions").WorkspaceTabPlacement; }) => store.openTab(input), - pinAgent: store.pinAgent, }; } diff --git a/packages/server/src/server/agent/providers/opencode-agent.test.ts b/packages/server/src/server/agent/providers/opencode-agent.test.ts index 154dcdb39d..687ae8a5d6 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.test.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.test.ts @@ -460,7 +460,7 @@ describe("OpenCodeAgentClient adapter smoke tests", () => { { sessionID: "session-1", directory: cwd, - time: { archived: null }, + time: { archived: 0 }, }, ]); expect(runtime.acquisitions.every((acquisition) => acquisition.releaseCount === 1)).toBe(true); diff --git a/packages/server/src/server/agent/providers/opencode-agent.ts b/packages/server/src/server/agent/providers/opencode-agent.ts index a3fd30f7f0..574ca43f62 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.ts @@ -1636,12 +1636,13 @@ export class OpenCodeAgentClient implements AgentClient { } async unarchiveNativeSession(handle: AgentPersistenceHandle): Promise { - await this.setNativeSessionArchived(handle, null); + // OpenCode's numeric archive field uses zero as the active-session sentinel. + await this.setNativeSessionArchived(handle, 0); } private async setNativeSessionArchived( handle: AgentPersistenceHandle, - archivedAt: number | null, + archivedAt: number, ): Promise { const metadata = (handle.metadata ?? {}) as Partial; if (!metadata.cwd) { @@ -1657,15 +1658,8 @@ export class OpenCodeAgentClient implements AgentClient { directory: metadata.cwd, }); try { - // OpenCode accepts null to clear the archive timestamp, but this SDK - // release's generated request type still exposes only number. - const updateSession = client.session.update.bind(client.session) as (parameters: { - sessionID: string; - directory?: string; - time?: { archived?: number | null }; - }) => ReturnType; const response = readOpenCodeRecord( - await updateSession({ + await client.session.update({ sessionID: handle.sessionId, directory: metadata.cwd, time: { archived: archivedAt }, @@ -1673,7 +1667,7 @@ export class OpenCodeAgentClient implements AgentClient { ); if (response?.error) { throw new Error( - `Failed to ${archivedAt === null ? "unarchive" : "archive"} OpenCode session: ${toDiagnosticErrorMessage(response.error)}`, + `Failed to ${archivedAt === 0 ? "unarchive" : "archive"} OpenCode session: ${toDiagnosticErrorMessage(response.error)}`, ); } } finally {