diff --git a/CHANGELOG.md b/CHANGELOG.md index b2764c9..bbed940 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.11.5] - 2026-08-22 + +### Fixed + +- `session/load` always replays the current todo list: the shared + per-session differ only fires PlanUpdate on plan CHANGE, so a re-attaching + client (the mobile app always re-attaches) never learned a plan a previous + client had already seen. The load path now runs a throwaway differ whose + "__none__" sentinel makes diffPlan always emit, while the shared differ's + full diff still runs for its mark-seen side effect (turn completion must + not re-emit replayed history). +- Regression test in `tests/load-plan-replay.test.ts` (fails without the + fix: a second load emits no PlanUpdate). + ## [0.11.4] - 2026-08-22 ### Fixed diff --git a/package.json b/package.json index 579b7d3..c24e9d7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "zcode-acp-server", - "version": "0.11.4", + "version": "0.11.5", "description": "Agent Client Protocol (ACP) server bridging headless ZCode to editors like Zed and JetBrains.", "type": "module", "license": "Apache-2.0", diff --git a/src/handlers/session.ts b/src/handlers/session.ts index ac7d919..1bf7b11 100644 --- a/src/handlers/session.ts +++ b/src/handlers/session.ts @@ -508,12 +508,19 @@ export async function loadSession( ); // Replay the existing todo list as an initial plan so a loaded session shows - // its todos immediately (filter to PlanUpdate only — text/tools were already - // replayed above and the differ hasn't mark_seen'd this history). + // its todos immediately. try { const snapshot = await buildSnapshot(server, zcodeSid); const loadDiffer = getOrCreateDiffer(server, zcodeSid); - const planEvents = loadDiffer.diff(snapshot).filter((e) => e.kind === "PlanUpdate"); + // Keep the shared differ's full diff for its mark-seen side effect on the + // replayed history (next turn-completion diff must not re-emit it). + loadDiffer.diff(snapshot); + // Emit the CURRENT todos on every load: the shared differ only fires on + // CHANGE since its lastPlanSig already matches after any prior client's + // diff — a re-attaching client (the mobile app always re-attaches) would + // otherwise never learn a plan a previous client already saw. A throwaway + // differ starts at the "__none__" sentinel, so diffPlan always emits. + const planEvents = new ProjectionDiffer().diffPlan(snapshot.todos ?? []); for (const iev of planEvents) { await dispatchEvent(server, cx, acpSid, iev, `load_${randomUUID().slice(0, 8)}`); } diff --git a/tests/load-plan-replay.test.ts b/tests/load-plan-replay.test.ts new file mode 100644 index 0000000..9e8bdfe --- /dev/null +++ b/tests/load-plan-replay.test.ts @@ -0,0 +1,97 @@ +/** + * session/load must emit the CURRENT todo list as a PlanUpdate on every load. + * + * The shared per-session differ only fires on plan CHANGE: once any client + * attached and its diff aligned `lastPlanSig`, a re-attaching client (the + * mobile app always re-attaches) would never learn the plan a previous + * client already saw. The load path therefore runs a throwaway differ whose + * "__none__" sentinel makes diffPlan always emit — while still running the + * shared differ's full diff for its mark-seen side effect. + */ + +import type * as acp from "@agentclientprotocol/sdk"; +import { describe, expect, it, vi } from "vitest"; + +import type { ZcodeBackend } from "../src/backend/client.js"; +import { loadSession } from "../src/handlers/session.js"; +import { ZcodeAcpServer } from "../src/server.js"; + +vi.mock("../src/tasks-index.js", () => ({ + upsertSessionTask: async () => true, + updateSessionTitle: async () => true, +})); + +const TODOS = [{ content: "wire the fix", status: "in_progress", priority: "high" }]; + +function fakeBackend(): ZcodeBackend { + return { + isDead: false, + request: async (_id: number, method: string) => { + switch (method) { + case "workspace/updateProviderRegistry": + case "session/resume": + return { result: {} }; + case "session/subscribe": + return { result: { eventSeq: 0 } }; + case "session/read": + return { result: { projection: { status: "idle" }, todos: TODOS } }; + case "session/messages": + return { + result: { + messages: [ + { info: { id: "m1", role: "user" }, parts: [{ type: "text", text: "hi" }] }, + ], + }, + }; + default: + return { result: {} }; + } + }, + send: () => {}, + pollServerRequests: () => [], + registerEventListener: () => {}, + unregisterEventListener: () => {}, + } as unknown as ZcodeBackend; +} + +/** cx that records every session/update notification. */ +function collectCx(): { cx: acp.AgentContext; updates: unknown[] } { + const updates: unknown[] = []; + const cx = { + notify: async (_method: string, params: Record) => { + updates.push(params); + }, + } as unknown as acp.AgentContext; + return { cx, updates }; +} + +const planUpdates = (updates: unknown[]) => + updates.filter( + (u) => (u as { update?: { sessionUpdate?: string } }).update?.sessionUpdate === "plan", + ); + +describe("session/load plan replay", () => { + it("emits a PlanUpdate on every load, even when the shared differ already saw the plan", async () => { + const server = new ZcodeAcpServer(); + server.backend = fakeBackend(); + server.registerSession("s-plan", "sess_plan"); + + const first = collectCx(); + await loadSession(server, { sessionId: "s-plan" } as acp.LoadSessionRequest, first.cx); + expect(planUpdates(first.updates)).toHaveLength(1); + + // Re-attach: the shared differ's lastPlanSig already matches, but the + // re-attaching client must still learn the current todos. + const second = collectCx(); + await loadSession(server, { sessionId: "s-plan" } as acp.LoadSessionRequest, second.cx); + const plans = planUpdates(second.updates); + expect(plans).toHaveLength(1); + expect( + (plans[0] as { update: { entries: Array<{ content: string }> } }).update.entries, + ).toEqual( + [{ content: "wire the fix", status: "in_progress", priority: "high" }].map((e) => + expect.objectContaining(e), + ), + ); + }); +});