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
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [0.11.2] - 2026-08-20

### Fixed

- Plan/todo panel lag: the backend writes the projection's todos
asynchronously after the tool-result event, so a single read at result-time
races that write and intermittently sees the stale list.
`dispatchPlanIfChanged` now re-checks once after a 600 ms delay when the
todos signature is unchanged, before giving up.
- Tests covering the 0.11.1 audit-fix batch (settle-once, turn-idle grace,
plan re-check) in `tests/audit-fixes.test.ts`.

## [0.11.1] - 2026-08-20

### 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.1",
"version": "0.11.2",
"description": "Agent Client Protocol (ACP) server bridging headless ZCode to editors like Zed and JetBrains.",
"type": "module",
"license": "Apache-2.0",
Expand Down
24 changes: 23 additions & 1 deletion src/handlers/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1658,16 +1658,26 @@ async function dispatchEditDiff(
* waiting for turn completion — the turn-completion diff would otherwise lag
* behind by the rest of the model's output.
*
* The backend writes the projection's todos asynchronously AFTER the
* tool-result event, so a single read at result-time races that write and
* intermittently sees the stale list (the editor's todo panel then lags until
* the next tool completes). When the signature is unchanged, re-check once
* after a short delay before giving up.
*
* Uses a lightweight `session/read` (no session/messages fetch). Failures are
* logged and swallowed: plan staleness is cosmetic, not worth crashing the turn.
*/
async function dispatchPlanIfChanged(
const PLAN_RECHECK_DELAY_MS = 600;

// Exported for unit tests (plan recheck timing).
export async function dispatchPlanIfChanged(
server: ZcodeAcpServer,
cx: acp.AgentContext,
acpSid: string,
zcodeSid: string,
differ: ProjectionDiffer,
chunkMsgId: string,
recheck = true,
): Promise<void> {
try {
const backend = server.ensureBackend();
Expand All @@ -1683,6 +1693,18 @@ async function dispatchPlanIfChanged(
};
const todos = flattenTodos(read.todos, read.todoGroups);
const events = differ.diffPlan(todos);
if (events.length === 0) {
if (!recheck) return;
const timer = setTimeout(() => {
void dispatchPlanIfChanged(server, cx, acpSid, zcodeSid, differ, chunkMsgId, false).catch(
() => {
/* best-effort: cosmetic staleness only */
},
);
}, PLAN_RECHECK_DELAY_MS);
timer.unref?.();
return;
}
for (const iev of events) {
await dispatchEvent(server, cx, acpSid, iev, chunkMsgId);
}
Expand Down
201 changes: 201 additions & 0 deletions tests/audit-fixes.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
/**
* Tests for the audit-fix batch (2026-08-20):
*
* 1. Settle-once: any throw during interaction forwarding must still reply to
* zcode (decline) and cache the result — an unanswered request makes the
* backend reannounce forever, refreshing the turn loop's no-progress timer
* until the turn hangs.
* 2. waitForTurnIdle grace: with expectLock, a lock never observed past the
* grace window counts a successful probe as released (covers turns that
* finish between probes and backend error-message drift).
* 3. Todo push recheck: when the todos signature is unchanged at tool-result
* time (the backend writes the projection asynchronously after the result
* event), one delayed re-check must still push the PlanUpdate.
*/

import type * as acp from "@agentclientprotocol/sdk";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

import type { ServerRequest, ZcodeBackend } from "../src/backend/client.js";
import type { ZcodeAcpServer } from "../src/server.js";

const ioMocks = vi.hoisted(() => ({
sendSessionUpdate: vi.fn(() => Promise.resolve(undefined)),
}));

vi.mock("../src/handlers/io.js", () => ({
sendSessionUpdate: ioMocks.sendSessionUpdate,
}));

import { handleServerRequests } from "../src/handlers/server-requests.js";
import { waitForTurnIdle } from "../src/handlers/extensions.js";
import { dispatchPlanIfChanged } from "../src/handlers/session.js";
import { ProjectionDiffer } from "../src/translators/projection-differ.js";

// ---------- 1. settle-once ----------

function makeBackend(queue: ServerRequest[]): {
backend: ZcodeBackend;
replies: Array<{ id: number | string; result: unknown }>;
} {
const replies: Array<{ id: number | string; result: unknown }> = [];
const backend = {
pollServerRequests: () => queue.splice(0, queue.length),
requeueServerRequests: () => {},
sendReply: (id: number | string, result: unknown) => replies.push({ id, result }),
} as unknown as ZcodeBackend;
return { backend, replies };
}

function permissionReq(id: number, requestId: string): ServerRequest {
return {
id,
method: "interaction/requestPermission",
params: {
requestId,
sessionId: "zs1",
toolCallId: "tc1",
toolName: "Bash",
input: { command: "ls" },
options: [{ optionId: "allow_once", kind: "allow_once", name: "Allow" }],
},
} as ServerRequest;
}

describe("settle-once: forward throw still replies to zcode", () => {
it("declines on throw and answers the reannounce from cache", async () => {
ioMocks.sendSessionUpdate.mockRejectedValueOnce(new Error("client connection dead"));
const server = { nextId: () => 1 } as unknown as ZcodeAcpServer;
const { backend, replies } = makeBackend([permissionReq(101, "r1")]);
const cx = { notify: vi.fn(), request: vi.fn() } as unknown as acp.AgentContext;

// First drain: the forward throws (sendSessionUpdate rejects above);
// settle-once must catch it and reply decline instead of leaking.
const handled = await handleServerRequests(server, backend, cx, "s1");
expect(handled).toBe(true);
expect(replies).toHaveLength(1);
expect(replies[0]).toEqual({
id: 101,
result: { action: "decline", reason: "bridge error during forward" },
});

// Reannounce with the same requestId (new zcode id): served from the
// cached result — no re-prompt, no leak.
const second = makeBackend([permissionReq(102, "r1")]);
await handleServerRequests(server, second.backend, cx, "s1");
expect(second.replies).toHaveLength(1);
expect(second.replies[0]).toEqual({
id: 102,
result: { action: "decline", reason: "bridge error during forward" },
});
});
});

// ---------- 2. waitForTurnIdle grace ----------

function probeServer(responses: unknown[]): ZcodeAcpServer {
let i = 0;
const backend = {
request: vi.fn(() => {
const r = responses[i] ?? responses[responses.length - 1];
i++;
return Promise.resolve(r);
}),
};
return {
ensureBackend: () => backend,
nextId: () => 1,
} as unknown as ZcodeAcpServer;
}

describe("waitForTurnIdle grace window", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});

it("counts a successful probe as released once the grace expires", async () => {
// Lock never observed: probe always succeeds (turn finished between
// probes, or the backend's lock error message drifted).
const server = probeServer([{ result: {} }]);
const p = waitForTurnIdle(server, "zs1", 60_000, "session/goal", true, 100);
await vi.advanceTimersByTimeAsync(0); // probe #1: in grace → sleep(500)
await vi.advanceTimersByTimeAsync(600); // probe #2: past grace → released
expect(await p).toBe(true);
});

it("still requires the lock observation inside the grace window", async () => {
// Lock observed on probe #1, released on probe #2 → released.
const server = probeServer([{ error: { message: "prompt is running" } }, { result: {} }]);
const p = waitForTurnIdle(server, "zs1", 60_000, "session/goal", true, 100);
await vi.advanceTimersByTimeAsync(0);
await vi.advanceTimersByTimeAsync(2500);
expect(await p).toBe(true);
});

it("non-lock error past grace also counts as released", async () => {
const server = probeServer([{ error: { message: "something else" } }]);
const p = waitForTurnIdle(server, "zs1", 60_000, "session/goal", true, 100);
await vi.advanceTimersByTimeAsync(0);
await vi.advanceTimersByTimeAsync(600);
expect(await p).toBe(true);
});
});

// ---------- 3. todo push recheck ----------

describe("dispatchPlanIfChanged delayed re-check", () => {
beforeEach(() => {
ioMocks.sendSessionUpdate.mockClear();
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});

it("pushes the PlanUpdate when the todos appear only on the re-check", async () => {
const oldTodos = [{ content: "a", status: "pending" }];
const newTodos = [
{ content: "a", status: "completed" },
{ content: "b", status: "in_progress" },
];
let readCount = 0;
const backend = {
request: vi.fn(() => {
readCount++;
return Promise.resolve({ result: { todos: readCount === 1 ? oldTodos : newTodos } });
}),
};
const server = {
ensureBackend: () => backend,
nextId: () => 1,
} as unknown as ZcodeAcpServer;
const cx = { notify: vi.fn().mockResolvedValue(undefined) } as unknown as acp.AgentContext;

const differ = new ProjectionDiffer();
differ.diffPlan(oldTodos); // prime the signature: first read sees no change

void dispatchPlanIfChanged(server, cx, "s1", "zs1", differ, "m1");
await vi.advanceTimersByTimeAsync(0);
// Signature unchanged at result-time: no push yet, one re-check scheduled.
expect(ioMocks.sendSessionUpdate).not.toHaveBeenCalled();

await vi.advanceTimersByTimeAsync(600);
expect(ioMocks.sendSessionUpdate).toHaveBeenCalledTimes(1);
const [, sid, update] = ioMocks.sendSessionUpdate.mock.calls[0] as unknown as [
acp.AgentContext,
string,
{ sessionUpdate: string; entries: Array<{ content: string; status: string }> },
];
expect(sid).toBe("s1");
expect(update.sessionUpdate).toBe("plan");
expect(update.entries).toEqual([
expect.objectContaining({ content: "a", status: "completed" }),
expect.objectContaining({ content: "b", status: "in_progress" }),
]);
// Exactly two reads: the result-time one plus one re-check (no loops).
expect(backend.request).toHaveBeenCalledTimes(2);
});
});
Loading