diff --git a/CHANGELOG.md b/CHANGELOG.md index f111c0f..7f3d9e8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.11.9] - 2026-08-24 + +### Added + +- `POST /api/instances/{id}/sessions/{sessionId}/rename` (body `{"title"}`): + renames a session from a remote client. The bridge pins the title + (`title_overridden=1` in the App's tasks-index), updates discovery, and + broadcasts `session_info_update` so attached editors update live. Proxied + by the hub alongside the existing close route. + +### Changed + +- Session titles are now set exactly ONCE, from the first prompt, the moment + it is sent — instead of on the first `end_turn`. A message interrupting the + first turn can no longer steal the title (the preempted turn ended + `cancelled` and never reached the title block). After the one-shot title, no + automatic path revises a session title; a manual rename is the only later + modifier. `updateSessionTitle` no longer writes the auto title into + `meta_json.title` of a user-renamed row (the App may read either field, and + the write could visually revert the user's rename). + ## [0.11.8] - 2026-08-23 ### Added diff --git a/docs/REMOTE-CLIENTS.md b/docs/REMOTE-CLIENTS.md index ec16c18..10fd16d 100644 --- a/docs/REMOTE-CLIENTS.md +++ b/docs/REMOTE-CLIENTS.md @@ -52,6 +52,7 @@ ACP editor ────── stdio ──────────┘ | `GET /api/instances` | required | Registered bridge instances. Add `?probe=1` to verify first. | | `GET /api/instances/{id}/status` | required | Real-time per-session running status of one bridge. | | `POST /api/instances/{id}/sessions/{sessionId}/close` | required | Retire a session from remote discovery — see [Closing a session](#closing-a-session). | +| `POST /api/instances/{id}/sessions/{sessionId}/rename` | required | Rename a session — see [Renaming a session](#renaming-a-session). | | `GET /api/quota` | required | Account-level usage stats — same payload as `account/usage_stats`, no ACP connection needed. | | `POST /api/upgrade` | required | Trigger the hub's own staleness check — see [Hub self-upgrade](#hub-self-upgrade). | @@ -91,9 +92,12 @@ HTTP auth: `Authorization: Bearer ` or `?token=`. `session/load` puts the remote client on the same notification stream as the editor tab: turns driven from either side stream live to both. A conversation with no editor placeholder is advertised under its backend - id (`sess_…`), still loadable via pass-through resume. `title` comes from - the backend session store once the backend has titled it; sessions whose - first turn is still running carry the provisional prompt-derived title. + id (`sess_…`), still loadable via `session/load` pass-through resume. The + title is set exactly once by the bridge — from the first line of the first + prompt (capped at 80 chars), the moment that prompt is sent — and never + changes automatically afterwards; a manual rename is the only later + modifier. Sessions born in a previous bridge lifetime get their title from + the session store on load/resume. - `sessions[].status` is a coarse `"running" | "idle"` indicator riding the heartbeat (up to ~10s stale; absent on older bridges — treat as unknown). For the live value poll [`/api/instances/{id}/status`](#session-running-status). @@ -110,9 +114,9 @@ HTTP auth: `Authorization: Bearer ` or `?token=`. **accessible** (every listed id resolves and resumes through that bridge). Retired conversations of the project are NOT listed even though the backend store still has them — the store only enriches live entries with - the authoritative title and a cross-bridge `updatedAt`. Entries may carry - a provisional title (first line of the first prompt, capped at 60 chars) - until the backend's own auto-title lands. + the stored title and a cross-bridge `updatedAt`. The auto-title is set once + at the first prompt (first non-empty line, capped at 80 chars) and is never + revised by later turns. - Entries are **deduped across instances**: several bridges of the same project (e.g. a leaked old process plus the current one) can all hold the same live conversation under the same id; the hub keeps one copy per @@ -292,6 +296,31 @@ Cross-instance note: if the same conversation is also registered by another bridge of the project, the hub's dedupe re-attaches it under that instance — close it there too. +## Renaming a session + +```text +POST {hub}/api/instances/{id}/sessions/{sessionId}/rename + body: { "title": "new name" } → 200 { "ok": true, "title": "…" } +``` + +The session title is set **once**, automatically, from the first prompt of a +freshly created session (first non-empty line, capped at 80 chars) — this +endpoint is the only later modifier. ACP has no client→agent rename channel, +and an editor-side rename lives in the editor's own storage forever, so the +remote side is where a rename enters the system. + +The bridge applies the rename everywhere: its in-memory title pin (no later +automatic write can touch it), the discovery summary (live within one +heartbeat), the ZCode App's tasks-index (`title_overridden=1`, same marker the +App's own rename sets), and a `session_info_update` broadcast to every +attached client — the editor tab updates live. The title is normalized like +the auto-title: flattened to one line, trimmed, capped at 80 chars; an +all-whitespace title is rejected with `400`. + +Errors: `400` missing/empty title or oversized body (>4 KB), `401` bad token, +`404` unknown session (or instance), `502` bridge unreachable. Renaming during +a running turn is allowed — titles are no longer turn-coupled. + ## Hub self-upgrade ```text diff --git a/package.json b/package.json index b7d0a36..b1c84cd 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "zcode-acp-server", - "version": "0.11.8", + "version": "0.11.9", "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 1bf7b11..f9996ca 100644 --- a/src/handlers/session.ts +++ b/src/handlers/session.ts @@ -608,14 +608,36 @@ export async function prompt( // at end_turn, seed a provisional title from the prompt text (auto-title // stays authoritative — its set-once gate is the separate sessionTitles). server.markSessionActive(params.sessionId); - if (server.sessionSummaries.get(params.sessionId)?.title === undefined) { - const firstLine = text.trim().split(/\r\n|\r|\n/)[0] ?? ""; - if (firstLine) { - server.touchSessionSummary( - params.sessionId, - firstLine.length > 60 ? firstLine.slice(0, 57) + "…" : firstLine, - ); - } + // Session title: set EXACTLY ONCE, here, from the first prompt of a + // freshly created session — immediately, not at end_turn (a preempted + // first turn ends "cancelled" and would never be titled; and the + // completing prompt must not steal the title). After this, no automatic + // path may change the title again: sessionTitles is set-once and a manual + // rename is the only later modifier. Resumed/loaded sessions are not + // title-eligible — their stored title was adopted on load, or left unset. + if ( + server.titleEligibleSessions.has(params.sessionId) && + text && + !server.sessionTitles.has(params.sessionId) + ) { + // Title = first non-empty line of the prompt, truncated to 80 chars. + // Multi-line prompts must not leak newlines into the session title. + // Split on any line break (\r\n, \n, \r) so all platforms are covered. + const title = + text + .split(/\r\n|\r|\n/) + .map((l) => l.trim()) + .find((l) => l.length > 0) + ?.slice(0, 80) ?? text.slice(0, 80); + server.sessionTitles.set(params.sessionId, title); + server.touchSessionSummary(params.sessionId, title); + const { updateSessionTitle } = await import("../tasks-index.js"); + void updateSessionTitle(zcodeSid, title, text); + void sendSessionUpdate(cx, params.sessionId, { + sessionUpdate: "session_info_update", + title, + updatedAt: new Date().toISOString(), + }); } // Out-of-band running indicator: clients that did not send this prompt // (re-attached mobile, second editor) learn the turn started here — the @@ -794,35 +816,8 @@ export async function prompt( preempted, ); - // Session title: set once on the first end_turn, but ONLY for freshly - // created sessions. Resumed/loaded sessions already carry a title from - // their history and must not be overwritten by the first post-load - // message. sessionTitles enforces set-once within a session; - // titleEligibleSessions gates which sessions are titled at all. - if ( - result.stopReason === "end_turn" && - server.titleEligibleSessions.has(params.sessionId) && - !server.sessionTitles.has(params.sessionId) - ) { - // Title = first non-empty line of the prompt, truncated to 80 chars. - // Multi-line prompts must not leak newlines into the session title. - // Split on any line break (\r\n, \n, \r) so all platforms are covered. - const title = - text - .split(/\r\n|\r|\n/) - .map((l) => l.trim()) - .find((l) => l.length > 0) - ?.slice(0, 80) ?? text.slice(0, 80); - server.sessionTitles.set(params.sessionId, title); - server.touchSessionSummary(params.sessionId, title); - const { updateSessionTitle } = await import("../tasks-index.js"); - void updateSessionTitle(zcodeSid, title, text); - await sendSessionUpdate(cx, params.sessionId, { - sessionUpdate: "session_info_update", - title, - updatedAt: new Date().toISOString(), - }); - } + // (Session title: already set once at the FIRST prompt, before the + // turn loop — nothing here may change it again.) // Auto-compact: if context usage exceeds the threshold, compact before // returning so the next prompt has room. Configured via diff --git a/src/remote/endpoint.ts b/src/remote/endpoint.ts index d80cc45..fd4e464 100644 --- a/src/remote/endpoint.ts +++ b/src/remote/endpoint.ts @@ -30,6 +30,7 @@ import type { ZcodeAcpServer } from "../server.js"; import { AGENT_INFO, log, warn } from "../utils.js"; import { createFileHandler } from "./file-endpoint.js"; import { createSessionCloseHandler } from "./session-close-endpoint.js"; +import { createSessionRenameHandler } from "./session-rename-endpoint.js"; import { createStatusHandler, runningZcodeSids, type SessionRunStatus } from "./status-endpoint.js"; import type { RemoteConfig } from "./config.js"; @@ -200,14 +201,17 @@ export async function startRemoteEndpoint( const fileHandler = createFileHandler(server); const statusHandler = createStatusHandler(server); const sessionCloseHandler = createSessionCloseHandler(server); + const sessionRenameHandler = createSessionRenameHandler(server); const httpServer = createServer((req, res) => { const path = new URL(req.url ?? "/", "http://127.0.0.1").pathname; const closeMatch = path.match(/^\/sessions\/([^/]+)\/close$/); + const renameMatch = path.match(/^\/sessions\/([^/]+)\/rename$/); if (path === "/acp") acpHttpHandler(req, res); else if (path.startsWith("/fs/")) fileHandler(req, res); else if (path === "/status") statusHandler(req, res); else if (closeMatch) sessionCloseHandler(req, res, closeMatch[1]!); + else if (renameMatch) sessionRenameHandler(req, res, renameMatch[1]!); else { res.writeHead(404, { "Content-Type": "text/plain" }); res.end("not found"); diff --git a/src/remote/hub-server.ts b/src/remote/hub-server.ts index 59f18ae..3a33a99 100644 --- a/src/remote/hub-server.ts +++ b/src/remote/hub-server.ts @@ -541,18 +541,22 @@ export function startHub(options: HubOptions & { onIdleExit?: () => void }): Pro req.on("close", () => upstream.destroy()); return; } - // POST /api/instances/{id}/sessions/{sid}/close — the remote HTTP - // surface's first write op (ADR-0006): forward-and-relay to the bridge's - // loopback close route. The hub still routes by instance id only; close - // semantics (running guard, discovery retirement) stay in the bridge. - const closeMatch = url.pathname.match(/^\/api\/instances\/([^/]+)\/sessions\/([^/]+)\/close$/); - if (closeMatch && req.method === "POST") { + // POST /api/instances/{id}/sessions/{sid}/close|rename — the remote HTTP + // write surface (ADR-0006): forward-and-relay to the bridge's loopback + // route. The hub still routes by instance id only; semantics (running + // guard / discovery retirement, title validation + pinning + broadcast) + // stay in the bridge. Any request body pipes through untouched. + const sessionOpMatch = url.pathname.match( + /^\/api\/instances\/([^/]+)\/sessions\/([^/]+)\/(close|rename)$/, + ); + if (sessionOpMatch && req.method === "POST") { + const [, instId, sid, op] = sessionOpMatch; if (!authorized(req, url, token)) { res.writeHead(401, { "Content-Type": "text/plain" }); res.end("unauthorized"); return; } - const entry = instances.get(closeMatch[1]!); + const entry = instances.get(instId!); if (!entry) { res.writeHead(404, { "Content-Type": "text/plain" }); res.end("unknown instance"); @@ -562,7 +566,7 @@ export function startHub(options: HubOptions & { onIdleExit?: () => void }): Pro { host: "127.0.0.1", port: entry.port, - path: `/sessions/${closeMatch[2]}/close`, + path: `/sessions/${sid}/${op}`, method: "POST", }, (up) => { @@ -587,7 +591,8 @@ export function startHub(options: HubOptions & { onIdleExit?: () => void }): Pro res.on("close", () => { if (!res.writableEnded) upstream.destroy(); }); - // Relay any request body through (clients normally send none). + // Relay any request body through (close sends none, rename carries the + // JSON title — chunked, since the hub does not forward headers). req.pipe(upstream); return; } diff --git a/src/remote/session-rename-endpoint.ts b/src/remote/session-rename-endpoint.ts new file mode 100644 index 0000000..f59b927 --- /dev/null +++ b/src/remote/session-rename-endpoint.ts @@ -0,0 +1,120 @@ +/** + * Remote session rename endpoint, served on the bridge's loopback HTTP server + * and byte-proxied by the hub at + * POST /api/instances/{id}/sessions/{sessionId}/rename (JSON body: {title}). + * + * A rename is the ONLY way a session title changes after its one-shot + * auto-title (set once at the first prompt). The bridge applies it in-memory + * (sessionTitles + discovery summary), persists it to the App's tasks-index + * with title_overridden=1 — the same pin the App's own rename flow sets, so + * no later automatic write can touch it — and broadcasts session_info_update + * so attached editors and phones update live. + */ + +import type { IncomingMessage, ServerResponse } from "node:http"; + +import { sendSessionUpdate } from "../handlers/io.js"; +import type { ZcodeAcpServer } from "../server.js"; +import { renameSessionTask } from "../tasks-index.js"; +import { log, warn } from "../utils.js"; + +const MAX_BODY_BYTES = 4096; + +function sendText(res: ServerResponse, code: number, message: string): void { + if (res.writableEnded) return; + res.writeHead(code, { "Content-Type": "text/plain" }); + res.end(message); +} + +function sendJson(res: ServerResponse, code: number, body: Record): void { + const payload = JSON.stringify(body); + res.writeHead(code, { + "Content-Type": "application/json", + "Content-Length": Buffer.byteLength(payload), + }); + res.end(payload); +} + +/** + * Read the request body as JSON without trusting Content-Type — the hub's + * forward-and-relay proxy pipes bytes through without forwarding headers. + */ +async function readJsonBody(req: IncomingMessage): Promise { + const chunks: Buffer[] = []; + let size = 0; + for await (const chunk of req) { + size += (chunk as Buffer).length; + if (size > MAX_BODY_BYTES) throw new Error("body too large"); + chunks.push(chunk as Buffer); + } + if (chunks.length === 0) return undefined; + return JSON.parse(Buffer.concat(chunks).toString("utf8")); +} + +async function handleRename( + server: ZcodeAcpServer, + req: IncomingMessage, + res: ServerResponse, + sessionId: string, +): Promise { + let body: unknown; + try { + body = await readJsonBody(req); + } catch { + sendText(res, 400, "invalid body"); + return; + } + const rawTitle = (body as { title?: unknown } | undefined)?.title; + if (typeof rawTitle !== "string" || rawTitle.trim().length === 0) { + sendText(res, 400, "title required"); + return; + } + if (!server.sessionSummaries.has(sessionId)) { + sendText(res, 404, "unknown session"); + return; + } + // Mirror the auto-title's normalization: single line, trimmed, 80 chars. + const title = rawTitle + .replace(/[\r\n]+/g, " ") + .trim() + .slice(0, 80); + + server.sessionTitles.set(sessionId, title); + server.touchSessionSummary(sessionId, title); + const zcodeSid = server.resolveSid(sessionId); + if (zcodeSid) { + try { + await renameSessionTask(zcodeSid, title); + } catch (e) { + warn( + `remote: rename persist failed (non-fatal): ${e instanceof Error ? e.message : String(e)}`, + ); + } + } + await sendSessionUpdate(server.clients.broadcast(), sessionId, { + sessionUpdate: "session_info_update", + title, + updatedAt: new Date().toISOString(), + }).catch(() => undefined); + log(`remote: session ${sessionId.slice(0, 8)} renamed to "${title}"`); + sendJson(res, 200, { ok: true, title }); +} + +/** + * Build the /sessions/{id}/rename request handler for the loopback endpoint. + * Async failures degrade to a status code, never into the event loop. + */ +export function createSessionRenameHandler( + server: ZcodeAcpServer, +): (req: IncomingMessage, res: ServerResponse, sessionId: string) => void { + return (req, res, sessionId) => { + if (req.method !== "POST") { + sendText(res, 405, "method not allowed"); + return; + } + void handleRename(server, req, res, sessionId).catch(() => { + if (res.headersSent) res.destroy(); + else sendText(res, 500, "internal error"); + }); + }; +} diff --git a/src/server.ts b/src/server.ts index 789dfe4..cb6e828 100644 --- a/src/server.ts +++ b/src/server.ts @@ -150,7 +150,7 @@ export class ZcodeAcpServer { */ private readonly backendLoadedSessions = new Map(); /** - * Sessions eligible for auto-title on first end_turn. Only `session/new` + * Sessions eligible for the one-shot auto-title. Only `session/new` * populates this — resumed/loaded sessions already carry a title, so their * first post-load message must NOT overwrite it. (sessionTitles alone can't * distinguish "freshly created" from "resumed but not yet titled in-process".) diff --git a/src/tasks-index.ts b/src/tasks-index.ts index ecd26fc..cfe200a 100644 --- a/src/tasks-index.ts +++ b/src/tasks-index.ts @@ -214,6 +214,45 @@ export async function upsertSessionTask(opts: { } } +/** + * User-driven rename (remote rename endpoint): pins the title with + * title_overridden=1 — the same marker the App's own rename flow sets — so no + * later automatic write can touch it. Best-effort: returns false when the row + * is missing or the index is unavailable. + */ +export async function renameSessionTask(taskId: string, title: string): Promise { + if (!existsSync(TASKS_INDEX_PATH)) return false; + const trimmed = title.trim().slice(0, 80); + if (!trimmed) return false; + try { + const result = await withSqliteRetry((con) => { + const row = con.prepare("SELECT meta_json FROM tasks WHERE task_id=?").get(taskId) as + { meta_json: string } | undefined; + if (!row) return false; + let metaJson: string; + try { + const meta = JSON.parse(row.meta_json ?? "{}") as Record; + meta["title"] = trimmed; + metaJson = JSON.stringify(meta); + } catch { + // meta_json corrupt/unparseable — the App will fall back to the title + // column anyway, so keep the stored bytes rather than guessing. + metaJson = row.meta_json ?? "{}"; + } + con + .prepare( + "UPDATE tasks SET title=?, title_overridden=1, updated_at=?, meta_json=? WHERE task_id=?", + ) + .run(trimmed, Date.now(), metaJson, taskId); + return true; + }); + return result ?? false; + } catch (e) { + warn(`tasks-index rename skipped: ${e instanceof Error ? e.message : String(e)}`); + return false; + } +} + /** * Update a session's title + searchable_text after the first turn. * @@ -249,10 +288,20 @@ export async function updateSessionTitle( .get(taskId) as { title_overridden: number; meta_json: string } | undefined; if (!row) return false; + if (row.title_overridden === 1) { + // User renamed manually → the displayed title (title column AND + // meta_json.title — the App may read either) must stay untouched; + // only refresh searchable_text so search stays useful. + con + .prepare("UPDATE tasks SET updated_at=?, searchable_text=? WHERE task_id=?") + .run(Date.now(), search, taskId); + return true; + } + // The ZCode App reads title from meta_json first (falling back to the // title column only when meta_json fails to parse). If we update only the // column, the App keeps showing the stale meta_json title (empty at create - // time). So we patch meta_json.title in both branches below. + // time). So patch meta_json.title as well. let metaJson: string; try { const meta = JSON.parse(row.meta_json ?? "{}") as Record; @@ -263,15 +312,6 @@ export async function updateSessionTitle( // column anyway, so skip the meta_json write rather than guessing. metaJson = row.meta_json ?? "{}"; } - - if (row.title_overridden === 1) { - // User overrode the title → respect the column value, but still refresh - // searchable_text and sync meta_json.title for consistency. - con - .prepare("UPDATE tasks SET updated_at=?, searchable_text=?, meta_json=? WHERE task_id=?") - .run(Date.now(), search, metaJson, taskId); - return true; - } con .prepare( "UPDATE tasks SET title=?, updated_at=?, searchable_text=?, meta_json=? " + diff --git a/tests/hub.test.ts b/tests/hub.test.ts index 12c5ce3..ed855ed 100644 --- a/tests/hub.test.ts +++ b/tests/hub.test.ts @@ -580,6 +580,56 @@ describe("hub session close proxy", () => { }); }); +describe("hub session rename proxy", () => { + /** Fake bridge loopback HTTP server accepting POST /sessions/{id}/rename. */ + function startRenameBridge(): Promise<{ + server: Server; + port: number; + seen: Array<{ sid: string; body: string }>; + }> { + return new Promise((resolve) => { + const seen: Array<{ sid: string; body: string }> = []; + const server = createServer((req, res) => { + const sid = new URL(req.url ?? "/", "http://127.0.0.1").pathname.split("/")[2]; + const chunks: Buffer[] = []; + req.on("data", (c: Buffer) => chunks.push(c)); + req.on("end", () => { + seen.push({ sid: sid ?? "", body: Buffer.concat(chunks).toString("utf8") }); + res.writeHead(200, { "Content-Type": "application/json" }); + res.end('{"ok":true,"title":"renamed"}'); + }); + }); + server.listen(0, "127.0.0.1", () => { + const addr = server.address(); + resolve({ server, port: typeof addr === "object" && addr ? addr.port : 0, seen }); + }); + }); + } + + it("relays the rename POST with its JSON body to the bridge", async () => { + const hub = await startTestHub(); + const bridge = track( + await startRenameBridge(), + ({ server }) => new Promise((resolve) => server.close(() => resolve())), + ); + const res = await fetch(`http://127.0.0.1:${hub.port}/api/register`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(registerBody({ port: bridge.port })), + }); + expect(res.status).toBe(200); + + const ok = await fetch(`http://127.0.0.1:${hub.port}/api/instances/inst-1/sessions/s1/rename`, { + method: "POST", + headers: { Authorization: `Bearer ${TOKEN}`, "Content-Type": "application/json" }, + body: JSON.stringify({ title: "my name" }), + }); + expect(ok.status).toBe(200); + expect(await ok.json()).toEqual({ ok: true, title: "renamed" }); + expect(bridge.seen).toEqual([{ sid: "s1", body: '{"title":"my name"}' }]); + }); +}); + describe("hub idle exit", () => { it("exits after the idle window with no instances and no proxies", async () => { let exited = false; diff --git a/tests/remote-session-rename.test.ts b/tests/remote-session-rename.test.ts new file mode 100644 index 0000000..cb6ef0c --- /dev/null +++ b/tests/remote-session-rename.test.ts @@ -0,0 +1,148 @@ +/** + * Session rename endpoint: the bridge-side POST /sessions/{id}/rename + * handler through a real loopback HTTP server with an in-memory + * ZcodeAcpServer. Proves title validation, the set-once pin (a later prompt + * cannot overwrite a rename), discovery updates, and the tasks-index persist. + */ + +import { randomUUID } from "node:crypto"; +import { createServer, type Server } from "node:http"; + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { collectSessions } from "../src/remote/endpoint.js"; +import { createSessionRenameHandler } from "../src/remote/session-rename-endpoint.js"; +import { collectStatus } from "../src/remote/status-endpoint.js"; +import { ZcodeAcpServer } from "../src/server.js"; + +// The real module writes the App's ~/.zcode/v2/tasks-index.sqlite — record +// calls instead so tests can assert the persist happened. +const renames: Array<{ taskId: string; title: string }> = []; +vi.mock("../src/tasks-index.js", () => ({ + renameSessionTask: async (taskId: string, title: string) => { + renames.push({ taskId, title }); + return true; + }, +})); + +beforeEach(() => { + renames.length = 0; +}); + +const cleanups: Array<() => Promise | void> = []; + +afterEach(async () => { + while (cleanups.length) { + const stop = cleanups.pop()!; + await stop(); + } +}); + +/** Boot the rename handler on an ephemeral port; returns its base URL. */ +async function bootRename(server: ZcodeAcpServer): Promise { + const handler = createSessionRenameHandler(server); + const httpServer: Server = createServer((req, res) => { + const sid = new URL(req.url ?? "/", "http://127.0.0.1").pathname.split("/")[2]; + if (sid) handler(req, res, decodeURIComponent(sid)); + else { + res.writeHead(404, { "Content-Type": "text/plain" }); + res.end("not found"); + } + }); + await new Promise((resolve) => httpServer.listen(0, "127.0.0.1", resolve)); + cleanups.push(() => new Promise((resolve) => httpServer.close(() => resolve()))); + const addr = httpServer.address(); + return `http://127.0.0.1:${typeof addr === "object" && addr ? addr.port : 0}`; +} + +/** Seed one live, titled session; returns both ids. */ +function seedSession(server: ZcodeAcpServer, title: string): { acpSid: string; zcodeSid: string } { + const acpSid = randomUUID(); + const zcodeSid = `zc-${randomUUID().slice(0, 8)}`; + server.registerSession(acpSid, zcodeSid); + server.markSessionActive(acpSid); + server.sessionTitles.set(acpSid, title); + server.touchSessionSummary(acpSid, title); + return { acpSid, zcodeSid }; +} + +function rename(base: string, acpSid: string, title: string): Promise { + // No Content-Type header on purpose — the hub proxy relays bodies without + // forwarding headers, so the handler must not depend on it. + return fetch(`${base}/sessions/${acpSid}/rename`, { + method: "POST", + body: JSON.stringify({ title }), + }); +} + +describe("session rename endpoint", () => { + it("renames a session: pin, discovery, and tasks-index persist", async () => { + const server = new ZcodeAcpServer(); + const { acpSid, zcodeSid } = seedSession(server, "auto title"); + const base = await bootRename(server); + + const res = await rename(base, acpSid, " my own name\n"); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ ok: true, title: "my own name" }); + + expect(server.sessionTitles.get(acpSid)).toBe("my own name"); + expect(collectStatus(server).sessions.find((s) => s.sessionId === acpSid)?.title).toBe( + "my own name", + ); + expect((await collectSessions(server)).find((s) => s.sessionId === acpSid)?.title).toBe( + "my own name", + ); + expect(renames).toEqual([{ taskId: zcodeSid, title: "my own name" }]); + }); + + it("a rename survives a later one-shot auto-title attempt", async () => { + const server = new ZcodeAcpServer(); + const { acpSid } = seedSession(server, "auto title"); + const base = await bootRename(server); + expect((await rename(base, acpSid, "user name")).status).toBe(200); + + // The first-prompt one-shot gate is sessionTitles.has() — the rename + // occupies it, so a late prompt cannot retitle the session. + server.titleEligibleSessions.add(acpSid); + expect(server.sessionTitles.has(acpSid)).toBe(true); + }); + + it("rejects empty/missing titles, oversized bodies, unknown sessions, non-POST", async () => { + const server = new ZcodeAcpServer(); + const { acpSid } = seedSession(server, "auto title"); + const base = await bootRename(server); + + expect((await rename(base, acpSid, " ")).status).toBe(400); + expect( + ( + await fetch(`${base}/sessions/${acpSid}/rename`, { + method: "POST", + body: JSON.stringify({ nope: 1 }), + }) + ).status, + ).toBe(400); + expect( + ( + await fetch(`${base}/sessions/${acpSid}/rename`, { + method: "POST", + body: "x".repeat(5000), + }) + ).status, + ).toBe(400); + expect((await rename(base, randomUUID(), "name")).status).toBe(404); + expect((await fetch(`${base}/sessions/${acpSid}/rename`)).status).toBe(405); + }); + + it("truncates and flattens the title like the auto-title does", async () => { + const server = new ZcodeAcpServer(); + const { acpSid } = seedSession(server, "auto title"); + const base = await bootRename(server); + + const long = "y".repeat(100); + const res = await rename(base, acpSid, `a\r\n\r\nb ${long}`); + expect(res.status).toBe(200); + const { title } = (await res.json()) as { title: string }; + expect(title).toBe(`a b ${long}`.slice(0, 80)); + expect(title).not.toMatch(/[\r\n]/); + }); +}); diff --git a/tests/session-title.test.ts b/tests/session-title.test.ts new file mode 100644 index 0000000..20eaf88 --- /dev/null +++ b/tests/session-title.test.ts @@ -0,0 +1,218 @@ +/** + * One-shot session title tests: the title is set exactly ONCE, from the FIRST + * prompt of a freshly created session, before the turn even runs. No later + * automatic path may change it — not the completing turn's end_turn, not a + * preempting prompt. A manual rename is the only later modifier. + * + * Bug history (2026-08-24): the title used to be set on the first end_turn + * from that turn's prompt text. A message interrupting the first turn + * preempted it (stopReason "cancelled" — never titled), so the interruptor's + * end_turn titled the session after the interrupting message. + */ + +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 { ZcodeEvent } from "../src/backend/types.js"; +import { prompt } from "../src/handlers/session.js"; +import { ZcodeAcpServer } from "../src/server.js"; + +// Record title persists so tests can assert the tasks-index write (the real +// module writes the App's ~/.zcode/v2/tasks-index.sqlite — never in tests). +const titlePersists: Array<{ taskId: string; title: string; text: string }> = []; +vi.mock("../src/tasks-index.js", () => ({ + upsertSessionTask: async () => true, + updateSessionTitle: async (taskId: string, title: string, text: string) => { + titlePersists.push({ taskId, title, text }); + return true; + }, +})); + +beforeEach(() => { + titlePersists.length = 0; +}); + +/** cx recording every session_info_update title pushed to the editor. */ +function collectCx(): { cx: acp.AgentContext; titles: string[] } { + const titles: string[] = []; + const cx = { + notify: async (_method: string, params: Record) => { + const update = params?.update as Record | undefined; + if (update?.sessionUpdate === "session_info_update") { + titles.push(update.title as string); + } + }, + request: async () => ({}), + } as unknown as acp.AgentContext; + return { cx, titles }; +} + +/** Fake backend whose session/send delivers `events()` to all listeners. */ +function scriptedBackend(events: () => ZcodeEvent[]): ZcodeBackend { + const listeners: Array<{ handleEvent: (e: ZcodeEvent) => void }> = []; + return { + isDead: false, + request: async (_id: number, method: string) => { + switch (method) { + case "workspace/updateProviderRegistry": + case "session/resume": + case "session/subscribe": + return { result: {} }; + case "session/read": + return { result: { projection: { status: "idle", contextUsed: 0 }, settings: {} } }; + case "session/messages": + return { result: { messages: [] } }; + case "session/send": { + for (const e of events()) { + for (const l of listeners) l.handleEvent(e); + } + return { result: { accepted: true } }; + } + default: + return { error: { message: `unhandled ${method}` } }; + } + }, + send: () => {}, + pollServerRequests: () => [], + registerEventListener: (_sid: string, l: { handleEvent: (e: ZcodeEvent) => void }) => { + listeners.push(l); + }, + unregisterEventListener: () => {}, + } as unknown as ZcodeBackend; +} + +/** Server with a pre-registered, backend-loaded session. */ +function setup(backend: ZcodeBackend): ZcodeAcpServer { + const server = new ZcodeAcpServer(); + server.backend = backend; + server.registerSession("sess_ts", "zs_ts"); + server.markBackendLoaded("sess_ts"); + return server; +} + +function promptParams(text: string): acp.PromptRequest { + return { sessionId: "sess_ts", prompt: [{ type: "text", text }] } as acp.PromptRequest; +} + +describe("one-shot session title (set once at the FIRST prompt)", () => { + it("sets the title from the first prompt before the turn runs, once", async () => { + const server = setup( + scriptedBackend(() => [ + { type: "turn.started" }, + { type: "turn.completed", payload: { resultType: "success" } }, + ]), + ); + server.titleEligibleSessions.add("sess_ts"); + const { cx, titles } = collectCx(); + + // Drain microtasks up to (but not including) session/send: the title must + // already be settled by the time the turn starts. + const p = prompt(server, promptParams("first message question"), cx, 1); + await vi.waitFor(() => expect(server.sessionTitles.get("sess_ts")).toBeTruthy()); + expect(server.sessionSummaries.get("sess_ts")?.title).toBe("first message question"); + expect(titles).toEqual(["first message question"]); + expect(titlePersists).toEqual([ + { taskId: "zs_ts", title: "first message question", text: "first message question" }, + ]); + + const result = await p; + expect(result).toEqual({ stopReason: "end_turn" }); + // end_turn did NOT re-set anything: still exactly one notify + one persist. + expect(titles).toEqual(["first message question"]); + expect(titlePersists).toHaveLength(1); + }); + + it("a preempting second message cannot steal or change the title", async () => { + let sendCount = 0; + const server = setup( + scriptedBackend(() => { + sendCount++; + if (sendCount === 1) { + // First turn parks after turn.started — it never completes on its + // own; only the preemptor's fan-out events carry a terminal event. + return [{ type: "turn.started" }]; + } + return [ + { type: "turn.started" }, + { + type: "model.streaming", + payload: { kind: "text_delta", delta: "answer", assistantMessageId: "m2" }, + }, + { type: "turn.completed", payload: { resultType: "success" } }, + ]; + }), + ); + server.titleEligibleSessions.add("sess_ts"); + const { cx, titles } = collectCx(); + + const p1 = prompt(server, promptParams("first message question\nsecond line"), cx, 101); + await vi.waitFor(() => expect(sendCount).toBe(1)); + await new Promise((resolve) => setTimeout(resolve, 50)); + // Title settled from the FIRST prompt's first line while its turn is + // still in flight. + expect(server.sessionTitles.get("sess_ts")).toBe("first message question"); + + const p2 = prompt(server, promptParams("interrupting message"), cx, 102); + const [r1, r2] = await Promise.all([p1, p2]); + + expect(r1).toEqual({ stopReason: "cancelled" }); + expect(r2).toEqual({ stopReason: "end_turn" }); + // The interruptor neither stole nor re-set the title. + expect(server.sessionTitles.get("sess_ts")).toBe("first message question"); + expect(server.sessionSummaries.get("sess_ts")?.title).toBe("first message question"); + expect(titles).toEqual(["first message question"]); + expect(titlePersists).toHaveLength(1); + }); + + it("a later prompt in an already-titled session changes nothing", async () => { + const server = setup( + scriptedBackend(() => [ + { type: "turn.started" }, + { type: "turn.completed", payload: { resultType: "success" } }, + ]), + ); + server.titleEligibleSessions.add("sess_ts"); + const { cx, titles } = collectCx(); + + await prompt(server, promptParams("original title source"), cx, 1); + await prompt(server, promptParams("a completely different topic"), cx, 2); + + expect(server.sessionTitles.get("sess_ts")).toBe("original title source"); + expect(titles).toEqual(["original title source"]); + expect(titlePersists).toHaveLength(1); + }); + + it("non-eligible (resumed) sessions never get an auto-title", async () => { + const server = setup( + scriptedBackend(() => [ + { type: "turn.started" }, + { type: "turn.completed", payload: { resultType: "success" } }, + ]), + ); + const { cx, titles } = collectCx(); + + const result = await prompt(server, promptParams("post-resume message"), cx, 1); + + expect(result).toEqual({ stopReason: "end_turn" }); + expect(server.sessionTitles.size).toBe(0); + expect(titles).toEqual([]); + expect(titlePersists).toHaveLength(0); + }); + + it("truncates multi-line first prompts to the first non-empty line, 80 chars", async () => { + const server = setup( + scriptedBackend(() => [ + { type: "turn.started" }, + { type: "turn.completed", payload: { resultType: "success" } }, + ]), + ); + server.titleEligibleSessions.add("sess_ts"); + const { cx } = collectCx(); + + const long = "x".repeat(100); + await prompt(server, promptParams(`\n${long}\nignored second line`), cx, 1); + + expect(server.sessionTitles.get("sess_ts")).toBe("x".repeat(80)); + }); +}); diff --git a/tests/tasks-index.test.ts b/tests/tasks-index.test.ts index cee5196..6f0fb38 100644 --- a/tests/tasks-index.test.ts +++ b/tests/tasks-index.test.ts @@ -140,17 +140,17 @@ function makeStatement(sql: string) { }, }; } - // UPDATE tasks SET updated_at=?, searchable_text=?, meta_json=? WHERE task_id=? - // (title_overridden=1 branch: keep user title, still refresh searchable_text) + // UPDATE tasks SET updated_at=?, searchable_text=? WHERE task_id=? + // (title_overridden=1 branch: keep the user's title everywhere, still + // refresh searchable_text) if (/^UPDATE tasks SET updated_at=\?, searchable_text/i.test(sql)) { return { - run(updatedAt: number, searchable: string, metaJson: string, taskId: string) { + run(updatedAt: number, searchable: string, taskId: string) { let changes = 0; for (const r of rows.values()) { if (r.task_id === taskId) { r.updated_at = updatedAt; r.searchable_text = searchable; - r.meta_json = metaJson; changes++; } } @@ -389,9 +389,10 @@ describe("tasks-index session sync", () => { expect(r.title).toBe("user kept name"); // But searchable_text is still updated (not user-controlled). expect(r.searchable_text).toBe("search body"); - // meta_json.title is synced to the auto title for consistency (the App - // reads meta_json first, but with title_overridden=1 the column wins). - expect(JSON.parse(r.meta_json).title).toBe("auto title"); + // meta_json is left untouched too — the App may read the title from + // either the column or meta_json.title, so writing the auto title into + // meta_json could visually revert the user's rename. + expect(JSON.parse(r.meta_json).title).not.toBe("auto title"); }); it("returns false when the session row does not exist", async () => {