From c8a7667a039d33b79f7a0af0cc964ad54e9a7c4f Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 22 Sep 2026 18:56:05 +0200 Subject: [PATCH] fix(app): keep conversations in the sidebar across a sequenced catch-up (#5189) When the client reconnects with a live sync cursor, the daemon answers with only the agents that changed, and the replica rebuilds the merged directory from what it already holds. That rebuild went through projectAgentDirectoryEntry, which returned null for any agent without a projectPlacement, so those members never reached the merged map, commitSnapshot treated them as absent, and removeFromDirectory deleted them from the store. An agent hydrated from its timeline or restored from the replica cache has no placement: neither normalizeAgentSnapshot nor deserializeAgent sets one. Every other path in the same flow derives a placement from the working directory instead of dropping the agent, so the projection does the same now via resolveProjectPlacement, and the function is total. The conversations were always intact on the daemon; only the client lost them, which is why a full restart brought them back until the next reconnect drained the sidebar again. Co-authored-by: Bag Raul Cristian <17720135+bagutzu@users.noreply.github.com> --- .../directory-sync/agent-replica.test.ts | 21 +++++++ .../runtime/directory-sync/agent-replica.ts | 17 ++++-- .../src/runtime/directory-sync/index.test.ts | 57 +++++++++++++++++++ 3 files changed, 89 insertions(+), 6 deletions(-) diff --git a/packages/app/src/runtime/directory-sync/agent-replica.test.ts b/packages/app/src/runtime/directory-sync/agent-replica.test.ts index 316e8e03bf..648c66c590 100644 --- a/packages/app/src/runtime/directory-sync/agent-replica.test.ts +++ b/packages/app/src/runtime/directory-sync/agent-replica.test.ts @@ -369,4 +369,25 @@ describe("AgentDirectoryReplica", () => { ).toEqual(directoryPlacement); store.clearSession(serverId); }); + + it("keeps a timeline-hydrated agent when a sequenced catch-up carries no entries", () => { + const serverId = "agent-replica-timeline-membership"; + const store = useSessionStore.getState(); + store.initializeSession(serverId, null as unknown as DaemonClient); + const replica = new AgentDirectoryReplica( + serverId, + () => undefined, + () => undefined, + ); + + // A conversation opened from history arrives over its timeline, without the + // project placement the directory carries, and is a member from then on. + replica.submitTimelineAgent(replica.captureTimeline("agent"), payload("timeline only")); + expect(useSessionStore.getState().sessions[serverId]?.agents.has("agent")).toBe(true); + + replica.commitChanges([], [], []); + + expect(useSessionStore.getState().sessions[serverId]?.agents.has("agent")).toBe(true); + store.clearSession(serverId); + }); }); diff --git a/packages/app/src/runtime/directory-sync/agent-replica.ts b/packages/app/src/runtime/directory-sync/agent-replica.ts index dac4ec5d28..37d5a1e4cb 100644 --- a/packages/app/src/runtime/directory-sync/agent-replica.ts +++ b/packages/app/src/runtime/directory-sync/agent-replica.ts @@ -6,15 +6,21 @@ import type { Agent } from "@/stores/session-store"; import { normalizeAgentSnapshot, projectAgentSnapshot } from "@/utils/agent-snapshots"; import { type AgentDirectoryDelta } from "@/utils/agent-directory-sync"; import { reconcileAgentDirectory } from "@/utils/agent-directory-reconciliation"; +import { resolveProjectPlacement } from "@/utils/project-placement"; import { applyLegacyDaemonWorkspaceOwnership } from "@/workspace/legacy-daemon-workspaces"; import type { DirectoryReplicaMutation } from "@/runtime/replica-cache"; import type { TurnLivenessTransition } from "@/timeline/turn-liveness"; import { AgentStoreProjection } from "./internal/agent-store"; -function projectAgentDirectoryEntry(agent: Agent): FetchAgentsEntry | null { - return agent.projectPlacement - ? { agent: projectAgentSnapshot(agent), project: agent.projectPlacement } - : null; +// A member hydrated from its timeline or restored from the replica cache carries +// no placement. Every other path in this flow derives one from the working +// directory, so this one does too; returning nothing here would drop the member +// from the merged directory and delete the conversation on the next catch-up. +function projectAgentDirectoryEntry(agent: Agent): FetchAgentsEntry { + return { + agent: projectAgentSnapshot(agent), + project: resolveProjectPlacement({ projectPlacement: agent.projectPlacement, cwd: agent.cwd }), + }; } export interface AgentLifecycleToken { @@ -164,8 +170,7 @@ export class AgentDirectoryReplica { const previous = this.storeProjection.snapshot(); const merged = new Map(); for (const agent of previous.values()) { - const entry = projectAgentDirectoryEntry(agent); - if (entry) merged.set(agent.id, entry); + merged.set(agent.id, projectAgentDirectoryEntry(agent)); } for (const entry of entries) merged.set(entry.agent.id, entry); const removalsAsDeltas: AgentDirectoryDelta[] = removals.map(({ id }) => ({ diff --git a/packages/app/src/runtime/directory-sync/index.test.ts b/packages/app/src/runtime/directory-sync/index.test.ts index 9f64a4fc60..ba8dc97b8e 100644 --- a/packages/app/src/runtime/directory-sync/index.test.ts +++ b/packages/app/src/runtime/directory-sync/index.test.ts @@ -640,6 +640,63 @@ describe("DirectorySync session readiness", () => { directory.dispose(); }); + it("reconciles agent changes on top of the accepted cached baseline", async () => { + const serverId = "cached-agent-changes"; + serverIds.add(serverId); + const client = new FakeDirectoryClient(); + const cachedAgent = createAgent(serverId, "cached-agent"); + const releaseNetwork = client.holdAgentFetch(); + const directory = new DirectorySync( + serverId, + { + onAgentStoppedRunning: () => undefined, + markAgentLoading: () => undefined, + markAgentReady: () => undefined, + markAgentError: () => undefined, + }, + { + readAgent: async () => undefined, + readWorkspace: async () => undefined, + readDirectory: async () => ({ + agents: new Map([[cachedAgent.id, cachedAgent]]), + workspaces: new Map(), + projects: new Map(), + checkpoint: { agents: { generation: "g", afterSeq: 7 } }, + }), + commitDirectoryMutations: () => undefined, + }, + ); + directory.connectionChanged({ + client: client as unknown as DaemonClient, + status: "online", + source: { clientGeneration: 1, connectionEpoch: 1 }, + }); + const store = useSessionStore.getState(); + store.initializeSession(serverId, client as unknown as DaemonClient, 1); + store.updateSessionServerInfo(serverId, { + serverId, + hostname: null, + version: "test", + features: { workspaceMultiplicity: true, directorySync: true }, + }); + + const refresh = directory.refreshAgents(); + await expect.poll(() => client.fetchAgentsCalls).toBe(1); + releaseNetwork({ + requestId: "agents", + entries: [], + pageInfo: { hasMore: false, nextCursor: null, prevCursor: null }, + sync: { generation: "g", headSeq: 7, mode: "changes", removals: [] }, + }); + await refresh; + + expect(client.lastAgentOptions).toMatchObject({ + sync: { generation: "g", afterSeq: 7 }, + }); + expect(useSessionStore.getState().sessions[serverId]?.agents.has(cachedAgent.id)).toBe(true); + directory.dispose(); + }); + it("does not use a cached checkpoint when the corresponding cache read loses its race", async () => { const serverId = "late-directory-cache"; serverIds.add(serverId);