Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
13 changes: 10 additions & 3 deletions src/handlers/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)}`);
}
Expand Down
97 changes: 97 additions & 0 deletions tests/load-plan-replay.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>) => {
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),
),
);
});
});
Loading