diff --git a/CHANGELOG.md b/CHANGELOG.md index 28560f5..a554f9a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.3.1] - 2026-08-17 + +### Fixed + +- Remote discovery no longer lists never-used sessions: an editor restart + auto-resumes its stored placeholder, materializing an empty backend session + that was pushed to the hub and opened empty on remote clients. Sessions now + appear in `/api/instances` only after real interaction (a prompt turn, a + load with replayable history, or an adopted stored title). +- `session/load` no longer skips the backend resume RPC for a session whose id + mapping exists but was never loaded into the current backend subprocess + (re-registered from the durable store, or left behind by a failed resume). + The backend only serves messages for loaded sessions, so the replay came + back empty — an older conversation opened blank on a remote client while + other sessions worked. Liveness is now tracked explicitly + (`backendLoadedSessions`), and `session/messages` errors are logged instead + of silently replaying nothing. + ## [0.3.0] - 2026-08-17 ### Added diff --git a/docs/REMOTE-CLIENTS.md b/docs/REMOTE-CLIENTS.md index 7d2307a..fa45322 100644 --- a/docs/REMOTE-CLIENTS.md +++ b/docs/REMOTE-CLIENTS.md @@ -78,6 +78,10 @@ HTTP auth: `Authorization: Bearer ` or `?token=`. after connecting. `title` is adopted from the backend for resumed sessions and set after a fresh session's first turn — it can still be absent for a session that has never completed a turn. +- `sessions` only lists sessions with real interaction. Editors restart into a + stored placeholder and materialize an empty backend session — those stay + hidden and appear within one heartbeat (~10s) after their first prompt (or + a titled resume/load). - Poll every 3–5s. There is no push notification for registry changes yet. - Fields are **additive-only** across releases — ignore fields you don't know. diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index 2055c4f..9746277 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -341,6 +341,18 @@ or a WS connect to it fails. 3. A few-seconds outage after upgrading the package is expected: a newer bridge triggers the hub's version-handshake restart, then re-spawns it. +### Remote access: a conversation opens empty + +**Symptom:** one session (typically an older one) opens EMPTY on a remote +client while other sessions show content. + +**Cause:** the backend subprocess only serves `session/messages` for sessions +it has loaded via `session/create`/`session/resume`. Older bridges trusted the +in-memory id mapping as "live" and skipped the resume RPC — a mapping +re-registered from the durable store without a resume (or left behind by a +failed one) therefore replayed nothing. Fixed by explicit backend-loaded +tracking; the backend also logs a warning now when `session/messages` errors. + ## Log Debugging ### Enable verbose logging diff --git a/package.json b/package.json index 1e592ad..3fa5733 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "zcode-acp-server", - "version": "0.3.0", + "version": "0.3.1", "description": "Agent Client Protocol (ACP) server bridging headless ZCode to editors like Zed and JetBrains.", "type": "module", "license": "Apache-2.0", diff --git a/registry/zcode-acp-server/agent.json b/registry/zcode-acp-server/agent.json index 7638ecb..22e02f2 100644 --- a/registry/zcode-acp-server/agent.json +++ b/registry/zcode-acp-server/agent.json @@ -1,7 +1,7 @@ { "id": "zcode-acp-server", "name": "ZCode", - "version": "0.3.0", + "version": "0.3.1", "description": "Standalone ACP server bridging the headless ZCode app-server (GLM-5.2) to editors like Zed and JetBrains. Supports streaming, tool calls, session fork/resume, mode switching, and reads GLM credentials locally — no editor-side API key required.", "repository": "https://github.com/william0wang/zcode-acp", "website": "https://github.com/william0wang/zcode-acp", diff --git a/src/handlers/replay.ts b/src/handlers/replay.ts index 7d9bc2c..6c3adb2 100644 --- a/src/handlers/replay.ts +++ b/src/handlers/replay.ts @@ -17,7 +17,7 @@ import type * as acp from "@agentclientprotocol/sdk"; import type { ZcodeMessage, ZcodeMessagesResult } from "../backend/types.js"; import type { ZcodeAcpServer } from "../server.js"; -import { log } from "../utils.js"; +import { log, warn } from "../utils.js"; import { throwError, withReplayBatch } from "./io.js"; /** Upper bound for a requested tail/page size (values above clamp to this). */ @@ -199,7 +199,12 @@ export async function fetchMessages( { sessionId: zcodeSid }, 8000, ); - if (resp.error) return []; + if (resp.error) { + // Swallowed on purpose (replay must not crash the load) — but loudly: a + // silent empty here renders the whole conversation blank for the client. + warn(`session/messages failed for ${zcodeSid}: ${resp.error.message ?? ""}`); + return []; + } const result = (resp.result ?? {}) as ZcodeMessagesResult; return result.messages ?? []; } diff --git a/src/handlers/session.ts b/src/handlers/session.ts index 8576e7c..49eac72 100644 --- a/src/handlers/session.ts +++ b/src/handlers/session.ts @@ -187,6 +187,8 @@ export async function ensureRealSession(server: ZcodeAcpServer, acpSid: string): server.pendingSessions.delete(acpSid); server.registerSession(acpSid, sid); + // session/create loads the session into this backend process. + server.backendLoadedSessions.add(acpSid); // Keep the durable alias in sync so a later bridge restart can still // resume this session via the placeholder id. recordMaterializedSession(acpSid, sid, pending.cwd); @@ -278,7 +280,11 @@ async function adoptStoredTitle( * A `session/new` placeholder has no backend counterpart until first use, yet * the editor may resume it anyway (panel reopen, bridge restart) — resolving it * here prevents an otherwise unavoidable "Session not found". Resolution order: - * 1. in-memory mapping → the session is already live in this subprocess; + * 1. in-memory mapping → live only if verified loaded in this backend + * subprocess (`backendLoadedSessions`); a bare mapping may have been + * re-registered from the durable store without a resume, and the backend + * only serves messages for sessions it has loaded — those must fall + * through to the resume RPC or the replay comes back empty; * 2. pending placeholder → materialize it (an empty session, matching the * pre-lazy behavior where a never-used session/new always resumed); * 3. durable store → a placeholder from a previous bridge lifetime: with a @@ -293,7 +299,9 @@ async function resolveResumeTarget( acpSid: string, ): Promise<{ zcodeSid: string; alreadyLive: boolean }> { const mapped = server.resolveSid(acpSid); - if (mapped) return { zcodeSid: mapped, alreadyLive: true }; + if (mapped) { + return { zcodeSid: mapped, alreadyLive: server.backendLoadedSessions.has(acpSid) }; + } if (server.pendingSessions.has(acpSid)) { return { zcodeSid: await ensureRealSession(server, acpSid), alreadyLive: true }; } @@ -347,6 +355,8 @@ export async function resumeSession( // registered to even process the resume turn. await syncProviderRegistry(server, cwd); await resumeBackendSession(server, zcParams); + // The resume RPC succeeded — the session is now loaded in this backend. + server.backendLoadedSessions.add(acpSid); } server.registerSession(acpSid, zcodeSid); @@ -393,6 +403,8 @@ export async function loadSession( // registered to process it. await syncProviderRegistry(server, cwd); await resumeBackendSession(server, zcParams); + // The resume RPC succeeded — the session is now loaded in this backend. + server.backendLoadedSessions.add(acpSid); } server.registerSession(acpSid, zcodeSid); log(`session/load → ${zcodeSid}`); @@ -400,6 +412,9 @@ export async function loadSession( await adoptStoredTitle(server, acpSid, zcodeSid); const messages = await fetchMessages(server, zcodeSid); + // History on disk = real interaction (covers untitled sessions resumed from + // a previous bridge lifetime) — make the session discoverable remotely. + if (messages.length > 0) server.markSessionActive(acpSid); // Tail replay (Proposal 0001): a `_meta.zcode.limit` replays only the last // N messages aligned to turn boundaries — the full replay stays the default // for editors that send no `_meta` (Zed path unchanged). @@ -716,9 +731,10 @@ export async function prompt( } finally { backend.unregisterEventListener(zcodeSid, listener); server.pendingTurns.delete(requestId); - // Turn end = session activity — refresh the discovery summary timestamp - // regardless of outcome (end_turn, cancelled, retries exhausted). - server.touchSessionSummary(params.sessionId); + // Turn end = session activity — refresh the discovery summary and mark the + // session discoverable regardless of outcome (end_turn, cancelled, retries + // exhausted). + server.markSessionActive(params.sessionId); } } diff --git a/src/remote/endpoint.ts b/src/remote/endpoint.ts index e79cd50..aa9b4c5 100644 --- a/src/remote/endpoint.ts +++ b/src/remote/endpoint.ts @@ -52,15 +52,24 @@ function tryListen(server: Server, port: number): Promise { }); } -/** Session summaries for the hub's discovery API. */ -function sessionsPayload( +/** + * Session summaries for the hub's discovery API. Only sessions with real + * interaction (`hasActivity`) are pushed: an editor restart auto-resumes its + * stored placeholder, materializing an empty backend session — pushing that + * would make remote clients list (and open) a conversation that never + * happened. The hub replaces the whole list on every register, so a session + * that gains activity shows up within one heartbeat (~10s). + */ +export function sessionsPayload( server: ZcodeAcpServer, ): Array<{ sessionId: string; title?: string; updatedAt: number }> { - return Array.from(server.sessionSummaries.entries(), ([sessionId, s]) => ({ - sessionId, - ...(s.title !== undefined ? { title: s.title } : {}), - updatedAt: s.updatedAt, - })); + return Array.from(server.sessionSummaries.entries()) + .filter(([, s]) => s.hasActivity) + .map(([sessionId, s]) => ({ + sessionId, + ...(s.title !== undefined ? { title: s.title } : {}), + updatedAt: s.updatedAt, + })); } async function postJson(url: string, body: unknown, timeoutMs = 3000): Promise { diff --git a/src/server.ts b/src/server.ts index 091d3f4..5e127bf 100644 --- a/src/server.ts +++ b/src/server.ts @@ -109,14 +109,31 @@ export class ZcodeAcpServer { readonly clients = new ClientRegistry(); /** * Lightweight session summaries for the remote hub's discovery API - * (acp_sid → { title, updatedAt }). In-memory only — the hub holds no - * business state and the bridge dies with its editor, so persistence would - * buy nothing. Maintained by `touchSessionSummary` at session registration, - * title set, and turn completion. + * (acp_sid → { title, updatedAt, hasActivity }). In-memory only — the hub + * holds no business state and the bridge dies with its editor, so persistence + * would buy nothing. Maintained by `touchSessionSummary` at session + * registration, title set, and turn completion. `hasActivity` gates the + * discovery payload: an editor restart auto-resumes its stored placeholder, + * materializing an empty backend session — never-used sessions stay invisible + * to remote clients until first real use. */ - readonly sessionSummaries = new Map(); + readonly sessionSummaries = new Map< + string, + { title?: string; updatedAt: number; hasActivity?: boolean } + >(); /** Session titles already set, to enforce set-once (acp_sid → title). */ readonly sessionTitles = new Map(); + /** + * Sessions verified as loaded in the CURRENT backend subprocess — populated + * only after a successful session/create or session/resume RPC. A bare + * `registerSession` mapping does NOT qualify: the backend answers + * `session/messages` only for sessions it has loaded, so `session/load` + * must not skip the resume RPC for a mapping that was never loaded (e.g. + * re-registered from the durable store by an early ensureRealSession + * caller, or left behind by a failed resume) — the replay would silently + * come back empty. + */ + readonly backendLoadedSessions = new Set(); /** * Sessions eligible for auto-title on first end_turn. Only `session/new` * populates this — resumed/loaded sessions already carry a title, so their @@ -196,6 +213,23 @@ export class ZcodeAcpServer { this.sessionSummaries.set(acpSid, { title: title ?? existing?.title, updatedAt: Date.now(), + // A title only exists once the session produced content (auto-title on + // first end_turn, or a stored title adopted on resume/load). + hasActivity: existing?.hasActivity || title !== undefined, + }); + } + + /** + * Mark a session as having real interaction (a prompt turn ran, or history + * was replayed on load). Gates the hub discovery payload — never-used + * sessions stay invisible to remote clients until first use. + */ + markSessionActive(acpSid: string): void { + const existing = this.sessionSummaries.get(acpSid); + this.sessionSummaries.set(acpSid, { + title: existing?.title, + updatedAt: Date.now(), + hasActivity: true, }); } diff --git a/tests/remote-endpoint.test.ts b/tests/remote-endpoint.test.ts index 5d939da..dba09e4 100644 --- a/tests/remote-endpoint.test.ts +++ b/tests/remote-endpoint.test.ts @@ -13,7 +13,7 @@ import { afterEach, describe, expect, it } from "vitest"; import type { RemoteConfig } from "../src/remote/config.js"; import { trackConnections } from "../src/remote/broadcast.js"; -import { startRemoteEndpoint } from "../src/remote/endpoint.js"; +import { sessionsPayload, startRemoteEndpoint } from "../src/remote/endpoint.js"; import { startHub } from "../src/remote/hub-server.js"; import { ZcodeAcpServer } from "../src/server.js"; import { AGENT_INFO } from "../src/utils.js"; @@ -253,3 +253,34 @@ describe("hub version handshake (bridge side)", () => { expect(bodies.length).toBeGreaterThanOrEqual(2); }, 15000); }); + +describe("discovery payload gating", () => { + it("excludes never-used sessions (editor-restart artifacts)", () => { + const server = new ZcodeAcpServer(); + // An editor restart auto-resumes its stored placeholder: the bridge + // materializes an empty backend session and registers it — no turn ever ran. + server.registerSession("s-artifact", "zc1"); + server.registerSession("s-live", "zc2"); + server.markSessionActive("s-live"); + + expect(sessionsPayload(server).map((s) => s.sessionId)).toEqual(["s-live"]); + }); + + it("includes sessions that gained a title (stored title adopted on resume)", () => { + const server = new ZcodeAcpServer(); + server.registerSession("s", "zc"); + server.touchSessionSummary("s", "Stored title"); + + const payload = sessionsPayload(server); + expect(payload).toHaveLength(1); + expect(payload[0]).toMatchObject({ sessionId: "s", title: "Stored title" }); + }); + + it("an omitted hasActivity field never leaks onto the wire", () => { + const server = new ZcodeAcpServer(); + server.registerSession("s", "zc"); + server.markSessionActive("s"); + + expect(Object.keys(sessionsPayload(server)[0]!).sort()).toEqual(["sessionId", "updatedAt"]); + }); +}); diff --git a/tests/server.test.ts b/tests/server.test.ts index b35e786..6b2d2f0 100644 --- a/tests/server.test.ts +++ b/tests/server.test.ts @@ -96,6 +96,32 @@ describe("ZcodeAcpServer discovery summaries", () => { expect(touched.updatedAt).toBeGreaterThan(titled.updatedAt); }); + it("plain touches do not mark activity; a title does", () => { + const server = new ZcodeAcpServer(); + server.registerSession("s-artifact", "zc1"); + expect(server.sessionSummaries.get("s-artifact")?.hasActivity).toBeFalsy(); + + server.touchSessionSummary("s-artifact"); + expect(server.sessionSummaries.get("s-artifact")?.hasActivity).toBeFalsy(); + + server.touchSessionSummary("s-artifact", "Stored title"); + expect(server.sessionSummaries.get("s-artifact")?.hasActivity).toBe(true); + }); + + it("markSessionActive sets the flag, bumps updatedAt, and keeps the title", async () => { + const server = new ZcodeAcpServer(); + server.registerSession("s1", "zc1"); + server.touchSessionSummary("s1", "My title"); + const before = server.sessionSummaries.get("s1")!; + + await new Promise((resolve) => setTimeout(resolve, 5)); + server.markSessionActive("s1"); + const after = server.sessionSummaries.get("s1")!; + expect(after.hasActivity).toBe(true); + expect(after.title).toBe("My title"); + expect(after.updatedAt).toBeGreaterThan(before.updatedAt); + }); + it("workspaceLabel prefers a known session cwd and falls back to process cwd", () => { const server = new ZcodeAcpServer(); expect(server.workspaceLabel()).toBe(process.cwd()); diff --git a/tests/session-lazy.test.ts b/tests/session-lazy.test.ts index cb5d9b6..a8c81f8 100644 --- a/tests/session-lazy.test.ts +++ b/tests/session-lazy.test.ts @@ -12,6 +12,7 @@ import type * as acp from "@agentclientprotocol/sdk"; import { beforeEach, describe, expect, it, vi } from "vitest"; import type { ZcodeBackend } from "../src/backend/client.js"; +import type { ZcodeMessage } from "../src/backend/types.js"; import { ensureRealSession, loadSession, @@ -56,11 +57,14 @@ beforeEach(() => { /** * Fake backend: answers session/create (counting creates), session/resume, - * session/read (empty projection/settings), session/messages (empty), the - * provider-registry push, and session/list (from `listed`, for title - * adoption); errors on everything else. + * session/read (empty projection/settings), session/messages (from `messages`, + * empty by default), the provider-registry push, and session/list (from + * `listed`, for title adoption); errors on everything else. */ -function fakeBackend(listed: Array<{ sessionId: string; title?: string }> = []): ZcodeBackend & { +function fakeBackend( + listed: Array<{ sessionId: string; title?: string }> = [], + messages: ZcodeMessage[] = [], +): ZcodeBackend & { calls: Array<{ method: string; params: unknown }>; } { const calls: Array<{ method: string; params: unknown }> = []; @@ -86,7 +90,7 @@ function fakeBackend(listed: Array<{ sessionId: string; title?: string }> = []): case "session/read": return { id, result: { projection: { contextUsed: 0 }, settings: {} } }; case "session/messages": - return { id, result: { messages: [] } }; + return { id, result: { messages } }; default: return { id, error: { message: `unhandled ${method}` } }; } @@ -440,3 +444,88 @@ describe("stored title adoption on load/resume", () => { expect(server.sessionSummaries.get("sess_real")?.title).toBe("Historical title"); }); }); + +describe("discovery activity gating", () => { + it("a never-used placeholder resumed by an editor restart stays hidden", async () => { + const server = new ZcodeAcpServer(); + const resp = await newSession(server, newSessionParams("/tmp/ws")); + const { backend } = fakeBackend(); + server.backend = backend; + + // Editor restart → session/resume of the stored placeholder materializes + // an empty backend session; no turn ever runs. + await resumeSession( + server, + { sessionId: resp.sessionId, cwd: "/tmp/ws" } as acp.ResumeSessionRequest, + {} as acp.AgentContext, + ); + + const summary = server.sessionSummaries.get(resp.sessionId); + expect(summary).toBeDefined(); + expect(summary?.hasActivity).toBeFalsy(); + }); + + it("loadSession with history marks the session discoverable", async () => { + const server = new ZcodeAcpServer(); + const history: ZcodeMessage[] = [ + { info: { id: "m1", role: "user" }, parts: [{ type: "text", text: "hello" }] }, + ]; + const { backend } = fakeBackend([], history); + server.backend = backend; + + await loadSession( + server, + { sessionId: "sess_hist" } as acp.LoadSessionRequest, + { notify: async () => {} } as unknown as acp.AgentContext, + ); + + expect(server.sessionSummaries.get("sess_hist")?.hasActivity).toBe(true); + }); +}); + +describe("backend-loaded session tracking", () => { + const stubCx = { notify: async () => {} } as unknown as acp.AgentContext; + + it("session/load re-issues the resume RPC for a mapping that was never loaded", async () => { + const server = new ZcodeAcpServer(); + // The poison case: a mapping re-registered from the durable store (or + // left by a failed resume) without the session ever being loaded into + // this backend subprocess. + server.registerSession("s-old", "sess_old"); + const history: ZcodeMessage[] = [ + { info: { id: "m1", role: "user" }, parts: [{ type: "text", text: "old turn" }] }, + ]; + const { backend, calls } = fakeBackend([], history); + server.backend = backend; + + await loadSession(server, { sessionId: "s-old" } as acp.LoadSessionRequest, stubCx); + + const resume = calls.find((c) => c.method === "session/resume"); + expect(resume?.params).toMatchObject({ sessionId: "sess_old" }); + expect(server.backendLoadedSessions.has("s-old")).toBe(true); + }); + + it("session/load skips the resume RPC once the session is verified loaded", async () => { + const server = new ZcodeAcpServer(); + server.registerSession("s-live", "sess_live"); + server.backendLoadedSessions.add("s-live"); + const { backend, calls } = fakeBackend(); + server.backend = backend; + + await loadSession(server, { sessionId: "s-live" } as acp.LoadSessionRequest, stubCx); + + expect(calls.some((c) => c.method === "session/resume")).toBe(false); + expect(calls.some((c) => c.method === "session/messages")).toBe(true); + }); + + it("materializing a placeholder marks it backend-loaded", async () => { + const server = new ZcodeAcpServer(); + const resp = await newSession(server, newSessionParams("/tmp/ws")); + const { backend } = fakeBackend(); + server.backend = backend; + + await ensureRealSession(server, resp.sessionId); + + expect(server.backendLoadedSessions.has(resp.sessionId)).toBe(true); + }); +});