diff --git a/src/config/model-cache.ts b/src/config/model-cache.ts index 94484c4..e8f8dfd 100644 --- a/src/config/model-cache.ts +++ b/src/config/model-cache.ts @@ -40,6 +40,13 @@ export async function currentModelCached( * Emit an initial usage_update after resume/load so the editor shows the * context bar. Skips when there's no token data (resume before any turn → 0). * Also syncs the differ's usage baseline so the first turn won't re-emit it. + * + * Note: the backend's `projection.contextUsed` is 0 after resume — it only + * updates after a new turn completes. So a resumed session with history won't + * show a context bar until the first post-resume message. That's acceptable: + * we don't have a reliable way to estimate the pre-resume context size, and + * guessing wrong (e.g. from per-turn token totals that include re-sent history) + * is worse than showing nothing. */ export async function emitInitialUsage( server: ZcodeAcpServer, diff --git a/src/config/options.ts b/src/config/options.ts index 80d6afa..85478b9 100644 --- a/src/config/options.ts +++ b/src/config/options.ts @@ -71,7 +71,13 @@ export async function buildModes( } return { currentModeId: currentMode, - availableModes: ["plan", "build", "edit", "yolo"].map((m) => ({ id: m, name: capitalize(m) })), + // ZCode 3.3.0 mode enum: plan/build/edit/yolo/auto. settings.mode only + // carries `current` (no `available` list, unlike thoughtLevel), so the full + // enum is advertised here. + availableModes: ["plan", "build", "edit", "yolo", "auto"].map((m) => ({ + id: m, + name: capitalize(m), + })), }; } diff --git a/src/handlers/extensions.ts b/src/handlers/extensions.ts index af92ff6..2c8ed15 100644 --- a/src/handlers/extensions.ts +++ b/src/handlers/extensions.ts @@ -53,9 +53,13 @@ export async function fork(server: ZcodeAcpServer, params: ExtensionParams): Pro 15000, ); if (resp.error) throw new Error(`fork failed: ${resp.error.message}`); - const result = (resp.result ?? {}) as { sessionId?: string }; - if (result.sessionId) server.sessionMap.set(result.sessionId, result.sessionId); - log(`session/fork → ${result.sessionId ?? "?"}`); + // 3.3.0 returns `forkedSessionId` (not `sessionId`) for the new session id. + // Register it so subsequent ACP calls targeting the fork can resolve the sid. + const result = (resp.result ?? {}) as { forkedSessionId?: string }; + if (result.forkedSessionId) { + server.sessionMap.set(result.forkedSessionId, result.forkedSessionId); + } + log(`session/fork → ${result.forkedSessionId ?? "?"}`); return result; } @@ -206,16 +210,13 @@ export async function setThoughtLevel( params: ExtensionParams, ): Promise { const zcodeSid = resolveSidOrThrow(server, params); - const thoughtLevel = params.thoughtLevel; - if (!thoughtLevel) throw new Error("setThoughtLevel requires thoughtLevel"); + // 3.3.0 marks thoughtLevel optional (omitting it resets to the model's + // default). Forward it only when present so a reset call isn't rejected. + const zcParams: Record = { sessionId: zcodeSid }; + if (params.thoughtLevel !== undefined) zcParams.thoughtLevel = params.thoughtLevel; const resp = await server .ensureBackend() - .request( - server.nextId(), - "session/setThoughtLevel", - { sessionId: zcodeSid, thoughtLevel }, - 15000, - ); + .request(server.nextId(), "session/setThoughtLevel", zcParams, 15000); if (resp.error) throw new Error(`setThoughtLevel failed: ${resp.error.message}`); log("session/setThoughtLevel → ok"); return (resp.result ?? {}) as Result; diff --git a/src/handlers/session.ts b/src/handlers/session.ts index 2cbf8cf..ff7968a 100644 --- a/src/handlers/session.ts +++ b/src/handlers/session.ts @@ -355,10 +355,18 @@ export async function prompt( server.titleEligibleSessions.has(params.sessionId) && !server.sessionTitles.has(params.sessionId) ) { - const title = text.slice(0, 80); + // 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); const { updateSessionTitle } = await import("../tasks-index.js"); - void updateSessionTitle(zcodeSid, title); + void updateSessionTitle(zcodeSid, title, text); await sendSessionUpdate(cx, params.sessionId, { sessionUpdate: "session_info_update", title, diff --git a/src/handlers/slash.ts b/src/handlers/slash.ts index 0ef772c..3bd13df 100644 --- a/src/handlers/slash.ts +++ b/src/handlers/slash.ts @@ -85,8 +85,10 @@ export async function handleSlashCommand( return ok(`✓ goal set: ${arg}`); } case "fork": { - const result = (await fork(server, { sessionId: acpSid })) as { sessionId?: string }; - return ok(`✓ forked new session: ${result.sessionId ?? "?"}`); + const result = (await fork(server, { sessionId: acpSid })) as { + forkedSessionId?: string; + }; + return ok(`✓ forked new session: ${result.forkedSessionId ?? "?"}`); } case "rewind": { await rewind(server, { sessionId: acpSid }); diff --git a/src/tasks-index.ts b/src/tasks-index.ts index 9ac212b..bb2239a 100644 --- a/src/tasks-index.ts +++ b/src/tasks-index.ts @@ -21,52 +21,58 @@ import path from "node:path"; import { log, ZCODE_CREDS_PATH } from "./utils.js"; +// Precise DatabaseSync constructor type from @types/node, captured without a +// runtime import (type position only). node:sqlite's API is prepared-statement +// based: con.prepare(sql) → StatementSync with .run(...)/.get(...); the +// DatabaseSync instance itself has NO .run/.get methods. +type DatabaseSyncCtor = (typeof import("node:sqlite"))["DatabaseSync"]; + /** * Dynamically load `node:sqlite` (Node ≥ 22). On older Node the import fails; * callers degrade gracefully (tasks-index sync is best-effort). We cache the * loaded class so repeated calls don't re-import. */ -let DatabaseSyncCtor: - | (( - path: string, - options?: { timeout?: number }, - ) => { - run: (...args: unknown[]) => void; - get: (...args: unknown[]) => unknown; - close: () => void; - }) - | null - | undefined; +let DatabaseSync: DatabaseSyncCtor | null | undefined; -async function loadSqlite() { - if (DatabaseSyncCtor !== undefined) return DatabaseSyncCtor; +async function loadSqlite(): Promise { + if (DatabaseSync !== undefined) return DatabaseSync; try { - // node:sqlite ships with Node ≥ 22. Cast through unknown so this compiles - // even if @types/node lags behind the runtime module. - const mod = (await import("node:sqlite")) as unknown as { - DatabaseSync: unknown; + // node:sqlite ships with Node ≥ 22 (experimental on 22.x — may need + // --experimental-sqlite on some builds; the catch below covers that). + const mod = (await import("node:sqlite")) as { + DatabaseSync: DatabaseSyncCtor; }; - DatabaseSyncCtor = mod.DatabaseSync as never; + DatabaseSync = mod.DatabaseSync; } catch { - DatabaseSyncCtor = null; // Node < 22 or sqlite unavailable + DatabaseSync = null; // Node < 22, sqlite unavailable, or flag missing } - return DatabaseSyncCtor; + return DatabaseSync; } /** tasks-index.sqlite sits next to config.json under ~/.zcode/v2/. */ const TASKS_INDEX_PATH = path.join(path.dirname(ZCODE_CREDS_PATH), "tasks-index.sqlite"); -/** Read provider id + model id from config.json (display-only fields). */ +/** + * Read provider id + model ref from config.json. + * + * The App stores `model` as the full `providerKey/modelId` path (e.g. + * `builtin:bigmodel-coding-plan/GLM-5.2`) — the provider map's KEY is the + * provider id, not the short label. We mirror that format so App-side + * filtering/grouping by model treats bridge-created rows identically. + * + * `providerId` stays the short label (`glm`) — that's what every row uses + * regardless of source. + */ function resolveProviderModel(): { providerId: string; modelRef: string } { try { const cfg = JSON.parse(readFileSync(ZCODE_CREDS_PATH, "utf8")) as { provider?: Record }>; }; - for (const [, p] of Object.entries(cfg.provider ?? {})) { + for (const [providerKey, p] of Object.entries(cfg.provider ?? {})) { if (p?.enabled) { const models = p.models ?? {}; const modelId = Object.keys(models)[0] ?? "GLM-5.2"; - return { providerId: "glm", modelRef: modelId }; + return { providerId: "glm", modelRef: `${providerKey}/${modelId}` }; } } } catch { @@ -91,8 +97,8 @@ export async function upsertSessionTask(opts: { status?: string; }): Promise { if (!existsSync(TASKS_INDEX_PATH)) return false; // App never installed → no index. - const DatabaseSync = await loadSqlite(); - if (!DatabaseSync) return false; // node:sqlite unavailable (Node < 22) + const Sqlite = await loadSqlite(); + if (!Sqlite) return false; // node:sqlite unavailable (Node < 22) const nowMs = Date.now(); const { providerId, modelRef } = resolveProviderModel(); const model = opts.model ?? modelRef; @@ -118,27 +124,30 @@ export async function upsertSessionTask(opts: { return false; } try { - const con = DatabaseSync(TASKS_INDEX_PATH, { timeout: 5000 }); + const con = new Sqlite(TASKS_INDEX_PATH, { timeout: 5000 }); try { - con.run( - "INSERT OR IGNORE INTO tasks " + - "(workspace_key, workspace_path, workspace_identity, task_id, " + - " title, task_status, provider, mode, model, " + - " created_at, updated_at, unread_at, pinned, archived, deleted, " + - " title_overridden, meta_json, searchable_text) " + - "VALUES (?, ?, NULL, ?, ?, ?, ?, 'build', ?, ?, ?, NULL, 0, 0, 0, 0, ?, ?)", - opts.workspaceKey, - opts.workspaceKey, - opts.taskId, - opts.title, - status, - providerId, - model, - nowMs, - nowMs, - metaJson, - opts.title, - ); + con + .prepare( + "INSERT OR IGNORE INTO tasks " + + "(workspace_key, workspace_path, workspace_identity, task_id, " + + " title, task_status, provider, mode, model, " + + " created_at, updated_at, unread_at, pinned, archived, deleted, " + + " title_overridden, meta_json, searchable_text) " + + "VALUES (?, ?, NULL, ?, ?, ?, ?, 'build', ?, ?, ?, NULL, 0, 0, 0, 0, ?, ?)", + ) + .run( + opts.workspaceKey, + opts.workspaceKey, + opts.taskId, + opts.title, + status, + providerId, + model, + nowMs, + nowMs, + metaJson, + opts.title, + ); } finally { con.close(); } @@ -150,30 +159,50 @@ export async function upsertSessionTask(opts: { } /** - * Update a session's title after the first turn. session/create leaves title - * empty; once the first prompt completes, set a meaningful title. Respects - * title_overridden: if the user already renamed in the App, their title wins. + * Update a session's title + searchable_text after the first turn. + * + * session/create leaves title empty; once the first prompt completes, set a + * meaningful title. Respects title_overridden: if the user already renamed in + * the App, their title wins (but searchable_text is still refreshed — it's not + * user-controlled). + * + * `searchableText` feeds the App's full-text search (the App builds it via + * `buildSearchableTextFromMessages`: each message's content trimmed + joined + * by newlines, capped at 200k chars). We pass the first user prompt here; the + * App later overwrites it with the full conversation when it reindexes, but + * having it non-empty from the start means the row shows up in search and + * matches the shape of App-created rows. */ -export async function updateSessionTitle(taskId: string, title: string): Promise { +export async function updateSessionTitle( + taskId: string, + title: string, + searchableText?: string, +): Promise { if (!existsSync(TASKS_INDEX_PATH) || !title) return false; - const DatabaseSync = await loadSqlite(); - if (!DatabaseSync) return false; + const Sqlite = await loadSqlite(); + if (!Sqlite) return false; const trimmed = title.trim().slice(0, 80); if (!trimmed) return false; + // Cap searchable_text at the App's limit (aD = 2e5 = 200000 chars). + const search = (searchableText ?? trimmed).trim().slice(0, 200_000); try { - const con = DatabaseSync(TASKS_INDEX_PATH, { timeout: 5000 }); + const con = new Sqlite(TASKS_INDEX_PATH, { timeout: 5000 }); try { - const row = con.get("SELECT title_overridden FROM tasks WHERE task_id=?", taskId) as + const row = con.prepare("SELECT title_overridden FROM tasks WHERE task_id=?").get(taskId) as { title_overridden: number } | undefined; if (!row) return false; - if (row.title_overridden === 1) return true; // user overrode → respect it - con.run( - "UPDATE tasks SET title=?, updated_at=?, searchable_text=? WHERE task_id=? AND title_overridden=0", - trimmed, - Date.now(), - trimmed, - taskId, - ); + if (row.title_overridden === 1) { + // User overrode the title → respect it, but still refresh searchable_text. + con + .prepare("UPDATE tasks SET updated_at=?, searchable_text=? WHERE task_id=?") + .run(Date.now(), search, taskId); + return true; + } + con + .prepare( + "UPDATE tasks SET title=?, updated_at=?, searchable_text=? WHERE task_id=? AND title_overridden=0", + ) + .run(trimmed, Date.now(), search, taskId); } finally { con.close(); } diff --git a/src/utils.ts b/src/utils.ts index c2d2151..4036510 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -76,6 +76,7 @@ export const CONFIG_META = { { value: "build", name: "build" }, { value: "edit", name: "edit" }, { value: "yolo", name: "yolo" }, + { value: "auto", name: "auto" }, ], }, thought: { diff --git a/tests/bugfixes.test.ts b/tests/bugfixes.test.ts index 94d9cfe..2adee97 100644 --- a/tests/bugfixes.test.ts +++ b/tests/bugfixes.test.ts @@ -88,7 +88,7 @@ describe("Bug 3: thought configOption metadata matches Python", () => { it("uses lowercase mode option names", () => { const names = CONFIG_META.mode.options.map((o) => o.name); - expect(names).toEqual(["plan", "build", "edit", "yolo"]); + expect(names).toEqual(["plan", "build", "edit", "yolo", "auto"]); }); }); diff --git a/tests/tasks-index.test.ts b/tests/tasks-index.test.ts new file mode 100644 index 0000000..981a4c6 --- /dev/null +++ b/tests/tasks-index.test.ts @@ -0,0 +1,331 @@ +/** + * Regression tests for tasks-index session sync. + * + * History: tasks-index originally mirrored the Python sqlite3 API + * (con.run(sql, ...params) / con.get(sql, ...params)), but Node's + * node:sqlite DatabaseSync has NO run/get methods — only prepare(sql) returns a + * StatementSync with .run(...)/.get(...). The wrong API threw TypeError, which + * the try/catch swallowed to `false` and the quiet-by-default log() hid, so + * session sync silently no-op'd for every ACP session. These tests lock the + * correct prepared-statement call shape and the row-level semantics + * (INSERT OR IGNORE, title_overridden respect, 80-char truncation). + * + * CI stability: node:sqlite is experimental on Node 22.x (may require + * --experimental-sqlite, absent on stock ubuntu CI). Rather than depend on the + * real module or real ~/.zcode/ files, we inject an in-memory DatabaseSync that + * implements the exact subset of SQL the production code issues. This mirrors + * the quota.test.ts pattern of mocking environment dependencies for CI. + */ + +import { beforeEach, describe, expect, it, vi } from "vitest"; + +// Row shape stored by the in-memory fake. Mirrors the columns the production +// code writes; defaults match the real `tasks` table DDL. +interface FakeRow { + workspace_key: string; + workspace_path: string; + workspace_identity: string | null; + task_id: string; + title: string; + task_status: string; + provider: string; + mode: string; + model: string; + created_at: number; + updated_at: number; + unread_at: number | null; + pinned: number; + archived: number; + deleted: number; + title_overridden: number; + meta_json: string; + searchable_text: string; +} + +/** A single shared in-memory `tasks` table, keyed by PK (workspace_key, task_id). */ +let rows: Map; + +/** Build the StatementSync-shaped object returned by DatabaseSync.prepare(). */ +function makeStatement(sql: string) { + // INSERT OR IGNORE INTO tasks (...) VALUES (..., 'build', ..., NULL, 0, 0, 0, 0, ?, ?) + // The production SQL embeds literal 'build' for mode and NULL for unread_at + // plus 0 for pinned/archived/deleted/title_overridden. We bind the remaining + // placeholders positionally in the exact order tasks-index.ts emits them. + if (/^INSERT OR IGNORE/i.test(sql)) { + return { + // prettier-ignore + run(...params: unknown[]) { + // Bound positional order (see tasks-index upsertSessionTask): + // 0 workspaceKey, 1 workspaceKey(path), 2 taskId, 3 title, 4 status, + // 5 providerId, 6 model, 7 nowMs(created), 8 nowMs(updated), + // 9 metaJson, 10 title(searchable_text) + const [ + workspaceKey, + _path, + taskId, + title, + status, + providerId, + model, + nowMs, + _nowMs2, + metaJson, + searchable, + ] = params as [string, string, string, string, string, string, string, number, number, string, string]; + const pk = `${workspaceKey}\u0000${taskId}`; + if (rows.has(pk)) return { changes: 0 }; // OR IGNORE — PK exists, skip. + rows.set(pk, { + workspace_key: workspaceKey, + workspace_path: workspaceKey, + workspace_identity: null, + task_id: taskId, + title, + task_status: status, + provider: providerId, + mode: "build", // literal in SQL + model, + created_at: nowMs, + updated_at: nowMs, + unread_at: null, // literal NULL in SQL + pinned: 0, + archived: 0, + deleted: 0, + title_overridden: 0, // literal 0 in SQL + meta_json: metaJson, + searchable_text: searchable, + }); + return { changes: 1 }; + }, + }; + } + // SELECT title_overridden FROM tasks WHERE task_id=? + if (/^SELECT title_overridden/i.test(sql)) { + return { + get(taskId: string) { + for (const r of rows.values()) { + if (r.task_id === taskId) { + return { title_overridden: r.title_overridden } as const; + } + } + return undefined; + }, + }; + } + // UPDATE tasks SET title=?, updated_at=?, searchable_text=? + // WHERE task_id=? AND title_overridden=0 + if (/^UPDATE tasks SET title/i.test(sql)) { + return { + run(title: string, updatedAt: number, searchable: string, taskId: string) { + let changes = 0; + for (const r of rows.values()) { + if (r.task_id === taskId && r.title_overridden === 0) { + r.title = title; + r.updated_at = updatedAt; + r.searchable_text = searchable; + changes++; + } + } + return { changes }; + }, + }; + } + // UPDATE tasks SET updated_at=?, searchable_text=? WHERE task_id=? + // (title_overridden=1 branch: keep user title, still refresh searchable_text) + if (/^UPDATE tasks SET updated_at=\?, searchable_text/i.test(sql)) { + return { + 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; + changes++; + } + } + return { changes }; + }, + }; + } + throw new Error(`unexpected SQL in tasks-index test fake: ${sql}`); +} + +vi.mock("node:sqlite", () => { + class DatabaseSync { + constructor(_path: string, _options?: { timeout?: number }) { + // no-op: tests use the shared in-memory `rows` map. + } + prepare(sql: string) { + return makeStatement(sql); + } + close() { + /* no-op */ + } + } + return { DatabaseSync }; +}); + +// Fake fs: pretend tasks-index.sqlite exists so the existsSync gate passes, +// and return a minimal provider config so resolveProviderModel() resolves. +vi.mock("node:fs", async () => { + const actual = await vi.importActual("node:fs"); + return { + ...actual, + existsSync: () => true, + readFileSync: () => + JSON.stringify({ + provider: { + "builtin:bigmodel-coding-plan": { enabled: true, models: { "GLM-5.2": {} } }, + }, + }), + }; +}); + +import { upsertSessionTask, updateSessionTitle } from "../src/tasks-index.js"; + +describe("tasks-index session sync", () => { + beforeEach(() => { + rows = new Map(); + }); + + describe("upsertSessionTask", () => { + it("inserts a row with correct meta and default flags", async () => { + const ok = await upsertSessionTask({ + workspaceKey: "/ws", + taskId: "sess_1", + title: "first prompt", + traceId: "trace_1", + }); + expect(ok).toBe(true); + expect(rows.size).toBe(1); + const r = rows.get("/ws\u0000sess_1")!; + expect(r.task_id).toBe("sess_1"); + expect(r.title).toBe("first prompt"); + expect(r.mode).toBe("build"); + expect(r.title_overridden).toBe(0); + expect(r.pinned).toBe(0); + expect(r.unread_at).toBeNull(); + // App stores model as the full providerKey/modelId path so its UI + // groups/filters bridge rows identically to App-created ones. + expect(r.model).toBe("builtin:bigmodel-coding-plan/GLM-5.2"); + const meta = JSON.parse(r.meta_json); + expect(meta.taskId).toBe("sess_1"); + expect(meta.traceId).toBe("trace_1"); + expect(meta.mode).toBe("build"); + expect(meta.provider).toBe("glm"); + expect(meta.model).toBe("builtin:bigmodel-coding-plan/GLM-5.2"); + }); + + it("defaults traceId to taskId when not provided", async () => { + await upsertSessionTask({ + workspaceKey: "/ws", + taskId: "sess_2", + title: "", + }); + const meta = JSON.parse(rows.get("/ws\u0000sess_2")!.meta_json); + expect(meta.traceId).toBe("sess_2"); + }); + + it("is a no-op on the second insert (INSERT OR IGNORE)", async () => { + await upsertSessionTask({ + workspaceKey: "/ws", + taskId: "sess_3", + title: "original", + }); + // Simulate the App renaming + flagging override on the existing row. + const row = rows.get("/ws\u0000sess_3")!; + row.title = "app renamed"; + row.title_overridden = 1; + + const ok = await upsertSessionTask({ + workspaceKey: "/ws", + taskId: "sess_3", + title: "should not clobber", + }); + expect(ok).toBe(true); + expect(rows.size).toBe(1); // no new row + // The App-managed row must be untouched. + expect(rows.get("/ws\u0000sess_3")!.title).toBe("app renamed"); + expect(rows.get("/ws\u0000sess_3")!.title_overridden).toBe(1); + }); + }); + + describe("updateSessionTitle", () => { + it("updates title + searchable_text when title_overridden=0", async () => { + await upsertSessionTask({ + workspaceKey: "/ws", + taskId: "sess_4", + title: "", + }); + const ok = await updateSessionTitle("sess_4", "new title"); + expect(ok).toBe(true); + const r = rows.get("/ws\u0000sess_4")!; + expect(r.title).toBe("new title"); + expect(r.searchable_text).toBe("new title"); + expect(r.title_overridden).toBe(0); + }); + + it("uses the full prompt text as searchable_text when provided", async () => { + await upsertSessionTask({ + workspaceKey: "/ws", + taskId: "sess_search", + title: "", + }); + const fullPrompt = "explain how the auth module works in detail please"; + await updateSessionTitle("sess_search", fullPrompt.slice(0, 80), fullPrompt); + const r = rows.get("/ws\u0000sess_search")!; + // title truncated to 80, searchable_text keeps the full prompt + expect(r.title).toBe(fullPrompt.slice(0, 80)); + expect(r.searchable_text).toBe(fullPrompt); + }); + + it("caps searchable_text at the App's 200k char limit", async () => { + await upsertSessionTask({ + workspaceKey: "/ws", + taskId: "sess_cap", + title: "", + }); + const huge = "a".repeat(250_000); + await updateSessionTitle("sess_cap", "t", huge); + expect(rows.get("/ws\u0000sess_cap")!.searchable_text.length).toBe(200_000); + }); + + it("truncates the title to 80 characters", async () => { + await upsertSessionTask({ + workspaceKey: "/ws", + taskId: "sess_5", + title: "", + }); + const long = "x".repeat(120); + await updateSessionTitle("sess_5", long); + const r = rows.get("/ws\u0000sess_5")!; + expect(r.title.length).toBe(80); + // Without explicit searchableText, title is used as fallback → also 80. + expect(r.searchable_text.length).toBe(80); + }); + + it("respects user title override but still refreshes searchable_text", async () => { + await upsertSessionTask({ + workspaceKey: "/ws", + taskId: "sess_6", + title: "", + }); + const row = rows.get("/ws\u0000sess_6")!; + row.title = "user kept name"; + row.title_overridden = 1; + + const ok = await updateSessionTitle("sess_6", "auto title", "search body"); + expect(ok).toBe(true); + const r = rows.get("/ws\u0000sess_6")!; + // User's title wins. + expect(r.title).toBe("user kept name"); + // But searchable_text is still updated (not user-controlled). + expect(r.searchable_text).toBe("search body"); + }); + + it("returns false when the session row does not exist", async () => { + const ok = await updateSessionTitle("never_created", "title"); + expect(ok).toBe(false); + expect(rows.size).toBe(0); + }); + }); +});