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
7 changes: 7 additions & 0 deletions src/config/model-cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,10 @@
import type * as acp from "@agentclientprotocol/sdk";

import type { ZcodeReadResult } from "../backend/types.js";
import { modelContextWindow } from "./options.js";

Check warning on line 13 in src/config/model-cache.ts

View workflow job for this annotation

GitHub Actions / Build & Test (22.x)

Imports should be sorted alphabetically
import { log } from "../utils.js";

Check warning on line 14 in src/config/model-cache.ts

View workflow job for this annotation

GitHub Actions / Build & Test (22.x)

Imports should be sorted alphabetically
import type { ZcodeAcpServer } from "../server.js";
import { dispatchEvent } from "../handlers/dispatch.js";

Check warning on line 16 in src/config/model-cache.ts

View workflow job for this annotation

GitHub Actions / Build & Test (22.x)

Imports should be sorted alphabetically
import type { ProjectionDiffer } from "../translators/projection-differ.js";

/** Read the session's current model id, with a per-session cache. */
Expand All @@ -40,6 +40,13 @@
* 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,
Expand Down
8 changes: 7 additions & 1 deletion src/config/options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
*/

import { readFileSync } from "node:fs";
import type * as acp from "@agentclientprotocol/sdk";

Check warning on line 14 in src/config/options.ts

View workflow job for this annotation

GitHub Actions / Build & Test (22.x)

Expected 'all' syntax before 'single' syntax

import type { ZcodeReadResult } from "../backend/types.js";
import { CONFIG_DISPATCH, CONFIG_META, ZCODE_CREDS_PATH } from "../utils.js";
Expand Down Expand Up @@ -71,7 +71,13 @@
}
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),
})),
};
}

Expand Down
23 changes: 12 additions & 11 deletions src/handlers/extensions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Comment on lines +56 to 63

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The ACP specification for session/fork expects the response to contain sessionId for the newly created session. Returning only forkedSessionId from ZCode 3.3.0 will break compatibility with standard ACP clients (such as the Zed editor) that look for sessionId. We should map forkedSessionId to sessionId in the returned object to ensure compatibility with both the ACP client and the ZCode backend.

  const result = (resp.result ?? {}) as { forkedSessionId?: string; sessionId?: string };
  const forkedSessionId = result.forkedSessionId ?? result.sessionId;
  if (forkedSessionId) {
    server.sessionMap.set(forkedSessionId, forkedSessionId);
  }
  log(`session/fork → ${forkedSessionId ?? "?"}`);
  return {
    ...result,
    sessionId: forkedSessionId,
  };

}

Expand Down Expand Up @@ -206,16 +210,13 @@ export async function setThoughtLevel(
params: ExtensionParams,
): Promise<Result> {
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<string, unknown> = { 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;
Expand Down
12 changes: 10 additions & 2 deletions src/handlers/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Comment on lines +361 to +366

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Splitting and mapping the entire prompt text by newlines to find the first non-empty line is highly inefficient and can cause performance bottlenecks or out-of-memory issues when handling very large prompts (e.g., pasted code or logs). Using a regular expression to match the first non-empty line is much more efficient as it avoids scanning or allocating arrays for the entire string.

      const firstLine = text.match(/^[ \t]*(\S[^\r\n]*)/m)?.[1];
      const title = (firstLine ?? text).trim().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,
Expand Down
6 changes: 4 additions & 2 deletions src/handlers/slash.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ?? "?"}`);
Comment on lines +88 to +91

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Align the slash command handler to use the mapped sessionId from the fork response, falling back to forkedSessionId if necessary.

        const result = (await fork(server, { sessionId: acpSid })) as {
          forkedSessionId?: string;
          sessionId?: string;
        };
        return ok(`✓ forked new session: ${result.sessionId ?? result.forkedSessionId ?? "?"}`);

}
case "rewind": {
await rewind(server, { sessionId: acpSid });
Expand Down
151 changes: 90 additions & 61 deletions src/tasks-index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<DatabaseSyncCtor | null> {
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<string, { enabled?: boolean; models?: Record<string, unknown> }>;
};
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 {
Expand All @@ -91,8 +97,8 @@ export async function upsertSessionTask(opts: {
status?: string;
}): Promise<boolean> {
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;
Expand All @@ -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();
}
Expand All @@ -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<boolean> {
export async function updateSessionTitle(
taskId: string,
title: string,
searchableText?: string,
): Promise<boolean> {
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();
}
Expand Down
1 change: 1 addition & 0 deletions src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
2 changes: 1 addition & 1 deletion tests/bugfixes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"]);
});
});

Expand Down
Loading
Loading