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
24 changes: 19 additions & 5 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,10 +41,12 @@ resume / fork`. The prompt capabilities (image/audio/embeddedContext) and
never supplies credentials. Omitting the `type` field defaults to `"agent"`,
which the ACP registry's auth-check accepts as "agent self-handles auth".

`initialize` does **not** spawn the backend. The backend is lazily created on
the first `session/new` (via `ensureBackend()`), so the handshake succeeds
even in an environment without `~/.zcode/v2/config.json` (e.g. the registry
CI runs `initialize` with an isolated `HOME`).
`initialize` does **not** spawn the backend, and neither does `session/new`:
the backend is lazily created on the first backend RPC — for a fresh session
that is the first `session/create` at its first use (prompt / config change /
extension method). This keeps the handshake succeeding even in an environment
without `~/.zcode/v2/config.json` (e.g. the registry CI runs `initialize` with
an isolated `HOME`).

Client capabilities advertised at `initialize` are recorded on the server
(`clientCapabilities`) and drive later behaviour: `supportsElicitationForm()`
Expand All @@ -56,13 +58,25 @@ terminal UI.
### 1. Session lifecycle

```
session/new → session/create → register EventListener
session/new → placeholder id (backend session NOT created yet)
|
first use: prompt / set_config_option / extension method
|
session/create → register EventListener
|
prompt request → session/send → EventTranslator translates → dispatchEvent
| |
end_turn / cancelled session/update notification
```

Sessions are materialized lazily (`ensureRealSession`): an editor startup that
never sends a message leaves no empty session in the backend or the App's task
index. The placeholder → backend-session mapping is persisted to
`~/.zcode/v2/acp-lazy-sessions.json` (`src/lazy-sessions.ts`), so a `session/
resume` / `session/load` of a placeholder from a previous bridge lifetime still
resolves: with a recorded backend id the real session is resumed, without one a
fresh (empty) session is materialized — never "Session not found".

### 2. Event stream subscription

```
Expand Down
12 changes: 11 additions & 1 deletion docs/PROTOCOL.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,10 @@ Messages are classified by the presence of `id` and `method`:

### `session/create`

Create a new session.
Create a new session. Note: the bridge defers this call until a session's
first use — ACP `session/new` returns a local placeholder id and materializes
the backend session (this RPC) on the first prompt / config change / extension
method, so an editor startup that never sends a message leaves no session.

**Request:**
```json
Expand Down Expand Up @@ -131,6 +134,13 @@ List all sessions.

Resume an existing session.

The sessionId may be a lazy `session/new` placeholder (the editor persists it
and resumes it after a bridge restart). The bridge resolves it before the
backend call: an in-memory or persisted (`acp-lazy-sessions.json`) mapping is
followed to the real backend session — resuming it, or materializing a fresh
empty one if the placeholder was never used. Real ids from `session/list` pass
through unchanged.

**Request:**
```json
{
Expand Down
66 changes: 48 additions & 18 deletions src/backend/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ interface PendingRequest {

/** A server→client request that we must reply to. */
export interface ServerRequest {
id: number;
id: number | string;
method: string;
params:
ZcodeInteractionPermissionParams | ZcodeInteractionUserInputParams | Record<string, unknown>;
Expand Down Expand Up @@ -161,6 +161,18 @@ export class ZcodeBackend {
// id + method: our pending response wins the race; else it's a server→client request.
if (this.pending.has(id)) {
this.resolvePending(id, msg as unknown as ZcodeResponse);
} else if (method === "session/requestRuntimePreferences") {
// Newer app-servers block `session/create` until this handshake is
// answered. Reply with defaults — no editor interaction needed.
// Keep askUserQuestionAutoResolutionEnabled false so AskUserQuestion
// still flows through the bridge's interaction path instead of being
// auto-resolved server-side. Without this reply, create hangs.
log(`backend: auto-replying ${method} (id=${String(id)}) with default preferences`);
this.sendReply(id, {
nativeSearchEnhancementsEnabled: false,
memoryEnabled: false,
askUserQuestionAutoResolutionEnabled: false,
});
} else {
this.serverRequests.push({
id,
Expand All @@ -174,23 +186,41 @@ export class ZcodeBackend {
// Notification.
if (method === "session/event") {
const ev = (msg.params ?? {}) as unknown as ZcodeEvent;
const sid = ev.sessionId;
const set = sid ? this.listeners.get(sid) : undefined;
if (set) {
// Iterate a snapshot so a listener that (un)registers during dispatch
// doesn't mutate the set under us.
for (const listener of [...set]) {
try {
listener.handleEvent(ev);
} catch (e) {
warn(
`backend: listener.handleEvent threw: ${e instanceof Error ? e.message : String(e)}`,
);
}
}
this.dispatchEvent(ev);
} else if (method === "state.updated") {
// Session settings changed (model/mode/thoughtLevel switch, incl.
// mid-turn). The params carry the authoritative full settings patch:
// { patch: {mode, model, thoughtLevel, …}, reason, revision, sessionId }
// Wrap as a ZcodeEvent so it flows through the same listener pipeline.
const params = (msg.params ?? {}) as Record<string, unknown>;
const ev: ZcodeEvent = {
sessionId: String(params.sessionId ?? ""),
seq: 0,
type: "state.updated",
payload: params,
};
this.dispatchEvent(ev);
}
// Other notifications are currently ignored (process/resourceSample, …).
}
}

/** Deliver a ZcodeEvent to every listener registered for its session. */
private dispatchEvent(ev: ZcodeEvent): void {
const sid = ev.sessionId;
const set = sid ? this.listeners.get(sid) : undefined;
if (set) {
// Iterate a snapshot so a listener that (un)registers during dispatch
// doesn't mutate the set under us.
for (const listener of [...set]) {
try {
listener.handleEvent(ev);
} catch (e) {
warn(
`backend: listener.handleEvent threw: ${e instanceof Error ? e.message : String(e)}`,
);
}
}
// Other notifications are currently ignored (state.updated, etc.).
}
}

Expand Down Expand Up @@ -251,7 +281,7 @@ export class ZcodeBackend {
}

/** Reply to a zcode server→client request with a result (id + result). */
sendReply(id: number, result: unknown): void {
sendReply(id: number | string, result: unknown): void {
const stdin = this.proc.stdin;
if (!stdin || stdin.destroyed) {
warn("backend: sendReply dropped (stdin closed)");
Expand All @@ -264,7 +294,7 @@ export class ZcodeBackend {
}

/** Reply to a zcode server→client request with an error. */
sendError(id: number, code: number, message: string): void {
sendError(id: number | string, code: number, message: string): void {
const stdin = this.proc.stdin;
if (!stdin || stdin.destroyed) {
warn("backend: sendError dropped (stdin closed)");
Expand Down
4 changes: 4 additions & 0 deletions src/backend/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,10 @@ export type ZcodeEventType =
| "turn.completed"
| "turn.failed"
| "session.updated"
// Backend pushes this notification (method: `state.updated`) whenever session
// settings change (model/mode/thoughtLevel switch, incl. mid-turn). The bridge
// wraps it as a ZcodeEvent so it flows through the same listener pipeline.
| "state.updated"
// app-server 0.15.2+: steer lifecycle + terminal turn. Not yet translated by
// the bridge; tracked in docs/BACKLOG.md. Listed here so unknown-type guards
// stay accurate.
Expand Down
92 changes: 55 additions & 37 deletions src/config/options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,18 +48,25 @@ export interface ModelRef {
}

/**
* Collect models from ALL enabled providers in config.json.
* Collect models from config.json for the dropdown.
*
* Only providers with `enabled: true` are listed — unenabled providers
* (including builtins without an `enabled` flag) are excluded so the dropdown
* reflects exactly what the user has activated in the ZCode desktop app.
* Builtin providers (id prefix `builtin:`) must be `enabled: true` — they
* reflect the plans the user activated in the ZCode desktop app. Custom
* (third-party) providers are included UNLESS explicitly `enabled: false`:
* the newer CLI leaves the flag unset on active third-party providers, so
* treating "absent" as enabled keeps them in the dropdown while still
* honoring an explicit disable.
*/
export function loadAllModels(): ModelRef[] {
try {
const cfg = readConfig() as ConfigShape;
const out: ModelRef[] = [];
for (const [pid, p] of Object.entries(cfg.provider ?? {})) {
if (!p?.enabled) continue;
if (isBuiltinProvider(pid)) {
if (p?.enabled !== true) continue;
} else if (p?.enabled === false) {
continue;
}
const providerName = p.name ?? pid;
for (const modelId of Object.keys(p.models ?? {})) {
out.push({ providerId: pid, providerName, modelId });
Expand Down Expand Up @@ -107,7 +114,7 @@ export function modelContextWindow(providerId: string, modelId: string): number
}

/** Builtin providerIds are prefixed with `builtin:` (e.g. `builtin:bigmodel`). */
function isBuiltinProvider(providerId: string): boolean {
export function isBuiltinProvider(providerId: string): boolean {
return providerId.startsWith("builtin:");
}

Expand Down Expand Up @@ -144,19 +151,23 @@ export function parseModelValue(value: string): { providerId: string; modelId: s
return { providerId: value.slice(0, idx), modelId: value.slice(idx + 1) };
}

/** Build the ACP SessionModeState ({currentModeId, availableModes}). */
/** Build the ACP SessionModeState ({currentModeId, availableModes}).
* zcodeSid null = pending session (session/new not yet materialized) — skip
* the backend read and return defaults. */
export async function buildModes(
server: ZcodeAcpServer,
zcodeSid: string,
zcodeSid: string | null,
): Promise<acp.SessionModeState> {
let currentMode = "yolo";
try {
const read = await sessionRead(server, zcodeSid);
const settings = (read.settings ?? {}) as Record<string, unknown>;
const modeSet = (settings.mode as Record<string, unknown>) ?? {};
currentMode = (modeSet.current as string) ?? currentMode;
} catch {
// keep default
if (zcodeSid !== null) {
try {
const read = await sessionRead(server, zcodeSid);
const settings = (read.settings ?? {}) as Record<string, unknown>;
const modeSet = (settings.mode as Record<string, unknown>) ?? {};
currentMode = (modeSet.current as string) ?? currentMode;
} catch {
// keep default
}
}
return {
currentModeId: currentMode,
Expand All @@ -170,36 +181,41 @@ export async function buildModes(
};
}

/** Build the ACP configOptions array (3 items: model/mode/thought). */
/** Build the ACP configOptions array (3 items: model/mode/thought).
* zcodeSid null = pending session — skip the backend read and use defaults;
* mode defaults to "yolo" (the mode session/create hardcodes) so the dropdown
* matches the mode indicator for a fresh session. */
export async function buildConfigOptions(
server: ZcodeAcpServer,
zcodeSid: string,
zcodeSid: string | null,
): Promise<acp.SessionConfigOption[]> {
let currentProviderId = "";
let currentModelId = "GLM-5.2";
let currentMode = "build";
let currentMode = zcodeSid === null ? "yolo" : "build";
let currentThought = "high";
let thoughtOptions: Array<{ value: string; name: string }> | null = null;

try {
const read = await sessionRead(server, zcodeSid);
const settings = (read.settings ?? {}) as Record<string, unknown>;
const modeSet = (settings.mode as Record<string, unknown>) ?? {};
currentMode = (modeSet.current as string) ?? currentMode;
const modelSet = (settings.model as Record<string, unknown>) ?? {};
// settings.model.current is { providerId, modelId, variant? } — read BOTH so
// we can disambiguate same-named models across providers.
const cur = (modelSet.current as { providerId?: string; modelId?: string }) ?? {};
if (cur.providerId) currentProviderId = cur.providerId;
if (cur.modelId) currentModelId = cur.modelId;
const tlSet = (settings.thoughtLevel as Record<string, unknown>) ?? {};
currentThought = (tlSet.current as string) ?? currentThought;
const tlAvail = (tlSet.available as Array<Record<string, string>>) ?? [];
if (tlAvail.length > 0) {
thoughtOptions = tlAvail.map((a) => ({ value: a.value, name: a.label ?? a.value }));
if (zcodeSid !== null) {
try {
const read = await sessionRead(server, zcodeSid);
const settings = (read.settings ?? {}) as Record<string, unknown>;
const modeSet = (settings.mode as Record<string, unknown>) ?? {};
currentMode = (modeSet.current as string) ?? currentMode;
const modelSet = (settings.model as Record<string, unknown>) ?? {};
// settings.model.current is { providerId, modelId, variant? } — read BOTH so
// we can disambiguate same-named models across providers.
const cur = (modelSet.current as { providerId?: string; modelId?: string }) ?? {};
if (cur.providerId) currentProviderId = cur.providerId;
if (cur.modelId) currentModelId = cur.modelId;
const tlSet = (settings.thoughtLevel as Record<string, unknown>) ?? {};
currentThought = (tlSet.current as string) ?? currentThought;
const tlAvail = (tlSet.available as Array<Record<string, string>>) ?? [];
if (tlAvail.length > 0) {
thoughtOptions = tlAvail.map((a) => ({ value: a.value, name: a.label ?? a.value }));
}
} catch {
// keep defaults
}
} catch {
// keep defaults
}

// currentValue encodes provider+model so the switch handler can locate the
Expand Down Expand Up @@ -330,7 +346,9 @@ export async function emitConfigOptionUpdate(
size,
});
} catch (e) {
log(`options: usage_update after model switch failed (${e instanceof Error ? e.message : String(e)})`);
log(
`options: usage_update after model switch failed (${e instanceof Error ? e.message : String(e)})`,
);
}
}
return options;
Expand Down
Loading
Loading