diff --git a/.env.example b/.env.example index 80f60b55b8..0881c1b8e4 100644 --- a/.env.example +++ b/.env.example @@ -735,6 +735,21 @@ NEXT_PUBLIC_ENABLE_SOCKS5_PROXY=true # CLI_AUGGIE_BIN=auggie # AUGGIE_BIN=auggie +# ── ZCode (Z.ai GLM coding-plan CLI) local provider ── +# The local "zcode" provider talks to the authenticated ZCode app-server over a +# custom framed stdio protocol. Overrides below tune that stdio lifecycle. +# ZCODE_BIN=zcode +# ZCODE_ARGS=["--some-flag"] +# ZCODE_CWD= +# ZCODE_PROVIDER_ID=builtin:zai-coding-plan +# ZCODE_SERVER_RUNTIME_ROOT=~/.zcode/server +# ZCODE_SERVER_NODE=~/.zcode/server/node +# ZCODE_SERVER_ENTRY=~/.zcode/server/zcode-server.cjs +# ZCODE_STARTUP_TIMEOUT_MS=10000 +# ZCODE_RPC_TIMEOUT_MS=30000 +# ZCODE_TURN_TIMEOUT_MS=120000 +# ZCODE_POLL_INTERVAL_MS=250 + # Override the Hermes Agent home directory (where OmniRoute reads/writes the # Hermes CLI config). Matches the env var the Hermes PowerShell installer sets # on Windows (%LOCALAPPDATA%\hermes); defaults to ~/.hermes when unset. diff --git a/docs/reference/ENVIRONMENT.md b/docs/reference/ENVIRONMENT.md index 86bd169eb8..8fc5a9666a 100644 --- a/docs/reference/ENVIRONMENT.md +++ b/docs/reference/ENVIRONMENT.md @@ -400,6 +400,17 @@ Controls how OmniRoute discovers and launches CLI sidecars (Claude Code, Codex, | `DEVIN_BRIDGE_SUBAGENT_MODEL` | `DEVIN_BRIDGE_MODEL` | `docker/devin-bridge/compose.yml` | Isolated bridge alias used for Claude Code subagents. | | `AUGGIE_BIN` | `auggie` | `open-sse/executors/auggie.ts` | Absolute-path override for the Augment (Auggie) CLI binary used by the local `auggie` provider. Falls back to `CLI_AUGGIE_BIN`, then a PATH lookup. | | `CLI_AUGGIE_BIN` | `auggie` | `open-sse/executors/auggie.ts` | Alias override for the Augment (Auggie) CLI binary path (checked after `AUGGIE_BIN`). | +| `ZCODE_BIN` | `zcode` | `open-sse/executors/zcode.ts` | Binary used for the local `zcode` provider's stdio client. Falls back to `zcode` on PATH. | +| `ZCODE_ARGS` | — | `open-sse/executors/zcode.ts` | JSON array (≤16 strings) of extra arguments passed to the `zcode` binary when launched via `cliTools`. | +| `ZCODE_CWD` | `process.cwd()` | `open-sse/executors/zcode.ts` | Working directory for the ZCode app-server subprocess. | +| `ZCODE_PROVIDER_ID` | `builtin:zai-coding-plan` | `open-sse/executors/zcode.ts` | Override for the provider id sent to the app-server. | +| `ZCODE_SERVER_RUNTIME_ROOT` | `~/.zcode/server` | `open-sse/executors/zcode.ts` | Root of the ZCode app-server runtime (where the bundled `node` and `zcode-server.cjs` live). | +| `ZCODE_SERVER_NODE` | `/node` | `open-sse/executors/zcode.ts` | Node executable used to host the ZCode app-server. | +| `ZCODE_SERVER_ENTRY` | `/zcode-server.cjs` | `open-sse/executors/zcode.ts` | App-server entry script used to host the ZCode server. | +| `ZCODE_STARTUP_TIMEOUT_MS` | `10000` | `open-sse/executors/zcode.ts` | Startup timeout (ms) before a ZCode app-server launch is considered failed. | +| `ZCODE_RPC_TIMEOUT_MS` | `30000` | `open-sse/executors/zcode.ts` | Per-request RPC timeout (ms) for a ZCode app-server call. | +| `ZCODE_TURN_TIMEOUT_MS` | `120000` | `open-sse/executors/zcode.ts` | Maximum duration (ms) of one ZCode turn before the supervisor times it out. | +| `ZCODE_POLL_INTERVAL_MS` | `250` | `open-sse/executors/zcode.ts` | Polling interval (ms) for ZCode turn completion. | | `HERMES_HOME` | `~/.hermes` | `src/lib/cli-helper/config-generator/hermesHome.ts` | Hermes Agent home directory where OmniRoute reads/writes the Hermes CLI config. Matches the env var the Hermes PowerShell installer sets on Windows (`%LOCALAPPDATA%\hermes`). | ### CLI Profile Auto-Sync diff --git a/open-sse/config/providers/index.ts b/open-sse/config/providers/index.ts index 6206719384..ff09ad02b6 100644 --- a/open-sse/config/providers/index.ts +++ b/open-sse/config/providers/index.ts @@ -154,6 +154,7 @@ import { command_codeProvider } from "./registry/command-code/index.ts"; import { novitaProvider } from "./registry/novita/index.ts"; import { regoloProvider } from "./registry/regolo/index.ts"; import { devin_desktopProvider } from "./registry/devin-desktop/index.ts"; +import { zcodeProvider } from "./registry/zcode/index.ts"; import { zed_hostedProvider } from "./registry/zed-hosted/index.ts"; import { nanogptProvider } from "./registry/nanogpt/index.ts"; import { scalewayProvider } from "./registry/scaleway/index.ts"; @@ -412,6 +413,7 @@ export const REGISTRY: Record = { novita: novitaProvider, regolo: regoloProvider, "devin-desktop": devin_desktopProvider, + zcode: zcodeProvider, "zed-hosted": zed_hostedProvider, nanogpt: nanogptProvider, scaleway: scalewayProvider, diff --git a/open-sse/config/providers/registry/zcode/index.ts b/open-sse/config/providers/registry/zcode/index.ts new file mode 100644 index 0000000000..cd2a4eece6 --- /dev/null +++ b/open-sse/config/providers/registry/zcode/index.ts @@ -0,0 +1,18 @@ +import type { RegistryEntry } from "../../shared.ts"; +import { GLM_SHARED_MODELS } from "../../../glmProvider.ts"; + +/** + * Local ZCode app-server backend. Authentication remains in the user's local + * ZCode profile (`builtin:zai-coding-plan`); OmniRoute does not receive or + * persist the Z.ai credential. + */ +export const zcodeProvider: RegistryEntry = { + id: "zcode", + alias: "zc", + format: "openai", + executor: "zcode", + baseUrl: "zcode://app-server/stdio", + authType: "none", + authHeader: "none", + models: [...GLM_SHARED_MODELS], +}; diff --git a/open-sse/executors/index.ts b/open-sse/executors/index.ts index 3c230ee91f..87f2c93e8d 100644 --- a/open-sse/executors/index.ts +++ b/open-sse/executors/index.ts @@ -33,6 +33,7 @@ import { NlpCloudExecutor } from "./nlpcloud.ts"; import { DevinDesktopExecutor } from "./devin-desktop.ts"; import { ZedHostedExecutor } from "./zed-hosted.ts"; import { DevinCliExecutor } from "./devin-cli.ts"; +import { ZcodeExecutor } from "./zcode.ts"; import { DevinCliAgenticExecutor } from "./devin-cli-agentic.ts"; import { AuggieExecutor } from "./auggie.ts"; import { DeepSeekWebExecutor } from "./deepseek-web.ts"; @@ -134,6 +135,8 @@ const executors = { "devin-desktop": new DevinDesktopExecutor(), "zed-hosted": new ZedHostedExecutor(), "devin-cli": new DevinCliExecutor(), + zcode: new ZcodeExecutor(), + zc: new ZcodeExecutor(), // Alias "devin-cli-agentic": new DevinCliAgenticExecutor(), devin: new DevinCliExecutor(), // Alias "deepseek-web": new DeepSeekWebWithAutoRefreshExecutor(), diff --git a/open-sse/executors/zcode.ts b/open-sse/executors/zcode.ts new file mode 100644 index 0000000000..0841b4daa8 --- /dev/null +++ b/open-sse/executors/zcode.ts @@ -0,0 +1,375 @@ +import { randomUUID } from "node:crypto"; +import { existsSync } from "node:fs"; +import { homedir } from "node:os"; +import { join, resolve } from "node:path"; +import { GLM_SHARED_MODELS } from "../config/glmProvider.ts"; +import { BaseExecutor, type ExecuteInput, type ExecutorExecuteResult, type ProviderCredentials } from "./base.ts"; +import { ZcodeAppServerClient, type ZcodeClientLike } from "./zcodeProtocol.ts"; +import { buildErrorBody, errorResponse, sanitizeErrorMessage } from "../utils/error.ts"; + +const ZCODE_URL = "zcode://app-server/stdio"; +const DEFAULT_PROVIDER_ID = "builtin:zai-coding-plan"; +const DEFAULT_TURN_TIMEOUT_MS = 120_000; +const DEFAULT_POLL_INTERVAL_MS = 250; +const TERMINAL_STATUSES = new Set(["completed", "idle", "paused", "error"]); +const ZCODE_MODEL_ALLOWLIST = new Set(GLM_SHARED_MODELS.map((model) => model.id)); +const DEFAULT_ZCODE_MODEL = GLM_SHARED_MODELS[0]?.id || "glm-5.2"; + +type JsonRecord = Record; +type OpenAIMsg = { role?: string; content?: unknown }; + +type ZcodeCommand = { command: string; args: string[] }; +type ZcodeModelResolution = { ok: true; model: string } | { ok: false; error: string }; + +export interface ZcodeExecutorOptions { + command?: string; + args?: string[]; + cwd?: string; + providerId?: string; + startupTimeoutMs?: number; + requestTimeoutMs?: number; + turnTimeoutMs?: number; + pollIntervalMs?: number; + clientFactory?: () => ZcodeClientLike; +} + +function asRecord(value: unknown): JsonRecord { + return value && typeof value === "object" && !Array.isArray(value) ? value as JsonRecord : {}; +} + +function textFromContent(content: unknown): string { + if (typeof content === "string") return content; + if (!Array.isArray(content)) return ""; + return content + .map((part) => { + if (typeof part === "string") return part; + const record = asRecord(part); + if (record.type === "text" || record.type === "input_text" || record.type === "output_text") { + return typeof record.text === "string" ? record.text : ""; + } + return ""; + }) + .join(""); +} + +/** Convert an OpenAI conversation into one explicit ZCode coding turn. */ +export function buildZcodePrompt(messages: OpenAIMsg[]): string { + const parts: string[] = []; + for (const message of messages) { + const text = textFromContent(message.content).trim(); + if (!text) continue; + const role = String(message.role || "user"); + const label = role === "system" ? "System" : role === "assistant" ? "Assistant" : "User"; + parts.push(`[${label}]\n${text}`); + } + return parts.join("\n\n") || "(empty)"; +} + +export function resolveZcodeModel(model: unknown): ZcodeModelResolution { + const requested = typeof model === "string" ? model.trim() : ""; + if (!requested) return { ok: true, model: DEFAULT_ZCODE_MODEL }; + if (requested.startsWith("-")) { + return { ok: false, error: `Invalid ZCode model \"${requested}\": model must not start with \"-\".` }; + } + const normalized = requested.startsWith("zcode/") + ? requested.slice("zcode/".length) + : requested; + if (!ZCODE_MODEL_ALLOWLIST.has(normalized)) { + return { + ok: false, + error: `Unknown ZCode model \"${requested}\". Supported models: ${[...ZCODE_MODEL_ALLOWLIST].join(", ")}.`, + }; + } + return { ok: true, model: normalized }; +} + +function parseArgs(raw: string | undefined): string[] { + if (!raw) return ["app-server"]; + const parsed: unknown = JSON.parse(raw); + if (!Array.isArray(parsed) || parsed.length > 16 || !parsed.every((arg) => typeof arg === "string" && arg.length <= 4096)) { + throw new Error("ZCODE_ARGS must be a JSON array of at most 16 strings"); + } + return parsed as string[]; +} + +function defaultCommand(): ZcodeCommand { + const runtimeRoot = process.env.ZCODE_SERVER_RUNTIME_ROOT || join(homedir(), ".zcode", "server"); + const serverNode = process.env.ZCODE_SERVER_NODE || join(runtimeRoot, "node"); + const serverEntry = process.env.ZCODE_SERVER_ENTRY || join(runtimeRoot, "zcode-server.cjs"); + if (existsSync(serverNode) && existsSync(serverEntry)) { + return { command: serverNode, args: [serverEntry] }; + } + return { command: process.env.ZCODE_BIN || "zcode", args: parseArgs(process.env.ZCODE_ARGS) }; +} + +function extractSessionId(value: unknown): string | undefined { + const root = asRecord(value); + const nested = asRecord(root.session); + const sessionId = nested.sessionId ?? root.sessionId; + return typeof sessionId === "string" && sessionId.trim() ? sessionId : undefined; +} + +function extractStatus(value: unknown): string | undefined { + const root = asRecord(value); + const nested = asRecord(root.session); + const status = nested.status ?? root.status; + return typeof status === "string" ? status : undefined; +} + +function extractTextFromMessage(value: unknown): { role?: string; text: string } { + const message = asRecord(value); + const info = asRecord(message.info); + const role = typeof info.role === "string" ? info.role : typeof message.role === "string" ? message.role : undefined; + const parts = Array.isArray(message.parts) ? message.parts : []; + const text = parts + .map((part) => { + const record = asRecord(part); + if (record.type === "text" && typeof record.text === "string") return record.text; + return ""; + }) + .join(""); + return { role, text }; +} + +function extractAssistantText(value: unknown): string { + const root = asRecord(value); + const messages = Array.isArray(root.messages) ? root.messages : []; + for (let i = messages.length - 1; i >= 0; i -= 1) { + const message = extractTextFromMessage(messages[i]); + if (message.text && (!message.role || message.role === "assistant")) return message.text; + } + const nestedMessage = extractTextFromMessage(root.message); + if (nestedMessage.text) return nestedMessage.text; + for (const candidate of [root.content, root.text, root.output_text]) { + if (typeof candidate === "string" && candidate.trim()) return candidate; + } + return ""; +} + +function extractErrorMessage(value: unknown): string { + const root = asRecord(value); + const nested = asRecord(root.error); + for (const candidate of [nested.message, root.message, root.reason]) { + if (typeof candidate === "string" && candidate.trim()) return candidate; + } + return "ZCode app-server returned an error"; +} + +function makeWorkspace(cwd: string): JsonRecord { + return { workspacePath: cwd, workspaceIdentity: cwd }; +} + +function abortError(): Error { + return new Error("ZCode request aborted"); +} + +async function raceAbort(promise: Promise, signal?: AbortSignal | null): Promise { + if (!signal) return promise; + if (signal.aborted) { + promise.catch(() => undefined); + throw abortError(); + } + let onAbort: (() => void) | undefined; + const aborted = new Promise((_, reject) => { + onAbort = () => reject(abortError()); + signal.addEventListener("abort", onAbort, { once: true }); + }); + promise.catch(() => undefined); + try { + return await Promise.race([promise, aborted]); + } finally { + if (onAbort) signal.removeEventListener("abort", onAbort); + } +} + +async function delay(ms: number, signal?: AbortSignal | null): Promise { + if (ms <= 0) { + if (signal?.aborted) throw abortError(); + return; + } + await raceAbort(new Promise((resolveDelay) => { + const timer = setTimeout(resolveDelay, ms); + timer.unref?.(); + }), signal); +} + +function estimateTokens(text: string): number { + return Math.max(1, Math.ceil(text.length / 4)); +} + +function completionResponse(model: string, prompt: string, content: string): Response { + const promptTokens = estimateTokens(prompt); + const completionTokens = estimateTokens(content); + return new Response(JSON.stringify({ + id: `chatcmpl-zcode-${Date.now()}`, + object: "chat.completion", + created: Math.floor(Date.now() / 1000), + model, + choices: [{ index: 0, message: { role: "assistant", content }, finish_reason: "stop" }], + usage: { + prompt_tokens: promptTokens, + completion_tokens: completionTokens, + total_tokens: promptTokens + completionTokens, + estimated: true, + }, + }), { status: 200, headers: { "Content-Type": "application/json" } }); +} + +function sseResponse(model: string, content: string): Response { + const id = `chatcmpl-zcode-${Date.now()}`; + const created = Math.floor(Date.now() / 1000); + const chunks = [ + { id, object: "chat.completion.chunk", created, model, choices: [{ index: 0, delta: { role: "assistant", content: "" }, finish_reason: null }] }, + { id, object: "chat.completion.chunk", created, model, choices: [{ index: 0, delta: { content }, finish_reason: null }] }, + { id, object: "chat.completion.chunk", created, model, choices: [{ index: 0, delta: {}, finish_reason: "stop" }] }, + ]; + const body = `${chunks.map((chunk) => `data: ${JSON.stringify(chunk)}\n\n`).join("")}data: [DONE]\n\n`; + return new Response(body, { + status: 200, + headers: { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", Connection: "keep-alive" }, + }); +} + +function sseErrorResponse(status: number, message: string): Response { + const body = `data: ${JSON.stringify(buildErrorBody(status, message))}\n\ndata: [DONE]\n\n`; + return new Response(body, { + status: 200, + headers: { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", Connection: "keep-alive" }, + }); +} + +export class ZcodeExecutor extends BaseExecutor { + private readonly options: ZcodeExecutorOptions; + + constructor(options: ZcodeExecutorOptions = {}) { + super("zcode", { id: "zcode", baseUrl: ZCODE_URL, format: "openai" }); + this.options = options; + } + + buildUrl(): string { + return ZCODE_URL; + } + + transformRequest(): null { + return null; + } + + async execute(input: ExecuteInput): Promise { + const resolution = resolveZcodeModel(input.model); + if (!resolution.ok) { + const message = "error" in resolution ? resolution.error : "Invalid ZCode model"; + return input.stream ? sseErrorResponse(400, message) : errorResponse(400, message); + } + + const body = asRecord(input.body); + const messages = Array.isArray(body.messages) ? body.messages as OpenAIMsg[] : []; + const prompt = buildZcodePrompt(messages); + input.log?.info?.("ZCODE", `local app-server turn started model=${resolution.model}`); + + try { + const content = await this.runTurn(resolution.model, prompt, input.signal, input.log); + const response = input.stream + ? sseResponse(resolution.model, content) + : completionResponse(resolution.model, prompt, content); + return { + response, + url: ZCODE_URL, + headers: {}, + transformedBody: { model: resolution.model, promptLength: prompt.length, buffered: true }, + transport: "local-zcode-app-server", + }; + } catch (error) { + const message = sanitizeErrorMessage(error instanceof Error ? error.message : String(error)); + input.log?.warn?.("ZCODE", message); + return input.stream ? sseErrorResponse(502, message) : errorResponse(502, message); + } + } + + private createClient(): ZcodeClientLike { + if (this.options.clientFactory) return this.options.clientFactory(); + const command = this.options.command || process.env.ZCODE_SERVER_NODE || defaultCommand().command; + const args = this.options.args || (process.env.ZCODE_SERVER_NODE + ? [process.env.ZCODE_SERVER_ENTRY || join(process.env.ZCODE_SERVER_RUNTIME_ROOT || join(homedir(), ".zcode", "server"), "zcode-server.cjs")] + : defaultCommand().args); + return new ZcodeAppServerClient({ + command, + args, + cwd: this.options.cwd || process.env.ZCODE_CWD || process.cwd(), + startupTimeoutMs: this.options.startupTimeoutMs ?? Number(process.env.ZCODE_STARTUP_TIMEOUT_MS || 10_000), + requestTimeoutMs: this.options.requestTimeoutMs ?? Number(process.env.ZCODE_RPC_TIMEOUT_MS || 30_000), + }); + } + + private async runTurn( + model: string, + prompt: string, + signal: AbortSignal | null | undefined, + log: ExecuteInput["log"] + ): Promise { + const client = this.createClient(); + const cwd = resolve(this.options.cwd || process.env.ZCODE_CWD || process.cwd()); + const workspace = makeWorkspace(cwd); + const providerId = this.options.providerId || process.env.ZCODE_PROVIDER_ID || DEFAULT_PROVIDER_ID; + const turnTimeoutMs = this.options.turnTimeoutMs ?? Number(process.env.ZCODE_TURN_TIMEOUT_MS || DEFAULT_TURN_TIMEOUT_MS); + const pollIntervalMs = this.options.pollIntervalMs ?? Number(process.env.ZCODE_POLL_INTERVAL_MS || DEFAULT_POLL_INTERVAL_MS); + let sessionId: string | undefined; + + try { + await raceAbort(client.start(), signal); + const initialized = asRecord(await raceAbort(client.call("zcode-agent", "initialize", [workspace]), signal)); + if (initialized.available !== true) { + throw new Error(extractErrorMessage(initialized)); + } + + const created = await raceAbort(client.call("zcode-agent", "createSession", [{ + ...workspace, + sessionTraceId: randomUUID(), + mode: "build", + persistence: "persistent", + }]), signal); + sessionId = extractSessionId(created); + if (!sessionId) throw new Error("ZCode createSession returned no sessionId"); + + await raceAbort(client.call("zcode-agent", "setModel", [{ + ...workspace, + sessionId, + model: { providerId, modelId: model }, + }]), signal); + + let state: unknown = await raceAbort(client.call("zcode-agent", "sendPrompt", [{ + ...workspace, + sessionId, + inputId: randomUUID(), + content: prompt, + }]), signal); + const deadline = Date.now() + Math.max(1, turnTimeoutMs); + + while (Date.now() <= deadline) { + if (signal?.aborted) throw abortError(); + const text = extractAssistantText(state); + const status = extractStatus(state); + if (text && (status === undefined || TERMINAL_STATUSES.has(status))) return text; + if (status === "error") throw new Error(extractErrorMessage(state)); + await delay(Math.max(0, pollIntervalMs), signal); + state = await raceAbort(client.call("zcode-agent", "readSession", [{ + ...workspace, + sessionId, + messageLimit: 200, + }]), signal); + } + const finalText = extractAssistantText(state); + if (finalText) return finalText; + throw new Error("ZCode turn timed out before an assistant response was available"); + } finally { + if (sessionId && !signal?.aborted) { + await client.call("zcode-agent", "closeSession", [{ ...workspace, sessionId }]).catch(() => undefined); + } + await client.close().catch((error) => log?.debug?.("ZCODE", `app-server close failed: ${sanitizeErrorMessage(error)}`)); + } + } + + // Credentials are intentionally ignored: the local ZCode profile owns auth. + override buildHeaders(_credentials: ProviderCredentials): Record { + return {}; + } +} diff --git a/open-sse/executors/zcodeProtocol.ts b/open-sse/executors/zcodeProtocol.ts new file mode 100644 index 0000000000..12a5cd1a0e --- /dev/null +++ b/open-sse/executors/zcodeProtocol.ts @@ -0,0 +1,438 @@ +import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; + +const HEADER_SIZE = 13; +const REGULAR_MESSAGE = 1; +const INITIALIZE_MESSAGE = 200; +const RESPONSE_MESSAGE = 201; +const ERROR_MESSAGE = 202; +const CANCELED_MESSAGE = 203; +const MAX_FRAME_BYTES = 32 * 1024 * 1024; + +type JsonRecord = Record; + +export interface ZcodeAppServerClientOptions { + command: string; + args?: string[]; + cwd?: string; + env?: NodeJS.ProcessEnv; + startupTimeoutMs?: number; + requestTimeoutMs?: number; +} + +export interface ZcodeClientLike { + start(): Promise; + call(channel: string, method: string, args: unknown[]): Promise; + close(): Promise; +} + +interface PendingRequest { + resolve: (value: unknown) => void; + reject: (error: Error) => void; + timer: ReturnType; +} + +interface DecodedValue { + value: unknown; + offset: number; +} + +function encodeVql(value: number): Buffer { + if (!Number.isSafeInteger(value) || value < 0) { + throw new Error(`ZCode protocol requires a non-negative integer, got ${String(value)}`); + } + const bytes: number[] = []; + let remaining = value; + do { + let next = remaining % 128; + remaining = Math.floor(remaining / 128); + if (remaining > 0) next |= 0x80; + bytes.push(next); + } while (remaining > 0); + return Buffer.from(bytes); +} + +function decodeVql(data: Uint8Array, offset: number): { value: number; offset: number } { + let value = 0; + let multiplier = 1; + let cursor = offset; + for (let i = 0; i < 8; i += 1) { + if (cursor >= data.byteLength) throw new Error("Truncated ZCode variable-length quantity"); + const next = data[cursor++]; + value += (next & 0x7f) * multiplier; + if ((next & 0x80) === 0) return { value, offset: cursor }; + multiplier *= 128; + } + throw new Error("Invalid ZCode variable-length quantity"); +} + +/** Serialize one value using ZCode's SocketProtocol value encoding. */ +export function encodeZcodeValue(value: unknown): Buffer { + if (value === undefined) return Buffer.from([0]); + if (typeof value === "string") { + const bytes = Buffer.from(value, "utf8"); + return Buffer.concat([Buffer.from([1]), encodeVql(bytes.byteLength), bytes]); + } + if (Buffer.isBuffer(value) || value instanceof Uint8Array) { + const bytes = Buffer.from(value); + return Buffer.concat([Buffer.from([2]), encodeVql(bytes.byteLength), bytes]); + } + if (Array.isArray(value)) { + return Buffer.concat([ + Buffer.from([4]), + encodeVql(value.length), + ...value.map((item) => encodeZcodeValue(item)), + ]); + } + if (typeof value === "number" && Number.isSafeInteger(value) && value >= 0) { + return Buffer.concat([Buffer.from([6]), encodeVql(value)]); + } + if (typeof value === "bigint" || typeof value === "function" || typeof value === "symbol") { + throw new Error(`Unsupported ZCode protocol value type: ${typeof value}`); + } + const bytes = Buffer.from(JSON.stringify(value), "utf8"); + return Buffer.concat([Buffer.from([5]), encodeVql(bytes.byteLength), bytes]); +} + +/** Decode one value from ZCode's SocketProtocol value encoding. */ +export function decodeZcodeValue(data: Uint8Array, offset = 0): DecodedValue { + if (offset >= data.byteLength) throw new Error("Truncated ZCode serialized value"); + const type = data[offset++]; + if (type === 0) return { value: undefined, offset }; + if (type === 1 || type === 2) { + const length = decodeVql(data, offset); + const end = length.offset + length.value; + if (end > data.byteLength) throw new Error("Truncated ZCode byte/string value"); + const bytes = data.slice(length.offset, end); + return { + value: type === 1 ? Buffer.from(bytes).toString("utf8") : Buffer.from(bytes), + offset: end, + }; + } + if (type === 4) { + const length = decodeVql(data, offset); + const values: unknown[] = []; + let cursor = length.offset; + for (let i = 0; i < length.value; i += 1) { + const decoded = decodeZcodeValue(data, cursor); + values.push(decoded.value); + cursor = decoded.offset; + } + return { value: values, offset: cursor }; + } + if (type === 5) { + const length = decodeVql(data, offset); + const end = length.offset + length.value; + if (end > data.byteLength) throw new Error("Truncated ZCode JSON value"); + return { + value: JSON.parse(Buffer.from(data.slice(length.offset, end)).toString("utf8")), + offset: end, + }; + } + if (type === 6) { + const decoded = decodeVql(data, offset); + return { value: decoded.value, offset: decoded.offset }; + } + throw new Error(`Unknown ZCode serialized value type ${type}`); +} + +export function encodeZcodeRpcCall( + id: number, + channel: string, + method: string, + args: unknown[] +): Buffer { + const body = Buffer.concat([ + encodeZcodeValue([100, id, channel, method]), + encodeZcodeValue(args), + ]); + const frame = Buffer.alloc(HEADER_SIZE + body.byteLength); + frame.writeUInt8(REGULAR_MESSAGE, 0); + frame.writeUInt32BE(0, 1); + frame.writeUInt32BE(0, 5); + frame.writeUInt32BE(body.byteLength, 9); + body.copy(frame, HEADER_SIZE); + return frame; +} + +function errorFromPayload(payload: unknown, fallback: string): Error { + if (payload && typeof payload === "object") { + const record = payload as JsonRecord; + const message = typeof record.message === "string" ? record.message : fallback; + const error = new Error(message); + if (typeof record.code === "string") Object.assign(error, { code: record.code }); + if (record.data !== undefined) Object.assign(error, { data: record.data }); + return error; + } + return new Error(fallback); +} + +/** + * Local stdio client for the ZCode app-server. The protocol starts with a JSON + * hello line and then switches to 13-byte length-prefixed binary frames. + */ +export class ZcodeAppServerClient implements ZcodeClientLike { + private readonly command: string; + private readonly args: string[]; + private readonly cwd?: string; + private readonly env?: NodeJS.ProcessEnv; + private readonly startupTimeoutMs: number; + private readonly requestTimeoutMs: number; + private child?: ChildProcessWithoutNullStreams; + private outputBuffer = Buffer.alloc(0); + private handshakeDone = false; + private ready = false; + private startPromise?: Promise; + private serverReady?: () => void; + private serverReadyError?: (error: Error) => void; + private nextRequestId = 1; + private readonly pending = new Map(); + + constructor(options: ZcodeAppServerClientOptions) { + this.command = options.command; + this.args = options.args ?? []; + this.cwd = options.cwd; + this.env = options.env; + this.startupTimeoutMs = options.startupTimeoutMs ?? 10_000; + this.requestTimeoutMs = options.requestTimeoutMs ?? 30_000; + } + + async start(): Promise { + if (this.ready) return; + if (this.startPromise) return this.startPromise; + this.startPromise = this.startInternal().finally(() => { + this.startPromise = undefined; + }); + return this.startPromise; + } + + private async startInternal(): Promise { + let child: ChildProcessWithoutNullStreams; + try { + child = spawn(this.command, this.args, { + cwd: this.cwd, + env: this.env ? { ...process.env, ...this.env } : process.env, + stdio: ["pipe", "pipe", "pipe"], + shell: false, + windowsHide: true, + }); + } catch (error) { + throw error instanceof Error ? error : new Error(String(error)); + } + + this.child = child; + this.outputBuffer = Buffer.alloc(0); + this.handshakeDone = false; + this.ready = false; + child.stdin.on("error", () => { + // EPIPE is expected when timeout/abort closes an already-exited runtime. + }); + + let settled = false; + const readyPromise = new Promise((resolve, reject) => { + this.serverReady = () => { + if (settled) return; + settled = true; + resolve(); + }; + this.serverReadyError = (error) => { + if (settled) return; + settled = true; + reject(error); + }; + }); + + child.stdout.on("data", (chunk: Buffer) => this.onStdout(chunk)); + child.stderr.on("data", () => { + // ZCode stderr is intentionally not forwarded: it can contain provider + // diagnostics or credentials from the user's local runtime. + }); + child.on("error", (error) => { + this.serverReadyError?.(error); + this.rejectPending(error); + }); + child.on("exit", (code, signal) => { + const error = new Error(`ZCode app-server exited: ${code ?? signal ?? "unknown"}`); + this.ready = false; + this.handshakeDone = false; + this.serverReadyError?.(error); + this.rejectPending(error); + if (this.child === child) this.child = undefined; + }); + + try { + await this.withTimeout(readyPromise, this.startupTimeoutMs, "ZCode app-server handshake timed out"); + this.ready = true; + } catch (error) { + await this.disposeChild(child); + throw error instanceof Error ? error : new Error(String(error)); + } finally { + this.serverReady = undefined; + this.serverReadyError = undefined; + } + } + + private onStdout(chunk: Buffer): void { + this.outputBuffer = Buffer.concat([this.outputBuffer, chunk]); + if (!this.handshakeDone) { + const newline = this.outputBuffer.indexOf(0x0a); + if (newline < 0) { + if (this.outputBuffer.byteLength > 64 * 1024) { + this.serverReadyError?.(new Error("ZCode hello line is too large")); + } + return; + } + const line = this.outputBuffer.subarray(0, newline).toString("utf8").trim(); + this.outputBuffer = this.outputBuffer.subarray(newline + 1); + let hello: unknown; + try { + hello = JSON.parse(line); + } catch { + this.serverReadyError?.(new Error("Invalid ZCode app-server hello")); + return; + } + if (!hello || typeof hello !== "object" || (hello as JsonRecord).type !== "zcode-hello") { + this.serverReadyError?.(new Error("Unexpected ZCode app-server hello")); + return; + } + const child = this.child; + if (!child) return; + child.stdin.write(`${JSON.stringify({ + type: "zcode-hello-ack", + version: "omniroute", + clientId: `omniroute-${process.pid}`, + })}\n`); + this.handshakeDone = true; + } + this.consumeFrames(); + } + + private consumeFrames(): void { + while (this.outputBuffer.byteLength >= HEADER_SIZE) { + const type = this.outputBuffer.readUInt8(0); + const length = this.outputBuffer.readUInt32BE(9); + if (length > MAX_FRAME_BYTES) { + const error = new Error("ZCode frame exceeds the configured safety limit"); + this.serverReadyError?.(error); + this.rejectPending(error); + return; + } + const frameLength = HEADER_SIZE + length; + if (this.outputBuffer.byteLength < frameLength) return; + const body = this.outputBuffer.subarray(HEADER_SIZE, frameLength); + this.outputBuffer = this.outputBuffer.subarray(frameLength); + if (type !== REGULAR_MESSAGE) continue; + try { + const header = decodeZcodeValue(body, 0); + const payload = decodeZcodeValue(body, header.offset); + this.handleMessage(header.value, payload.value); + } catch (error) { + const normalized = error instanceof Error ? error : new Error(String(error)); + this.serverReadyError?.(normalized); + this.rejectPending(normalized); + } + } + } + + private handleMessage(headerValue: unknown, payload: unknown): void { + if (!Array.isArray(headerValue)) return; + const type = headerValue[0]; + if (type === INITIALIZE_MESSAGE) { + this.serverReady?.(); + return; + } + if (type !== RESPONSE_MESSAGE && type !== ERROR_MESSAGE && type !== CANCELED_MESSAGE) return; + const requestId = headerValue[1]; + if (typeof requestId !== "number") return; + const request = this.pending.get(requestId); + if (!request) return; + this.pending.delete(requestId); + clearTimeout(request.timer); + if (type === RESPONSE_MESSAGE) { + request.resolve(payload); + } else { + request.reject(errorFromPayload( + payload, + type === ERROR_MESSAGE ? "ZCode RPC request failed" : "ZCode RPC request canceled" + )); + } + } + + async call(channel: string, method: string, args: unknown[]): Promise { + await this.start(); + const child = this.child; + if (!child || !this.ready) throw new Error("ZCode app-server is not ready"); + const requestId = this.nextRequestId++; + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + this.pending.delete(requestId); + reject(new Error(`ZCode RPC request timed out: ${channel}.${method}`)); + }, this.requestTimeoutMs); + timer.unref?.(); + this.pending.set(requestId, { resolve, reject, timer }); + try { + child.stdin.write(encodeZcodeRpcCall(requestId, channel, method, args)); + } catch (error) { + clearTimeout(timer); + this.pending.delete(requestId); + reject(error instanceof Error ? error : new Error(String(error))); + } + }); + } + + async close(): Promise { + const child = this.child; + this.ready = false; + this.handshakeDone = false; + this.child = undefined; + this.serverReadyError?.(new Error("ZCode app-server closed")); + this.rejectPending(new Error("ZCode app-server closed")); + if (child) await this.disposeChild(child); + } + + private rejectPending(error: Error): void { + for (const [id, pending] of this.pending) { + clearTimeout(pending.timer); + pending.reject(error); + this.pending.delete(id); + } + } + + private async disposeChild(child: ChildProcessWithoutNullStreams): Promise { + if (child.exitCode !== null || child.signalCode !== null) return; + const exited = new Promise((resolve) => child.once("close", () => resolve())); + try { + child.stdin.end(); + } catch { + // The process may already have closed stdin. + } + if (!child.killed) child.kill("SIGTERM"); + let timer: ReturnType | undefined; + await Promise.race([ + exited, + new Promise((resolve) => { + timer = setTimeout(resolve, 1500); + timer.unref?.(); + }), + ]); + if (timer) clearTimeout(timer); + if (child.exitCode === null && child.signalCode === null) { + child.kill("SIGKILL"); + await exited; + } + } + + private async withTimeout(promise: Promise, timeoutMs: number, message: string): Promise { + let timer: ReturnType | undefined; + try { + return await Promise.race([ + promise, + new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error(message)), timeoutMs); + timer.unref?.(); + }), + ]); + } finally { + if (timer) clearTimeout(timer); + } + } +} diff --git a/src/lib/acp/registry.ts b/src/lib/acp/registry.ts index 6945762409..a1cc297b3e 100644 --- a/src/lib/acp/registry.ts +++ b/src/lib/acp/registry.ts @@ -96,6 +96,15 @@ const AGENT_DEFINITIONS: Omit[] = [ spawnArgs: ["--no-auto-commits"], protocol: "stdio", }, + { + id: "zcode", + name: "ZCode (GLM Coding Plan)", + binary: "zcode", + versionCommand: "zcode --version", + providerAlias: "zcode", + spawnArgs: ["app-server"], + protocol: "stdio", + }, { id: "opencode", name: "OpenCode", diff --git a/src/shared/constants/cliTools.ts b/src/shared/constants/cliTools.ts index d66927f96a..acdba14050 100644 --- a/src/shared/constants/cliTools.ts +++ b/src/shared/constants/cliTools.ts @@ -85,6 +85,25 @@ export const CLI_TOOLS: Record = { baseUrlSupport: "full", defaultCommand: "codex", }, + zcode: { + id: "zcode", + name: "ZCode (GLM Coding Plan)", + color: "#3B82F6", + description: "Local ZCode app-server backend; auth remains in the user's ZCode profile", + docsUrl: "https://zcode.z.ai", + configType: "custom", + category: "code", + vendor: "Z.ai", + // ZCode's app-server is a native length-prefixed protocol, not ACP. The + // zcode provider executor owns its lifecycle instead of ACP spawning it. + acpSpawnable: false, + baseUrlSupport: "none", + defaultCommand: "zcode", + notes: [ + { type: "info", text: "Uses the local ZCode app-server and its existing builtin:zai-coding-plan login." }, + { type: "warning", text: "The response is buffered until the ZCode turn completes." }, + ], + }, droid: { id: "droid", name: "Factory Droid", diff --git a/src/shared/constants/providers.ts b/src/shared/constants/providers.ts index 143a11a45d..05957989f0 100644 --- a/src/shared/constants/providers.ts +++ b/src/shared/constants/providers.ts @@ -38,6 +38,8 @@ export const FREE_APIKEY_PROVIDER_IDS = new Set([ // accepts an optional connection row for display/priority/testStatus tracking — // no apiKey is ever required or sent upstream. "auggie", + // zcode is a local app-server backend; auth stays in the ZCode profile. + "zcode", ]); export function supportsApiKeyOnFreeProvider(providerId: unknown): boolean { diff --git a/src/shared/constants/providers/noauth.ts b/src/shared/constants/providers/noauth.ts index 7b7246beba..b572b68888 100644 --- a/src/shared/constants/providers/noauth.ts +++ b/src/shared/constants/providers/noauth.ts @@ -157,6 +157,24 @@ export const NOAUTH_PROVIDERS = { text: "Augment (Auggie CLI) requires the `auggie` binary installed and authenticated locally (`auggie login`). OmniRoute spawns it as a subprocess and never sees or stores your Augment credentials.", }, }, + zcode: { + id: "zcode", + alias: "zc", + name: "ZCode (GLM Coding Plan)", + icon: "terminal", + color: "#3B82F6", + textIcon: "ZC", + website: "https://zcode.z.ai", + noAuth: true, + hasFree: false, + serviceKinds: ["llm"], + isLocalCli: true, + authHint: + "No API key stored by OmniRoute. The local ZCode app-server uses the existing builtin:zai-coding-plan login.", + notice: { + text: "ZCode runs locally through its native app-server. OmniRoute never receives or stores the Z.ai credential.", + }, + }, aihorde: { id: "aihorde", alias: "horde", diff --git a/src/shared/services/cliRuntime.ts b/src/shared/services/cliRuntime.ts index bee2577555..a41cab7d8b 100644 --- a/src/shared/services/cliRuntime.ts +++ b/src/shared/services/cliRuntime.ts @@ -93,6 +93,17 @@ const CLI_TOOLS: Record = { }, }, }, + zcode: { + defaultCommand: "zcode", + envBinKey: "ZCODE_BIN", + requiresBinary: true, + // The app-server performs a local runtime handshake and can be slower on + // the first launch while the user's ZCode profile is loaded. + healthcheckTimeoutMs: 15000, + paths: { + config: ".zcode", + }, + }, cline: { defaultCommand: "cline", envBinKey: "CLI_CLINE_BIN", diff --git a/tests/fixtures/fake-zcode-app-server.mjs b/tests/fixtures/fake-zcode-app-server.mjs new file mode 100644 index 0000000000..9996caf06f --- /dev/null +++ b/tests/fixtures/fake-zcode-app-server.mjs @@ -0,0 +1,167 @@ +const HEADER_SIZE = 13; +let input = Buffer.alloc(0); +let handshaken = false; +let sessionId = "fake-zcode-session"; +let selectedModel = null; + +function vql(value) { + if (!Number.isSafeInteger(value) || value < 0) throw new Error("invalid vql value"); + const bytes = []; + let remaining = value; + do { + let next = remaining % 128; + remaining = Math.floor(remaining / 128); + if (remaining > 0) next |= 0x80; + bytes.push(next); + } while (remaining > 0); + return Buffer.from(bytes); +} + +function encode(value) { + if (value === undefined) return Buffer.from([0]); + if (typeof value === "string") { + const bytes = Buffer.from(value, "utf8"); + return Buffer.concat([Buffer.from([1]), vql(bytes.length), bytes]); + } + if (Buffer.isBuffer(value) || value instanceof Uint8Array) { + const bytes = Buffer.from(value); + return Buffer.concat([Buffer.from([2]), vql(bytes.length), bytes]); + } + if (Array.isArray(value)) { + return Buffer.concat([Buffer.from([4]), vql(value.length), ...value.map(encode)]); + } + if (typeof value === "number" && Number.isSafeInteger(value) && value >= 0) { + return Buffer.concat([Buffer.from([6]), vql(value)]); + } + const bytes = Buffer.from(JSON.stringify(value), "utf8"); + return Buffer.concat([Buffer.from([5]), vql(bytes.length), bytes]); +} + +function readVql(data, state) { + let value = 0; + let multiplier = 1; + for (let i = 0; i < 8; i += 1) { + if (state.offset >= data.length) throw new Error("truncated vql"); + const next = data[state.offset++]; + value += (next & 0x7f) * multiplier; + if ((next & 0x80) === 0) return value; + multiplier *= 128; + } + throw new Error("invalid vql"); +} + +function decode(data, state) { + const type = data[state.offset++]; + if (type === 0) return undefined; + if (type === 1 || type === 2) { + const length = readVql(data, state); + const end = state.offset + length; + if (end > data.length) throw new Error("truncated bytes"); + const bytes = data.subarray(state.offset, end); + state.offset = end; + return type === 1 ? bytes.toString("utf8") : bytes; + } + if (type === 4) { + const length = readVql(data, state); + return Array.from({ length }, () => decode(data, state)); + } + if (type === 5) { + const length = readVql(data, state); + const end = state.offset + length; + const value = JSON.parse(data.subarray(state.offset, end).toString("utf8")); + state.offset = end; + return value; + } + if (type === 6) return readVql(data, state); + throw new Error(`unknown type ${type}`); +} + +function frame(body) { + const result = Buffer.alloc(HEADER_SIZE + body.length); + result.writeUInt8(1, 0); + result.writeUInt32BE(0, 1); + result.writeUInt32BE(0, 5); + result.writeUInt32BE(body.length, 9); + body.copy(result, HEADER_SIZE); + return result; +} + +function send(header, payload) { + const packet = frame(Buffer.concat([encode(header), encode(payload)])); + process.stdout.write(packet.subarray(0, 5)); + setTimeout(() => process.stdout.write(packet.subarray(5)), 1); +} + +function response(id, payload) { + send([201, id], payload); +} + +function handleFrame(body) { + const state = { offset: 0 }; + const header = decode(body, state); + const args = decode(body, state); + const id = Array.isArray(header) ? header[1] : undefined; + const method = Array.isArray(header) ? header[3] : undefined; + const request = Array.isArray(args) && args[0] && typeof args[0] === "object" ? args[0] : {}; + + switch (method) { + case "initialize": + response(id, { available: true, protocolName: "ZCode Protocol", protocolVersion: 1, transportKind: "stdio" }); + break; + case "createSession": + sessionId = "fake-zcode-session"; + response(id, { session: { sessionId, status: "idle", workspace: { workspacePath: request.workspacePath } }, messages: [] }); + break; + case "setModel": + selectedModel = request.model; + response(id, { ok: true, model: selectedModel }); + break; + case "sendPrompt": + response(id, { session: { sessionId, status: "running" }, accepted: true }); + break; + case "readSession": + response(id, { + session: { sessionId, status: "completed", model: selectedModel }, + messages: [ + { info: { messageId: "fake-user-message", role: "user" }, parts: [{ type: "text", text: request.content || "prompt" }] }, + { info: { messageId: "fake-assistant-message", role: "assistant" }, parts: [{ type: "text", text: "fake zcode response" }] }, + ], + }); + break; + case "closeSession": + response(id, { ok: true }); + break; + default: + response(id, { ok: true }); + break; + } +} + +function consumeFrames() { + while (input.length >= HEADER_SIZE) { + const length = input.readUInt32BE(9); + const total = HEADER_SIZE + length; + if (input.length < total) return; + const body = input.subarray(HEADER_SIZE, total); + input = input.subarray(total); + handleFrame(body); + } +} + +process.stdout.write(`${JSON.stringify({ type: "zcode-hello", version: "fixture", platform: "test", arch: "test", pid: process.pid })}\n`); + +process.stdin.on("data", (chunk) => { + input = Buffer.concat([input, chunk]); + if (!handshaken) { + const newline = input.indexOf(0x0a); + if (newline < 0) return; + const ack = JSON.parse(input.subarray(0, newline).toString("utf8")); + if (ack.type !== "zcode-hello-ack") throw new Error("missing ZCode hello ack"); + input = input.subarray(newline + 1); + handshaken = true; + send([200], undefined); + } + consumeFrames(); +}); + +process.stdin.on("end", () => process.exit(0)); diff --git a/tests/snapshots/provider/translate-path.json b/tests/snapshots/provider/translate-path.json index 699c8acbd4..10db04e9cd 100644 --- a/tests/snapshots/provider/translate-path.json +++ b/tests/snapshots/provider/translate-path.json @@ -6156,6 +6156,29 @@ "stream": "https://chat.z.ai" } }, + "zcode": { + "format": "openai", + "headers": { + "apiKey": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/json" + }, + "nonStream": { + "Authorization": "Bearer ", + "Content-Type": "application/json" + }, + "oauth": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/json" + } + }, + "url": { + "nonStream": "zcode://app-server/stdio", + "stream": "zcode://app-server/stdio" + } + }, "zed-hosted": { "format": "openai", "headers": { diff --git a/tests/unit/autoCombo/provider-family-combos.test.ts b/tests/unit/autoCombo/provider-family-combos.test.ts index 45c28c3a30..3a7e33e4f2 100644 --- a/tests/unit/autoCombo/provider-family-combos.test.ts +++ b/tests/unit/autoCombo/provider-family-combos.test.ts @@ -141,7 +141,10 @@ describe("auto/ materialization (#6453)", () => { // `devin-cli-agentic` joined for the same documented reason as `auggie`: // #8914 added the Devin ACP bridge whose catalog (registry/devin/catalog.ts) // advertises the glm-5-2* line, so it genuinely serves the family. - assert.deepEqual(providerIds, ["auggie", "devin-cli-agentic", "glm", "zai"]); + // `zcode` joined for the same documented reason too — #10184 added the local + // ZCode app-server backend whose registry (registry/zcode) advertises the + // full GLM_SHARED_MODELS line-up, so it genuinely serves the family. + assert.deepEqual(providerIds, ["auggie", "devin-cli-agentic", "glm", "zai", "zcode"]); // Every candidate must be a glm-family model (the Cartesian pool now surfaces // each backend's full glm line-up, not only the glm-5.2 default), and the // connected openai/gpt-4o-mini backend must be excluded — same family diff --git a/tests/unit/zcode-executor.test.ts b/tests/unit/zcode-executor.test.ts new file mode 100644 index 0000000000..c82e7b3368 --- /dev/null +++ b/tests/unit/zcode-executor.test.ts @@ -0,0 +1,84 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +const fixture = join(process.cwd(), "tests/fixtures/fake-zcode-app-server.mjs"); +const TEST_DATA_DIR = mkdtempSync(join(tmpdir(), "omniroute-zcode-")); +process.env.DATA_DIR = TEST_DATA_DIR; + +test.after(() => rmSync(TEST_DATA_DIR, { recursive: true, force: true })); + +async function loadZcodeExecutor() { + return import("../../open-sse/executors/zcode.ts"); +} + +function requestBody() { + return { + messages: [ + { role: "system", content: "You are a coding assistant." }, + { role: "user", content: "Reply with a short status." }, + ], + }; +} + +test("ZCode accepts GLM Coding Plan models and rejects unsafe/unknown ids", async () => { + const { resolveZcodeModel } = await loadZcodeExecutor(); + assert.deepEqual(resolveZcodeModel("glm-5.2"), { ok: true, model: "glm-5.2" }); + assert.equal(resolveZcodeModel("-unexpected").ok, false); + assert.equal(resolveZcodeModel("unknown-model").ok, false); +}); + +test("ZCode runs a local app-server turn and returns an OpenAI chat completion", async () => { + const { ZcodeExecutor } = await loadZcodeExecutor(); + const executor = new ZcodeExecutor({ + command: process.execPath, + args: [fixture], + cwd: process.cwd(), + requestTimeoutMs: 3000, + turnTimeoutMs: 3000, + pollIntervalMs: 1, + }); + + const result = await executor.execute({ + model: "glm-5.2", + body: requestBody(), + stream: false, + credentials: {}, + }); + const response = "response" in result ? result.response : result; + assert.equal(response.status, 200); + assert.match(response.headers.get("content-type") || "", /application\/json/); + const body = await response.json(); + assert.equal(body.object, "chat.completion"); + assert.equal(body.model, "glm-5.2"); + assert.equal(body.choices?.[0]?.message?.role, "assistant"); + assert.equal(body.choices?.[0]?.message?.content, "fake zcode response"); + assert.equal(body.choices?.[0]?.finish_reason, "stop"); +}); + +test("ZCode buffers the completed turn into OpenAI SSE when stream=true", async () => { + const { ZcodeExecutor } = await loadZcodeExecutor(); + const executor = new ZcodeExecutor({ + command: process.execPath, + args: [fixture], + cwd: process.cwd(), + requestTimeoutMs: 3000, + turnTimeoutMs: 3000, + pollIntervalMs: 1, + }); + + const result = await executor.execute({ + model: "glm-5.2-high", + body: requestBody(), + stream: true, + credentials: {}, + }); + const response = "response" in result ? result.response : result; + const text = await response.text(); + assert.equal(response.status, 200); + assert.match(response.headers.get("content-type") || "", /text\/event-stream/); + assert.match(text, /fake zcode response/); + assert.match(text, /data: \[DONE\]/); +}); diff --git a/tests/unit/zcode-protocol.test.ts b/tests/unit/zcode-protocol.test.ts new file mode 100644 index 0000000000..6f40c6f9fd --- /dev/null +++ b/tests/unit/zcode-protocol.test.ts @@ -0,0 +1,30 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { join } from "node:path"; +import { ZcodeAppServerClient } from "../../open-sse/executors/zcodeProtocol.ts"; + +const fixture = join(process.cwd(), "tests/fixtures/fake-zcode-app-server.mjs"); + +test("ZCode protocol performs hello handshake and exchanges fragmented framed RPC", async () => { + const client = new ZcodeAppServerClient({ + command: process.execPath, + args: [fixture], + cwd: process.cwd(), + startupTimeoutMs: 3000, + requestTimeoutMs: 3000, + }); + try { + await client.start(); + const result = await client.call("zcode-agent", "initialize", [ + { workspacePath: "/workspace", workspaceIdentity: "/workspace" }, + ]); + assert.deepEqual(result, { + available: true, + protocolName: "ZCode Protocol", + protocolVersion: 1, + transportKind: "stdio", + }); + } finally { + await client.close(); + } +}); diff --git a/tests/unit/zcode-provider.test.ts b/tests/unit/zcode-provider.test.ts new file mode 100644 index 0000000000..3acf3c862c --- /dev/null +++ b/tests/unit/zcode-provider.test.ts @@ -0,0 +1,14 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { zcodeProvider } from "../../open-sse/config/providers/registry/zcode/index.ts"; + +test("ZCode provider registry exposes a local no-auth GLM Coding Plan backend", () => { + assert.equal(zcodeProvider.id, "zcode"); + assert.equal(zcodeProvider.alias, "zc"); + assert.equal(zcodeProvider.executor, "zcode"); + assert.equal(zcodeProvider.format, "openai"); + assert.equal(zcodeProvider.baseUrl, "zcode://app-server/stdio"); + assert.equal(zcodeProvider.authType, "none"); + assert.equal(zcodeProvider.authHeader, "none"); + assert.equal(zcodeProvider.models.some((model) => model.id === "glm-5.2"), true); +});