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 648c66c590..03fc0632a9 100644 --- a/packages/app/src/runtime/directory-sync/agent-replica.test.ts +++ b/packages/app/src/runtime/directory-sync/agent-replica.test.ts @@ -390,4 +390,39 @@ describe("AgentDirectoryReplica", () => { expect(useSessionStore.getState().sessions[serverId]?.agents.has("agent")).toBe(true); store.clearSession(serverId); }); + it("keeps unchanged agent fields when an update changes only the status", () => { + const serverId = "agent-replica-unchanged-fields"; + const store = useSessionStore.getState(); + store.initializeSession(serverId, null as unknown as DaemonClient); + const replica = new AgentDirectoryReplica( + serverId, + () => undefined, + () => undefined, + ); + const agentInStore = () => useSessionStore.getState().sessions[serverId]?.agents.get("agent"); + replica.commitSnapshot([entry(payload("agent"))], []); + const before = agentInStore(); + + replica.applyDelta({ + kind: "upsert", + agent: { ...payload("agent"), status: "running", updatedAt: "2026-07-17T00:02:00.000Z" }, + project: entry(payload("agent")).project, + }); + const afterDelta = agentInStore(); + replica.commitSnapshot( + [entry({ ...payload("agent"), status: "idle", updatedAt: "2026-07-17T00:03:00.000Z" })], + [], + ); + const afterSnapshot = agentInStore(); + + expect(afterDelta?.status).toBe("running"); + expect(afterSnapshot?.status).toBe("idle"); + for (const agent of [afterDelta, afterSnapshot]) { + expect(agent?.capabilities).toBe(before?.capabilities); + expect(agent?.availableModes).toBe(before?.availableModes); + expect(agent?.labels).toBe(before?.labels); + expect(agent?.projectPlacement).toBe(before?.projectPlacement); + } + store.clearSession(serverId); + }); }); diff --git a/packages/app/src/runtime/directory-sync/internal/agent-store.ts b/packages/app/src/runtime/directory-sync/internal/agent-store.ts index 97c8208da5..6be4b5e9a5 100644 --- a/packages/app/src/runtime/directory-sync/internal/agent-store.ts +++ b/packages/app/src/runtime/directory-sync/internal/agent-store.ts @@ -21,6 +21,26 @@ function withoutAgent(agentId: string) { }; } +/** + * Every update arrives as a whole new agent, so each nested value (capabilities, modes, labels) is + * a new object even when it did not change. Keep the stored value for every field that is equal, + * and the stored agent when nothing changed, so a reader of one field sees a new value only when + * that field changes. + */ +function keepUnchangedFields(stored: Agent, incoming: Agent): Agent { + const next = { ...incoming }; + let changed = Object.keys(stored).length !== Object.keys(next).length; + for (const key of Object.keys(next) as (keyof Agent)[]) { + if (next[key] === stored[key]) continue; + if (key in stored && equal(next[key], stored[key])) { + Object.assign(next, { [key]: stored[key] }); + } else { + changed = true; + } + } + return changed ? next : stored; +} + function mergeSnapshotTurn(previous: Agent | undefined, incoming: Agent): Agent { if (!previous) return incoming; const activeTurn = @@ -59,7 +79,8 @@ export class AgentStoreProjection { let accepted = agent; useSessionStore.getState().setAgents(this.serverId, (current) => { const previous = current.get(agent.id); - accepted = acceptAgentDirectoryUpdate(previous, mergeSnapshotTurn(previous, agent)); + const winner = acceptAgentDirectoryUpdate(previous, mergeSnapshotTurn(previous, agent)); + accepted = previous ? keepUnchangedFields(previous, winner) : winner; if (accepted === previous) return current; const next = new Map(current); next.set(agent.id, accepted); @@ -117,7 +138,7 @@ export class AgentStoreProjection { for (const [agentId, fetchedAgent] of fetched) { const existing = current.get(agentId); const merged = mergeSnapshotTurn(existing, fetchedAgent); - agents.set(agentId, existing && equal(existing, merged) ? existing : merged); + agents.set(agentId, existing ? keepUnchangedFields(existing, merged) : merged); if (fetchedAgent.archivedAt) { clearArchiveAgentPending({ queryClient, serverId: this.serverId, agentId }); } diff --git a/packages/app/src/utils/agent-directory-reconciliation.test.ts b/packages/app/src/utils/agent-directory-reconciliation.test.ts index 85e311345b..35feb27cd2 100644 --- a/packages/app/src/utils/agent-directory-reconciliation.test.ts +++ b/packages/app/src/utils/agent-directory-reconciliation.test.ts @@ -126,6 +126,23 @@ describe("agent directory reconciliation", () => { }).toEqual({ title: "newer page", status: "running", projectName: "repo" }); }); + it("takes the project of a newer buffered upsert", () => { + const snapshotEntry = entry("agent", "idle"); + const newerProject = { ...snapshotEntry.project, projectName: "renamed project" }; + const result = reconcileAgentDirectory({ + snapshot: [snapshotEntry], + deltas: [ + { + kind: "upsert", + agent: { ...snapshot("agent", "idle"), updatedAt: "2026-07-12T11:00:00.000Z" }, + project: newerProject, + }, + ], + }); + + expect(result[0]?.project.projectName).toBe("renamed project"); + }); + it("clears a snapshot stop when a newer buffered upsert is running", () => { const result = reconcileAgentDirectory({ snapshot: [entry("agent", "idle")],