From b942f3a8605f2626acd70b38f6f524285dd8065b Mon Sep 17 00:00:00 2001 From: panudetjt <3482285+panudetjt@users.noreply.github.com> Date: Mon, 9 Nov 2026 20:27:21 +0700 Subject: [PATCH 01/23] feat(provider-zcode): add ZCode as a provider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit T3 Code gains a native ZCode driver that wraps the ZCode CLI's app-server protocol (NDJSON over stdio): session create/subscribe/ send/stop/compact, streaming text and reasoning deltas, tool-call lifecycle, native approval and question requests, steering, model selection per turn, and T3 MCP server injection at session create. Runtime modes map onto ZCode's own enforcement (guarded/edit/yolo); capabilities the CLI cannot yet prove — rollback, fork, subagent projection, restart resume — stay off so the UI never promises them. Auth is CLI-managed like the Kiro provider: the user runs `zcode login`, and the status probe only reads the credential store. Verified against the real app-server binary with recorded transcripts of the session lifecycle, event streaming, and server->client request surface; adapter behavior is covered by 6 orchestrator-level tests driving the real adapter over a scripted connection, plus 26 package unit tests. A completed model turn is currently blocked inside ZCode's own standalone credential flow (project-token exchange fails under plain `zcode -p` too), so live replay fixtures land once the CLI supports standalone turns. Model: GLM-5.3-Flash via ZCode (account:zai-individual-coding-plan). Harness: ZCode. --- README.md | 3 +- apps/marketing/src/pages/llms.txt.ts | 2 +- .../src/features/settings/settings-search.ts | 1 + apps/server/package.json | 1 + .../Adapters/ZcodeAdapterV2.test.ts | 442 ++++ .../Adapters/ZcodeAdapterV2.testkit.ts | 266 ++ apps/server/src/provider/builtInDrivers.ts | 5 +- apps/server/src/provider/model-manifest.json | 8 +- .../src/provider/providerStatusCache.ts | 1 + apps/server/src/settingsSecrets.ts | 2 + .../src/components/settings/settingsSearch.ts | 2 +- docs/user/faq.md | 2 +- docs/user/install.md | 4 +- docs/user/permission-modes.md | 3 + docs/user/providers-zcode.md | 36 + package.json | 2 +- packages/client-runtime/package.json | 1 + .../client-runtime/src/providerClients.ts | 2 + packages/contracts/src/model.ts | 4 + packages/contracts/src/settings.ts | 2 +- packages/provider-zcode/package.json | 40 + packages/provider-zcode/src/client.ts | 20 + packages/provider-zcode/src/server.ts | 6 + packages/provider-zcode/src/server/adapter.ts | 2206 +++++++++++++++++ packages/provider-zcode/src/server/driver.ts | 152 ++ .../provider-zcode/src/server/status.test.ts | 181 ++ packages/provider-zcode/src/server/status.ts | 295 +++ .../src/server/zcodeProtocol.ts | 446 ++++ .../src/server/zcodeRuntime.test.ts | 328 +++ .../provider-zcode/src/server/zcodeRuntime.ts | 290 +++ packages/provider-zcode/src/settings.ts | 41 + packages/provider-zcode/src/testing.ts | 13 + packages/provider-zcode/tsconfig.json | 7 + pnpm-lock.yaml | 37 + 34 files changed, 4842 insertions(+), 9 deletions(-) create mode 100644 apps/server/src/orchestration-v2/Adapters/ZcodeAdapterV2.test.ts create mode 100644 apps/server/src/orchestration-v2/Adapters/ZcodeAdapterV2.testkit.ts create mode 100644 docs/user/providers-zcode.md create mode 100644 packages/provider-zcode/package.json create mode 100644 packages/provider-zcode/src/client.ts create mode 100644 packages/provider-zcode/src/server.ts create mode 100644 packages/provider-zcode/src/server/adapter.ts create mode 100644 packages/provider-zcode/src/server/driver.ts create mode 100644 packages/provider-zcode/src/server/status.test.ts create mode 100644 packages/provider-zcode/src/server/status.ts create mode 100644 packages/provider-zcode/src/server/zcodeProtocol.ts create mode 100644 packages/provider-zcode/src/server/zcodeRuntime.test.ts create mode 100644 packages/provider-zcode/src/server/zcodeRuntime.ts create mode 100644 packages/provider-zcode/src/settings.ts create mode 100644 packages/provider-zcode/src/testing.ts create mode 100644 packages/provider-zcode/tsconfig.json diff --git a/README.md b/README.md index 4abbd5bd932cf..ec038533faf59 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ T3 Code is an "agent harness control surface". It enables control of the agents on your machine with a best-in-class mobile app ([iOS](https://apps.apple.com/us/app/t3-code-remote-claude-more/id6787819824), [Android](https://play.google.com/store/apps/details?id=com.t3tools.t3code)), [web app](https://app.t3.codes) and [Electron-based desktop app](https://t3.codes). -Works with your subscriptions on Claude Code, Codex, Cursor, Grok Build, OpenCode, Google Antigravity, and fx. If they're set up on your computer, T3 Code can control them. +Works with your subscriptions on Claude Code, Codex, Cursor, Grok Build, OpenCode, Google Antigravity, ZCode, and fx. If they're set up on your computer, T3 Code can control them. ## "Wait, what are you selling me?" @@ -22,6 +22,7 @@ We wanted something performant, remote-ready, and truly open. If we ever go the > - OpenCode: install [OpenCode](https://opencode.ai) and run `opencode auth login` > - Antigravity: enable it in Settings, then use **Install Antigravity** and **Sign in with Google**. No CLI is required. > - fx: install [fx](https://fx.sh) and run `fx login` on the machine running the T3 Code server ([setup guide](./docs/user/providers-fx.md)) +> - ZCode: install [ZCode](./docs/user/providers-zcode.md) and run `zcode login` on the machine running the T3 Code server ### Command line diff --git a/apps/marketing/src/pages/llms.txt.ts b/apps/marketing/src/pages/llms.txt.ts index ec7fdde354a2f..f8b8fac4dddd6 100644 --- a/apps/marketing/src/pages/llms.txt.ts +++ b/apps/marketing/src/pages/llms.txt.ts @@ -7,7 +7,7 @@ import { absoluteUrl, SITE_TAGLINE } from "../lib/seo"; export const GET: APIRoute = () => { const body = `# T3 Code -> ${SITE_TAGLINE}. T3 Code is free, MIT-licensed, and built by T3 Tools Inc. It runs Claude Code, Codex, Cursor, OpenCode, Grok, Antigravity, and other coding agents from one desktop, web, and mobile app. +> ${SITE_TAGLINE}. T3 Code is free, MIT-licensed, and built by T3 Tools Inc. It runs Claude Code, Codex, Cursor, OpenCode, Grok, Antigravity, ZCode, and other coding agents from one desktop, web, and mobile app. The only official websites are https://t3.codes and the web app at https://app.t3.codes. Official builds come from ${absoluteUrl("/download")}, ${GITHUB_REPOSITORY_URL}/releases, the \`t3\` package on npm (\`npx t3@latest\`), the iOS App Store, and Google Play. Any other website that offers a T3 Code download is not affiliated with T3 Tools Inc. diff --git a/apps/mobile/src/features/settings/settings-search.ts b/apps/mobile/src/features/settings/settings-search.ts index 4d02bd9789b1d..3eca76666865c 100644 --- a/apps/mobile/src/features/settings/settings-search.ts +++ b/apps/mobile/src/features/settings/settings-search.ts @@ -83,6 +83,7 @@ const SETTINGS_SEARCH_TERMS = { "claude", "cursor", "opencode", + "zcode", "sign in", "login", "auth", diff --git a/apps/server/package.json b/apps/server/package.json index 3a6797d90da28..b4a3df017ea7d 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -53,6 +53,7 @@ "@t3tools/provider-muse": "workspace:*", "@t3tools/provider-opencode": "workspace:*", "@t3tools/provider-pi": "workspace:*", + "@t3tools/provider-zcode": "workspace:*", "@t3tools/source-control-azure-devops": "workspace:*", "@t3tools/source-control-bitbucket": "workspace:*", "@t3tools/source-control-core": "workspace:*", diff --git a/apps/server/src/orchestration-v2/Adapters/ZcodeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ZcodeAdapterV2.test.ts new file mode 100644 index 0000000000000..8ba3e62157995 --- /dev/null +++ b/apps/server/src/orchestration-v2/Adapters/ZcodeAdapterV2.test.ts @@ -0,0 +1,442 @@ +// Frame-driven unit tests for the ZCode adapter: a fake NDJSON peer is injected through +// ZcodeAdapterV2Options.makeConnection, and the real makeZcodeAdapterV2 drives it end to end. +// Frames are hand-written from the Phase 0 probe notes; live-recorded replay fixtures (Phase 3) +// must replace or augment these before merge confidence is complete. +import { assert, describe, it } from "@effect/vitest"; +import { + EnvironmentId, + MessageId, + NodeId, + type OrchestrationV2AppThread, + type OrchestrationV2ProviderThread, + ProjectId, + ProviderInstanceId, + ProviderSessionId, + RunAttemptId, + RunId, + ThreadId, +} from "@t3tools/contracts"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Queue from "effect/Queue"; +import * as Stream from "effect/Stream"; + +import * as McpProviderSessions from "@t3tools/provider-core/server/McpProviderSessions"; +import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; +import { ZcodeAdapterV2Driver } from "@t3tools/provider-zcode/server"; +import { ZCODE_PROVIDER, makeZcodeAdapterV2 } from "@t3tools/provider-zcode/testing"; + +import { + type FakeZcodeScenario, + ZcodeAdapterTestLayer, + makeFakeZcodeConnection, + makeZcodeAdapterOptions, + zcodeDriverKind, + zcodeServerRequest, + zcodeSessionEvent, +} from "./ZcodeAdapterV2.testkit.ts"; + +const INSTANCE_ID = ProviderInstanceId.make("zcode_frame_test"); +const THREAD_ID = ThreadId.make("thread-zcode-frame-test"); +const PROVIDER_SESSION_ID = ProviderSessionId.make("provider-session-zcode-frame-test"); +const MODEL_SELECTION = { instanceId: INSTANCE_ID, model: "default" }; +const TURN_TEXT = "Run the zcode frame tests"; + +const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ + runtimeMode: "approval-required", + interactionMode: "default", + cwd: null, +}); + +const makeTurnInput = Effect.fnUntraced(function* ( + providerThread: OrchestrationV2ProviderThread, + text = TURN_TEXT, +): Effect.fn.Return { + const now = yield* DateTime.now; + const threadId = providerThread.appThreadId ?? THREAD_ID; + const appThread: OrchestrationV2AppThread = { + id: threadId, + projectId: ProjectId.make("project-zcode-frame-test"), + title: "ZCode frame test", + createdBy: "user", + creationSource: "web", + providerInstanceId: providerThread.providerInstanceId, + modelSelection: MODEL_SELECTION, + runtimeMode: runtimePolicy.runtimeMode, + interactionMode: runtimePolicy.interactionMode, + branch: null, + worktreePath: null, + activeProviderThreadId: providerThread.id, + lineage: { parentThreadId: null, relationshipToParent: null, rootThreadId: threadId }, + forkedFrom: null, + createdAt: now, + updatedAt: now, + archivedAt: null, + settledOverride: null, + settledAt: null, + lastVisitedAt: null, + deletedAt: null, + }; + return { + appThread, + threadId, + runId: RunId.make("run-zcode-frame-1"), + runOrdinal: 1, + providerTurnOrdinal: 1, + attemptId: RunAttemptId.make("attempt-zcode-frame-1"), + rootNodeId: NodeId.make("root-zcode-frame-1"), + providerThread, + message: { + messageId: MessageId.make("message-zcode-frame-1"), + text, + attachments: [], + createdBy: "user", + creationSource: "web", + scheduledTaskId: undefined, + senderThreadId: undefined, + }, + modelSelection: MODEL_SELECTION, + runtimePolicy, + }; +}); + +const makeHarness = Effect.fnUntraced(function* () { + const scenario = yield* makeFakeZcodeConnection(); + const adapter = yield* makeZcodeAdapterV2({ + ...makeZcodeAdapterOptions(), + makeConnection: () => Effect.succeed(scenario.connection), + }); + const runtime = yield* adapter.openSession({ + threadId: THREAD_ID, + providerSessionId: PROVIDER_SESSION_ID, + modelSelection: MODEL_SELECTION, + runtimePolicy, + }); + const emitted = yield* Queue.unbounded(); + const allEvents: Array = []; + yield* runtime.events.pipe( + Stream.runForEach((event) => + Effect.gen(function* () { + allEvents.push(event); + yield* Queue.offer(emitted, event); + }), + ), + Effect.ignore, + Effect.forkScoped, + ); + const providerThread = yield* runtime.ensureThread({ + threadId: THREAD_ID, + modelSelection: MODEL_SELECTION, + runtimePolicy, + }); + const takeEvent = Effect.fnUntraced(function* < + T extends ProviderAdapter.ProviderAdapterV2Event["type"], + >( + type: T, + predicate: ( + event: Extract, + ) => boolean = () => true, + ) { + while (true) { + const event = yield* Queue.take(emitted); + if ( + event.type === type && + predicate(event as Extract) + ) { + return event as Extract; + } + } + }); + const offerTurnEvent = (type: string, payload: Record) => + scenario.offerEvent( + zcodeSessionEvent(scenario.createdSessionId() ?? "sess_missing", type, payload), + ); + return { + scenario, + runtime, + providerThread, + allEvents, + takeEvent, + offerTurnEvent, + startTurn: (text?: string) => + Effect.gen(function* () { + const input = yield* makeTurnInput(providerThread, text); + return yield* runtime.startTurn(input); + }), + }; +}); + +const requestFor = (scenario: FakeZcodeScenario, method: string) => + scenario.requests.find((call) => call.method === method); + +describe("ZcodeAdapterV2 (frame-driven)", () => { + it("exposes the driver kind and default config through the package driver", () => { + assert.equal(ZcodeAdapterV2Driver.driverKind, ZCODE_PROVIDER); + assert.equal(zcodeDriverKind, ZCODE_PROVIDER); + assert.equal(ZcodeAdapterV2Driver.defaultConfig().binaryPath, "zcode"); + }); + + it.effect( + "creates the native session, answers server requests, and injects the T3 MCP server", + () => + Effect.gen(function* () { + yield* (yield* McpProviderSessions.McpProviderSessions).set({ + environmentId: EnvironmentId.make("environment-zcode-frame-test"), + threadId: THREAD_ID, + providerSessionId: "zcode-frame-session", + providerInstanceId: INSTANCE_ID, + endpoint: "http://127.0.0.1:43999/mcp", + authorizationHeader: "Bearer zcode-frame-test-token", + browserToolsAvailable: true, + }); + const harness = yield* makeHarness(); + assert.equal(harness.providerThread.nativeThreadRef?.nativeId, "sess_1"); + + const create = requestFor(harness.scenario, "session/create"); + assert.isDefined(create); + const workspace = (create!.params["workspace"] ?? {}) as Record; + assert.equal(workspace["workspaceKey"], workspace["workspacePath"]); + assert.equal((workspace["workspacePath"] as string).length > 0, true); + assert.equal(create!.params["titleGenerationEnabled"], false); + // "default" defers to the CLI's own model selection. + assert.equal(create!.params["model"], undefined); + assert.deepEqual(create!.params["mcpServers"], [ + { + name: "t3-code", + type: "http", + url: "http://127.0.0.1:43999/mcp", + headers: [{ name: "Authorization", value: "Bearer zcode-frame-test-token" }], + }, + ]); + + // The broker requests the fake pushed during session/create must be answered; the + // respond queue synchronizes the assertions with the adapter's pump. + const preferenceRespond = yield* harness.scenario.takeRespond; + assert.deepEqual(preferenceRespond.result, { + nativeSearchEnhancementsEnabled: false, + toolSearchMode: "off", + memoryEnabled: false, + askUserQuestionAutoResolutionEnabled: true, + modelContextBudgetStrategy: "preflight-v1", + }); + const mcpAuthRespond = yield* harness.scenario.takeRespond; + assert.deepEqual(mcpAuthRespond.result, {}); + + yield* harness.takeEvent("provider_thread.updated"); + assert.deepEqual(harness.scenario.unexpectedMethods(), []); + }).pipe(Effect.scoped, Effect.provide(ZcodeAdapterTestLayer)), + ); + + it.effect("streams text deltas and completes the turn with token usage", () => + Effect.gen(function* () { + const harness = yield* makeHarness(); + yield* harness.startTurn(); + const send = requestFor(harness.scenario, "session/send"); + assert.isDefined(send); + assert.equal(send!.params["sessionId"], "sess_1"); + assert.equal(send!.params["content"], TURN_TEXT); + assert.equal(send!.params["modelSelection"], undefined); + + yield* harness.takeEvent( + "provider_turn.updated", + (event) => event.providerTurn.status === "running", + ); + yield* harness.offerTurnEvent("turn.started", {}); + yield* harness.offerTurnEvent("part.delta", { + messageId: "msg_a", + partId: "p1", + field: "text", + delta: "Hello", + }); + yield* harness.offerTurnEvent("part.delta", { + messageId: "msg_a", + partId: "p1", + field: "text", + delta: " world", + }); + yield* harness.offerTurnEvent("part.upserted", { + part: { + partId: "p1", + sessionId: "sess_1", + messageId: "msg_a", + type: "text", + text: "Hello world", + }, + }); + yield* harness.offerTurnEvent("turn.completed", { + response: "Hello world", + tokenCount: 120, + toolCallCount: 0, + duration: 420, + resultType: "success", + }); + + const firstDelta = yield* harness.takeEvent("command_output.delta"); + assert.equal(firstDelta.chunk, "Hello"); + const secondDelta = yield* harness.takeEvent("command_output.delta"); + assert.equal(secondDelta.chunk, " world"); + + yield* harness.takeEvent("turn_item.updated", (event) => + event.turnItem.type === "assistant_message" + ? event.turnItem.streaming === false && event.turnItem.text === "Hello world" + : false, + ); + yield* harness.takeEvent("message.updated", (event) => event.message.text === "Hello world"); + // The completed turn update precedes the terminal on the adapter stream. + const finalTurnUpdate = yield* harness.takeEvent( + "provider_turn.updated", + (event) => event.providerTurn.status === "completed", + ); + assert.equal(finalTurnUpdate.providerTurn.tokenUsage?.usedTokens, 120); + yield* harness.takeEvent("turn.terminal", (event) => event.status === "completed"); + assert.deepEqual(harness.scenario.unexpectedMethods(), []); + }).pipe(Effect.scoped, Effect.provide(ZcodeAdapterTestLayer)), + ); + + it.effect("maps shell tool lifecycle into command execution items", () => + Effect.gen(function* () { + const harness = yield* makeHarness(); + yield* harness.startTurn(); + yield* harness.offerTurnEvent("tool.updated", { + kind: "started", + toolCallId: "call_1", + toolName: "Bash", + input: { command: "echo hi" }, + }); + yield* harness.offerTurnEvent("tool.updated", { + kind: "result", + toolCallId: "call_1", + duration: 12, + result: { output: "hi" }, + }); + yield* harness.offerTurnEvent("turn.completed", { + response: "done", + tokenCount: 10, + toolCallCount: 1, + duration: 100, + resultType: "success", + }); + + const started = yield* harness.takeEvent( + "turn_item.updated", + (event) => + event.turnItem.type === "command_execution" && event.turnItem.status === "running", + ); + assert.equal( + started.turnItem.type === "command_execution" ? started.turnItem.input : "", + "echo hi", + ); + const finished = yield* harness.takeEvent( + "turn_item.updated", + (event) => + event.turnItem.type === "command_execution" && event.turnItem.status === "completed", + ); + assert.equal( + finished.turnItem.type === "command_execution" ? finished.turnItem.output : "", + "hi", + ); + yield* harness.takeEvent("turn.terminal", (event) => event.status === "completed"); + assert.deepEqual(harness.scenario.unexpectedMethods(), []); + }).pipe(Effect.scoped, Effect.provide(ZcodeAdapterTestLayer)), + ); + + it.effect("relays the exact scripted PermissionResponse to the broker request", () => + Effect.gen(function* () { + const harness = yield* makeHarness(); + yield* harness.startTurn(); + const allowResponse = { decision: "allow", reason: "Approved for this command" }; + yield* harness.offerTurnEvent("permission.requested", { + requestId: "perm_1", + toolCallId: "call_9", + toolName: "Bash", + riskLevel: "medium", + reason: "Run a shell command", + input: { command: "echo hi" }, + options: [ + { + optionId: "allowOnce", + kind: "allow_once", + name: "Allow once", + response: allowResponse, + }, + { + optionId: "deny", + kind: "deny", + name: "Deny", + response: { decision: "deny", reason: "Denied by user" }, + }, + ], + }); + // The broker request is delivered after the event but before the user answers; the + // adapter must hold it open so the eventual answer still reaches zcode. + const broker = zcodeServerRequest("interaction/requestPermission", { + sessionId: harness.scenario.createdSessionId(), + requestId: "perm_1", + toolCallId: "call_9", + reason: "Run a shell command", + riskLevel: "medium", + }); + yield* harness.scenario.offerEvent(broker); + + const requested = yield* harness.takeEvent( + "runtime_request.updated", + (event) => event.runtimeRequest.status === "pending", + ); + assert.equal(requested.runtimeRequest.kind, "command"); + assert.equal(requested.runtimeRequest.nativeRequestRef?.nativeId, "perm_1"); + yield* harness.takeEvent( + "turn_item.updated", + (event) => event.turnItem.type === "approval_request", + ); + + yield* harness.runtime.respondToRuntimeRequest({ + requestId: requested.runtimeRequest.id, + decision: "accept", + }); + + const relayed = harness.scenario.responds.find((respond) => respond.id === broker.id); + assert.isDefined(relayed); + assert.deepEqual(relayed!.result, allowResponse); + yield* harness.takeEvent( + "runtime_request.updated", + (event) => event.runtimeRequest.status === "resolved", + ); + yield* harness.offerTurnEvent("turn.completed", { + response: "done", + tokenCount: 3, + toolCallCount: 1, + duration: 80, + resultType: "success", + }); + yield* harness.takeEvent("turn.terminal", (event) => event.status === "completed"); + assert.deepEqual(harness.scenario.unexpectedMethods(), []); + }).pipe(Effect.scoped, Effect.provide(ZcodeAdapterTestLayer)), + ); + + it.effect("interrupts via session/stop and reports the cancelled result as interrupted", () => + Effect.gen(function* () { + const harness = yield* makeHarness(); + yield* harness.startTurn(); + const running = yield* harness.takeEvent( + "provider_turn.updated", + (event) => event.providerTurn.status === "running", + ); + yield* harness.runtime.interruptTurn({ + providerThread: harness.providerThread, + providerTurnId: running.providerTurn.id, + }); + const stop = requestFor(harness.scenario, "session/stop"); + assert.isDefined(stop); + assert.equal(stop!.params["sessionId"], "sess_1"); + yield* harness.offerTurnEvent("turn.completed", { + response: "", + tokenCount: 5, + toolCallCount: 0, + duration: 50, + resultType: "cancelled", + }); + yield* harness.takeEvent("turn.terminal", (event) => event.status === "interrupted"); + assert.deepEqual(harness.scenario.unexpectedMethods(), []); + }).pipe(Effect.scoped, Effect.provide(ZcodeAdapterTestLayer)), + ); +}); diff --git a/apps/server/src/orchestration-v2/Adapters/ZcodeAdapterV2.testkit.ts b/apps/server/src/orchestration-v2/Adapters/ZcodeAdapterV2.testkit.ts new file mode 100644 index 0000000000000..e15b1027afbe8 --- /dev/null +++ b/apps/server/src/orchestration-v2/Adapters/ZcodeAdapterV2.testkit.ts @@ -0,0 +1,266 @@ +// Frame-driven unit tests for the ZCode adapter. Every frame below is hand-written from the +// Phase 0 probe notes (PROTOCOL-NOTES.md), i.e. a guess about the protocol; live-recorded +// replay fixtures (Phase 3) must replace or augment these before merge confidence is complete. +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { ProviderInstanceId } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Queue from "effect/Queue"; + +import * as TestProviderHost from "@t3tools/provider-testing/TestProviderHost"; +import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; +import * as McpProviderSessions from "@t3tools/provider-core/server/McpProviderSessions"; +import { ZcodeAdapterV2Driver, ZCODE_PROVIDER } from "@t3tools/provider-zcode/server"; +import { + type ZcodeConnection, + type ZcodeProtocolError, + type ZcodeRecord, + type ZcodeAdapterV2Options, +} from "@t3tools/provider-zcode/testing"; + +export interface CapturedZcodeRequest { + readonly method: string; + readonly params: ZcodeRecord; +} + +export interface CapturedZcodeRespond { + readonly id: string; + readonly result: unknown; +} + +export interface FakeZcodeScenario { + readonly connection: ZcodeConnection; + /** Client→server requests in wire order. */ + readonly requests: ReadonlyArray; + /** Responses the adapter sent to server→client requests, in order. */ + readonly responds: ReadonlyArray; + /** Blocks until the adapter answers the next server→client request. */ + readonly takeRespond: Effect.Effect; + /** Pushes a notification record onto the wire for the adapter's pump. */ + readonly offerEvent: (record: ZcodeRecord) => Effect.Effect; + /** The session id the fake handed out for session/create. */ + readonly createdSessionId: () => string | undefined; + /** Methods the adapter sent that this fake does not script; must stay empty. */ + readonly unexpectedMethods: () => ReadonlyArray; +} + +const KNOWN_METHODS = new Set([ + "provider/updateAccountConfig", + "provider/refreshRegistry", + "session/create", + "session/read", + "session/resume", + "session/send", + "session/stop", + "session/subscribe", +]); + +const SESSION_CREATE_EPOCH_MS = 1_794_206_541_509; + +function zcodeSessionCreateResult(sessionId: string, params: ZcodeRecord): ZcodeRecord { + const workspace = (params["workspace"] ?? {}) as ZcodeRecord; + return { + messages: [], + projection: { + activeToolCalls: [], + backgroundJobs: [], + contextUsed: 0, + contextWindow: 200000, + mode: "build", + pendingPermissions: [], + sessionId: "unknown", + status: "idle", + target: null, + totalTokenCount: 0, + turnCount: 0, + }, + protocol: { name: "ZCode Protocol", version: 1 }, + runtime: { + eventSeq: 0, + pendingRequestIds: [], + goalVerifications: [], + goalVerificationTimeline: [], + stateRevision: 0, + }, + session: { + createdAt: SESSION_CREATE_EPOCH_MS, + mode: "build", + traceId: "trace-fake-zcode", + sessionId, + sessionKind: "interactive", + status: "idle", + target: null, + title: "", + updatedAt: SESSION_CREATE_EPOCH_MS, + workspace: { + workspacePath: String(workspace["workspacePath"] ?? ""), + workspaceKey: String(workspace["workspaceKey"] ?? ""), + }, + }, + settings: { + mode: { current: "build" }, + model: { available: [] }, + permission: { mode: "build" }, + thoughtLevel: { available: [], enabled: false }, + }, + slashCommands: [], + }; +} + +/** Builds the fake `zcode app-server` NDJSON peer the adapter talks to. */ +export const makeFakeZcodeConnection = Effect.fnUntraced(function* (): Effect.fn.Return< + FakeZcodeScenario, + never, + never +> { + const events = yield* Queue.unbounded(); + const respondQueue = yield* Queue.unbounded(); + const requests: Array = []; + const responds: Array = []; + let sessionCounter = 0; + let serverRequestCounter = 0; + let createdSessionId: string | undefined; + + const offerServerRequest = (method: string, params: ZcodeRecord) => + Queue.offer(events, { + id: `server-${++serverRequestCounter}`, + method, + params, + }).pipe(Effect.asVoid); + + const request = ( + method: string, + params: ZcodeRecord, + ): Effect.Effect => + Effect.gen(function* () { + requests.push({ method, params }); + if (!KNOWN_METHODS.has(method)) return {}; + switch (method) { + case "session/create": { + createdSessionId = `sess_${++sessionCounter}`; + // The real server asks these while materializing the session runtime. + yield* offerServerRequest("session/requestRuntimePreferences", { + sessionId: createdSessionId, + scope: "runtime-materialization", + }); + yield* offerServerRequest("interaction/requestOfficialMcpAuthHeaders", { + mcpKey: "image_search", + pluginId: "image-search@zcode-plugins-official", + requestId: "official-mcp-auth:1", + targetOrigin: "https://zcode.z.ai", + workspace: { + workspacePath: String(params["workspace"] ?? ""), + workspaceKey: String(params["workspace"] ?? ""), + }, + }); + return zcodeSessionCreateResult(createdSessionId, params); + } + case "session/subscribe": + return { sessionId: createdSessionId, eventSeq: 1, events: [] }; + case "session/send": { + yield* Queue.offer(events, { + method: "state.updated", + params: { + patch: { status: "running" }, + reason: "prompt_started", + revision: 1, + scope: "session", + sessionId: createdSessionId, + }, + }); + return { sessionId: createdSessionId, accepted: true, stateRevision: 1 }; + } + case "provider/updateAccountConfig": + return { + receivedRevision: String(params["revision"] ?? ""), + providerCount: Object.keys((params["providers"] ?? {}) as ZcodeRecord).length, + status: "received", + }; + case "provider/refreshRegistry": + return { refreshed: true }; + case "session/read": + return zcodeSessionCreateResult(createdSessionId ?? "sess_unknown", params); + case "session/stop": + case "session/resume": + return {}; + default: + return {}; + } + }); + + const connection: ZcodeConnection = { + request, + respond: (id, result) => + Effect.gen(function* () { + const captured: CapturedZcodeRespond = { id, result }; + responds.push(captured); + yield* Queue.offer(respondQueue, captured); + }), + events, + exited: Effect.never, + terminate: Effect.void, + }; + + return { + connection, + requests, + responds, + takeRespond: Queue.take(respondQueue), + offerEvent: (record) => Queue.offer(events, record).pipe(Effect.asVoid), + createdSessionId: () => createdSessionId, + unexpectedMethods: () => + requests.filter((call) => !KNOWN_METHODS.has(call.method)).map((call) => call.method), + }; +}); + +let sharedEventCounter = 0; +let sharedServerRequestCounter = 0; + +/** + * session/event notification with the full envelope inside `params`, exactly as the wire + * carries it (PROTOCOL-NOTES.md: "params = envelope + type"). + */ +export const zcodeSessionEvent = ( + sessionId: string, + type: string, + payload: ZcodeRecord, +): ZcodeRecord => ({ + method: "session/event", + params: { + eventId: `evt_${++sharedEventCounter}`, + sessionId, + turnId: "turn_native_1", + seq: sharedEventCounter, + timestamp: SESSION_CREATE_EPOCH_MS + sharedEventCounter, + deliveryKind: "desktop-continuous", + type, + payload, + }, +}); + +/** Server→client request record the adapter must answer via connection.respond. */ +export const zcodeServerRequest = (method: string, params: ZcodeRecord): ZcodeRecord => ({ + id: `server-frame-${++sharedServerRequestCounter}`, + method, + params, +}); + +export const makeZcodeAdapterOptions = (): ZcodeAdapterV2Options => ({ + instanceId: ProviderInstanceId.make(ZcodeAdapterV2Driver.driverKind), + settings: ZcodeAdapterV2Driver.defaultConfig(), + environment: {}, +}); + +/** The driver kind the package driver and the hand-built adapter options must agree on. */ +export const zcodeDriverKind: typeof ZCODE_PROVIDER = ZCODE_PROVIDER; + +/** + * Context for driving the real adapter: ProviderHost (paths/credentials), node runtime services + * (spawner/file system/clock), derived id allocation, and the MCP session registry. + */ +export const ZcodeAdapterTestLayer = Layer.mergeAll( + TestProviderHost.layer().pipe(Layer.provide(NodeServices.layer)), + NodeServices.layer, + IdAllocator.layer, + McpProviderSessions.layer, +); diff --git a/apps/server/src/provider/builtInDrivers.ts b/apps/server/src/provider/builtInDrivers.ts index 342a6780e9ad0..feea373399aeb 100644 --- a/apps/server/src/provider/builtInDrivers.ts +++ b/apps/server/src/provider/builtInDrivers.ts @@ -16,6 +16,7 @@ import type { ProviderUsageReaderEnv, } from "@t3tools/provider-core/server/driver"; import { KiroDriver, type KiroDriverEnv } from "@t3tools/provider-kiro/server"; +import { ZcodeDriver, type ZcodeDriverEnv } from "@t3tools/provider-zcode/server"; export type BuiltInDriversEnv = | AcpRegistryDriverEnv @@ -28,7 +29,8 @@ export type BuiltInDriversEnv = | KiroDriverEnv | OpenCodeDriverEnv | PiDriverEnv - | MuseDriverEnv; + | MuseDriverEnv + | ZcodeDriverEnv; export const BUILT_IN_DRIVERS: ReadonlyArray> = [ CodexDriver, @@ -41,6 +43,7 @@ export const BUILT_IN_DRIVERS: ReadonlyArray=2.27.0", "ranges": [{ "range": ">=2.27.0", "status": "supported" }] }, + { + "driver": "zcode", + "t3CodeRange": ">=0.0.46", + "recommendedRange": ">=0.17.1", + "ranges": [{ "range": ">=0.17.1", "status": "supported" }] + }, { "driver": "pi", "t3CodeRange": ">=0.0.46", diff --git a/apps/server/src/provider/providerStatusCache.ts b/apps/server/src/provider/providerStatusCache.ts index 66e7d64d70a64..7130fe223d7bb 100644 --- a/apps/server/src/provider/providerStatusCache.ts +++ b/apps/server/src/provider/providerStatusCache.ts @@ -36,6 +36,7 @@ const BUILT_IN_DRIVER_ORDER: ReadonlyArray = [ "antigravity", "fx", "kiro", + "zcode", ]; const driverRank = (driver: string): number => { diff --git a/apps/server/src/settingsSecrets.ts b/apps/server/src/settingsSecrets.ts index 22ea8ae882583..8555d7ca4fb18 100644 --- a/apps/server/src/settingsSecrets.ts +++ b/apps/server/src/settingsSecrets.ts @@ -20,6 +20,7 @@ import { kiroClient } from "@t3tools/provider-kiro/client"; import { museClient } from "@t3tools/provider-muse/client"; import { openCodeClient } from "@t3tools/provider-opencode/client"; import { piClient } from "@t3tools/provider-pi/client"; +import { zcodeClient } from "@t3tools/provider-zcode/client"; import { readSecretFieldValues, SOURCE_CONTROL_HOST_SECRET_FIELDS, @@ -40,6 +41,7 @@ const providerSecretFields = new Map([ museClient, openCodeClient, piClient, + zcodeClient, acpRegistryClient, ].map( (definition) => diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index 768ce76c2238b..47697edc41828 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -932,7 +932,7 @@ export const SETTINGS_SEARCH_ITEMS = [ title: "Providers", to: "/settings/providers", searchTerms: [ - "agents cli codex claude cursor grok fx kiro opencode antigravity google sign in sign out install subscription instances authentication api key models configuration binary path config directory codex_home shadow home endpoint arguments environment variables display name accent color custom favorite hidden auto compact native sessions import resume", + "agents cli codex claude cursor grok fx kiro opencode antigravity zcode z ai glm sign in sign out install subscription instances authentication api key models configuration binary path config directory codex_home shadow home endpoint arguments environment variables display name accent color custom favorite hidden auto compact native sessions import resume", ], }, { diff --git a/docs/user/faq.md b/docs/user/faq.md index 8d34de5e2ea45..4c6f1cc0bc3c0 100644 --- a/docs/user/faq.md +++ b/docs/user/faq.md @@ -6,7 +6,7 @@ No. A harness is what turns an LLM into an agent: it gives the model tools, a wa Chef = LLM. Kitchen = harness. T3 Code = the restaurant that lets you bring whatever kitchen you want. Our MCP is a cookbook we hand the kitchen, but the chef won't open it unless you tell them to :) I wrote the long version here: [What is a harness?](https://t3.gg/blog/post/what-is-a-harness) -Bring your own: Claude Code, Codex, Cursor, Grok Build, OpenCode, Google Antigravity, Pi, or Muse Code. [Provider setup](./install.md#providers) +Bring your own: Claude Code, Codex, Cursor, Grok Build, OpenCode, Google Antigravity, Pi, ZCode, or Muse Code. [Provider setup](./install.md#providers) ## Why use T3 Code instead of Claude Code or the Codex app? diff --git a/docs/user/install.md b/docs/user/install.md index 48d96a4fef611..fbad99bde64df 100644 --- a/docs/user/install.md +++ b/docs/user/install.md @@ -198,6 +198,7 @@ computer. | Pi | Install [Pi](./providers-pi.md#set-up-pi) with Node.js 22.19 or newer, then run `pi` and `/login`. | | Muse Code | Install [Muse Code](https://dev.meta.ai/docs/muse-code) on the server, run `muse login`, then enable it in Settings → Providers. | | fx | Install [fx](https://fx.sh) with `curl -fsSL https://fx.sh/setup.sh \| bash`, then run `fx login`. | +| ZCode | Install [ZCode](./providers-zcode.md#set-up-zcode), then run `zcode login`. | Provider CLIs must be on the server's `PATH`. If T3 Code cannot find one, set its **Binary path** in provider settings, especially when using a version manager. @@ -230,7 +231,8 @@ their original values. For provider-specific setup and accounts, see [Codex](./providers-codex.md), [Claude](./providers-claude.md), [OpenCode](./providers-opencode.md), [Antigravity](./providers-antigravity.md), [Kiro](./providers-kiro.md), -[Pi](./providers-pi.md), [Muse Code](./providers-muse.md), and [fx](./providers-fx.md). +[Pi](./providers-pi.md), [Muse Code](./providers-muse.md), [fx](./providers-fx.md), and +[ZCode](./providers-zcode.md). ## Next steps diff --git a/docs/user/permission-modes.md b/docs/user/permission-modes.md index 796e3f316122d..a49b7a8501cb3 100644 --- a/docs/user/permission-modes.md +++ b/docs/user/permission-modes.md @@ -57,4 +57,7 @@ approvals for actions that support them. Kiro offers only **Supervised** and **Full access**. In **Supervised** Kiro also asks you to review a turn's file changes. See [Kiro permissions](./providers-kiro.md#permission-modes). +ZCode maps **Supervised** to its guarded mode, which gates every tool call through T3 Code. +Approvals offer only the choices ZCode sends. See [ZCode permissions](./providers-zcode.md#permission-modes). + See the [provider guides](./install.md#providers) for setup and provider-specific limits. diff --git a/docs/user/providers-zcode.md b/docs/user/providers-zcode.md new file mode 100644 index 0000000000000..b4befa0289b7b --- /dev/null +++ b/docs/user/providers-zcode.md @@ -0,0 +1,36 @@ +# ZCode + +T3 Code runs the ZCode CLI as an agent, using your own Z.AI account, skills, plugins, and MCP +configuration. + +## Set Up ZCode + +1. Install ZCode on the machine running the T3 Code server. The ZCode app installs the `zcode` + CLI; make sure it is on that machine's `PATH`, or set its **Binary path** in provider settings. +2. Run `zcode login` once in a terminal on that machine. Login opens a browser authorization for + your Z.AI account. +3. Open **Settings → Providers**, enable ZCode, and refresh it. + +ZCode runs on the server's machine only. A desktop ZCode app elsewhere does not share its sign-in +with T3 Code; the server's own `zcode login` is what counts. + +## Models + +The model picker lists the GLM models your Z.AI plan offers. New threads start on `default`, +which defers to the model your ZCode session resolves on its own. A model your account cannot +use stops the turn with an error instead of running on another model. + +## Permission Modes + +ZCode gates tool calls by its own permission modes, and T3 Code answers by the permission mode: + +- **Supervised** maps to ZCode's guarded mode and sends tool approvals to you. Approvals offer + only the choices ZCode sends. +- **Auto-accept edits** maps to ZCode's edit mode, which approves file changes without asking. +- **Full access** maps to ZCode's yolo mode and approves tool calls without asking you. + +## Steering, Forks and Compaction + +- A message you send while ZCode is working joins the running turn. +- Forking a thread creates a ZCode fork of the conversation from that point. +- `/compact` uses ZCode's own compaction. diff --git a/package.json b/package.json index 0664806c3ad61..349c22da1e521 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,7 @@ "knip": "knip --preprocessor ./scripts/knip-schemas.ts", "knip:check": "vp run knip:check:all", "knip:check:files": "knip --include files,dependencies --no-config-hints", - "knip:check:exports": "knip --workspace apps/server --workspace apps/desktop --workspace apps/web --workspace packages/client-runtime --workspace packages/contracts --workspace packages/effect-acp --workspace packages/effect-codex-app-server --workspace packages/shared --workspace packages/ssh --workspace packages/tailscale --workspace packages/provider-core --workspace packages/provider-acp --workspace packages/provider-acp-registry --workspace packages/provider-testing --workspace packages/provider-pi --workspace packages/provider-muse --workspace packages/provider-opencode --workspace packages/provider-cursor --workspace packages/provider-fx --workspace packages/provider-grok --workspace packages/provider-kiro --workspace packages/secret-provider-core --workspace packages/secret-provider-definitions --workspace packages/secret-provider-onepassword --workspace packages/source-control-core --workspace packages/source-control-definitions --workspace packages/source-control-azure-devops --workspace packages/source-control-bitbucket --workspace packages/source-control-forgejo --workspace packages/source-control-gitcafe --workspace packages/source-control-github --workspace packages/source-control-gitlab --workspace packages/source-control-testing --exports --preprocessor ./scripts/knip-schemas.ts --no-config-hints", + "knip:check:exports": "knip --workspace apps/server --workspace apps/desktop --workspace apps/web --workspace packages/client-runtime --workspace packages/contracts --workspace packages/effect-acp --workspace packages/effect-codex-app-server --workspace packages/shared --workspace packages/ssh --workspace packages/tailscale --workspace packages/provider-core --workspace packages/provider-acp --workspace packages/provider-acp-registry --workspace packages/provider-testing --workspace packages/provider-pi --workspace packages/provider-muse --workspace packages/provider-opencode --workspace packages/provider-cursor --workspace packages/provider-fx --workspace packages/provider-grok --workspace packages/provider-kiro --workspace packages/provider-zcode --workspace packages/secret-provider-core --workspace packages/secret-provider-definitions --workspace packages/secret-provider-onepassword --workspace packages/source-control-core --workspace packages/source-control-definitions --workspace packages/source-control-azure-devops --workspace packages/source-control-bitbucket --workspace packages/source-control-forgejo --workspace packages/source-control-gitcafe --workspace packages/source-control-github --workspace packages/source-control-gitlab --workspace packages/source-control-testing --exports --preprocessor ./scripts/knip-schemas.ts --no-config-hints", "knip:production": "knip --production --preprocessor ./scripts/knip-schemas.ts", "lint:mobile": "node scripts/mobile-native-static-check.ts", "mobile:install": "node scripts/mobile-native-client.ts ensure", diff --git a/packages/client-runtime/package.json b/packages/client-runtime/package.json index f52115577f108..674162b36b135 100644 --- a/packages/client-runtime/package.json +++ b/packages/client-runtime/package.json @@ -556,6 +556,7 @@ "@t3tools/provider-muse": "workspace:*", "@t3tools/provider-opencode": "workspace:*", "@t3tools/provider-pi": "workspace:*", + "@t3tools/provider-zcode": "workspace:*", "@t3tools/secret-provider-definitions": "workspace:*", "@t3tools/shared": "workspace:*", "@t3tools/source-control-core": "workspace:*", diff --git a/packages/client-runtime/src/providerClients.ts b/packages/client-runtime/src/providerClients.ts index 3759cecbc7c7d..125d127c2915d 100644 --- a/packages/client-runtime/src/providerClients.ts +++ b/packages/client-runtime/src/providerClients.ts @@ -15,6 +15,7 @@ import { kiroClient } from "@t3tools/provider-kiro/client"; import { museClient } from "@t3tools/provider-muse/client"; import { openCodeClient } from "@t3tools/provider-opencode/client"; import { piClient } from "@t3tools/provider-pi/client"; +import { zcodeClient } from "@t3tools/provider-zcode/client"; // OpenRouter's Ink and Cloud glyphs from https://openrouter.ai/brand. export const openRouterIcon: ProviderIcon = { @@ -56,6 +57,7 @@ export const providerClients = makeProviderClientRegistry([ kiroClient, museClient, piClient, + zcodeClient, acpRegistryClient, ]); diff --git a/packages/contracts/src/model.ts b/packages/contracts/src/model.ts index 1a2a519e1abc7..a6fa700cc59f5 100644 --- a/packages/contracts/src/model.ts +++ b/packages/contracts/src/model.ts @@ -124,6 +124,7 @@ const MUSE_DRIVER_KIND = ProviderDriverKind.make("muse"); const PI_DRIVER_KIND = ProviderDriverKind.make("pi"); const ACP_REGISTRY_DRIVER_KIND = ProviderDriverKind.make("acpRegistry"); const OPENCODE_DRIVER_KIND = ProviderDriverKind.make("opencode"); +const ZCODE_DRIVER_KIND = ProviderDriverKind.make("zcode"); export const DEFAULT_MODEL = "gpt-6-astra"; @@ -155,6 +156,8 @@ export const DEFAULT_MODEL_BY_PROVIDER: Partial> [ACP_REGISTRY_DRIVER_KIND]: "ACP Registry", [PI_DRIVER_KIND]: "Pi", [OPENCODE_DRIVER_KIND]: "OpenCode", + [ZCODE_DRIVER_KIND]: "ZCode", }; diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index cd112d84bf9f4..ed5178305a491 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -1565,7 +1565,7 @@ export const providerInstanceConfigEnabledFlag = (config: unknown): boolean | un // Must match each driver's enabled decoding default. const DEFAULT_DISABLED_PROVIDER_DRIVERS: ReadonlySet = new Set( - ["cursor", "grok", "fx", "kiro", "muse", "pi", "opencode", "antigravity"].map((driver) => + ["cursor", "grok", "fx", "kiro", "muse", "pi", "opencode", "antigravity", "zcode"].map((driver) => ProviderDriverKind.make(driver), ), ); diff --git a/packages/provider-zcode/package.json b/packages/provider-zcode/package.json new file mode 100644 index 0000000000000..8199c290b53d4 --- /dev/null +++ b/packages/provider-zcode/package.json @@ -0,0 +1,40 @@ +{ + "name": "@t3tools/provider-zcode", + "private": true, + "type": "module", + "exports": { + "./client": { + "types": "./src/client.ts", + "import": "./src/client.ts" + }, + "./settings": { + "types": "./src/settings.ts", + "import": "./src/settings.ts" + }, + "./server": { + "types": "./src/server.ts", + "import": "./src/server.ts" + }, + "./testing": { + "types": "./src/testing.ts", + "import": "./src/testing.ts" + } + }, + "scripts": { + "typecheck": "effect-tsgo tsrs --noEmit", + "test": "vp test run --config ../../vite.config.ts --dir ." + }, + "dependencies": { + "@t3tools/contracts": "workspace:*", + "@t3tools/provider-core": "workspace:*", + "@t3tools/shared": "workspace:*", + "effect": "catalog:" + }, + "devDependencies": { + "@effect/platform-node": "catalog:", + "@effect/vitest": "catalog:", + "@t3tools/provider-testing": "workspace:*", + "@types/node": "catalog:", + "vite-plus": "catalog:" + } +} diff --git a/packages/provider-zcode/src/client.ts b/packages/provider-zcode/src/client.ts new file mode 100644 index 0000000000000..7b4d738e60e9c --- /dev/null +++ b/packages/provider-zcode/src/client.ts @@ -0,0 +1,20 @@ +import { ProviderDriverKind } from "@t3tools/contracts"; +import { defineProviderClient } from "@t3tools/provider-core/client"; + +import { ZcodeSettings } from "./settings.ts"; + +export const zcodeClient = defineProviderClient({ + driverKind: ProviderDriverKind.make("zcode"), + label: "ZCode", + settingsSchema: ZcodeSettings, + icon: { + viewBox: "0 0 1024 1024", + fill: { light: "#18181B", dark: "#FAFAFA" }, + paths: [ + { + // Simplified trace of the official ZCode italic "Z" glyph. + d: "M304 288 H804 V340 L560 684 H804 V736 H280 V684 L524 340 H304 Z", + }, + ], + }, +}); diff --git a/packages/provider-zcode/src/server.ts b/packages/provider-zcode/src/server.ts new file mode 100644 index 0000000000000..132a4ce0c57be --- /dev/null +++ b/packages/provider-zcode/src/server.ts @@ -0,0 +1,6 @@ +export { ZcodeDriver, type ZcodeDriverEnv } from "./server/driver.ts"; +export { + ZCODE_PROVIDER, + ZcodeAdapterV2Driver, + type ZcodeAdapterV2DriverEnv, +} from "./server/adapter.ts"; diff --git a/packages/provider-zcode/src/server/adapter.ts b/packages/provider-zcode/src/server/adapter.ts new file mode 100644 index 0000000000000..b9a2eb3577a30 --- /dev/null +++ b/packages/provider-zcode/src/server/adapter.ts @@ -0,0 +1,2206 @@ +import { AgentScope } from "@t3tools/shared/AgentScope"; +import * as HostProcess from "@t3tools/shared/HostProcess"; +import * as Predicate from "effect/Predicate"; +import { + type OrchestrationV2ConversationMessage, + type OrchestrationV2ExecutionNode, + type OrchestrationV2ProviderCapabilities, + type OrchestrationV2ProviderFailure, + type OrchestrationV2ProviderRef, + type OrchestrationV2ProviderSession, + type OrchestrationV2ProviderThread, + type OrchestrationV2ProviderTurn, + type OrchestrationV2RuntimeRequest, + type OrchestrationV2TurnItem, + type OrchestrationV2UserInputQuestion, + type ProviderApprovalDecision, + type ProviderApprovalOption, + ProviderDriverKind, + type ProviderInstanceId, + type ProviderRequestKind, + type RuntimeMode, + type RuntimeRequestId, +} from "@t3tools/contracts"; +import { ZcodeSettings } from "../settings.ts"; +import * as Cause from "effect/Cause"; +import * as DateTime from "effect/DateTime"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Option from "effect/Option"; +import * as Queue from "effect/Queue"; +import * as Schema from "effect/Schema"; +import * as Semaphore from "effect/Semaphore"; +import * as Stream from "effect/Stream"; +import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; + +import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; +import { providerMessageTextWithAttachmentPaths } from "@t3tools/provider-core/server/attachmentPrompt"; +import { mcpToolPresentation } from "@t3tools/provider-core/server/mcpToolPresentation"; +import * as McpProviderSessions from "@t3tools/provider-core/server/McpProviderSessions"; +import type { McpProviderSessionConfig } from "@t3tools/provider-core/server/mcpSession"; +import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; +import { makeProviderFailure } from "@t3tools/provider-core/server/failure"; +import { turnScopedSelectionTransition } from "@t3tools/provider-core/server/selectionTransition"; +import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost"; +import { mergeProviderInstanceEnvironment } from "@t3tools/provider-core/server/instanceEnvironment"; +import { + ProviderAdapterDriverCreateError, + type ProviderAdapterDriver, + type ProviderAdapterDriverCreateInput, +} from "@t3tools/provider-core/server/adapterDriver"; + +import { + isServerRequest, + makeZcodeConnection, + zcodeJsonStringify, + zcodeRecordArray, + zcodeRecordField, + zcodeRecordNumber, + zcodeRecordString, + type ZcodeConnection, + type ZcodeRecord, +} from "./zcodeProtocol.ts"; +import { + resolveZcodeCatalogFiles, + zcodeAccountConfigPush, + zcodeCommand, + zcodeModelSelectionFromSlug, + zcodeRuntimeEnvironment, + zcodeSpawnArgs, + type ZcodeCatalogFileSet, +} from "./zcodeRuntime.ts"; + +export const ZCODE_PROVIDER: ProviderDriverKind = ProviderDriverKind.make("zcode"); +const DEFAULT_ZCODE_SETTINGS = Schema.decodeSync(ZcodeSettings)({}); + +const STREAM_FLUSH_MS = 50; +const DEFAULT_REQUEST_TIMEOUT_MS = 30_000; +// MCP boot dominates session startup; the probe measured 10s+ with plugins enabled. +const SESSION_CREATE_TIMEOUT_MS = 150_000; +const T3_MCP_SERVER_NAME = "t3-code"; +const USAGE_LIMIT_ERROR_PATTERN = /rate limit|quota|usage limit|too many requests/i; + +const ZcodeProviderCapabilitiesV2: OrchestrationV2ProviderCapabilities = { + runtimePolicy: { enforcement: "client-boundary" }, + sessions: { + supportsMultipleProviderThreadsPerSession: false, + supportsModelSwitchInSession: true, + supportsProviderSwitchingViaHandoff: true, + supportsRuntimeModeSwitchInSession: false, + pendingRequestsSurviveRestart: false, + }, + threads: { + canCreateEmptyThread: true, + canReadThreadSnapshot: true, + // The protocol has no conversation rollback method. + canRollbackThread: false, + // session/fork exists on the wire, but its thread-handoff mapping into T3 is unwired. + canForkThread: false, + canForkFromTurn: false, + canForkFromSubagentThread: false, + exposesNativeThreadId: true, + }, + turns: { + exposesNativeTurnId: false, + emitsTurnStarted: true, + emitsTurnCompleted: true, + supportsInterrupt: true, + supportsActiveSteering: true, + activeSteeringInterruptsTools: false, + activeSteeringAcceptsAttachments: false, + supportsSteeringByInterruptRestart: false, + supportsQueuedMessages: true, + terminalStatusQuality: "strong", + }, + streaming: { + streamsAssistantText: true, + streamsReasoning: true, + streamsToolOutput: true, + streamsPlanText: false, + emitsMessageCompleted: true, + }, + tools: { + exposesToolItemIds: true, + emitsToolStarted: true, + emitsToolCompleted: true, + emitsToolOutput: true, + supportsMcpTools: true, + supportsDynamicToolCallbacks: false, + }, + approvals: { + supportsCommandApproval: true, + supportsFileReadApproval: false, + supportsFileChangeApproval: false, + supportsApplyPatchApproval: false, + approvalsHaveNativeRequestIds: true, + approvalCallbacksAreLiveOnly: true, + approvalsCanOriginateFromSubagents: false, + }, + planning: { + emitsPlanUpdated: false, + emitsTodoList: false, + emitsProposedPlan: false, + supportsStructuredQuestions: true, + planDeltasHaveItemIds: false, + }, + subagents: { + // tool.updated carries subagent attribution, but the child-session projection shape is + // unverified; wrong OrchestrationV2Subagent records would corrupt the run tree. + supportsSubagents: false, + exposesSubagentThreadIds: false, + emitsSubagentLifecycle: false, + canWaitForSubagents: false, + canCloseSubagents: false, + canForkSubagentThread: false, + }, + context: { + acceptsSystemContext: false, + acceptsDeveloperContext: false, + acceptsSyntheticUserContext: true, + canGenerateSummaries: false, + canConsumeHandoffSummaries: true, + supportsDeltaHandoff: false, + supportsFullThreadHandoff: false, + maxRecommendedHandoffChars: null, + }, + checkpointing: { + appCanCheckpointFilesystem: true, + supportsNestedCheckpointScopes: false, + providerCanRollbackConversation: false, + providerRollbackReturnsSnapshot: false, + providerCanReadConversationSnapshot: true, + }, + identity: { + nativeThreadIds: "strong", + nativeTurnIds: "weak", + nativeItemIds: "strong", + nativeRequestIds: "strong", + }, +}; + +export interface ZcodeAdapterV2Options { + readonly instanceId: ProviderInstanceId; + readonly settings: ZcodeSettings; + /** Instance environment already merged with the host process environment. */ + readonly environment: NodeJS.ProcessEnv; + /** Testkit injection point; production spawns the real `zcode app-server`. */ + readonly makeConnection?: typeof makeZcodeConnection; +} + +function providerRef( + nativeId: string, + strength: "strong" | "weak" = "strong", +): OrchestrationV2ProviderRef { + return { driver: ZCODE_PROVIDER, nativeId, strength }; +} + +/** + * Zcode enforces tool gating itself: guarded gates everything through the permission client, + * edit auto-accepts file changes, yolo never asks. Unoffered stored modes arrive as + * Supervised, so anything unexpected falls back to the strictest mode. + */ +function zcodeSessionMode(runtimeMode: RuntimeMode | undefined): "guarded" | "edit" | "yolo" { + switch (runtimeMode) { + case "auto-accept-edits": + return "edit"; + case "full-access": + return "yolo"; + default: + return "guarded"; + } +} + +function contentText(content: unknown): string { + if (!Array.isArray(content)) return typeof content === "string" ? content : ""; + return content + .map((block) => + zcodeRecordString(block ?? {}, "type") === "text" + ? (zcodeRecordString(block, "text") ?? "") + : "", + ) + .join(""); +} + +function toolResultOutput(result: unknown): string | undefined { + if (!Predicate.isObject(result)) return undefined; + const direct = zcodeRecordString(result, "output") ?? zcodeRecordString(result, "text"); + if (direct !== undefined) return direct; + const content = zcodeRecordField(result, "content"); + if (content !== undefined) { + const text = contentText(content); + if (text.length > 0) return text; + } + return undefined; +} + +const SHELL_TOOL_NAMES = new Set(["bash", "shell", "powershell", "cmd", "terminal", "exec"]); +const FILE_WRITE_TOOL_NAMES = new Set(["write", "edit", "applypatch", "multiedit", "notebookedit"]); + +interface ZcodeStreamItemState { + readonly nativeItemId: string; + readonly kind: "assistant_message" | "reasoning"; + text: string; + completed: boolean; + flushScheduled: boolean; + readonly startedAt: DateTime.Utc; +} + +interface ZcodeToolState { + readonly toolCallId: string; + name: string | undefined; + input: unknown; + readonly startedAt: DateTime.Utc; + output: string | undefined; +} + +interface ZcodePermissionOptionRecord { + readonly optionId: string; + readonly kind: string; + readonly name: string; + readonly response: ZcodeRecord; +} + +function zcodePermissionOptions(value: unknown): ReadonlyArray { + const raw = zcodeRecordArray(value ?? {}, "options") ?? []; + const options: Array = []; + for (const entry of raw) { + const optionId = zcodeRecordString(entry ?? {}, "optionId"); + const kind = zcodeRecordString(entry ?? {}, "kind"); + const name = zcodeRecordString(entry ?? {}, "name"); + const response = zcodeRecordField(entry ?? {}, "response"); + if ( + optionId === undefined || + kind === undefined || + name === undefined || + !Predicate.isObject(response) + ) { + continue; + } + options.push({ optionId, kind, name, response }); + } + return options; +} + +/** Chooses the exact response object zcode's broker expects for a T3 decision. */ +function zcodeApprovalResponseForDecision( + options: ReadonlyArray, + decision: ProviderApprovalDecision, +): ZcodeRecord | undefined { + const byKind = (kinds: ReadonlyArray) => + options.find((option) => kinds.includes(option.kind))?.response; + const byResponseDecision = (value: string) => + options.find((option) => zcodeRecordString(option.response, "decision") === value)?.response; + switch (decision) { + case "accept": + return byKind(["allow_once"]) ?? byResponseDecision("allow"); + case "acceptForSession": + case "acceptAlways": + return byKind(["allow_always"]) ?? byKind(["allow_once"]) ?? byResponseDecision("allow"); + case "decline": + case "cancel": + return byKind(["deny", "reject"]) ?? byResponseDecision("deny"); + } +} + +function zcodeApprovalDecisionForOption( + option: ZcodePermissionOptionRecord, +): ProviderApprovalDecision { + if (option.kind === "allow_once") return "accept"; + if (option.kind === "allow_always") return "acceptForSession"; + if (option.kind === "deny" || option.kind === "reject") return "decline"; + return zcodeRecordString(option.response, "decision") === "allow" ? "accept" : "decline"; +} + +interface ZcodePendingApproval { + readonly nativeRequestId: string; + readonly options: ReadonlyArray; + runtimeRequest: OrchestrationV2RuntimeRequest; + readonly node: OrchestrationV2ExecutionNode; + readonly turnItem: OrchestrationV2TurnItem; + /** The exact PermissionResponse object zcode's broker expects; null until the user answers. */ + chosenResponse: ZcodeRecord | null; +} + +interface ZcodePendingQuestion { + readonly nativeRequestId: string; + readonly questionId: string; + runtimeRequest: OrchestrationV2RuntimeRequest; + readonly node: OrchestrationV2ExecutionNode; + readonly turnItem: OrchestrationV2TurnItem; + answer: { readonly value: string } | { readonly cancelled: true } | null; +} + +interface ZcodeAwaitingRequest { + readonly respondId: string; + readonly nativeRequestId: string; +} + +interface ActiveZcodeTurn { + readonly turnInput: ProviderAdapter.ProviderAdapterV2TurnInput; + providerTurn: OrchestrationV2ProviderTurn; + readonly itemOrdinals: Map; + nextItemOrdinal: number; + readonly streamItems: Map; + readonly tools: Map; + upsertedItemId: string | null; + interrupted: boolean; + failure: OrchestrationV2ProviderFailure | null; + finalized: boolean; +} + +interface ZcodeThreadState { + providerThread: OrchestrationV2ProviderThread; + readonly nativeSessionId: string; + activeTurn: ActiveZcodeTurn | null; +} + +function zcodeFailureFromErrorDetail(error: unknown): OrchestrationV2ProviderFailure { + const record = Predicate.isObject(error) ? error : {}; + const message = zcodeRecordString(record, "message") ?? "ZCode reported a turn error."; + const usageLimited = USAGE_LIMIT_ERROR_PATTERN.test(message); + return makeProviderFailure({ + message, + code: zcodeRecordString(record, "code") ?? null, + class: usageLimited ? "usage_limit" : "provider_error", + ...(usageLimited ? { retryable: true as const } : {}), + }); +} + +export const makeZcodeAdapterV2 = Effect.fn("makeZcodeAdapterV2")(function* ( + options: ZcodeAdapterV2Options, +) { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const agentScope = yield* AgentScope; + const fileSystem = yield* FileSystem.FileSystem; + const idAllocator = yield* IdAllocator.IdAllocatorV2; + const host = yield* ProviderHost.ProviderHost; + const mcpSessions = yield* McpProviderSessions.McpProviderSessions; + + const protocolError = (detail: string, payload?: unknown) => + new ProviderAdapter.ProviderAdapterProtocolError({ + driver: ZCODE_PROVIDER, + detail, + ...(payload === undefined ? {} : { payload }), + }); + + const zcodeMcpServers = ( + session: McpProviderSessionConfig | undefined, + ): ReadonlyArray | undefined => + session === undefined + ? undefined + : [ + { + name: T3_MCP_SERVER_NAME, + type: "http", + url: session.endpoint, + headers: [{ name: "Authorization", value: session.authorizationHeader }], + }, + ]; + + return ProviderAdapter.ProviderAdapterV2.of({ + instanceId: options.instanceId, + driver: ZCODE_PROVIDER, + getCapabilities: Effect.fn("ZcodeAdapter.getCapabilities")(function () { + return Effect.succeed(ZcodeProviderCapabilitiesV2); + }), + planSelectionTransition: Effect.fn("ZcodeAdapter.planSelectionTransition")(function () { + return Effect.succeed(turnScopedSelectionTransition()); + }), + openSession: Effect.fn("ZcodeAdapterV2.openSession")(function* ( + input: ProviderAdapter.ProviderAdapterV2OpenSessionInput, + ) { + const scope = yield* Effect.scope; + const cwd = input.runtimePolicy.cwd ?? host.paths.cwd; + // Catalog files must exist before spawn: the CLI fails closed on dangling env paths. + const catalogFiles: ZcodeCatalogFileSet = yield* resolveZcodeCatalogFiles( + fileSystem, + options.environment, + ); + const env = zcodeRuntimeEnvironment(options.environment, catalogFiles); + const scopedLaunch = yield* agentScope.wrap({ + command: zcodeCommand(options.settings), + args: zcodeSpawnArgs(options.settings.launchArgs), + name: "zcode", + threadId: input.threadId, + env, + }); + // Finalizers run in reverse order: reap detached work after terminating zcode. + const scopeUnit = scopedLaunch.unit; + const stopScope = + scopeUnit === undefined ? Effect.void : yield* Effect.cached(agentScope.stop(scopeUnit)); + yield* Effect.addFinalizer(() => stopScope); + const connection: ZcodeConnection = yield* (options.makeConnection ?? makeZcodeConnection)({ + command: scopedLaunch.command, + args: scopedLaunch.args, + cwd, + env, + }).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + Effect.mapError( + (cause) => + new ProviderAdapter.ProviderAdapterOpenSessionError({ + driver: ZCODE_PROVIDER, + providerSessionId: input.providerSessionId, + cause, + }), + ), + ); + // Detached children can keep stdout open after zcode exits. + if (scopeUnit !== undefined) { + yield* connection.exited.pipe( + Effect.matchEffect({ onFailure: () => stopScope, onSuccess: () => stopScope }), + Effect.forkIn(scope), + ); + } + + const now = yield* DateTime.now; + let sessionEntity: OrchestrationV2ProviderSession = { + id: input.providerSessionId, + driver: ZCODE_PROVIDER, + providerInstanceId: options.instanceId, + status: "ready", + cwd, + model: input.modelSelection.model, + capabilities: ZcodeProviderCapabilitiesV2, + extensionStatuses: [], + createdAt: now, + updatedAt: now, + lastError: null, + }; + const events = yield* Queue.unbounded< + ProviderAdapter.ProviderAdapterV2Event, + ProviderAdapter.ProviderAdapterV2Error | Cause.Done + >(); + const pendingApprovals = new Map(); + const pendingQuestions = new Map(); + const awaitingPermissionRequests = new Map(); + const awaitingUserInputRequests = new Map(); + // Pump, turn installs, and dialog answers share one permit so terminal events cannot + // overtake the runtime request resolution they correlate with. + const sessionEventPermit = yield* Semaphore.make(1); + let threadState: ZcodeThreadState | null = null; + let transportDown = false; + + yield* Effect.addFinalizer(() => + Effect.sync(() => { + transportDown = true; + }), + ); + + const emit = (event: ProviderAdapter.ProviderAdapterV2Event) => + Queue.offer(events, event).pipe(Effect.asVoid); + + const updateProviderSession = + /* Provider events include streaming deltas; avoid a span for each update. */ + Effect.fnUntraced(function* ( + status: OrchestrationV2ProviderSession["status"], + lastError: string | null = sessionEntity.lastError, + ) { + const updatedAt = yield* DateTime.now; + sessionEntity = { ...sessionEntity, status, lastError, updatedAt }; + yield* emit({ + type: "provider_session.updated", + driver: ZCODE_PROVIDER, + providerSession: sessionEntity, + }); + }); + + const updateProviderThread = + /* Provider events include streaming deltas; avoid a span for each update. */ + Effect.fnUntraced(function* ( + state: ZcodeThreadState, + patch: Partial, + ) { + const updatedAt = yield* DateTime.now; + state.providerThread = { ...state.providerThread, ...patch, updatedAt }; + yield* emit({ + type: "provider_thread.updated", + driver: ZCODE_PROVIDER, + providerThread: state.providerThread, + }); + }); + + const request = ( + method: string, + params: ZcodeRecord, + timeoutMs = DEFAULT_REQUEST_TIMEOUT_MS, + ) => connection.request(method, params, timeoutMs); + + const itemOrdinal = (turn: ActiveZcodeTurn, nativeItemId: string): number => { + const existing = turn.itemOrdinals.get(nativeItemId); + if (existing !== undefined) return existing; + const ordinal = turn.nextItemOrdinal++; + turn.itemOrdinals.set(nativeItemId, ordinal); + return ordinal; + }; + + const baseItemFields = ( + turn: ActiveZcodeTurn, + nativeItemId: string, + startedAt: DateTime.Utc, + updatedAt: DateTime.Utc, + ) => ({ + id: idAllocator.derive.turnItemFromProviderItem({ + driver: ZCODE_PROVIDER, + nativeItemId, + }), + threadId: turn.turnInput.threadId, + runId: turn.turnInput.runId, + nodeId: idAllocator.derive.nodeFromProviderItem({ + driver: ZCODE_PROVIDER, + nativeItemId, + }), + providerThreadId: turn.turnInput.providerThread.id, + providerTurnId: turn.providerTurn.id, + nativeItemRef: providerRef(nativeItemId), + parentItemId: null, + ordinal: itemOrdinal(turn, nativeItemId), + startedAt, + updatedAt, + }); + + const emitItemNode = ( + turn: ActiveZcodeTurn, + nativeItemId: string, + kind: OrchestrationV2ExecutionNode["kind"], + status: OrchestrationV2ExecutionNode["status"], + startedAt: DateTime.Utc, + completedAt: DateTime.Utc | null, + ) => + emit({ + type: "node.updated", + driver: ZCODE_PROVIDER, + node: { + id: idAllocator.derive.nodeFromProviderItem({ + driver: ZCODE_PROVIDER, + nativeItemId, + }), + threadId: turn.turnInput.threadId, + runId: turn.turnInput.runId, + parentNodeId: turn.turnInput.rootNodeId, + rootNodeId: turn.turnInput.rootNodeId, + kind, + status, + countsForRun: false, + providerThreadId: turn.turnInput.providerThread.id, + providerTurnId: turn.providerTurn.id, + nativeItemRef: providerRef(nativeItemId), + runtimeRequestId: null, + checkpointScopeId: null, + startedAt, + completedAt, + }, + }); + + const emitStreamItem = + /* Provider events include streaming deltas; avoid a span for each update. */ + Effect.fnUntraced(function* ( + turn: ActiveZcodeTurn, + item: ZcodeStreamItemState, + streaming: boolean, + ) { + const emittedAt = yield* DateTime.now; + const base = baseItemFields(turn, item.nativeItemId, item.startedAt, emittedAt); + yield* emitItemNode( + turn, + item.nativeItemId, + item.kind, + streaming ? "running" : "completed", + item.startedAt, + streaming ? null : emittedAt, + ); + if (item.kind === "assistant_message") { + const messageId = idAllocator.derive.messageFromProviderItem({ + driver: ZCODE_PROVIDER, + nativeItemId: item.nativeItemId, + }); + yield* emit({ + type: "turn_item.updated", + driver: ZCODE_PROVIDER, + turnItem: { + ...base, + status: streaming ? "running" : "completed", + title: null, + completedAt: streaming ? null : emittedAt, + type: "assistant_message", + messageId, + text: item.text, + streaming, + }, + }); + yield* emit({ + type: "message.updated", + driver: ZCODE_PROVIDER, + message: { + id: messageId, + threadId: turn.turnInput.threadId, + runId: turn.turnInput.runId, + nodeId: base.nodeId, + role: "assistant", + text: item.text, + attachments: [], + streaming, + createdBy: "agent", + creationSource: "provider", + createdAt: item.startedAt, + updatedAt: emittedAt, + }, + }); + return; + } + yield* emit({ + type: "turn_item.updated", + driver: ZCODE_PROVIDER, + turnItem: { + ...base, + status: streaming ? "running" : "completed", + title: null, + completedAt: streaming ? null : emittedAt, + type: "reasoning", + text: item.text, + streaming, + }, + }); + }); + + const scheduleStreamFlush = + /* Provider events include streaming deltas; avoid a span for each update. */ + Effect.fnUntraced(function* (turn: ActiveZcodeTurn, item: ZcodeStreamItemState) { + if (item.flushScheduled || item.completed || turn.finalized) return; + item.flushScheduled = true; + yield* Effect.sleep(Duration.millis(STREAM_FLUSH_MS)).pipe( + Effect.andThen( + Effect.suspend(() => { + item.flushScheduled = false; + return item.completed || turn.finalized + ? Effect.void + : emitStreamItem(turn, item, true); + }), + ), + Effect.forkIn(scope), + ); + }); + + const streamItemFor = Effect.fnUntraced(function* ( + turn: ActiveZcodeTurn, + kind: ZcodeStreamItemState["kind"], + messageId: string, + partId: string, + ) { + const nativeItemId = `${messageId}:${partId}`; + const existing = turn.streamItems.get(nativeItemId); + if (existing !== undefined) return existing; + const startedAt = yield* DateTime.now; + const item: ZcodeStreamItemState = { + nativeItemId, + kind, + text: "", + completed: false, + flushScheduled: false, + startedAt, + }; + turn.streamItems.set(nativeItemId, item); + itemOrdinal(turn, nativeItemId); + return item; + }); + + const completeStreamItem = ( + turn: ActiveZcodeTurn, + item: ZcodeStreamItemState, + text?: string, + ) => + Effect.suspend(() => { + if (item.completed) return Effect.void; + item.completed = true; + if (text !== undefined && text.length > 0) item.text = text; + return item.text.length === 0 ? Effect.void : emitStreamItem(turn, item, false); + }); + + const completeOpenStreamItems = (turn: ActiveZcodeTurn) => + Effect.forEach( + Array.from(turn.streamItems.values()).filter((item) => !item.completed), + (item) => completeStreamItem(turn, item), + { discard: true }, + ); + + const toolStateFor = ( + turn: ActiveZcodeTurn, + payload: ZcodeRecord, + startedAt: DateTime.Utc, + ): ZcodeToolState | undefined => { + const toolCallId = zcodeRecordString(payload, "toolCallId"); + if (toolCallId === undefined) return undefined; + const existing = turn.tools.get(toolCallId); + if (existing !== undefined) { + const name = zcodeRecordString(payload, "toolName"); + if (name !== undefined) existing.name = name; + return existing; + } + const state: ZcodeToolState = { + toolCallId, + name: zcodeRecordString(payload, "toolName"), + input: zcodeRecordField(payload, "input"), + startedAt, + output: undefined, + }; + turn.tools.set(toolCallId, state); + return state; + }; + + const emitToolItem = Effect.fnUntraced(function* ( + turn: ActiveZcodeTurn, + tool: ZcodeToolState, + status: OrchestrationV2TurnItem["status"], + ) { + const emittedAt = yield* DateTime.now; + const completed = status === "completed" || status === "failed" || status === "interrupted"; + const base = baseItemFields(turn, tool.toolCallId, tool.startedAt, emittedAt); + const nodeStatus: OrchestrationV2ExecutionNode["status"] = + status === "failed" ? "failed" : completed ? status : "running"; + yield* emitItemNode( + turn, + tool.toolCallId, + "tool_call", + nodeStatus, + tool.startedAt, + completed ? emittedAt : null, + ); + const toolName = tool.name ?? "tool"; + const shared = { + ...base, + status, + completedAt: completed ? emittedAt : null, + } as const; + if (SHELL_TOOL_NAMES.has(toolName.toLowerCase())) { + yield* emit({ + type: "turn_item.updated", + driver: ZCODE_PROVIDER, + turnItem: { + ...shared, + title: toolName, + type: "command_execution", + input: + Predicate.isObject(tool.input) && typeof tool.input["command"] === "string" + ? tool.input["command"] + : zcodeJsonStringify(tool.input ?? {}), + ...(tool.output === undefined ? {} : { output: tool.output }), + }, + }); + return; + } + const fileName = Predicate.isObject(tool.input) + ? (zcodeRecordString(tool.input, "file_path") ?? zcodeRecordString(tool.input, "path")) + : undefined; + if (FILE_WRITE_TOOL_NAMES.has(toolName.toLowerCase()) && fileName !== undefined) { + yield* emit({ + type: "turn_item.updated", + driver: ZCODE_PROVIDER, + turnItem: { + ...shared, + title: toolName, + type: "file_change", + fileName, + ...(tool.output === undefined ? {} : { diffStr: tool.output }), + }, + }); + return; + } + yield* emit({ + type: "turn_item.updated", + driver: ZCODE_PROVIDER, + turnItem: { + ...shared, + title: toolName, + type: "dynamic_tool", + ...mcpToolPresentation({ toolName }), + toolName, + input: Predicate.isObject(tool.input) ? tool.input : {}, + ...(tool.output === undefined ? {} : { output: tool.output }), + }, + }); + }); + + const emitRuntimeRequestShell = Effect.fnUntraced(function* ( + nativeRequestId: string, + kind: OrchestrationV2RuntimeRequest["kind"], + questions: ReadonlyArray | undefined, + approval: + | { + readonly requestKind: ProviderRequestKind; + readonly prompt: string; + readonly options: ReadonlyArray; + } + | undefined, + state: ZcodeThreadState | null, + turn: ActiveZcodeTurn | null, + ): Effect.fn.Return { + const createdAt = yield* DateTime.now; + const threadId = + turn?.turnInput.threadId ?? state?.providerThread.appThreadId ?? input.threadId; + const providerThreadId = state?.providerThread.id ?? null; + const providerTurnId = turn?.providerTurn.id ?? null; + const requestId = yield* idAllocator.allocate.runtimeRequest({ + driver: ZCODE_PROVIDER, + ...(providerTurnId === null ? {} : { providerTurnId }), + nativeRequestId, + }); + const nodeId = idAllocator.derive.approvalNode({ requestId }); + const runtimeRequest: OrchestrationV2RuntimeRequest = { + id: requestId, + nodeId, + providerTurnId, + nativeRequestRef: providerRef(nativeRequestId), + kind, + status: "pending", + responseCapability: { type: "live", providerSessionId: input.providerSessionId }, + createdAt, + resolvedAt: null, + }; + const node: OrchestrationV2ExecutionNode = { + id: nodeId, + threadId, + runId: turn?.turnInput.runId ?? null, + parentNodeId: turn?.turnInput.rootNodeId ?? null, + rootNodeId: turn?.turnInput.rootNodeId ?? nodeId, + kind: questions !== undefined ? "user_input_request" : "approval_request", + status: "waiting", + countsForRun: false, + providerThreadId, + providerTurnId, + nativeItemRef: providerRef(nativeRequestId), + runtimeRequestId: requestId, + checkpointScopeId: null, + startedAt: createdAt, + completedAt: null, + }; + const itemBase = { + id: idAllocator.derive.approvalTurnItem({ requestId }), + threadId, + runId: turn?.turnInput.runId ?? null, + nodeId, + providerThreadId, + providerTurnId, + nativeItemRef: providerRef(nativeRequestId), + parentItemId: null, + ordinal: turn === null ? 0 : itemOrdinal(turn, nativeRequestId), + status: "waiting" as const, + title: null, + startedAt: createdAt, + completedAt: null, + updatedAt: createdAt, + }; + const turnItem: OrchestrationV2TurnItem = + questions !== undefined + ? { + ...itemBase, + type: "user_input_request", + requestId, + questions: [...questions], + } + : { + ...itemBase, + type: "approval_request", + requestId, + requestKind: approval?.requestKind ?? "command", + ...(approval === undefined ? {} : { prompt: approval.prompt }), + ...(approval === undefined || approval.options.length === 0 + ? {} + : { options: approval.options }), + }; + if (questions !== undefined) { + pendingQuestions.set(String(requestId), { + nativeRequestId, + questionId: questions[0]?.id ?? nativeRequestId, + runtimeRequest, + node, + turnItem, + answer: null, + }); + } else { + pendingApprovals.set(String(requestId), { + nativeRequestId, + options: [], + runtimeRequest, + node, + turnItem, + chosenResponse: null, + }); + } + yield* emit({ + type: "runtime_request.updated", + driver: ZCODE_PROVIDER, + threadId, + runtimeRequest, + }); + yield* emit({ type: "node.updated", driver: ZCODE_PROVIDER, node }); + yield* emit({ type: "turn_item.updated", driver: ZCODE_PROVIDER, turnItem }); + return requestId; + }); + + const resolveRuntimeRequestRecords = Effect.fnUntraced(function* ( + pending: ZcodePendingApproval | ZcodePendingQuestion, + resolvedAt: DateTime.Utc, + ) { + pending.runtimeRequest = { ...pending.runtimeRequest, status: "resolved", resolvedAt }; + yield* emit({ + type: "runtime_request.updated", + driver: ZCODE_PROVIDER, + threadId: pending.node.threadId, + runtimeRequest: pending.runtimeRequest, + }); + yield* emit({ + type: "node.updated", + driver: ZCODE_PROVIDER, + node: { ...pending.node, status: "completed", completedAt: resolvedAt }, + }); + yield* emit({ + type: "turn_item.updated", + driver: ZCODE_PROVIDER, + turnItem: { + ...pending.turnItem, + status: "completed", + completedAt: resolvedAt, + updatedAt: resolvedAt, + }, + }); + }); + + const cancelRuntimeRequest = Effect.fnUntraced(function* ( + pending: ZcodePendingApproval | ZcodePendingQuestion, + respondWith: unknown, + ) { + const resolvedAt = yield* DateTime.now; + pending.runtimeRequest = { ...pending.runtimeRequest, status: "cancelled", resolvedAt }; + if ("chosenResponse" in pending) { + pendingApprovals.delete(String(pending.runtimeRequest.id)); + for (const [respondId, awaitingRequest] of awaitingPermissionRequests) { + if (awaitingRequest.nativeRequestId === pending.nativeRequestId) { + awaitingPermissionRequests.delete(respondId); + // The transport may already be down during teardown; the deny is best-effort. + yield* connection.respond(respondId, respondWith).pipe(Effect.ignore); + } + } + } else { + pendingQuestions.delete(String(pending.runtimeRequest.id)); + for (const [respondId, awaitingRequest] of awaitingUserInputRequests) { + if (awaitingRequest.nativeRequestId === pending.nativeRequestId) { + awaitingUserInputRequests.delete(respondId); + yield* connection.respond(respondId, respondWith).pipe(Effect.ignore); + } + } + } + yield* emit({ + type: "runtime_request.updated", + driver: ZCODE_PROVIDER, + threadId: pending.node.threadId, + runtimeRequest: pending.runtimeRequest, + }); + yield* emit({ + type: "node.updated", + driver: ZCODE_PROVIDER, + node: { ...pending.node, status: "cancelled", completedAt: resolvedAt }, + }); + yield* emit({ + type: "turn_item.updated", + driver: ZCODE_PROVIDER, + turnItem: { + ...pending.turnItem, + status: "cancelled", + completedAt: resolvedAt, + updatedAt: resolvedAt, + }, + }); + }); + + const cancelPendingRuntimeRequests = Effect.fnUntraced(function* () { + for (const pending of Array.from(pendingApprovals.values())) { + yield* cancelRuntimeRequest(pending, { decision: "deny", reason: "No user response" }); + } + for (const pending of Array.from(pendingQuestions.values())) { + yield* cancelRuntimeRequest(pending, { cancelled: true }); + } + }); + + const finalizeTurn = Effect.fnUntraced(function* (state: ZcodeThreadState) { + const turn = state.activeTurn; + if (turn === null || turn.finalized) return; + turn.finalized = true; + state.activeTurn = null; + const completedAt = yield* DateTime.now; + yield* completeOpenStreamItems(turn); + yield* cancelPendingRuntimeRequests(); + const failure = turn.interrupted ? null : turn.failure; + yield* emit({ + type: "provider_turn.updated", + driver: ZCODE_PROVIDER, + threadId: turn.turnInput.threadId, + providerTurn: { + ...turn.providerTurn, + status: turn.interrupted ? "interrupted" : failure !== null ? "failed" : "completed", + completedAt, + }, + }); + yield* updateProviderThread(state, { status: "idle" }); + yield* updateProviderSession( + failure !== null ? "error" : "ready", + failure?.message ?? null, + ); + if (failure !== null) { + const failureItemId = `terminal-failure:${turn.providerTurn.id}`; + yield* emit({ + type: "turn_item.updated", + driver: ZCODE_PROVIDER, + turnItem: { + ...baseItemFields(turn, failureItemId, completedAt, completedAt), + status: "failed", + title: null, + completedAt, + type: "error", + failure, + }, + }); + yield* emit({ + type: "turn.terminal", + driver: ZCODE_PROVIDER, + providerThreadId: state.providerThread.id, + providerTurnId: turn.providerTurn.id, + runOrdinal: turn.turnInput.runOrdinal, + failureItemOrdinal: itemOrdinal(turn, failureItemId), + status: "failed", + failure, + threadDisposition: "reusable", + }); + return; + } + yield* emit({ + type: "turn.terminal", + driver: ZCODE_PROVIDER, + providerThreadId: state.providerThread.id, + providerTurnId: turn.providerTurn.id, + runOrdinal: turn.turnInput.runOrdinal, + status: turn.interrupted ? "interrupted" : "completed", + failure: null, + threadDisposition: "reusable", + }); + }); + + const findApprovalByNativeId = (nativeRequestId: string) => + Array.from(pendingApprovals.values()).find( + (candidate) => candidate.nativeRequestId === nativeRequestId, + ); + const findQuestionByNativeId = (nativeRequestId: string) => + Array.from(pendingQuestions.values()).find( + (candidate) => candidate.nativeRequestId === nativeRequestId, + ); + // The broker request can arrive after the user already answered; keep chosen answers + // findable so those late requests still carry the decision instead of parking forever. + const answeredPermissions = new Map(); + const answeredUserInputs = new Map< + string, + { readonly value: string } | { readonly cancelled: true } + >(); + + const handleSessionEvent = Effect.fnUntraced(function* ( + eventType: string, + payload: ZcodeRecord, + ) { + const state = threadState; + const turn = state === null ? null : state.activeTurn; + switch (eventType) { + case "turn.started": { + if (turn !== null) yield* updateProviderSession("running", null); + return; + } + case "part.delta": { + if (turn === null) return; + const messageId = zcodeRecordString(payload, "messageId"); + const partId = zcodeRecordString(payload, "partId"); + const delta = zcodeRecordString(payload, "delta"); + const field = zcodeRecordString(payload, "field") ?? "text"; + if (messageId === undefined || partId === undefined || delta === undefined) return; + if (field !== "text" && field !== "reasoning") return; + const item = yield* streamItemFor( + turn, + field === "text" ? "assistant_message" : "reasoning", + messageId, + partId, + ); + item.text += delta; + yield* emit({ + type: "command_output.delta", + driver: ZCODE_PROVIDER, + threadId: turn.turnInput.threadId, + itemId: idAllocator.derive.turnItemFromProviderItem({ + driver: ZCODE_PROVIDER, + nativeItemId: item.nativeItemId, + }), + chunk: delta, + }); + yield* scheduleStreamFlush(turn, item); + return; + } + case "part.started": + case "part.upserted": { + if (turn === null) return; + const part = zcodeRecordField(payload, "part"); + if (!Predicate.isObject(part)) return; + const partType = zcodeRecordString(part, "type"); + const messageId = zcodeRecordString(part, "messageId"); + const partId = zcodeRecordString(part, "partId"); + if (messageId === undefined || partId === undefined) return; + if (partType !== "text" && partType !== "reasoning") return; + const item = yield* streamItemFor( + turn, + partType === "text" ? "assistant_message" : "reasoning", + messageId, + partId, + ); + yield* completeStreamItem(turn, item, zcodeRecordString(part, "text") ?? item.text); + return; + } + case "message.upserted": { + if (turn === null) return; + // Fallback for providers that skip part deltas: upserts carry no message id, so + // they only contribute content when no streaming item exists for this turn. + const text = zcodeRecordString(payload, "content") ?? contentText(payload); + if (text.length === 0) return; + const nativeItemId = turn.upsertedItemId ?? `upserted:${turn.providerTurn.id}:0`; + const hasStreamItem = Array.from(turn.streamItems.keys()).some( + (key) => key.startsWith("upserted:") === false, + ); + if (hasStreamItem) return; + turn.upsertedItemId = nativeItemId; + const item = yield* streamItemFor(turn, "assistant_message", nativeItemId, "final"); + yield* completeStreamItem(turn, item, text); + return; + } + case "tool.updated": { + if (turn === null) return; + // Subagent-attributed calls belong to child sessions; projecting them as parent + // tools would misplace work, and subagent capabilities are off. + if (zcodeRecordString(payload, "source") === "subagent") return; + const kind = zcodeRecordString(payload, "kind"); + const toolState = toolStateFor(turn, payload, yield* DateTime.now); + if (toolState === undefined) return; + switch (kind) { + case "scheduled": + case "started": { + yield* emitToolItem(turn, toolState, "running"); + return; + } + case "progress": { + const stdoutTail = zcodeRecordString(payload, "stdoutTail"); + const stderrTail = zcodeRecordString(payload, "stderrTail"); + const outputPreview = zcodeRecordField(payload, "outputPreview"); + const previewText = Predicate.isObject(outputPreview) + ? (zcodeRecordString(outputPreview, "text") ?? + zcodeRecordString(outputPreview, "output")) + : undefined; + const tail = [stdoutTail, stderrTail].filter((part) => part !== undefined).join(""); + const next = tail.length > 0 ? tail : previewText; + if (next !== undefined && next.length > 0 && next !== toolState.output) { + toolState.output = next; + yield* emitToolItem(turn, toolState, "running"); + } + return; + } + case "result": { + const output = toolResultOutput(zcodeRecordField(payload, "result")); + if (output !== undefined) toolState.output = output; + yield* emitToolItem(turn, toolState, "completed"); + return; + } + case "error": { + const error = zcodeRecordField(payload, "error"); + const message = + (Predicate.isObject(error) ? zcodeRecordString(error, "message") : undefined) ?? + "Tool failed."; + toolState.output = message; + yield* emitToolItem(turn, toolState, turn.interrupted ? "interrupted" : "failed"); + return; + } + default: + return; + } + } + case "permission.requested": { + const nativeRequestId = + zcodeRecordString(payload, "requestId") ?? zcodeRecordString(payload, "toolCallId"); + if (nativeRequestId === undefined) return; + const options = zcodePermissionOptions(payload); + const requestId = yield* emitRuntimeRequestShell( + nativeRequestId, + "command", + undefined, + { + requestKind: "command", + prompt: + zcodeRecordString(payload, "reason") ?? + `${zcodeRecordString(payload, "toolName") ?? "Tool"} needs approval`, + options: options.map((option) => ({ + decision: zcodeApprovalDecisionForOption(option), + label: option.name, + })), + }, + state, + turn, + ); + const pending = pendingApprovals.get(String(requestId)); + if (pending !== undefined) + pendingApprovals.set(String(requestId), { ...pending, options }); + return; + } + case "permission.resolved": { + const nativeRequestId = + zcodeRecordString(payload, "requestId") ?? zcodeRecordString(payload, "toolCallId"); + if (nativeRequestId === undefined) return; + const pending = findApprovalByNativeId(nativeRequestId); + if (pending === undefined) return; + pendingApprovals.delete(String(pending.runtimeRequest.id)); + yield* resolveRuntimeRequestRecords(pending, yield* DateTime.now); + return; + } + case "userInput.requested": { + const nativeRequestId = zcodeRecordString(payload, "requestId"); + if (nativeRequestId === undefined) return; + const prompt = zcodeRecordString(payload, "prompt") ?? "The agent asked a question"; + const inputType = zcodeRecordString(payload, "inputType") ?? "text"; + const choices = (zcodeRecordArray(payload, "choices") ?? []).filter( + (choice): choice is string => typeof choice === "string" && choice.length > 0, + ); + yield* emitRuntimeRequestShell( + nativeRequestId, + "user_input", + [ + { + id: nativeRequestId, + header: "Question", + question: prompt, + options: choices.map((choice) => ({ + label: choice, + description: choice, + value: choice, + })), + // Free-text asks keep a custom answer even with preset choices. + ...(inputType === "text" || choices.length === 0 + ? { allowCustomAnswer: true } + : {}), + }, + ], + undefined, + state, + turn, + ); + return; + } + case "userInput.resolved": { + const nativeRequestId = zcodeRecordString(payload, "requestId"); + if (nativeRequestId === undefined) return; + const pending = findQuestionByNativeId(nativeRequestId); + if (pending === undefined) return; + pendingQuestions.delete(String(pending.runtimeRequest.id)); + yield* resolveRuntimeRequestRecords(pending, yield* DateTime.now); + return; + } + case "turn.completed": { + if (state === null || turn === null || turn.finalized) return; + const resultType = zcodeRecordString(payload, "resultType") ?? "success"; + if (resultType === "success") { + const tokenCount = zcodeRecordNumber(payload, "tokenCount"); + if (tokenCount !== undefined && tokenCount > 0) { + const updatedAt = yield* DateTime.now; + turn.providerTurn = { + ...turn.providerTurn, + tokenUsage: { usedTokens: tokenCount, updatedAt: DateTime.formatIso(updatedAt) }, + }; + } + yield* finalizeTurn(state); + return; + } + if (resultType === "cancelled") { + turn.interrupted = true; + yield* finalizeTurn(state); + return; + } + turn.failure = + resultType === "error_max_budget" + ? makeProviderFailure({ + message: "The model provider usage limit was reached.", + class: "usage_limit", + code: resultType, + retryable: true, + }) + : makeProviderFailure({ + message: `ZCode turn failed (${resultType}).`, + class: "provider_error", + code: resultType, + }); + yield* finalizeTurn(state); + return; + } + case "turn.failed": { + if (state === null || turn === null || turn.finalized) return; + turn.failure = zcodeFailureFromErrorDetail(zcodeRecordField(payload, "error")); + yield* finalizeTurn(state); + return; + } + case "turn.steerQueued": + case "turn.steerDrained": + case "model.streaming": + case "session.created": + case "session.resumed": + case "session.updated": + case "session.titleUpdated": + case "session.closed": + case "checkpoint.created": + case "rewind.triggered": + case "part.removed": + case "message.removed": + return; + default: + yield* Effect.logDebug("Ignoring zcode session event.", { eventType }); + return; + } + }); + + const handleServerRequest = Effect.fnUntraced(function* (record: ZcodeRecord) { + const method = zcodeRecordString(record, "method") ?? ""; + const respondId = String(zcodeRecordField(record, "id") ?? ""); + switch (method) { + case "session/requestRuntimePreferences": { + yield* connection.respond(respondId, { + nativeSearchEnhancementsEnabled: false, + toolSearchMode: "off", + memoryEnabled: false, + askUserQuestionAutoResolutionEnabled: true, + modelContextBudgetStrategy: "preflight-v1", + }); + return; + } + case "interaction/requestOfficialMcpAuthHeaders": + case "interaction/requestProviderRuntimeHeaders": { + yield* connection.respond(respondId, {}); + return; + } + case "interaction/requestPermission": { + const params = zcodeRecordField(record, "params"); + const nativeRequestId = + zcodeRecordString(params ?? {}, "requestId") ?? + zcodeRecordString(params ?? {}, "toolCallId") ?? + respondId; + const answered = findApprovalByNativeId(nativeRequestId); + if (answered !== undefined && answered.chosenResponse !== null) { + yield* connection.respond(respondId, answered.chosenResponse); + return; + } + const stored = answeredPermissions.get(nativeRequestId); + if (stored !== undefined) { + yield* connection.respond(respondId, stored); + return; + } + // Hold the broker request so a slower user answer still reaches zcode; cancel + // paths answer it with a "No user response" denial instead. + awaitingPermissionRequests.set(respondId, { respondId, nativeRequestId }); + return; + } + case "interaction/requestUserInput": { + const params = zcodeRecordField(record, "params"); + const nativeRequestId = zcodeRecordString(params ?? {}, "requestId") ?? respondId; + const answered = findQuestionByNativeId(nativeRequestId); + if (answered !== undefined && answered.answer !== null) { + yield* connection.respond(respondId, answered.answer); + return; + } + const stored = answeredUserInputs.get(nativeRequestId); + if (stored !== undefined) { + yield* connection.respond(respondId, stored); + return; + } + awaitingUserInputRequests.set(respondId, { respondId, nativeRequestId }); + return; + } + default: { + yield* Effect.logDebug("Answering unknown zcode server request with {}.", { method }); + yield* connection.respond(respondId, {}); + return; + } + } + }); + + const handleLifecycle = Effect.fnUntraced(function* (params: ZcodeRecord) { + const sessionId = zcodeRecordString(params, "sessionId"); + const state = threadState; + if (state !== null && sessionId !== undefined && sessionId !== state.nativeSessionId) + return; + const lifecycleState = zcodeRecordString(params, "state"); + const lastError = zcodeRecordField(params, "lastError"); + switch (lifecycleState) { + case "ready": + yield* updateProviderSession(state?.activeTurn != null ? "running" : "ready", null); + return; + case "unloaded": { + const errorDetail = Predicate.isObject(lastError) + ? zcodeRecordString(lastError, "message") + : undefined; + if (state?.activeTurn != null) { + state.activeTurn.failure = makeProviderFailure({ + message: errorDetail ?? "ZCode unloaded the session while a turn was running.", + class: "transport_error", + retryable: false, + }); + yield* finalizeTurn(state); + } + yield* updateProviderSession("stopped", errorDetail ?? null); + return; + } + default: + return; + } + }); + + const handleRecord = Effect.fnUntraced(function* (record: ZcodeRecord) { + if (isServerRequest(record)) { + yield* handleServerRequest(record); + return; + } + const method = zcodeRecordString(record, "method"); + if (method === "session/event") { + const params = zcodeRecordField(record, "params"); + if (!Predicate.isObject(params)) return; + const eventType = zcodeRecordString(params, "type"); + if (eventType === undefined) return; + const payload = zcodeRecordField(params, "payload"); + yield* handleSessionEvent(eventType, Predicate.isObject(payload) ? payload : {}); + return; + } + if (method === "session/lifecycle/changed") { + const params = zcodeRecordField(record, "params"); + if (Predicate.isObject(params)) yield* handleLifecycle(params); + return; + } + }); + + yield* Effect.gen(function* () { + while (true) { + const record = yield* Queue.take(connection.events); + yield* sessionEventPermit.withPermits(1)(handleRecord(record)); + } + }).pipe( + Effect.catchCause((cause) => + Effect.gen(function* () { + transportDown = true; + const state = threadState; + const interrupted = state?.activeTurn?.interrupted === true; + if (state?.activeTurn != null) { + state.activeTurn.failure = interrupted + ? null + : makeProviderFailure({ + cause, + message: "zcode app-server exited unexpectedly.", + class: "transport_error", + }); + yield* finalizeTurn(state); + } else { + yield* cancelPendingRuntimeRequests(); + } + yield* updateProviderSession( + interrupted ? "stopped" : "error", + interrupted + ? "zcode app-server was stopped." + : "zcode app-server exited unexpectedly.", + ); + yield* Queue.fail( + events, + new ProviderAdapter.ProviderAdapterEventStreamError({ + driver: ZCODE_PROVIDER, + providerSessionId: input.providerSessionId, + cause, + }), + ); + }), + ), + Effect.forkIn(scope), + ); + + // Provisioning must precede session/create: the probe showed create resolves the model + // against the registry immediately. Best-effort — a rejected push surfaces honestly as a + // "Select a model" turn failure instead of failing the session open. + const bootstrapProviderRegistry = Effect.fn("ZcodeAdapter.bootstrapProviderRegistry")( + function* () { + const push = + catalogFiles.builtinCatalog === undefined + ? undefined + : zcodeAccountConfigPush( + catalogFiles.builtinCatalog, + `t3code-${input.providerSessionId}`, + ); + if (push === undefined) { + yield* Effect.logWarning( + "ZCode builtin catalog unavailable; account models will report as missing.", + { builtinPath: catalogFiles.builtinPath ?? null }, + ); + return; + } + yield* request("provider/updateAccountConfig", push as unknown as ZcodeRecord).pipe( + Effect.tapError((error) => + Effect.logWarning("ZCode account config push failed.", { + operation: error.operation, + }), + ), + Effect.ignore, + ); + yield* request("provider/refreshRegistry", { reason: "t3code-session" }).pipe( + Effect.tapError((error) => + Effect.logWarning("ZCode registry refresh failed.", { + operation: error.operation, + }), + ), + Effect.ignore, + ); + }, + ); + yield* bootstrapProviderRegistry(); + + const subscribeSession = (sessionId: string) => + request("session/subscribe", { + sessionId, + deliveryKind: "desktop-continuous", + includeSnapshot: true, + }); + + const createNativeSession = Effect.fnUntraced(function* ( + threadInput: ProviderAdapter.ProviderAdapterV2EnsureThreadInput, + ) { + const mcpServers = zcodeMcpServers(yield* mcpSessions.read(threadInput.threadId)); + const createParams: ZcodeRecord = { + workspace: { workspacePath: cwd, workspaceKey: cwd }, + // Mode changes restart the session (supportsRuntimeModeSwitchInSession: false), so + // the mode is set once here and zcode enforces it natively for the session's life. + mode: zcodeSessionMode(threadInput.runtimePolicy?.runtimeMode), + titleGenerationEnabled: false, + ...(mcpServers === undefined ? {} : { mcpServers }), + }; + const modelSelection = zcodeModelSelectionFromSlug(threadInput.modelSelection.model); + if (modelSelection !== undefined) createParams["model"] = modelSelection; + const result = yield* request("session/create", createParams, SESSION_CREATE_TIMEOUT_MS); + const resultRecord = Predicate.isObject(result) ? result : {}; + const sessionRecord = zcodeRecordField(resultRecord, "session"); + const nativeSessionId = zcodeRecordString(sessionRecord ?? {}, "sessionId"); + if (nativeSessionId === undefined) { + return yield* protocolError("session/create returned no sessionId", result); + } + yield* subscribeSession(nativeSessionId); + return nativeSessionId; + }); + + const resumeNativeSession = Effect.fnUntraced(function* ( + nativeSessionId: string, + threadInput: ProviderAdapter.ProviderAdapterV2EnsureThreadInput, + ) { + const mcpServers = zcodeMcpServers(yield* mcpSessions.read(threadInput.threadId)); + yield* request( + "session/resume", + { + sessionId: nativeSessionId, + workspace: { workspacePath: cwd, workspaceKey: cwd }, + ...(mcpServers === undefined ? {} : { mcpServers }), + }, + SESSION_CREATE_TIMEOUT_MS, + ); + yield* subscribeSession(nativeSessionId); + return nativeSessionId; + }); + + const registerThread = Effect.fnUntraced(function* ( + threadInput: ProviderAdapter.ProviderAdapterV2EnsureThreadInput, + ) { + if (threadState !== null && threadState.activeTurn !== null) { + return yield* protocolError("Cannot switch ZCode threads while a turn is active"); + } + const existing = threadInput.existingProviderThread; + if ( + threadState !== null && + existing !== undefined && + threadState.providerThread.id === existing.id + ) { + return threadState.providerThread; + } + const nativeId = existing?.nativeThreadRef?.nativeId ?? threadState?.nativeSessionId; + const nativeSessionId = + nativeId !== undefined + ? yield* resumeNativeSession(nativeId, threadInput) + : yield* createNativeSession(threadInput); + const createdAt = yield* DateTime.now; + const providerThread: OrchestrationV2ProviderThread = + existing !== undefined + ? { + ...existing, + providerSessionId: input.providerSessionId, + nativeThreadRef: providerRef(nativeSessionId), + status: "idle", + updatedAt: createdAt, + } + : { + id: idAllocator.derive.providerThread({ + driver: ZCODE_PROVIDER, + nativeThreadId: nativeSessionId, + }), + driver: ZCODE_PROVIDER, + providerInstanceId: options.instanceId, + providerSessionId: input.providerSessionId, + appThreadId: threadInput.threadId, + ownerNodeId: null, + nativeThreadRef: providerRef(nativeSessionId), + nativeConversationHeadRef: null, + status: "idle", + firstRunOrdinal: null, + lastRunOrdinal: null, + handoffIds: [], + forkedFrom: null, + pendingBackgroundTasks: [], + createdAt, + updatedAt: createdAt, + }; + threadState = { providerThread, nativeSessionId, activeTurn: null }; + yield* emit({ + type: "provider_thread.updated", + driver: ZCODE_PROVIDER, + providerThread, + }); + return providerThread; + }); + + const resolveTurnPayload = Effect.fnUntraced(function* ( + text: string, + attachments: ProviderAdapter.ProviderAdapterV2TurnMessage["attachments"], + ) { + return yield* providerMessageTextWithAttachmentPaths({ + text, + attachments, + resolveAttachmentPath: host.resolveAttachmentPath, + resolveExtractedTextPath: host.resolveExtractedTextPath, + }); + }); + + const installTurn = Effect.fnUntraced(function* ( + state: ZcodeThreadState, + turnInput: ProviderAdapter.ProviderAdapterV2TurnInput, + nativeTurnId: string, + ): Effect.fn.Return { + if ( + threadState !== state || + state.activeTurn !== null || + transportDown || + state.providerThread.nativeThreadRef?.nativeId !== + turnInput.providerThread.nativeThreadRef?.nativeId + ) { + return yield* protocolError("ZCode turn ownership changed before it could start"); + } + const startedAt = yield* DateTime.now; + return { + turnInput, + providerTurn: { + id: idAllocator.derive.providerTurn({ + driver: ZCODE_PROVIDER, + nativeTurnId, + }), + providerThreadId: turnInput.providerThread.id, + nodeId: turnInput.rootNodeId, + runAttemptId: turnInput.attemptId, + nativeTurnRef: providerRef(nativeTurnId, "weak"), + ordinal: turnInput.providerTurnOrdinal, + status: "running", + startedAt, + completedAt: null, + }, + itemOrdinals: new Map(), + nextItemOrdinal: turnInput.providerTurnOrdinal * 100 + 1, + streamItems: new Map(), + tools: new Map(), + upsertedItemId: null, + interrupted: false, + failure: null, + finalized: false, + } satisfies ActiveZcodeTurn; + }); + + const beginTurnWork = Effect.fnUntraced(function* ( + state: ZcodeThreadState, + turn: ActiveZcodeTurn, + ) { + state.providerThread = turn.turnInput.providerThread; + state.activeTurn = turn; + yield* emit({ + type: "provider_turn.updated", + driver: ZCODE_PROVIDER, + threadId: turn.turnInput.threadId, + providerTurn: turn.providerTurn, + }); + yield* updateProviderThread(state, { + status: "active", + firstRunOrdinal: state.providerThread.firstRunOrdinal ?? turn.turnInput.runOrdinal, + lastRunOrdinal: turn.turnInput.runOrdinal, + }); + yield* updateProviderSession("running", null); + }); + + const runtime: ProviderAdapter.ProviderAdapterV2SessionRuntime = { + instanceId: options.instanceId, + driver: ZCODE_PROVIDER, + providerSessionId: input.providerSessionId, + get providerSession() { + return sessionEntity; + }, + // session/resume after a host restart is unverified against a logged-in CLI; until it + // is, restarts keep the conversation alive rather than attempting a lossy restore. + canResumeAfterRestart: false, + events: Stream.fromQueue(events), + ensureThread: (threadInput) => + registerThread(threadInput).pipe( + Effect.mapError( + (cause) => + new ProviderAdapter.ProviderAdapterEnsureThreadError({ + driver: ZCODE_PROVIDER, + threadId: threadInput.threadId, + cause, + }), + ), + ), + resumeThread: (threadInput) => + registerThread({ + threadId: + threadInput.threadId ?? threadInput.providerThread.appThreadId ?? input.threadId, + modelSelection: threadInput.modelSelection ?? input.modelSelection, + runtimePolicy: threadInput.runtimePolicy ?? input.runtimePolicy, + existingProviderThread: threadInput.providerThread, + }).pipe( + Effect.mapError( + (cause) => + new ProviderAdapter.ProviderAdapterResumeThreadError({ + driver: ZCODE_PROVIDER, + providerSessionId: input.providerSessionId, + providerThreadId: threadInput.providerThread.id, + cause, + }), + ), + ), + startTurn: Effect.fn("ZcodeAdapter.startTurn")( + function* (turnInput: ProviderAdapter.ProviderAdapterV2TurnInput) { + const state = threadState; + if (state === null) { + return yield* protocolError("ZCode session has no registered thread"); + } + if (state.activeTurn !== null) { + return yield* new ProviderAdapter.ProviderAdapterProtocolError({ + driver: ZCODE_PROVIDER, + detail: `ZCode provider thread ${turnInput.providerThread.id} already has an active turn`, + reason: "previous_turn_active", + }); + } + if ( + state.providerThread.nativeThreadRef?.nativeId !== + turnInput.providerThread.nativeThreadRef?.nativeId + ) { + return yield* protocolError("ZCode turn requested for a different native session"); + } + const content = yield* resolveTurnPayload( + turnInput.message.text, + turnInput.message.attachments, + ); + const nativeTurnId = `${state.nativeSessionId}:attempt:${turnInput.attemptId}`; + const sendParams: ZcodeRecord = { + sessionId: state.nativeSessionId, + content, + }; + const modelSelection = zcodeModelSelectionFromSlug(turnInput.modelSelection.model); + if (modelSelection !== undefined) sendParams["modelSelection"] = modelSelection; + yield* sessionEventPermit.withPermits(1)( + Effect.gen(function* () { + const turn = yield* installTurn(state, turnInput, nativeTurnId); + yield* beginTurnWork(state, turn); + yield* request("session/send", sendParams).pipe( + Effect.tapError(() => + Effect.sync(() => { + if (state.activeTurn === turn) state.activeTurn = null; + }), + ), + ); + }), + ); + // session/send acks before turn events; rejections surface via turn.failed. + }, + (effect, turnInput) => + effect.pipe( + Effect.mapError( + (cause) => + new ProviderAdapter.ProviderAdapterTurnStartError({ + driver: ZCODE_PROVIDER, + threadId: turnInput.threadId, + providerThreadId: turnInput.providerThread.id, + runId: turnInput.runId, + cause, + }), + ), + ), + ), + compactThread: Effect.fn("ZcodeAdapter.compactThread")( + function* (turnInput: ProviderAdapter.ProviderAdapterV2TurnInput) { + const state = threadState; + if (state === null) { + return yield* protocolError("ZCode session has no registered thread"); + } + if (state.activeTurn !== null) { + return yield* new ProviderAdapter.ProviderAdapterProtocolError({ + driver: ZCODE_PROVIDER, + detail: `ZCode provider thread ${turnInput.providerThread.id} already has an active turn`, + reason: "previous_turn_active", + }); + } + const nativeTurnId = `${state.nativeSessionId}:compact:${turnInput.attemptId}`; + const instructions = + turnInput.message.text.trim() === "/compact" || + turnInput.message.text.trim().length === 0 + ? undefined + : turnInput.message.text; + yield* sessionEventPermit.withPermits(1)( + Effect.gen(function* () { + const turn = yield* installTurn(state, turnInput, nativeTurnId); + yield* beginTurnWork(state, turn); + const result = yield* request( + "session/compact", + { + sessionId: state.nativeSessionId, + ...(instructions === undefined ? {} : { instructions }), + }, + SESSION_CREATE_TIMEOUT_MS, + ).pipe( + Effect.mapError((cause) => { + turn.failure = makeProviderFailure({ + cause, + message: "ZCode context compaction failed.", + class: "provider_error", + }); + return cause; + }), + ); + const resultRecord = Predicate.isObject(result) ? result : {}; + const summary = zcodeRecordString(resultRecord, "response"); + const compactState = zcodeRecordString( + zcodeRecordField(resultRecord, "compact") ?? {}, + "state", + ); + if (compactState === "already_running") { + turn.failure = makeProviderFailure({ + message: "ZCode is already compacting this thread.", + class: "provider_error", + retryable: true, + }); + } + const emittedAt = yield* DateTime.now; + const compactionItemId = `compaction:${turn.providerTurn.id}`; + const failed = turn.failure !== null; + yield* emitItemNode( + turn, + compactionItemId, + "system", + failed ? "failed" : "completed", + turn.providerTurn.startedAt ?? emittedAt, + emittedAt, + ); + yield* emit({ + type: "turn_item.updated", + driver: ZCODE_PROVIDER, + turnItem: { + ...baseItemFields( + turn, + compactionItemId, + turn.providerTurn.startedAt ?? emittedAt, + emittedAt, + ), + status: failed ? "failed" : "completed", + title: failed ? "Context compaction failed" : "Context compacted", + completedAt: emittedAt, + type: "compaction", + driver: ZCODE_PROVIDER, + ...(failed || summary === undefined ? {} : { summary }), + }, + }); + yield* finalizeTurn(state); + }), + ); + }, + (effect, turnInput) => + effect.pipe( + Effect.mapError( + (cause) => + new ProviderAdapter.ProviderAdapterTurnStartError({ + driver: ZCODE_PROVIDER, + threadId: turnInput.threadId, + providerThreadId: turnInput.providerThread.id, + runId: turnInput.runId, + cause, + }), + ), + ), + ), + steerTurn: Effect.fn("ZcodeAdapter.steerTurn")( + function* (steerInput: ProviderAdapter.ProviderAdapterV2SteerInput) { + const state = threadState; + const turn = state?.activeTurn ?? null; + if ( + state === null || + turn === null || + turn.providerTurn.id !== steerInput.providerTurnId + ) { + return yield* protocolError(`ZCode turn ${steerInput.providerTurnId} is not active`); + } + const content = yield* resolveTurnPayload( + steerInput.message.text, + steerInput.message.attachments, + ); + // zcode queues steering natively; turn.steerQueued/drain events follow. + yield* request("session/send", { sessionId: state.nativeSessionId, content }); + }, + (effect, steerInput) => + effect.pipe( + Effect.mapError( + (cause) => + new ProviderAdapter.ProviderAdapterSteerRunError({ + driver: ZCODE_PROVIDER, + providerThreadId: steerInput.providerThread.id, + providerTurnId: steerInput.providerTurnId, + cause, + }), + ), + ), + ), + interruptTurn: Effect.fn("ZcodeAdapter.interruptTurn")( + function* (interruptInput: ProviderAdapter.ProviderAdapterV2InterruptInput) { + const state = threadState; + const turn = state?.activeTurn ?? null; + if ( + state === null || + turn === null || + turn.providerTurn.id !== interruptInput.providerTurnId + ) { + return yield* protocolError( + `ZCode turn ${interruptInput.providerTurnId} is not active`, + ); + } + turn.interrupted = true; + yield* request("session/stop", { sessionId: state.nativeSessionId }); + }, + (effect, interruptInput) => + effect.pipe( + Effect.mapError( + (cause) => + new ProviderAdapter.ProviderAdapterInterruptError({ + driver: ZCODE_PROVIDER, + providerThreadId: interruptInput.providerThread.id, + providerTurnId: interruptInput.providerTurnId, + cause, + }), + ), + ), + ), + respondToRuntimeRequest: Effect.fn("ZcodeAdapter.respondToRuntimeRequest")( + function* (requestInput: ProviderAdapter.ProviderAdapterV2RuntimeRequestResponseInput) { + const key = String(requestInput.requestId); + const approval = pendingApprovals.get(key); + const question = pendingQuestions.get(key); + if (approval === undefined && question === undefined) { + return yield* protocolError( + `No pending ZCode runtime request ${requestInput.requestId}`, + ); + } + if (approval !== undefined) { + const decision: ProviderApprovalDecision = + requestInput.decision ?? + (requestInput.feedback !== undefined ? "decline" : "cancel"); + const response = zcodeApprovalResponseForDecision(approval.options, decision) ?? { + decision: "deny", + reason: requestInput.feedback ?? "Denied in T3 Code", + }; + approval.chosenResponse = response; + pendingApprovals.delete(key); + answeredPermissions.set(approval.nativeRequestId, response); + for (const [respondId, awaiting] of awaitingPermissionRequests) { + if (awaiting.nativeRequestId === approval.nativeRequestId) { + awaitingPermissionRequests.delete(respondId); + yield* connection.respond(respondId, response); + } + } + yield* resolveRuntimeRequestRecords(approval, yield* DateTime.now); + return; + } + const pendingQuestion = question!; + const answerValue = requestInput.answers?.[pendingQuestion.questionId]; + pendingQuestion.answer = + requestInput.decision === "cancel" || typeof answerValue !== "string" + ? { cancelled: true } + : { value: answerValue }; + pendingQuestions.delete(key); + answeredUserInputs.set(pendingQuestion.nativeRequestId, pendingQuestion.answer); + for (const [respondId, awaiting] of awaitingUserInputRequests) { + if (awaiting.nativeRequestId === pendingQuestion.nativeRequestId) { + awaitingUserInputRequests.delete(respondId); + yield* connection.respond(respondId, pendingQuestion.answer); + } + } + yield* resolveRuntimeRequestRecords(pendingQuestion, yield* DateTime.now); + }, + (effect, requestInput) => + effect.pipe( + sessionEventPermit.withPermits(1), + Effect.mapError( + (cause) => + new ProviderAdapter.ProviderAdapterRuntimeRequestResponseError({ + driver: ZCODE_PROVIDER, + requestId: requestInput.requestId, + cause, + }), + ), + ), + ), + readThreadSnapshot: Effect.fn("ZcodeAdapter.readThreadSnapshot")( + function* (snapshotInput: ProviderAdapter.ProviderAdapterV2ReadThreadSnapshotInput) { + const state = threadState; + const wantedNativeId = snapshotInput.providerThread.nativeThreadRef?.nativeId; + if ( + state === null || + wantedNativeId == null || + state.nativeSessionId !== wantedNativeId + ) { + return yield* protocolError( + "ZCode snapshot requested for a thread this session does not host", + ); + } + const result = yield* request("session/read", { sessionId: state.nativeSessionId }); + const resultRecord = Predicate.isObject(result) ? result : {}; + const messagesRaw = zcodeRecordArray(resultRecord, "messages") ?? []; + const threadId = state.providerThread.appThreadId ?? input.threadId; + const messages = messagesRaw.flatMap( + (entry: unknown, index: number): Array => { + const record = Predicate.isObject(entry) ? entry : {}; + const info = zcodeRecordField(record, "info"); + const role = zcodeRecordString(info ?? {}, "role"); + if ( + (role !== "user" && role !== "assistant") || + info === undefined || + !Predicate.isObject(info) + ) { + return []; + } + const parts = zcodeRecordArray(record, "parts") ?? []; + const text = parts + .filter((part) => zcodeRecordString(part ?? {}, "type") === "text") + .map((part) => zcodeRecordString(part, "text") ?? "") + .join(""); + if (text.length === 0) return []; + const createdMs = zcodeRecordNumber( + zcodeRecordField(info, "time") ?? {}, + "created", + ); + const at = + createdMs !== undefined + ? Option.getOrElse( + DateTime.make(createdMs), + () => state.providerThread.createdAt, + ) + : state.providerThread.createdAt; + return [ + { + id: idAllocator.derive.messageFromProviderItem({ + driver: ZCODE_PROVIDER, + nativeItemId: `${wantedNativeId}:snapshot-message:${index}`, + }), + threadId, + runId: null, + nodeId: null, + role: role === "user" ? ("user" as const) : ("assistant" as const), + text, + attachments: [], + streaming: false, + createdBy: role === "user" ? ("user" as const) : ("agent" as const), + creationSource: "provider" as const, + createdAt: at, + updatedAt: at, + }, + ]; + }, + ); + return { + providerThread: state.providerThread, + providerTurns: [], + messages, + runtimeRequests: [], + }; + }, + (effect, snapshotInput) => + effect.pipe( + Effect.mapError( + (cause) => + new ProviderAdapter.ProviderAdapterReadThreadSnapshotError({ + driver: ZCODE_PROVIDER, + providerThreadId: snapshotInput.providerThread.id, + cause, + }), + ), + ), + ), + rollbackThread: (_rollbackInput) => + Effect.fail( + new ProviderAdapter.ProviderAdapterProtocolError({ + driver: ZCODE_PROVIDER, + detail: "ZCode has no rollback method", + }), + ), + forkThread: (_forkInput) => + Effect.fail( + new ProviderAdapter.ProviderAdapterProtocolError({ + driver: ZCODE_PROVIDER, + detail: + "ZCode session/fork exists, but its thread-handoff mapping into T3 is not implemented", + }), + ), + }; + return runtime; + }), + }); +}); + +export type ZcodeAdapterV2DriverEnv = + | ChildProcessSpawner.ChildProcessSpawner + | FileSystem.FileSystem + | IdAllocator.IdAllocatorV2 + | McpProviderSessions.McpProviderSessions + | ProviderHost.ProviderHost; + +export const ZcodeAdapterV2Driver: ProviderAdapterDriver = { + driverKind: ZCODE_PROVIDER, + configSchema: ZcodeSettings, + defaultConfig: (): ZcodeSettings => DEFAULT_ZCODE_SETTINGS, + create: Effect.fn("ZcodeAdapterV2Driver.create")( + function* (input: ProviderAdapterDriverCreateInput) { + const hostEnvironment = yield* HostProcess.Environment; + return yield* makeZcodeAdapterV2({ + instanceId: input.instanceId, + settings: { ...input.config, enabled: input.enabled }, + environment: yield* mergeProviderInstanceEnvironment(input.environment, hostEnvironment), + }); + }, + (effect, input) => + effect.pipe( + Effect.mapError( + (cause) => + new ProviderAdapterDriverCreateError({ + driver: ZCODE_PROVIDER, + instanceId: input.instanceId, + detail: "Failed to create ZCode adapter.", + cause, + }), + ), + ), + ), +}; diff --git a/packages/provider-zcode/src/server/driver.ts b/packages/provider-zcode/src/server/driver.ts new file mode 100644 index 0000000000000..bc9e710317bde --- /dev/null +++ b/packages/provider-zcode/src/server/driver.ts @@ -0,0 +1,152 @@ +import { ProviderDriverKind, TextGenerationError } from "@t3tools/contracts"; +import { ZcodeSettings } from "../settings.ts"; +import * as HostProcess from "@t3tools/shared/HostProcess"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Schema from "effect/Schema"; +import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; + +import type * as ProviderHost from "@t3tools/provider-core/server/ProviderHost"; +import { + defaultProviderContinuationIdentity, + type ProviderDriver, + type ProviderInstance, +} from "@t3tools/provider-core/server/driver"; +import { ProviderDriverError } from "@t3tools/provider-core/server/errors"; +import { mergeProviderInstanceEnvironment } from "@t3tools/provider-core/server/instanceEnvironment"; +import { withInstanceIdentity } from "@t3tools/provider-core/server/instanceIdentity"; +import { makeManualOnlyProviderMaintenanceCapabilities } from "@t3tools/provider-core/server/maintenanceResolver"; +import { makeManagedServerProvider } from "@t3tools/provider-core/server/managedProvider"; +import { + haveProviderSnapshotSettingsChanged, + makeProviderSnapshotSettingsSource, + type ProviderSnapshotSettings, +} from "@t3tools/provider-core/server/snapshotSettings"; +import type { ProviderTextGeneration } from "@t3tools/provider-core/server/textGeneration"; +import { ZcodeAdapterV2Driver, type ZcodeAdapterV2DriverEnv } from "./adapter.ts"; +import { buildInitialZcodeProviderSnapshot, checkZcodeProviderStatus } from "./status.ts"; + +const DRIVER_KIND = ProviderDriverKind.make("zcode"); +const decodeZcodeSettings = Schema.decodeSync(ZcodeSettings); +// Auth is CLI-managed (`zcode login`), and zcode updates itself in the background, +// so T3 offers no one-click install/update. +const MAINTENANCE = makeManualOnlyProviderMaintenanceCapabilities({ + provider: DRIVER_KIND, + packageName: null, +}); + +const unsupportedTextGeneration: ProviderTextGeneration = (() => { + const unsupported = (operation: string) => + Effect.fail( + new TextGenerationError({ + operation, + detail: "ZCode does not provide application text generation.", + }), + ); + return { + generateCommitMessage: () => unsupported("generateCommitMessage"), + generatePrContent: () => unsupported("generatePrContent"), + generateBranchName: () => unsupported("generateBranchName"), + generateThreadTitle: () => unsupported("generateThreadTitle"), + generateReviewGuide: () => unsupported("generateReviewGuide"), + generateTranscriptionPostProcessing: () => unsupported("generateTranscriptionPostProcessing"), + }; +})(); + +export type ZcodeDriverEnv = + | ZcodeAdapterV2DriverEnv + | ChildProcessSpawner.ChildProcessSpawner + | ProviderHost.ProviderHost; + +export const ZcodeDriver: ProviderDriver = { + driverKind: DRIVER_KIND, + metadata: { + displayName: "ZCode", + supportsMultipleInstances: true, + }, + configSchema: ZcodeSettings, + defaultConfig: (): ZcodeSettings => decodeZcodeSettings({}), + create: Effect.fn("ZcodeDriver.create")(function* ({ + instanceId, + displayName, + accentColor, + environment, + enabled, + config, + }) { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const fileSystem = yield* FileSystem.FileSystem; + const processEnv = yield* mergeProviderInstanceEnvironment( + environment, + yield* HostProcess.Environment, + ); + const continuationIdentity = defaultProviderContinuationIdentity({ + driverKind: DRIVER_KIND, + instanceId, + }); + const stampIdentity = withInstanceIdentity({ + instanceId, + driverKind: DRIVER_KIND, + displayName, + accentColor, + continuationGroupKey: continuationIdentity.continuationKey, + }); + const effectiveConfig = { ...config, enabled } satisfies ZcodeSettings; + const snapshotSettings = yield* makeProviderSnapshotSettingsSource(effectiveConfig); + const snapshot = yield* makeManagedServerProvider>({ + resolveMaintenance: () => Effect.succeed(MAINTENANCE), + getSettings: snapshotSettings.getSettings, + streamSettings: snapshotSettings.streamSettings, + haveSettingsChanged: haveProviderSnapshotSettingsChanged, + initialSnapshot: (settings) => + buildInitialZcodeProviderSnapshot(settings.provider).pipe(Effect.map(stampIdentity)), + checkProvider: checkZcodeProviderStatus(effectiveConfig, processEnv).pipe( + Effect.map(stampIdentity), + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + Effect.provideService(FileSystem.FileSystem, fileSystem), + ), + }).pipe( + Effect.mapError( + (cause) => + new ProviderDriverError({ + driver: DRIVER_KIND, + instanceId, + // The cause keeps the failure; the detail reaches the snapshot, so it stays fixed. + detail: "Failed to build ZCode snapshot.", + cause, + }), + ), + ); + + const orchestrationAdapter = yield* ZcodeAdapterV2Driver.create({ + instanceId, + displayName, + accentColor, + environment, + enabled, + config, + }).pipe( + Effect.mapError( + (cause) => + new ProviderDriverError({ + driver: DRIVER_KIND, + instanceId, + detail: "Failed to build ZCode orchestration adapter.", + cause, + }), + ), + ); + + return { + instanceId, + driverKind: DRIVER_KIND, + continuationIdentity, + displayName, + accentColor, + enabled, + snapshot, + orchestrationAdapter, + textGeneration: unsupportedTextGeneration, + } satisfies ProviderInstance; + }), +}; diff --git a/packages/provider-zcode/src/server/status.test.ts b/packages/provider-zcode/src/server/status.test.ts new file mode 100644 index 0000000000000..73bfcf6c99dc9 --- /dev/null +++ b/packages/provider-zcode/src/server/status.test.ts @@ -0,0 +1,181 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, describe, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Schema from "effect/Schema"; + +import { writeFakeCli } from "@t3tools/provider-testing/fakeCli"; + +import { ZcodeSettings } from "../settings.ts"; +import { checkZcodeProviderStatus, zcodeAuthFromCredentials } from "./status.ts"; + +const settings = Schema.decodeSync(ZcodeSettings); +const BASE_ENV = { PATH: process.env.PATH }; + +const credentialsWithActiveProvider = JSON.stringify([ + ["oauth:active_provider", "account:zai-individual-coding-plan"], + ["oauth:account:zai-individual-coding-plan", { accessToken: "redacted" }], +]); + +const fakeDoctor = (source: string) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const directory = yield* fs.makeTempDirectoryScoped({ prefix: "zcode-status-test-" }); + return writeFakeCli({ directory, name: "zcode", source }); + }); + +const dataDirWithCredentials = (credentials: string | null) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "zcode-credentials-test-" }); + if (credentials !== null) { + yield* fs.makeDirectory(`${root}/.zcode/v2`, { recursive: true }); + yield* fs.writeFileString(`${root}/.zcode/v2/credentials.json`, credentials); + } + return root; + }); + +describe("zcodeAuthFromCredentials", () => { + it("reads the credential store's active provider", () => { + assert.equal( + zcodeAuthFromCredentials(JSON.parse(credentialsWithActiveProvider)), + "authenticated", + ); + assert.equal(zcodeAuthFromCredentials([]), "unauthenticated"); + assert.equal(zcodeAuthFromCredentials([["other", "value"]]), "unauthenticated"); + assert.equal(zcodeAuthFromCredentials([["oauth:active_provider", ""]]), "unauthenticated"); + assert.equal(zcodeAuthFromCredentials(["oauth:active_provider"]), "unauthenticated"); + assert.equal(zcodeAuthFromCredentials(undefined), "unknown"); + assert.equal(zcodeAuthFromCredentials({ oauth: true }), "unknown"); + }); +}); + +it.layer(NodeServices.layer)("Zcode status", (it) => { + it.effect("reports an authenticated ready provider from doctor JSON", () => + Effect.scoped( + Effect.gen(function* () { + const binaryPath = yield* fakeDoctor( + 'process.stdout.write(\'{"cli":{"version":"3.17.2"}}\');process.exit(0);', + ); + const dataDir = yield* dataDirWithCredentials(credentialsWithActiveProvider); + const snapshot = yield* checkZcodeProviderStatus(settings({ enabled: true, binaryPath }), { + ...BASE_ENV, + ZCODE_DATA_BASE_DIR: dataDir, + }); + assert.equal(snapshot.enabled, true); + assert.equal(snapshot.installed, true); + assert.equal(snapshot.version, "3.17.2"); + assert.equal(snapshot.status, "ready"); + assert.equal(snapshot.auth.status, "authenticated"); + assert.equal(snapshot.message, undefined); + }), + ), + ); + + it.effect("flags a signed-out CLI as warning", () => + Effect.scoped( + Effect.gen(function* () { + const binaryPath = yield* fakeDoctor( + 'process.stdout.write(\'{"cli":{"version":"3.17.2"}}\');process.exit(0);', + ); + const dataDir = yield* dataDirWithCredentials(null); + const snapshot = yield* checkZcodeProviderStatus(settings({ enabled: true, binaryPath }), { + ...BASE_ENV, + ZCODE_DATA_BASE_DIR: dataDir, + }); + assert.equal(snapshot.installed, true); + assert.equal(snapshot.version, "3.17.2"); + assert.equal(snapshot.status, "warning"); + assert.equal(snapshot.auth.status, "unauthenticated"); + assert.match(snapshot.message ?? "", /zcode login/); + }), + ), + ); + + it.effect("falls back to plain stdout when doctor output is not JSON", () => + Effect.scoped( + Effect.gen(function* () { + const binaryPath = yield* fakeDoctor( + 'process.stdout.write("zcode agent CLI 0.17.1\\n");process.exit(0);', + ); + const dataDir = yield* dataDirWithCredentials(credentialsWithActiveProvider); + const snapshot = yield* checkZcodeProviderStatus(settings({ enabled: true, binaryPath }), { + ...BASE_ENV, + ZCODE_DATA_BASE_DIR: dataDir, + }); + assert.equal(snapshot.version, "0.17.1"); + assert.equal(snapshot.status, "ready"); + }), + ), + ); + + it.effect("reports an unsupported version as error", () => + Effect.scoped( + Effect.gen(function* () { + const binaryPath = yield* fakeDoctor( + 'process.stdout.write(\'{"cli":{"version":"0.16.0"}}\');process.exit(0);', + ); + const dataDir = yield* dataDirWithCredentials(credentialsWithActiveProvider); + const snapshot = yield* checkZcodeProviderStatus(settings({ enabled: true, binaryPath }), { + ...BASE_ENV, + ZCODE_DATA_BASE_DIR: dataDir, + }); + assert.equal(snapshot.installed, true); + assert.equal(snapshot.version, "0.16.0"); + assert.equal(snapshot.status, "error"); + assert.equal(snapshot.auth.status, "unknown"); + assert.match(snapshot.message ?? "", /unsupported/i); + }), + ), + ); + + it.effect("reports a failing doctor run without leaking its stderr", () => + Effect.scoped( + Effect.gen(function* () { + const binaryPath = yield* fakeDoctor( + 'process.stderr.write("secret-doctor-value");process.exit(2);', + ); + const dataDir = yield* dataDirWithCredentials(null); + const snapshot = yield* checkZcodeProviderStatus(settings({ enabled: true, binaryPath }), { + ...BASE_ENV, + ZCODE_DATA_BASE_DIR: dataDir, + }); + assert.equal(snapshot.installed, true); + assert.equal(snapshot.version, null); + assert.equal(snapshot.status, "error"); + assert.equal(snapshot.message ?? "", "ZCode CLI is installed but failed to run."); + assert.equal((snapshot.message ?? "").includes("secret-doctor-value"), false); + }), + ), + ); + + it.effect("reports a missing binary as uninstalled", () => + Effect.scoped( + Effect.gen(function* () { + const snapshot = yield* checkZcodeProviderStatus( + settings({ enabled: true, binaryPath: "/nonexistent/zcode-missing" }), + BASE_ENV, + ); + assert.equal(snapshot.installed, false); + assert.equal(snapshot.version, null); + assert.equal(snapshot.status, "error"); + assert.match(snapshot.message ?? "", /not installed or not on PATH/); + }), + ), + ); + + it.effect("skips probing while the provider is disabled", () => + Effect.scoped( + Effect.gen(function* () { + const snapshot = yield* checkZcodeProviderStatus( + settings({ enabled: false, binaryPath: "/nonexistent/zcode-missing" }), + BASE_ENV, + ); + assert.equal(snapshot.enabled, false); + assert.equal(snapshot.installed, false); + assert.equal(snapshot.status, "disabled"); + assert.match(snapshot.message ?? "", /disabled/i); + }), + ), + ); +}); diff --git a/packages/provider-zcode/src/server/status.ts b/packages/provider-zcode/src/server/status.ts new file mode 100644 index 0000000000000..097824cd4ef31 --- /dev/null +++ b/packages/provider-zcode/src/server/status.ts @@ -0,0 +1,295 @@ +import { type CustomModelSetting, type ServerProviderModel } from "@t3tools/contracts"; +import type { ZcodeSettings } from "../settings.ts"; +import { resolveSpawnCommand } from "@t3tools/shared/shell"; +import { compareSemverVersions } from "@t3tools/shared/semver"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Option from "effect/Option"; +import * as Result from "effect/Result"; +import * as Schema from "effect/Schema"; +import * as ChildProcess from "effect/process/ChildProcess"; +import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; + +import { + buildServerProvider, + isCommandMissingCause, + parseGenericCliVersion, + providerModelsFromSettings, + spawnAndCollect, + type ServerProviderDraft, +} from "@t3tools/provider-core/server/snapshotProbe"; + +const ZCODE_PRESENTATION = { + displayName: "ZCode", + showInteractionModeToggle: false, + supportedRuntimeModes: ["approval-required", "auto-accept-edits", "full-access"], + reportsContextWindow: true, +} as const; + +const VERSION_PROBE_TIMEOUT_MS = 10_000; + +/** `zcode doctor --json` reports cli.version, the wire-protocol carrier T3 depends on. */ +const MINIMUM_ZCODE_VERSION = "0.17.0"; + +const decodeDoctorOutput = Schema.decodeUnknownOption( + Schema.fromJsonString( + Schema.Struct({ + cli: Schema.Struct({ version: Schema.optional(Schema.String) }), + }), + ), +); + +const ZCODE_FALLBACK_MODELS: ReadonlyArray = [ + { slug: "default", name: "ZCode default", isCustom: false, capabilities: {} }, + { + slug: "account:zai-individual-coding-plan/GLM-5.3", + name: "GLM-5.3", + isCustom: false, + capabilities: {}, + }, + { + slug: "account:zai-individual-coding-plan/GLM-5.3-Flash", + name: "GLM-5.3 Flash", + isCustom: false, + capabilities: {}, + }, +]; + +function zcodeModelsFromSettings( + customModels: ReadonlyArray | undefined, +): ReadonlyArray { + return providerModelsFromSettings(ZCODE_FALLBACK_MODELS, customModels ?? [], {}); +} + +const runZcodeDoctor = Effect.fn("ZcodeProviderStatus.runDoctor")(function* ( + zcodeSettings: ZcodeSettings, + environment: NodeJS.ProcessEnv, +) { + const command = zcodeSettings.binaryPath || "zcode"; + const spawnCommand = yield* resolveSpawnCommand(command, ["doctor", "--json"], { + env: environment, + }); + return yield* spawnAndCollect( + command, + ChildProcess.make(spawnCommand.command, spawnCommand.args, { + env: environment, + shell: spawnCommand.shell, + }), + ); +}); + +/** Auth is CLI-managed (`zcode login`); the probe only reads the credential store's active provider. */ +export const zcodeAuthFromCredentials = ( + data: unknown, +): "authenticated" | "unauthenticated" | "unknown" => { + if (!Array.isArray(data)) return "unknown"; + const hasActiveProvider = data.some( + (entry) => + Array.isArray(entry) && + entry[0] === "oauth:active_provider" && + typeof entry[1] === "string" && + entry[1].length > 0, + ); + return hasActiveProvider ? "authenticated" : "unauthenticated"; +}; + +const decodeCredentials = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Unknown)); + +const readZcodeAuthStatus = Effect.fn("ZcodeProviderStatus.readAuthStatus")(function* ( + fileSystem: FileSystem.FileSystem, + credentialsPath: string, +): Effect.fn.Return<"authenticated" | "unauthenticated" | "unknown", never, never> { + const raw = yield* fileSystem.readFile(credentialsPath).pipe( + Effect.map((bytes) => new TextDecoder().decode(bytes)), + Effect.orElseSucceed(() => undefined), + ); + if (raw === undefined) return "unauthenticated" as const; + return zcodeAuthFromCredentials(Option.getOrUndefined(decodeCredentials(raw))); +}); + +const resolveZcodeCredentialsPath = (environment: NodeJS.ProcessEnv): string => { + const dataBaseDir = environment.ZCODE_DATA_BASE_DIR?.trim() || environment.HOME || ""; + return `${dataBaseDir}/.zcode/v2/credentials.json`; +}; + +export const buildInitialZcodeProviderSnapshot = Effect.fn( + "ZcodeProviderStatus.buildInitialSnapshot", +)(function* (zcodeSettings: ZcodeSettings): Effect.fn.Return { + const checkedAt = yield* Effect.map(DateTime.now, DateTime.formatIso); + const models = zcodeModelsFromSettings(zcodeSettings.customModels); + if (!zcodeSettings.enabled) { + return buildServerProvider({ + presentation: ZCODE_PRESENTATION, + enabled: false, + checkedAt, + models, + probe: { + installed: false, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: "ZCode is disabled in T3 Code settings.", + }, + }); + } + return buildServerProvider({ + presentation: ZCODE_PRESENTATION, + enabled: true, + checkedAt, + models, + probe: { + installed: true, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: "Checking ZCode CLI availability...", + }, + }); +}); + +export const checkZcodeProviderStatus = Effect.fn("checkZcodeProviderStatus")(function* ( + zcodeSettings: ZcodeSettings, + environment: NodeJS.ProcessEnv = process.env, +): Effect.fn.Return< + ServerProviderDraft, + never, + ChildProcessSpawner.ChildProcessSpawner | FileSystem.FileSystem +> { + const checkedAt = DateTime.formatIso(yield* DateTime.now); + const fallbackModels = zcodeModelsFromSettings(zcodeSettings.customModels); + + if (!zcodeSettings.enabled) { + return buildServerProvider({ + presentation: ZCODE_PRESENTATION, + enabled: false, + checkedAt, + models: fallbackModels, + probe: { + installed: false, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: "ZCode is disabled in T3 Code settings.", + }, + }); + } + + const versionResult = yield* runZcodeDoctor(zcodeSettings, environment).pipe( + Effect.timeoutOption(VERSION_PROBE_TIMEOUT_MS), + Effect.result, + ); + + if (Result.isFailure(versionResult)) { + const error = versionResult.failure; + yield* Effect.logWarning("ZCode CLI health check failed.", { errorTag: error._tag }); + return buildServerProvider({ + presentation: ZCODE_PRESENTATION, + enabled: zcodeSettings.enabled, + checkedAt, + models: fallbackModels, + probe: { + installed: !isCommandMissingCause(error), + version: null, + status: "error", + auth: { status: "unknown" }, + message: isCommandMissingCause(error) + ? "ZCode CLI (`zcode`) is not installed or not on PATH. Install the ZCode CLI, then run `zcode login`." + : "Failed to execute ZCode CLI health check.", + }, + }); + } + + if (Option.isNone(versionResult.success)) { + return buildServerProvider({ + presentation: ZCODE_PRESENTATION, + enabled: zcodeSettings.enabled, + checkedAt, + models: fallbackModels, + probe: { + installed: true, + version: null, + status: "error", + auth: { status: "unknown" }, + message: "ZCode CLI is installed but timed out while running `zcode doctor`.", + }, + }); + } + + const probeOutput = versionResult.success.value; + if (probeOutput.code !== 0) { + return buildServerProvider({ + presentation: ZCODE_PRESENTATION, + enabled: zcodeSettings.enabled, + checkedAt, + models: fallbackModels, + probe: { + installed: true, + version: null, + status: "error", + auth: { status: "unknown" }, + message: "ZCode CLI is installed but failed to run.", + }, + }); + } + + const doctor = Option.getOrUndefined( + Option.map(decodeDoctorOutput(probeOutput.stdout), (decoded) => decoded.cli), + ); + const version = doctor?.version ?? parseGenericCliVersion(probeOutput.stdout); + if (version === null) { + return buildServerProvider({ + presentation: ZCODE_PRESENTATION, + enabled: zcodeSettings.enabled, + checkedAt, + models: fallbackModels, + probe: { + installed: true, + version: null, + status: "error", + auth: { status: "unknown" }, + message: `T3 Code could not determine the ZCode CLI version. ZCode ${MINIMUM_ZCODE_VERSION} or newer is required.`, + }, + }); + } + + if (compareSemverVersions(version, MINIMUM_ZCODE_VERSION) < 0) { + return buildServerProvider({ + presentation: ZCODE_PRESENTATION, + enabled: zcodeSettings.enabled, + checkedAt, + models: fallbackModels, + probe: { + installed: true, + version, + status: "error", + auth: { status: "unknown" }, + message: `ZCode CLI ${version} is unsupported. Update to ZCode ${MINIMUM_ZCODE_VERSION} or newer.`, + }, + }); + } + + const fileSystem = yield* FileSystem.FileSystem; + const authStatus = yield* readZcodeAuthStatus( + fileSystem, + resolveZcodeCredentialsPath(environment), + ); + return buildServerProvider({ + presentation: ZCODE_PRESENTATION, + enabled: zcodeSettings.enabled, + checkedAt, + models: fallbackModels, + probe: { + installed: true, + version, + status: authStatus === "authenticated" ? "ready" : "warning", + auth: { status: authStatus, type: "zcode" }, + ...(authStatus === "authenticated" + ? {} + : { + message: + "ZCode CLI is installed but not signed in. Run `zcode login` in a terminal, then refresh this provider.", + }), + }, + }); +}); diff --git a/packages/provider-zcode/src/server/zcodeProtocol.ts b/packages/provider-zcode/src/server/zcodeProtocol.ts new file mode 100644 index 0000000000000..e88401ca09e06 --- /dev/null +++ b/packages/provider-zcode/src/server/zcodeProtocol.ts @@ -0,0 +1,446 @@ +// ZCode app-server speaks NDJSON on stdio; responses carry {id, result|error} while server→client +// requests repeat the {id, method, params} shape and must be answered via respond(). +import * as Clock from "effect/Clock"; +import * as Deferred from "effect/Deferred"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Predicate from "effect/Predicate"; +import * as Queue from "effect/Queue"; +import * as Scope from "effect/Scope"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; +import * as ChildProcess from "effect/process/ChildProcess"; +import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; +import * as HostProcess from "@t3tools/shared/HostProcess"; +import { resolveSpawnCommand } from "@t3tools/shared/shell"; + +import { signalProcessGroup } from "@t3tools/provider-core/server/processGroup"; + +export class ZcodeProtocolError extends Schema.TaggedError()( + "ZcodeProtocolError", + { + operation: Schema.String, + detail: Schema.optional(Schema.String), + cause: Schema.optional(Schema.Defect()), + }, +) { + override get message(): string { + return `ZCode protocol ${this.operation} failed${this.detail === undefined ? "" : `: ${this.detail}`}.`; + } +} + +export class ZcodeProtocolTimeoutError extends Schema.TaggedError()( + "ZcodeProtocolTimeoutError", + { + operation: Schema.String, + timeoutMs: Schema.Int.check(Schema.isBetween({ minimum: 0, maximum: Number.MAX_SAFE_INTEGER })), + }, +) { + override get message(): string { + return `ZCode protocol ${this.operation} failed: timed out after ${this.timeoutMs}ms.`; + } +} + +export type ZcodeRecord = Record; + +export function zcodeRecordField(input: unknown, key: string): unknown { + return Predicate.isObject(input) ? input[key] : undefined; +} + +export function zcodeRecordString(input: unknown, key: string): string | undefined { + const value = zcodeRecordField(input, key); + return Predicate.isString(value) ? value : undefined; +} + +export function zcodeRecordNumber(input: unknown, key: string): number | undefined { + const value = zcodeRecordField(input, key); + return Predicate.isNumber(value) && Number.isFinite(value) ? value : undefined; +} + +export function zcodeRecordArray(input: unknown, key: string): ReadonlyArray | undefined { + const value = zcodeRecordField(input, key); + return Array.isArray(value) ? value : undefined; +} + +export const isServerRequest = (record: ZcodeRecord): boolean => + record["id"] !== undefined && Predicate.isString(record["method"]); + +export interface ZcodeSpawnOptions { + readonly command: string; + readonly args: ReadonlyArray; + readonly cwd: string | undefined; + readonly env: NodeJS.ProcessEnv; +} + +export interface ZcodeConnection { + readonly request: ( + method: string, + params: ZcodeRecord, + timeoutMs?: number, + ) => Effect.Effect; + readonly respond: (id: string, result: unknown) => Effect.Effect; + readonly events: Queue.Queue; + readonly exited: Effect.Effect; + readonly terminate: Effect.Effect; +} + +const DEFAULT_REQUEST_TIMEOUT_MS = 30_000; +const TERMINATION_GRACE = Duration.seconds(1); + +interface PendingZcodeRequest { + readonly deferred: Deferred.Deferred; +} + +const MAX_ZCODE_RECORD_CHARS = 16 * 1024 * 1024; + +function makeJsonlFramer() { + let buffer = ""; + let dropping = false; + return (chunk: string) => { + const lines: string[] = []; + const droppedLengths: number[] = []; + let start = 0; + while (start < chunk.length) { + const newline = chunk.indexOf("\n", start); + const end = newline < 0 ? chunk.length : newline; + if (!dropping) { + if (buffer.length + end - start > MAX_ZCODE_RECORD_CHARS) { + droppedLengths.push(buffer.length + end - start); + buffer = ""; + dropping = true; + } else { + buffer += chunk.slice(start, end); + } + } + if (newline < 0) break; + if (!dropping && buffer.length > 0) { + lines.push(buffer.endsWith("\r") ? buffer.slice(0, -1) : buffer); + } + buffer = ""; + dropping = false; + start = newline + 1; + } + return { lines, droppedLengths }; + }; +} + +const logDroppedZcodeRecords = (droppedLengths: ReadonlyArray) => + Effect.forEach( + droppedLengths, + (recordLength) => + Effect.logWarning("Dropping oversized zcode stdout record.", { + recordLength, + maxRecordLength: MAX_ZCODE_RECORD_CHARS, + }), + { discard: true }, + ); + +const UnknownFromJsonString = Schema.fromJsonString(Schema.Unknown); +const decodeJsonLine = Schema.decodeSync(UnknownFromJsonString); +const encodeJsonLine = Schema.encodeSync(UnknownFromJsonString); + +/** JSON text for wire params/diagnostics; pure, so it may fall back to an empty string. */ +export function zcodeJsonStringify(value: unknown): string { + try { + return encodeJsonLine(value); + } catch { + return ""; + } +} + +const ZCODE_ERROR_DETAIL_MAX_CHARS = 600; + +function summarizeZcodeError(error: unknown): string { + const text = typeof error === "string" ? error : JSON.stringify(error); + if (text === undefined) return "unknown error"; + return text.length > ZCODE_ERROR_DETAIL_MAX_CHARS + ? `${text.slice(0, ZCODE_ERROR_DETAIL_MAX_CHARS)}…` + : text; +} + +function describeZcodeStdoutClosure(exitCode: number | undefined): string { + if (exitCode === undefined || !Number.isFinite(exitCode)) { + return "zcode app-server closed stdout"; + } + return `zcode app-server exited with code ${exitCode}`; +} + +function parseZcodeRecord(line: string): ZcodeRecord | undefined { + try { + const parsed: unknown = decodeJsonLine(line); + return Predicate.isObject(parsed) ? parsed : undefined; + } catch { + return undefined; + } +} + +const terminateZcodeProcess = Effect.fn("ZcodeProtocol.terminateZcodeProcess")(function* ( + kill: (signal: NodeJS.Signals) => boolean, + hasExited: () => boolean, +) { + if (hasExited()) return; + if (!kill("SIGTERM")) return; + yield* Effect.sleep(TERMINATION_GRACE); + if (hasExited()) return; + kill("SIGKILL"); +}); + +export const makeZcodeConnection = Effect.fnUntraced(function* (options: ZcodeSpawnOptions) { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const platform = yield* HostProcess.Platform; + const scope = yield* Effect.scope; + + const spawnCommand = yield* resolveSpawnCommand(options.command, [...options.args], { + env: options.env, + }).pipe(Effect.mapError((cause) => new ZcodeProtocolError({ operation: "spawn", cause }))); + const child = yield* spawner + .spawn( + ChildProcess.make(spawnCommand.command, spawnCommand.args, { + ...(options.cwd === undefined ? {} : { cwd: options.cwd }), + env: options.env, + extendEnv: false, + shell: spawnCommand.shell, + detached: platform !== "win32", + stdin: { stream: "pipe", endOnDone: false }, + }), + ) + .pipe(Effect.mapError((cause) => new ZcodeProtocolError({ operation: "spawn", cause }))); + + let childExited = false; + let diagnosingStdoutClose = false; + + const killProcessGroup = (signal: NodeJS.Signals): boolean => { + try { + if (platform === "win32") { + process.kill(Number(child.pid), signal); + } else { + signalProcessGroup(Number(child.pid), signal); + } + return true; + } catch { + return false; + } + }; + + const hasExited = (): boolean => { + if (childExited) return true; + try { + if (platform === "win32") process.kill(Number(child.pid), 0); + else signalProcessGroup(Number(child.pid), 0); + return false; + } catch { + return true; + } + }; + + const terminateWindowsTree = Effect.gen(function* () { + if (hasExited()) return; + const taskkill = yield* spawner.spawn( + ChildProcess.make("taskkill", ["/PID", String(child.pid), "/T", "/F"]), + ); + yield* taskkill.exitCode; + }).pipe(Effect.scoped, Effect.ignore); + + const terminateProcess = + platform === "win32" + ? terminateWindowsTree + : terminateZcodeProcess(killProcessGroup, hasExited); + + yield* Scope.addFinalizer(scope, terminateProcess.pipe(Effect.ignore, Effect.uninterruptible)); + + const pendingRequests = new Map(); + const events = yield* Queue.unbounded(); + const outgoing = yield* Queue.unbounded(); + const transportDown = yield* Deferred.make(); + const exitDeferred = yield* Deferred.make(); + let nextRequestId = 1; + + const failTransport = Effect.fn("ZcodeProtocol.failTransport")(function* ( + error: ZcodeProtocolError, + ) { + const claimed = yield* Deferred.fail(transportDown, error); + if (!claimed) return; + for (const [key, pending] of pendingRequests) { + pendingRequests.delete(key); + yield* Deferred.fail(pending.deferred, error); + } + yield* Queue.fail(outgoing, error); + yield* Queue.fail(events, error); + }); + + const routeRecord = + /* Streaming deltas ride through here; avoid a span per record. */ + Effect.fnUntraced(function* (record: ZcodeRecord) { + if (isServerRequest(record)) { + // Server→client requests are surfaced as events; the adapter answers via respond(). + yield* Queue.offer(events, record); + return; + } + if (record["id"] !== undefined) { + const key = String(record["id"]); + const pending = pendingRequests.get(key); + if (pending !== undefined) { + pendingRequests.delete(key); + if (record["error"] === undefined) { + yield* Deferred.succeed(pending.deferred, record["result"]); + } else { + yield* Deferred.fail( + pending.deferred, + new ZcodeProtocolError({ + operation: String(record["method"] ?? "request"), + detail: summarizeZcodeError(record["error"]), + ...(record["error"] === undefined ? {} : { cause: record["error"] }), + }), + ); + } + return; + } + } + yield* Queue.offer(events, record); + }); + + yield* child.exitCode.pipe( + Effect.matchEffect({ + onFailure: (cause) => + Deferred.fail(exitDeferred, new ZcodeProtocolError({ operation: "exit", cause })), + onSuccess: (code) => + Effect.suspend(() => { + childExited = true; + return Deferred.succeed(exitDeferred, Number(code)); + }), + }), + Effect.forkIn(scope), + ); + + yield* Effect.gen(function* () { + const frame = makeJsonlFramer(); + yield* child.stdout.pipe( + Stream.decodeText(), + Stream.runForEach((chunk) => + Effect.gen(function* () { + const { lines, droppedLengths } = frame(chunk); + yield* logDroppedZcodeRecords(droppedLengths); + for (const line of lines) { + const record = parseZcodeRecord(line); + if (record === undefined) { + yield* Effect.logDebug("Dropping non-JSON zcode stdout line.", { + lineLength: line.length, + }); + continue; + } + yield* routeRecord(record); + } + }), + ), + ); + const flushed = frame("\n"); + yield* logDroppedZcodeRecords(flushed.droppedLengths); + for (const line of flushed.lines) { + const trailing = parseZcodeRecord(line); + if (trailing !== undefined) yield* routeRecord(trailing); + } + }).pipe( + Effect.matchCauseEffect({ + onFailure: (cause) => failTransport(new ZcodeProtocolError({ operation: "read", cause })), + onSuccess: Effect.fnUntraced(function* () { + diagnosingStdoutClose = true; + const polled = yield* Deferred.poll(exitDeferred); + const maybeExitCode = Option.isSome(polled) + ? yield* polled.value.pipe( + Effect.asSome, + Effect.orElseSucceed(() => Option.none()), + ) + : yield* Deferred.await(exitDeferred).pipe( + Effect.timeoutOption(Duration.millis(250)), + // Stdout-close waits need wall time; TestClock would strand Stop-with-restart without an exit. + Effect.provideService(Clock.Clock, Clock.Clock.defaultValue()), + Effect.orElseSucceed(() => Option.none()), + ); + return yield* failTransport( + new ZcodeProtocolError({ + operation: "read", + detail: describeZcodeStdoutClosure( + Option.isSome(maybeExitCode) ? maybeExitCode.value : undefined, + ), + }), + ); + }), + }), + Effect.forkIn(scope), + ); + + yield* child.stderr.pipe( + Stream.decodeText(), + Stream.runForEach((chunk) => + chunk.trim().length === 0 + ? Effect.void + : Effect.logDebug("zcode app-server stderr", { stderrLength: chunk.length }), + ), + Effect.ignore, + Effect.forkIn(scope), + ); + + yield* Stream.fromQueue(outgoing).pipe( + Stream.run(child.stdin), + Effect.catchCause((cause) => + diagnosingStdoutClose + ? Effect.void + : failTransport(new ZcodeProtocolError({ operation: "write", cause })), + ), + Effect.forkIn(scope), + ); + + const send = + /* Streaming deltas ride through here; avoid a span per record. */ + Effect.fnUntraced(function* (record: ZcodeRecord): Effect.fn.Return { + const accepted = yield* Queue.offer( + outgoing, + new TextEncoder().encode(`${encodeJsonLine(record)}\n`), + ); + if (!accepted) { + return yield* Deferred.await(transportDown); + } + }); + + const request = Effect.fn("ZcodeProtocol.request")(function* ( + method: string, + params: ZcodeRecord, + timeoutMs = DEFAULT_REQUEST_TIMEOUT_MS, + ): Effect.fn.Return { + const id = nextRequestId++; + const deferred = yield* Deferred.make(); + pendingRequests.set(String(id), { deferred }); + yield* send({ id, method, params }).pipe( + Effect.tapError(() => Effect.sync(() => pendingRequests.delete(String(id)))), + ); + return yield* Effect.raceFirst(Deferred.await(deferred), Deferred.await(transportDown)).pipe( + Effect.timeoutOrElse({ + duration: Duration.millis(timeoutMs), + orElse: () => Effect.fail(new ZcodeProtocolTimeoutError({ operation: method, timeoutMs })), + }), + Effect.onInterrupt(() => Effect.sync(() => pendingRequests.delete(String(id)))), + Effect.onError(() => Effect.sync(() => pendingRequests.delete(String(id)))), + ); + }); + + const respond = Effect.fnUntraced(function* ( + id: string, + result: unknown, + ): Effect.fn.Return { + yield* send({ id, result }); + }); + + return { + request, + respond, + events, + exited: Deferred.await(exitDeferred), + terminate: failTransport( + new ZcodeProtocolError({ + operation: "terminate", + detail: "zcode app-server was stopped", + }), + ).pipe(Effect.andThen(terminateProcess), Effect.ignore, Effect.uninterruptible), + } satisfies ZcodeConnection; +}); diff --git a/packages/provider-zcode/src/server/zcodeRuntime.test.ts b/packages/provider-zcode/src/server/zcodeRuntime.test.ts new file mode 100644 index 0000000000000..485ca53940988 --- /dev/null +++ b/packages/provider-zcode/src/server/zcodeRuntime.test.ts @@ -0,0 +1,328 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as HostProcess from "@t3tools/shared/HostProcess"; +import { assert, describe, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; + +import { + resolveZcodeCatalogFiles, + readZcodeAppVersion, + zcodeAccountConfigPush, + zcodeBuiltinCatalogPath, + zcodeBundledCatalogPath, + zcodeCommand, + zcodeDataRoot, + zcodeModelSelectionFromSlug, + zcodePersonalProviderConfigPath, + zcodePlatformDir, + zcodeRuntimeEnvironment, + zcodeSpawnArgs, +} from "./zcodeRuntime.ts"; + +// Verified against the on-disk zcode 3.17.2 runtime catalog directory for https://zcode.z.ai. +const ZCODE_ENDPOINT_HASH = "78d7c3bef4024722642626fe3669a799"; + +const inlineCatalog = { + schemaVersion: 1, + revision: 43, + config: { + providerConfigRules: { + templateRules: [ + { + templateId: "zai-api", + config: { access: { type: "api-key" }, builtinModelIds: ["GLM-5.3"] }, + }, + ], + providerRules: [ + { + providerId: "account:zai-individual-coding-plan", + config: { + access: { type: "zhipu-account", accountType: "zai" }, + builtinModelIds: ["GLM-5.3", "GLM-5.3-Flash"], + }, + }, + { + providerId: "account:zai-team-coding-plan", + config: { + access: { type: "zhipu-account", accountType: "zai" }, + builtinModelIds: ["GLM-5.3"], + }, + }, + { + providerId: "kimi-api", + config: { access: { type: "api-key" }, builtinModelIds: ["kimi-k3"] }, + }, + { + providerId: "broken-empty-models", + config: { access: { type: "zhipu-account" }, builtinModelIds: [] }, + }, + ], + }, + }, +}; + +describe("zcodePlatformDir", () => { + it("maps zcode's own platform spellings", () => { + assert.equal(zcodePlatformDir("linux", "x64"), "linux-x86_64"); + assert.equal(zcodePlatformDir("darwin", "x64"), "darwin-x64"); + assert.equal(zcodePlatformDir("darwin", "arm64"), "darwin-arm64"); + assert.equal(zcodePlatformDir("win32", "x64"), "win32-x64"); + }); + + it("returns undefined where zcode has no runtime catalog", () => { + assert.equal(zcodePlatformDir("linux", "arm64"), undefined); + assert.equal(zcodePlatformDir("win32", "arm64"), undefined); + assert.equal(zcodePlatformDir("freebsd", "x64"), undefined); + }); +}); + +describe("zcodeBuiltinCatalogPath", () => { + it("keys the CDN-refreshed catalog on the endpoint-origin sha256 prefix", () => { + assert.equal( + zcodeBuiltinCatalogPath({ + root: "/home/dev", + appVersion: "3.17.2", + platform: "linux", + arch: "x64", + }), + `/home/dev/.zcode/v2/runtime/provider/linux-x86_64/3.17.2/endpoint-${ZCODE_ENDPOINT_HASH}/zcode-builtin.json`, + ); + assert.equal( + zcodeBuiltinCatalogPath({ + root: "C:\\d", + appVersion: "3.17.2", + platform: "win32", + arch: "x64", + }), + `C:\\d/.zcode/v2/runtime/provider/win32-x64/3.17.2/endpoint-${ZCODE_ENDPOINT_HASH}/zcode-builtin.json`, + ); + }); + + it("derives the bundled and personal paths from the data root", () => { + assert.equal( + zcodeBundledCatalogPath("/home/dev"), + "/home/dev/.zcode/v2/runtime/provider/bundled/zcode-builtin.json", + ); + assert.equal( + zcodePersonalProviderConfigPath("/home/dev"), + "/home/dev/.zcode/v2/provider_config.json", + ); + assert.equal(zcodeDataRoot({ HOME: "/home/dev" }), "/home/dev"); + assert.equal(zcodeDataRoot({ ZCODE_DATA_BASE_DIR: " /tmp/z ", HOME: "/home/dev" }), "/tmp/z"); + assert.equal(zcodeDataRoot({}), undefined); + }); +}); + +describe("zcodeSpawnArgs", () => { + it("launches app-server --stdio with launch args split on whitespace", () => { + assert.deepEqual(zcodeSpawnArgs(""), ["app-server", "--stdio"]); + assert.deepEqual(zcodeSpawnArgs("--verbose --trace file "), [ + "app-server", + "--stdio", + "--verbose", + "--trace", + "file", + ]); + }); + + it("keeps an explicit binaryPath, defaulting to zcode on PATH", () => { + assert.equal(zcodeCommand({ binaryPath: "/opt/bin/zcode" }), "/opt/bin/zcode"); + assert.equal(zcodeCommand({ binaryPath: " " }), "zcode"); + }); +}); + +describe("zcodeRuntimeEnvironment", () => { + it("always points the CLI at the endpoint origin", () => { + const env = zcodeRuntimeEnvironment({ HOME: "/home/dev", KEEP: "1" }, {}); + assert.equal(env.ZCODE_BASE_URL, "https://zcode.z.ai"); + assert.equal(env.KEEP, "1"); + assert.equal(env.ZCODE_BUILTIN_PROVIDER_CONFIG_FILE, undefined); + assert.equal(env.ZCODE_PERSONAL_PROVIDER_CONFIG_FILE, undefined); + }); + + it("emits catalog env vars only for files that exist", () => { + const env = zcodeRuntimeEnvironment( + {}, + { + builtinPath: "/data/builtin.json", + bundledPath: "/data/bundled.json", + personalPath: "/data/personal.json", + }, + ); + assert.equal(env.ZCODE_BUILTIN_PROVIDER_CONFIG_FILE, "/data/builtin.json"); + assert.equal(env.ZCODE_BUILTIN_PROVIDER_BUNDLED_CONFIG_FILE, "/data/bundled.json"); + assert.equal(env.ZCODE_PERSONAL_PROVIDER_CONFIG_FILE, "/data/personal.json"); + }); + + it("drops the bundled var and the paired personal var when those files are missing", () => { + const env = zcodeRuntimeEnvironment({}, { builtinPath: "/data/builtin.json" }); + assert.equal(env.ZCODE_BUILTIN_PROVIDER_CONFIG_FILE, "/data/builtin.json"); + assert.equal(env.ZCODE_BUILTIN_PROVIDER_BUNDLED_CONFIG_FILE, undefined); + assert.equal(env.ZCODE_PERSONAL_PROVIDER_CONFIG_FILE, undefined); + }); +}); + +describe("zcodeAccountConfigPush", () => { + it("pushes only catalog zhipu-account providers with entitled access and states", () => { + const push = zcodeAccountConfigPush(inlineCatalog, "t3code-test"); + assert.isDefined(push); + assert.deepEqual(Object.keys(push.providers), [ + "account:zai-individual-coding-plan", + "account:zai-team-coding-plan", + ]); + assert.deepEqual(push.providers["account:zai-individual-coding-plan"]!, { + builtinModelIds: ["GLM-5.3", "GLM-5.3-Flash"], + access: { type: "zhipu-account", entitled: true }, + }); + assert.equal(push.basedOnZCodeBuiltinRevision, "43"); + assert.equal(push.revision, "t3code-test"); + const states = push.states; + assert.equal(states["account:zai-individual-coding-plan"]!.current, true); + assert.equal(states["account:zai-team-coding-plan"]!.current, false); + assert.equal(states["account:zai-team-coding-plan"]!.availability, "available"); + assert.equal(states["account:zai-team-coding-plan"]!.entitled, true); + }); + + it("returns undefined without a usable catalog", () => { + assert.equal(zcodeAccountConfigPush(undefined, "r"), undefined); + assert.equal(zcodeAccountConfigPush({}, "r"), undefined); + assert.equal( + zcodeAccountConfigPush( + { + config: { + providerConfigRules: { + providerRules: [{ providerId: "kimi-api", config: { access: { type: "api-key" } } }], + }, + }, + }, + "r", + ), + undefined, + ); + }); + + it("tolerates a flattened providerRules list", () => { + const push = zcodeAccountConfigPush( + { + revision: 7, + providerRules: [ + { + providerId: "account:zai-start-plan", + config: { access: { type: "zhipu-account" }, builtinModelIds: ["GLM-5.3-Flash"] }, + }, + ], + }, + "r", + ); + assert.isDefined(push); + assert.equal(push.basedOnZCodeBuiltinRevision, "7"); + assert.deepEqual(Object.keys(push.providers), ["account:zai-start-plan"]); + }); +}); + +describe("zcodeModelSelectionFromSlug", () => { + it("defers default slugs to the CLI's own default", () => { + assert.equal(zcodeModelSelectionFromSlug("default"), undefined); + assert.equal(zcodeModelSelectionFromSlug("default$high"), undefined); + assert.equal(zcodeModelSelectionFromSlug(""), undefined); + }); + + it("maps account provider slugs to zcode model selections", () => { + assert.deepEqual(zcodeModelSelectionFromSlug("account:zai-individual-coding-plan/GLM-5.3"), { + providerId: "account:zai-individual-coding-plan", + modelId: "GLM-5.3", + }); + assert.deepEqual( + zcodeModelSelectionFromSlug("account:zai-individual-coding-plan/GLM-5.3-Flash$high"), + { + providerId: "account:zai-individual-coding-plan", + modelId: "GLM-5.3-Flash", + options: { reasoningLevel: "high" }, + }, + ); + }); + + it("rejects slugs outside the account: provider namespace", () => { + assert.equal(zcodeModelSelectionFromSlug("openai/gpt-5"), undefined); + assert.equal(zcodeModelSelectionFromSlug("/GLM-5.3"), undefined); + assert.equal(zcodeModelSelectionFromSlug("account:zai-individual-coding-plan/"), undefined); + assert.equal(zcodeModelSelectionFromSlug("no-slash"), undefined); + }); +}); + +it.layer(NodeServices.layer)("ZcodeRuntime catalog resolution", (it) => { + it.effect("reads the app version and emits only existing catalog files", () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "zcode-runtime-test-" }); + const platformDir = zcodePlatformDir( + yield* HostProcess.Platform, + yield* HostProcess.Architecture, + ); + const currentPath = `${root}/.zcode/server/current.json`; + const personalPath = zcodePersonalProviderConfigPath(root); + + // No server/current.json yet: only the personal file resolves. + yield* fs.makeDirectory(`${root}/.zcode/v2`, { recursive: true }); + yield* fs.makeDirectory(`${root}/.zcode/server`, { recursive: true }); + yield* fs.writeFileString(personalPath, "{}"); + const withoutVersion = yield* resolveZcodeCatalogFiles(fs, { + ZCODE_DATA_BASE_DIR: root, + }); + assert.equal(withoutVersion.builtinPath, undefined); + assert.equal(withoutVersion.personalPath, personalPath); + + if (platformDir === undefined) return; + const builtinDir = `${root}/.zcode/v2/runtime/provider/${platformDir}/3.17.1/endpoint-${ZCODE_ENDPOINT_HASH}`; + yield* fs.makeDirectory(builtinDir, { recursive: true }); + yield* fs.writeFileString(currentPath, '{"version":"3.17.1"}'); + yield* fs.writeFileString( + `${builtinDir}/zcode-builtin.json`, + JSON.stringify(inlineCatalog), + ); + const resolved = yield* resolveZcodeCatalogFiles(fs, { ZCODE_DATA_BASE_DIR: root }); + assert.equal(resolved.builtinPath, `${builtinDir}/zcode-builtin.json`); + assert.equal(resolved.bundledPath, undefined); + assert.equal(resolved.personalPath, personalPath); + const push = zcodeAccountConfigPush(resolved.builtinCatalog, "r"); + assert.deepEqual(Object.keys(push?.providers ?? {}), [ + "account:zai-individual-coding-plan", + "account:zai-team-coding-plan", + ]); + }), + ), + ); + + it.effect("keeps an unreadable current.json from producing catalog env vars", () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "zcode-runtime-test-" }); + yield* fs.makeDirectory(`${root}/.zcode/server`, { recursive: true }); + yield* fs.writeFileString(`${root}/.zcode/server/current.json`, "not-json"); + const resolved = yield* resolveZcodeCatalogFiles(fs, { ZCODE_DATA_BASE_DIR: root }); + assert.equal(resolved.builtinPath, undefined); + assert.equal(resolved.personalPath, undefined); + }), + ), + ); + + it.effect("readZcodeAppVersion returns the pinned server version", () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "zcode-runtime-test-" }); + yield* fs.makeDirectory(`${root}/.zcode/server`, { recursive: true }); + yield* fs.writeFileString( + `${root}/.zcode/server/current.json`, + '{"version":"3.17.2","releaseDir":"/x"}', + ); + const version = yield* readZcodeAppVersion(fs, `${root}/.zcode/server/current.json`); + assert.equal(version, "3.17.2"); + const missing = yield* readZcodeAppVersion(fs, `${root}/missing.json`); + assert.equal(missing, undefined); + }), + ), + ); +}); diff --git a/packages/provider-zcode/src/server/zcodeRuntime.ts b/packages/provider-zcode/src/server/zcodeRuntime.ts new file mode 100644 index 0000000000000..63aae33c9b0b8 --- /dev/null +++ b/packages/provider-zcode/src/server/zcodeRuntime.ts @@ -0,0 +1,290 @@ +// @effect-diagnostics nodeBuiltinImport:off -- Catalog paths key on sha256 of the endpoint origin; Effect's crypto service adds a dependency the driver does not carry. +// Provisioning for `zcode app-server --stdio`: spawn args, catalog-backed environment, the +// account config push that makes catalog models selectable, and T3 model slug mapping. +// Everything protocol-shaped here mirrors the Phase 0 probe notes (PROTOCOL-NOTES.md). +import * as NodeCrypto from "node:crypto"; + +import * as HostProcess from "@t3tools/shared/HostProcess"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Option from "effect/Option"; +import * as Predicate from "effect/Predicate"; +import * as Schema from "effect/Schema"; + +import { + zcodeRecordArray, + zcodeRecordField, + zcodeRecordString, + type ZcodeRecord, +} from "./zcodeProtocol.ts"; + +const ZCODE_BASE_URL = "https://zcode.z.ai"; + +const DEFAULT_ZCODE_BINARY = "zcode"; + +/** The CLI filters account providers to ids present in this catalog; inventing ids breaks selection. */ +export interface ZcodeAccountProviderPush { + readonly builtinModelIds: ReadonlyArray; + readonly access: { readonly type: "zhipu-account"; readonly entitled: true }; +} + +export interface ZcodeAccountConfigPush { + readonly revision: string; + readonly basedOnZCodeBuiltinRevision: string; + readonly providers: Record; + readonly states: Record< + string, + { readonly availability: "available"; readonly entitled: true; readonly current: boolean } + >; +} + +/** Wire shape of `model` in session/create and `modelSelection` in session/send. */ +export interface ZcodeModelSelection { + readonly providerId: string; + readonly modelId: string; + readonly options?: { readonly reasoningLevel?: string }; +} + +export interface ZcodeCatalogFileSet { + readonly builtinPath?: string; + readonly bundledPath?: string; + readonly personalPath?: string; + /** Parsed contents of `builtinPath`; the account config push is built from it. */ + readonly builtinCatalog?: unknown; +} + +const ZCODE_ENDPOINT_HASH_LENGTH = 32; +const ZCODE_ENDPOINT_HASH = NodeCrypto.createHash("sha256") + .update(ZCODE_BASE_URL) + .digest("hex") + .slice(0, ZCODE_ENDPOINT_HASH_LENGTH); + +/** zcode stores CDN-refreshed catalogs under its own platform spelling, not process.platform. */ +export function zcodePlatformDir(platform: NodeJS.Platform, arch: string): string | undefined { + switch (platform) { + case "darwin": + return arch === "arm64" ? "darwin-arm64" : "darwin-x64"; + case "linux": + return arch === "x64" ? "linux-x86_64" : undefined; + case "win32": + return arch === "x64" ? "win32-x64" : undefined; + default: + return undefined; + } +} + +export function zcodeDataRoot(environment: NodeJS.ProcessEnv): string | undefined { + const root = environment.ZCODE_DATA_BASE_DIR?.trim() || environment.HOME; + return root === undefined || root.length === 0 ? undefined : root; +} + +export function zcodeBuiltinCatalogPath(input: { + readonly root: string; + readonly appVersion: string; + readonly platform: NodeJS.Platform; + readonly arch: string; +}): string | undefined { + const platformDir = zcodePlatformDir(input.platform, input.arch); + if (platformDir === undefined) return undefined; + return `${input.root}/.zcode/v2/runtime/provider/${platformDir}/${input.appVersion}/endpoint-${ZCODE_ENDPOINT_HASH}/zcode-builtin.json`; +} + +export function zcodeBundledCatalogPath(root: string): string { + return `${root}/.zcode/v2/runtime/provider/bundled/zcode-builtin.json`; +} + +export function zcodePersonalProviderConfigPath(root: string): string { + return `${root}/.zcode/v2/provider_config.json`; +} + +const decodeServerCurrent = Schema.decodeUnknownOption( + Schema.Struct({ version: Schema.optional(Schema.String) }), +); + +const decodeVersionField = (entry: { readonly version?: string | undefined }) => + entry.version === undefined ? Option.none() : Option.some(entry.version); + +const decodeJsonText = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Unknown)); + +/** Reads the appVersion the CLI pins its runtime catalog to from `/.zcode/server/current.json`. */ +export const readZcodeAppVersion = Effect.fn("ZcodeRuntime.readAppVersion")(function* ( + fileSystem: FileSystem.FileSystem, + path: string, +) { + const raw = yield* fileSystem.readFileString(path).pipe(Effect.option); + if (Option.isNone(raw)) return undefined; + const current = Option.flatMap(decodeJsonText(raw.value), decodeServerCurrent); + return Option.getOrUndefined(Option.flatMap(current, decodeVersionField)); +}); + +const catalogFileExists = Effect.fn("ZcodeRuntime.catalogFileExists")(function* ( + fileSystem: FileSystem.FileSystem, + path: string, +) { + return yield* fileSystem.exists(path).pipe(Effect.orElseSucceed(() => false)); +}); + +/** + * Resolves the catalog files the CLI needs through env vars. Each var is set only when its file + * exists: the CLI fails closed on dangling paths, and with none set the adapter reports models + * honestly unavailable instead of pretending success. + */ +export const resolveZcodeCatalogFiles = Effect.fn("ZcodeRuntime.resolveCatalogFiles")(function* ( + fileSystem: FileSystem.FileSystem, + environment: NodeJS.ProcessEnv, +) { + const root = zcodeDataRoot(environment); + if (root === undefined) return {}; + const result: { + builtinPath?: string; + bundledPath?: string; + personalPath?: string; + builtinCatalog?: unknown; + } = {}; + const appVersion = yield* readZcodeAppVersion(fileSystem, `${root}/.zcode/server/current.json`); + if (appVersion !== undefined) { + const builtinPath = zcodeBuiltinCatalogPath({ + root, + appVersion, + platform: yield* HostProcess.Platform, + arch: yield* HostProcess.Architecture, + }); + if (builtinPath !== undefined && (yield* catalogFileExists(fileSystem, builtinPath))) { + result.builtinPath = builtinPath; + const bundledPath = zcodeBundledCatalogPath(root); + if (yield* catalogFileExists(fileSystem, bundledPath)) result.bundledPath = bundledPath; + const raw = yield* fileSystem.readFileString(builtinPath).pipe(Effect.option); + if (Option.isSome(raw)) { + result.builtinCatalog = Option.getOrUndefined(decodeJsonText(raw.value)); + } + } + } + const personalPath = zcodePersonalProviderConfigPath(root); + if (yield* catalogFileExists(fileSystem, personalPath)) result.personalPath = personalPath; + return result; +}); + +/** + * The CLI requires ZCODE_BUILTIN_PROVIDER_CONFIG_FILE and ZCODE_PERSONAL_PROVIDER_CONFIG_FILE + * together; a builtin var without the personal one fails session startup, so the personal var + * is dropped when its file is missing. + */ +export function zcodeRuntimeEnvironment( + environment: NodeJS.ProcessEnv, + files: ZcodeCatalogFileSet, +): NodeJS.ProcessEnv { + const next: NodeJS.ProcessEnv = { ...environment, ZCODE_BASE_URL }; + if (files.builtinPath !== undefined) { + next.ZCODE_BUILTIN_PROVIDER_CONFIG_FILE = files.builtinPath; + if (files.bundledPath !== undefined) { + next.ZCODE_BUILTIN_PROVIDER_BUNDLED_CONFIG_FILE = files.bundledPath; + } + if (files.personalPath !== undefined) { + next.ZCODE_PERSONAL_PROVIDER_CONFIG_FILE = files.personalPath; + } + } + return next; +} + +/** + * Builds the `provider/updateAccountConfig` params from the builtin catalog's zhipu-account + * providers. `entitled: true` in both providers.access and states is what makes the registry + * treat a model as executable; `current` marks the first provider so a default exists. + */ +export function zcodeAccountConfigPush( + catalog: unknown, + revision: string, +): ZcodeAccountConfigPush | undefined { + const rules = zcodeProviderRules(catalog); + if (rules === undefined) return undefined; + const providers: Record = {}; + const states: ZcodeAccountConfigPush["states"] = {}; + // Real catalogs carry a numeric revision; the wire expects the string form. + const rawRevision = zcodeRecordField(catalog ?? {}, "revision"); + const basedOnRevision = + typeof rawRevision === "string" && rawRevision.length > 0 + ? rawRevision + : typeof rawRevision === "number" && Number.isFinite(rawRevision) + ? String(Math.trunc(rawRevision)) + : "0"; + for (const rule of rules) { + const providerId = zcodeRecordString(rule, "providerId"); + const config = zcodeRecordField(rule, "config"); + const access = Predicate.isObject(config) ? zcodeRecordField(config, "access") : undefined; + const accessType = zcodeRecordString(access ?? {}, "type"); + const builtinModelIds = zcodeRecordStringArray( + Predicate.isObject(config) ? zcodeRecordField(config, "builtinModelIds") : undefined, + ); + if ( + providerId === undefined || + accessType !== "zhipu-account" || + builtinModelIds.length === 0 + ) { + continue; + } + providers[providerId] = { + builtinModelIds, + access: { type: "zhipu-account", entitled: true }, + }; + states[providerId] = { + availability: "available", + entitled: true, + current: Object.keys(providers).length === 1, + }; + } + if (Object.keys(providers).length === 0) return undefined; + return { + revision, + basedOnZCodeBuiltinRevision: basedOnRevision, + providers, + states, + }; +} + +/** providerRules sits at config.providerConfigRules.providerRules; tolerate a flattened copy. */ +function zcodeProviderRules(catalog: unknown): ReadonlyArray | undefined { + if (!Predicate.isObject(catalog)) return undefined; + const direct = zcodeRecordArray(catalog, "providerRules"); + if (direct !== undefined) return direct.filter(Predicate.isObject); + const config = zcodeRecordField(catalog, "config"); + if (!Predicate.isObject(config)) return undefined; + const nested = zcodeRecordField(config, "providerConfigRules"); + if (!Predicate.isObject(nested)) return undefined; + const rules = zcodeRecordArray(nested, "providerRules"); + return rules === undefined ? undefined : rules.filter(Predicate.isObject); +} + +function zcodeRecordStringArray(value: unknown): ReadonlyArray { + if (!Array.isArray(value)) return []; + return value.filter((entry): entry is string => typeof entry === "string" && entry.length > 0); +} + +/** + * T3 model slug → zcode ModelSelection. "default" and "default$" defer to the CLI's own + * default (undefined means omit `model`), and "/$" maps to the + * account provider ids the catalog carries. + */ +export function zcodeModelSelectionFromSlug(slug: string): ZcodeModelSelection | undefined { + const trimmed = slug.trim(); + if (trimmed.length === 0 || trimmed === "default") return undefined; + const separator = trimmed.indexOf("/"); + if (separator <= 0) return undefined; + const providerId = trimmed.slice(0, separator); + if (!providerId.startsWith("account:")) return undefined; + const rest = trimmed.slice(separator + 1); + if (rest.length === 0) return undefined; + const dollar = rest.indexOf("$"); + if (dollar < 0) return { providerId, modelId: rest }; + const modelId = rest.slice(0, dollar); + const reasoningLevel = rest.slice(dollar + 1); + if (modelId.length === 0 || reasoningLevel.length === 0) return undefined; + return { providerId, modelId, options: { reasoningLevel } }; +} + +export function zcodeSpawnArgs(launchArgs: string): ReadonlyArray { + return ["app-server", "--stdio", ...launchArgs.split(/\s+/).filter((arg) => arg.length > 0)]; +} + +export function zcodeCommand(settings: { readonly binaryPath: string }): string { + return settings.binaryPath.trim().length > 0 ? settings.binaryPath : DEFAULT_ZCODE_BINARY; +} diff --git a/packages/provider-zcode/src/settings.ts b/packages/provider-zcode/src/settings.ts new file mode 100644 index 0000000000000..824acf95f7c13 --- /dev/null +++ b/packages/provider-zcode/src/settings.ts @@ -0,0 +1,41 @@ +// Shared with the client settings form; keep schema code browser-safe. +import { + CustomModelSetting, + makeBinaryPathSetting, + makeProviderSettingsSchema, + TrimmedString, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; + +export const ZcodeSettings = makeProviderSettingsSchema( + { + enabled: Schema.Boolean.pipe( + Schema.withDecodingDefault(Effect.succeed(false)), + Schema.annotateKey({ providerSettingsForm: { hidden: true } }), + ), + binaryPath: makeBinaryPathSetting("zcode").pipe( + Schema.annotateKey({ + title: "Binary path", + description: "Path to the ZCode CLI binary.", + providerSettingsForm: { placeholder: "zcode", clearWhenEmpty: "omit" }, + }), + ), + launchArgs: TrimmedString.pipe( + Schema.withDecodingDefault(Effect.succeed("")), + Schema.annotateKey({ + title: "Launch arguments", + description: "Additional CLI arguments passed to zcode app-server on session start.", + providerSettingsForm: { clearWhenEmpty: "omit" }, + }), + ), + customModels: Schema.Array(CustomModelSetting).pipe( + Schema.withDecodingDefault(Effect.succeed([])), + Schema.annotateKey({ providerSettingsForm: { hidden: true } }), + ), + }, + { + order: ["binaryPath", "launchArgs"], + }, +); +export type ZcodeSettings = typeof ZcodeSettings.Type; diff --git a/packages/provider-zcode/src/testing.ts b/packages/provider-zcode/src/testing.ts new file mode 100644 index 0000000000000..9f8c2d5e1d8a5 --- /dev/null +++ b/packages/provider-zcode/src/testing.ts @@ -0,0 +1,13 @@ +export { + ZCODE_PROVIDER, + makeZcodeAdapterV2, + type ZcodeAdapterV2Options, +} from "./server/adapter.ts"; +// Wire-shape types for frame-driven testkits; knip tracks types separately from consumed exports. +export type { + ZcodeConnection, + ZcodeProtocolError, + ZcodeProtocolTimeoutError, + ZcodeRecord, + ZcodeSpawnOptions, +} from "./server/zcodeProtocol.ts"; diff --git a/packages/provider-zcode/tsconfig.json b/packages/provider-zcode/tsconfig.json new file mode 100644 index 0000000000000..374bac55202dd --- /dev/null +++ b/packages/provider-zcode/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "types": ["node"] + }, + "include": ["src"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1dbfd849d6831..c315ee553c3a3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -884,6 +884,9 @@ importers: '@t3tools/provider-pi': specifier: workspace:* version: link:../../packages/provider-pi + '@t3tools/provider-zcode': + specifier: workspace:* + version: link:../../packages/provider-zcode '@t3tools/source-control-azure-devops': specifier: workspace:* version: link:../../packages/source-control-azure-devops @@ -1523,6 +1526,9 @@ importers: '@t3tools/provider-pi': specifier: workspace:* version: link:../provider-pi + '@t3tools/provider-zcode': + specifier: workspace:* + version: link:../provider-zcode '@t3tools/secret-provider-definitions': specifier: workspace:* version: link:../secret-provider-definitions @@ -1980,6 +1986,37 @@ importers: specifier: 24.12.4 version: 24.12.4 + packages/provider-zcode: + dependencies: + '@t3tools/contracts': + specifier: workspace:* + version: link:../contracts + '@t3tools/provider-core': + specifier: workspace:* + version: link:../provider-core + '@t3tools/shared': + specifier: workspace:* + version: link:../shared + effect: + specifier: 4.1.2 + version: 4.1.2 + devDependencies: + '@effect/platform-node': + specifier: 4.1.2 + version: 4.1.2(bufferutil@4.1.0)(effect@4.1.2)(redis@6.2.1)(utf-8-validate@6.0.6) + '@effect/vitest': + specifier: 4.1.2 + version: 4.1.2(patch_hash=299510b71c36537a832a67e9d160a5f112a5f326a29e95c41cb45fc8234e47c7)(@astrojs/compiler-rs@0.5.1)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(typescript@6.0.3))(bufferutil@4.1.0)(effect@4.1.2)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + '@t3tools/provider-testing': + specifier: workspace:* + version: link:../provider-testing + '@types/node': + specifier: 24.12.4 + version: 24.12.4 + vite-plus: + specifier: 1.4.0 + version: 1.4.0(@astrojs/compiler-rs@0.5.1)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(typescript@6.0.3))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + packages/secret-provider-core: dependencies: '@t3tools/contracts': From 2f55907d89f34ac3d3e3520fef89411f8b3fb830 Mon Sep 17 00:00:00 2001 From: panudetjt <3482285+panudetjt@users.noreply.github.com> Date: Mon, 9 Nov 2026 20:59:13 +0700 Subject: [PATCH 02/23] fix(provider-zcode): fall back to the desktop CLI shim when zcode is off PATH Desktop installs ship the CLI at ~/.zcode/server/bin/zcode and often leave it off the server process's PATH, so enabling the provider in a dev server failed its health check with 'Failed to execute ZCode CLI health check'. The status probe and session spawn now probe PATH manually (resolveSpawnCommand defers to exec on POSIX) and fall back to the desktop shim before reporting the CLI missing. Also maps T3 runtime modes onto zcode's own enforcement at session create (Supervised -> guarded, Auto-accept edits -> edit, Full access -> yolo) instead of relying on the CLI default. Model: GLM-5.3-Flash via ZCode (account:zai-individual-coding-plan). Harness: ZCode. --- packages/provider-zcode/src/server/adapter.ts | 9 +++- packages/provider-zcode/src/server/status.ts | 8 ++-- .../src/server/zcodeRuntime.test.ts | 46 +++++++++++++++++-- .../provider-zcode/src/server/zcodeRuntime.ts | 42 +++++++++++++++-- 4 files changed, 92 insertions(+), 13 deletions(-) diff --git a/packages/provider-zcode/src/server/adapter.ts b/packages/provider-zcode/src/server/adapter.ts index b9a2eb3577a30..90285859d473b 100644 --- a/packages/provider-zcode/src/server/adapter.ts +++ b/packages/provider-zcode/src/server/adapter.ts @@ -64,7 +64,7 @@ import { import { resolveZcodeCatalogFiles, zcodeAccountConfigPush, - zcodeCommand, + resolveZcodeLaunchCommand, zcodeModelSelectionFromSlug, zcodeRuntimeEnvironment, zcodeSpawnArgs, @@ -418,8 +418,13 @@ export const makeZcodeAdapterV2 = Effect.fn("makeZcodeAdapterV2")(function* ( options.environment, ); const env = zcodeRuntimeEnvironment(options.environment, catalogFiles); + const launchCommand = yield* resolveZcodeLaunchCommand( + options.settings, + options.environment, + fileSystem, + ); const scopedLaunch = yield* agentScope.wrap({ - command: zcodeCommand(options.settings), + command: launchCommand, args: zcodeSpawnArgs(options.settings.launchArgs), name: "zcode", threadId: input.threadId, diff --git a/packages/provider-zcode/src/server/status.ts b/packages/provider-zcode/src/server/status.ts index 097824cd4ef31..409740092f0dc 100644 --- a/packages/provider-zcode/src/server/status.ts +++ b/packages/provider-zcode/src/server/status.ts @@ -1,5 +1,6 @@ import { type CustomModelSetting, type ServerProviderModel } from "@t3tools/contracts"; import type { ZcodeSettings } from "../settings.ts"; +import { resolveZcodeLaunchCommand } from "./zcodeRuntime.ts"; import { resolveSpawnCommand } from "@t3tools/shared/shell"; import { compareSemverVersions } from "@t3tools/shared/semver"; import * as DateTime from "effect/DateTime"; @@ -65,8 +66,9 @@ function zcodeModelsFromSettings( const runZcodeDoctor = Effect.fn("ZcodeProviderStatus.runDoctor")(function* ( zcodeSettings: ZcodeSettings, environment: NodeJS.ProcessEnv, + fileSystem: FileSystem.FileSystem, ) { - const command = zcodeSettings.binaryPath || "zcode"; + const command = yield* resolveZcodeLaunchCommand(zcodeSettings, environment, fileSystem); const spawnCommand = yield* resolveSpawnCommand(command, ["doctor", "--json"], { env: environment, }); @@ -157,6 +159,7 @@ export const checkZcodeProviderStatus = Effect.fn("checkZcodeProviderStatus")(fu ChildProcessSpawner.ChildProcessSpawner | FileSystem.FileSystem > { const checkedAt = DateTime.formatIso(yield* DateTime.now); + const fileSystem = yield* FileSystem.FileSystem; const fallbackModels = zcodeModelsFromSettings(zcodeSettings.customModels); if (!zcodeSettings.enabled) { @@ -175,7 +178,7 @@ export const checkZcodeProviderStatus = Effect.fn("checkZcodeProviderStatus")(fu }); } - const versionResult = yield* runZcodeDoctor(zcodeSettings, environment).pipe( + const versionResult = yield* runZcodeDoctor(zcodeSettings, environment, fileSystem).pipe( Effect.timeoutOption(VERSION_PROBE_TIMEOUT_MS), Effect.result, ); @@ -269,7 +272,6 @@ export const checkZcodeProviderStatus = Effect.fn("checkZcodeProviderStatus")(fu }); } - const fileSystem = yield* FileSystem.FileSystem; const authStatus = yield* readZcodeAuthStatus( fileSystem, resolveZcodeCredentialsPath(environment), diff --git a/packages/provider-zcode/src/server/zcodeRuntime.test.ts b/packages/provider-zcode/src/server/zcodeRuntime.test.ts index 485ca53940988..1810e68ace3e6 100644 --- a/packages/provider-zcode/src/server/zcodeRuntime.test.ts +++ b/packages/provider-zcode/src/server/zcodeRuntime.test.ts @@ -7,10 +7,10 @@ import * as FileSystem from "effect/FileSystem"; import { resolveZcodeCatalogFiles, readZcodeAppVersion, + resolveZcodeLaunchCommand, zcodeAccountConfigPush, zcodeBuiltinCatalogPath, zcodeBundledCatalogPath, - zcodeCommand, zcodeDataRoot, zcodeModelSelectionFromSlug, zcodePersonalProviderConfigPath, @@ -124,11 +124,47 @@ describe("zcodeSpawnArgs", () => { "file", ]); }); +}); - it("keeps an explicit binaryPath, defaulting to zcode on PATH", () => { - assert.equal(zcodeCommand({ binaryPath: "/opt/bin/zcode" }), "/opt/bin/zcode"); - assert.equal(zcodeCommand({ binaryPath: " " }), "zcode"); - }); +describe("resolveZcodeLaunchCommand", () => { + it.effect("explicit binaryPath wins over every fallback", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const command = yield* resolveZcodeLaunchCommand( + { binaryPath: "/opt/bin/zcode" }, + { HOME: "/home/dev" }, + fileSystem, + ); + assert.equal(command, "/opt/bin/zcode"); + }).pipe(Effect.provide(NodeServices.layer)), + ); + + it.effect("falls back to the desktop shim when zcode is off PATH", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const home = yield* fileSystem.makeTempDirectory(); + yield* fileSystem.makeDirectory(`${home}/.zcode/server/bin`, { recursive: true }); + yield* fileSystem.writeFileString(`${home}/.zcode/server/bin/zcode`, "#!/bin/sh\n"); + const command = yield* resolveZcodeLaunchCommand( + { binaryPath: "" }, + { HOME: home, PATH: "/nonexistent" }, + fileSystem, + ); + assert.equal(command, `${home}/.zcode/server/bin/zcode`); + }).pipe(Effect.provide(NodeServices.layer)), + ); + + it.effect("keeps the plain binary name when no fallback exists", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const command = yield* resolveZcodeLaunchCommand( + { binaryPath: "" }, + { HOME: "/nonexistent-home", PATH: "/nonexistent" }, + fileSystem, + ); + assert.equal(command, "zcode"); + }).pipe(Effect.provide(NodeServices.layer)), + ); }); describe("zcodeRuntimeEnvironment", () => { diff --git a/packages/provider-zcode/src/server/zcodeRuntime.ts b/packages/provider-zcode/src/server/zcodeRuntime.ts index 63aae33c9b0b8..44e7e6c3f8179 100644 --- a/packages/provider-zcode/src/server/zcodeRuntime.ts +++ b/packages/provider-zcode/src/server/zcodeRuntime.ts @@ -285,6 +285,42 @@ export function zcodeSpawnArgs(launchArgs: string): ReadonlyArray { return ["app-server", "--stdio", ...launchArgs.split(/\s+/).filter((arg) => arg.length > 0)]; } -export function zcodeCommand(settings: { readonly binaryPath: string }): string { - return settings.binaryPath.trim().length > 0 ? settings.binaryPath : DEFAULT_ZCODE_BINARY; -} +/** + * Desktop installs ship the CLI as `~/.zcode/server/bin/zcode` and often leave it off the + * server process's PATH, so a missing `zcode` falls back to that shim before failing. + * PATH is probed manually: resolveSpawnCommand is a no-op on POSIX and defers to exec. + */ +export const resolveZcodeLaunchCommand = Effect.fn("ZcodeRuntime.resolveLaunchCommand")(function* ( + settings: { readonly binaryPath: string }, + environment: NodeJS.ProcessEnv, + fileSystem: FileSystem.FileSystem, +) { + const explicit = settings.binaryPath.trim(); + if (explicit.length > 0) return explicit; + const onPath = yield* zcodeOnPath(environment, fileSystem); + if (onPath) return DEFAULT_ZCODE_BINARY; + const home = environment.ZCODE_DATA_BASE_DIR?.trim() || environment.HOME; + if (home === undefined || home === "") return DEFAULT_ZCODE_BINARY; + const desktopShim = `${home}/.zcode/server/bin/zcode`; + const shimExists = yield* fileSystem.exists(desktopShim).pipe(Effect.orElseSucceed(() => false)); + return shimExists ? desktopShim : DEFAULT_ZCODE_BINARY; +}); + +const zcodeOnPath = Effect.fn("ZcodeRuntime.zcodeOnPath")(function* ( + environment: NodeJS.ProcessEnv, + fileSystem: FileSystem.FileSystem, +) { + const pathValue = environment.PATH ?? ""; + const platform = yield* HostProcess.Platform; + const suffixes = platform === "win32" ? ["zcode.exe", "zcode.cmd"] : ["zcode"]; + for (const dir of pathValue.split(":")) { + if (dir === "") continue; + for (const suffix of suffixes) { + const found = yield* fileSystem + .exists(`${dir}/${suffix}`) + .pipe(Effect.orElseSucceed(() => false)); + if (found) return true; + } + } + return false; +}); From 4c9782493f451423b5e9ae767c943dee39c30450 Mon Sep 17 00:00:00 2001 From: panudetjt <3482285+panudetjt@users.noreply.github.com> Date: Mon, 9 Nov 2026 21:09:37 +0700 Subject: [PATCH 03/23] fix(provider-zcode): read the CLI credential store's object shape The zcode credential store is a JSON object keyed by credential id, so the auth probe reported unknown for every real store. Active-provider detection now handles the object shape, keeping unauthenticated for a readable store without an active provider. Model: GLM-5.3-Flash via ZCode (account:zai-individual-coding-plan). Harness: ZCode. --- .../provider-zcode/src/server/status.test.ts | 5 +++- packages/provider-zcode/src/server/status.ts | 26 ++++++++++++------- 2 files changed, 21 insertions(+), 10 deletions(-) diff --git a/packages/provider-zcode/src/server/status.test.ts b/packages/provider-zcode/src/server/status.test.ts index 73bfcf6c99dc9..395b448b2ed42 100644 --- a/packages/provider-zcode/src/server/status.test.ts +++ b/packages/provider-zcode/src/server/status.test.ts @@ -46,7 +46,10 @@ describe("zcodeAuthFromCredentials", () => { assert.equal(zcodeAuthFromCredentials([["oauth:active_provider", ""]]), "unauthenticated"); assert.equal(zcodeAuthFromCredentials(["oauth:active_provider"]), "unauthenticated"); assert.equal(zcodeAuthFromCredentials(undefined), "unknown"); - assert.equal(zcodeAuthFromCredentials({ oauth: true }), "unknown"); + // A readable store without an active provider is a not-signed-in store, whatever the shape. + assert.equal(zcodeAuthFromCredentials({}), "unauthenticated"); + assert.equal(zcodeAuthFromCredentials({ oauth: true }), "unauthenticated"); + assert.equal(zcodeAuthFromCredentials({ "oauth:active_provider": "zai" }), "authenticated"); }); }); diff --git a/packages/provider-zcode/src/server/status.ts b/packages/provider-zcode/src/server/status.ts index 409740092f0dc..8a1d6a4310c2f 100644 --- a/packages/provider-zcode/src/server/status.ts +++ b/packages/provider-zcode/src/server/status.ts @@ -85,15 +85,23 @@ const runZcodeDoctor = Effect.fn("ZcodeProviderStatus.runDoctor")(function* ( export const zcodeAuthFromCredentials = ( data: unknown, ): "authenticated" | "unauthenticated" | "unknown" => { - if (!Array.isArray(data)) return "unknown"; - const hasActiveProvider = data.some( - (entry) => - Array.isArray(entry) && - entry[0] === "oauth:active_provider" && - typeof entry[1] === "string" && - entry[1].length > 0, - ); - return hasActiveProvider ? "authenticated" : "unauthenticated"; + if (Array.isArray(data)) { + const hasActiveProvider = data.some( + (entry) => + Array.isArray(entry) && + entry[0] === "oauth:active_provider" && + typeof entry[1] === "string" && + entry[1].length > 0, + ); + return hasActiveProvider ? "authenticated" : "unauthenticated"; + } + // The CLI store is a JSON object keyed by credential id. + if (typeof data === "object" && data !== null) { + const active = (data as Record)["oauth:active_provider"]; + if (active === undefined) return "unauthenticated"; + return typeof active === "string" && active.length > 0 ? "authenticated" : "unknown"; + } + return "unknown"; }; const decodeCredentials = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Unknown)); From 98fb5aa6da50c33dcc6ded412bd01513c6585ac8 Mon Sep 17 00:00:00 2001 From: panudetjt <3482285+panudetjt@users.noreply.github.com> Date: Mon, 9 Nov 2026 22:24:19 +0700 Subject: [PATCH 04/23] feat(provider-zcode): degrade gracefully when the CLI cannot provision models Seven protocol experiments against zcode 0.17.1 show the app-server registry never marks pushed account models executable over NDJSON (push accepted, refresh ok, models still absent; the CLI's own headless runs resolve fine), so pinning a model always fails session/create. The adapter now retries create without the pinned model when it hits that registry error and turns the CLI's 'Select a model' failure into an actionable message, so a future CLI fix lights the flow up without an adapter change. Also trims the launch-command fallback comment. Model: GLM-5.3-Flash via ZCode (account:zai-individual-coding-plan). Harness: ZCode. --- packages/provider-zcode/src/server/adapter.ts | 36 +++++++++++++++++-- packages/provider-zcode/src/server/status.ts | 7 +++- .../src/server/zcodeRuntime.test.ts | 3 +- .../provider-zcode/src/server/zcodeRuntime.ts | 8 ++--- 4 files changed, 46 insertions(+), 8 deletions(-) diff --git a/packages/provider-zcode/src/server/adapter.ts b/packages/provider-zcode/src/server/adapter.ts index 90285859d473b..64c470e57d4b0 100644 --- a/packages/provider-zcode/src/server/adapter.ts +++ b/packages/provider-zcode/src/server/adapter.ts @@ -222,6 +222,17 @@ function contentText(content: unknown): string { .join(""); } +/** + * The CLI rejects a pinned model with this registry error when its account provisioning could + * not make any model executable — as of CLI 0.17.1 this happens for every push over this + * protocol, while the CLI's own headless runs resolve models fine. + */ +function isRegistryModelGapError(result: unknown): boolean { + if (!Predicate.isObject(result) || !Predicate.isObject(result["error"])) return false; + const message = zcodeRecordString(result["error"], "message") ?? ""; + return message.includes("Provider Registry") && message.includes("Model"); +} + function toolResultOutput(result: unknown): string | undefined { if (!Predicate.isObject(result)) return undefined; const direct = zcodeRecordString(result, "output") ?? zcodeRecordString(result, "text"); @@ -359,8 +370,13 @@ function zcodeFailureFromErrorDetail(error: unknown): OrchestrationV2ProviderFai const record = Predicate.isObject(error) ? error : {}; const message = zcodeRecordString(record, "message") ?? "ZCode reported a turn error."; const usageLimited = USAGE_LIMIT_ERROR_PATTERN.test(message); + const configurationGap = + zcodeRecordString(record, "code") === "CONFIGURATION_ERROR" && + message.toLowerCase().includes("select a model"); return makeProviderFailure({ - message, + message: configurationGap + ? "ZCode resolved no usable model for this environment. Update the ZCode CLI on the server machine, then refresh the provider. Model provisioning over the local protocol is broken in CLI 0.17.1." + : message, code: zcodeRecordString(record, "code") ?? null, class: usageLimited ? "usage_limit" : "provider_error", ...(usageLimited ? { retryable: true as const } : {}), @@ -1588,7 +1604,23 @@ export const makeZcodeAdapterV2 = Effect.fn("makeZcodeAdapterV2")(function* ( }; const modelSelection = zcodeModelSelectionFromSlug(threadInput.modelSelection.model); if (modelSelection !== undefined) createParams["model"] = modelSelection; - const result = yield* request("session/create", createParams, SESSION_CREATE_TIMEOUT_MS); + let result = yield* request("session/create", createParams, SESSION_CREATE_TIMEOUT_MS); + if ( + result !== undefined && + isRegistryModelGapError(result) && + modelSelection !== undefined + ) { + // CLI 0.17.1 cannot make pushed account models executable over this protocol, so the + // pinned model is rejected at create. Retry without it: turns then fail with the CLI's + // own "Select a model" error, and a CLI that fixes provisioning lights up unchanged. + yield* Effect.logWarning( + "ZCode rejected the pinned model; retrying session create without it.", + { providerId: modelSelection.providerId, modelId: modelSelection.modelId }, + ); + const fallbackParams: ZcodeRecord = { ...createParams }; + delete fallbackParams["model"]; + result = yield* request("session/create", fallbackParams, SESSION_CREATE_TIMEOUT_MS); + } const resultRecord = Predicate.isObject(result) ? result : {}; const sessionRecord = zcodeRecordField(resultRecord, "session"); const nativeSessionId = zcodeRecordString(sessionRecord ?? {}, "sessionId"); diff --git a/packages/provider-zcode/src/server/status.ts b/packages/provider-zcode/src/server/status.ts index 8a1d6a4310c2f..dcd2572dae708 100644 --- a/packages/provider-zcode/src/server/status.ts +++ b/packages/provider-zcode/src/server/status.ts @@ -6,6 +6,7 @@ import { compareSemverVersions } from "@t3tools/shared/semver"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; +import * as PlatformError from "effect/PlatformError"; import * as Option from "effect/Option"; import * as Result from "effect/Result"; import * as Schema from "effect/Schema"; @@ -193,7 +194,11 @@ export const checkZcodeProviderStatus = Effect.fn("checkZcodeProviderStatus")(fu if (Result.isFailure(versionResult)) { const error = versionResult.failure; - yield* Effect.logWarning("ZCode CLI health check failed.", { errorTag: error._tag }); + const detail = + error instanceof PlatformError.PlatformError + ? `${error.reason._tag}: ${error.message}` + : error._tag; + yield* Effect.logWarning("ZCode CLI health check failed.", { errorTag: error._tag, detail }); return buildServerProvider({ presentation: ZCODE_PRESENTATION, enabled: zcodeSettings.enabled, diff --git a/packages/provider-zcode/src/server/zcodeRuntime.test.ts b/packages/provider-zcode/src/server/zcodeRuntime.test.ts index 1810e68ace3e6..04c91b5e85b17 100644 --- a/packages/provider-zcode/src/server/zcodeRuntime.test.ts +++ b/packages/provider-zcode/src/server/zcodeRuntime.test.ts @@ -145,8 +145,9 @@ describe("resolveZcodeLaunchCommand", () => { const home = yield* fileSystem.makeTempDirectory(); yield* fileSystem.makeDirectory(`${home}/.zcode/server/bin`, { recursive: true }); yield* fileSystem.writeFileString(`${home}/.zcode/server/bin/zcode`, "#!/bin/sh\n"); + // binaryPath decodes to the bare fallback when the user leaves the field empty. const command = yield* resolveZcodeLaunchCommand( - { binaryPath: "" }, + { binaryPath: "zcode" }, { HOME: home, PATH: "/nonexistent" }, fileSystem, ); diff --git a/packages/provider-zcode/src/server/zcodeRuntime.ts b/packages/provider-zcode/src/server/zcodeRuntime.ts index 44e7e6c3f8179..dcd8be9a3ea86 100644 --- a/packages/provider-zcode/src/server/zcodeRuntime.ts +++ b/packages/provider-zcode/src/server/zcodeRuntime.ts @@ -286,9 +286,9 @@ export function zcodeSpawnArgs(launchArgs: string): ReadonlyArray { } /** - * Desktop installs ship the CLI as `~/.zcode/server/bin/zcode` and often leave it off the - * server process's PATH, so a missing `zcode` falls back to that shim before failing. - * PATH is probed manually: resolveSpawnCommand is a no-op on POSIX and defers to exec. + * Falls back to the desktop shim (`~/.zcode/server/bin/zcode`) when PATH lacks `zcode`; the + * bare "zcode" is the schema's decoded default, not an explicit choice. PATH is probed + * manually: resolveSpawnCommand is a no-op on POSIX and defers to exec. */ export const resolveZcodeLaunchCommand = Effect.fn("ZcodeRuntime.resolveLaunchCommand")(function* ( settings: { readonly binaryPath: string }, @@ -296,7 +296,7 @@ export const resolveZcodeLaunchCommand = Effect.fn("ZcodeRuntime.resolveLaunchCo fileSystem: FileSystem.FileSystem, ) { const explicit = settings.binaryPath.trim(); - if (explicit.length > 0) return explicit; + if (explicit.length > 0 && explicit !== DEFAULT_ZCODE_BINARY) return explicit; const onPath = yield* zcodeOnPath(environment, fileSystem); if (onPath) return DEFAULT_ZCODE_BINARY; const home = environment.ZCODE_DATA_BASE_DIR?.trim() || environment.HOME; From dd2902bc4294c6df1ba3080bcfe7a0703bba02a3 Mon Sep 17 00:00:00 2001 From: panudetjt <3482285+panudetjt@users.noreply.github.com> Date: Tue, 10 Nov 2026 00:14:31 +0700 Subject: [PATCH 05/23] feat(provider-zcode): provision account models and serve OAuth request auth The CLI's app-server registry only applies an account snapshot whose basedOn revision hashes the injected catalog path, and asks the host for project-token runtime headers before every account-model request. The adapter now pushes an entitlement-ladder snapshot (setting.json OAuth selection + decrypted credential store), creates sessions without a pinned model, attaches the advertised default reasoning level at send, and mints short-lived project access tokens for the runtime-headers request, mirroring the desktop host. Verified end-to-end: a real GLM turn completes through the protocol. Model: GLM-5.3-Flash via ZCode (account:zai-individual-coding-plan). Harness: ZCode. --- .../Adapters/ZcodeAdapterV2.test.ts | 62 ++- .../Adapters/ZcodeAdapterV2.testkit.ts | 28 +- docs/user/providers-zcode.md | 11 +- packages/provider-zcode/src/server/adapter.ts | 182 +++++-- .../src/server/zcodeRequestAuth.test.ts | 117 +++++ .../src/server/zcodeRequestAuth.ts | 491 ++++++++++++++++++ .../src/server/zcodeRuntime.test.ts | 209 ++++++-- .../provider-zcode/src/server/zcodeRuntime.ts | 97 ---- 8 files changed, 980 insertions(+), 217 deletions(-) create mode 100644 packages/provider-zcode/src/server/zcodeRequestAuth.test.ts create mode 100644 packages/provider-zcode/src/server/zcodeRequestAuth.ts diff --git a/apps/server/src/orchestration-v2/Adapters/ZcodeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ZcodeAdapterV2.test.ts index 8ba3e62157995..3816a0e27674d 100644 --- a/apps/server/src/orchestration-v2/Adapters/ZcodeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ZcodeAdapterV2.test.ts @@ -29,12 +29,14 @@ import { ZCODE_PROVIDER, makeZcodeAdapterV2 } from "@t3tools/provider-zcode/test import { type FakeZcodeScenario, ZcodeAdapterTestLayer, + fakeZcodeRequestAuth, makeFakeZcodeConnection, makeZcodeAdapterOptions, zcodeDriverKind, zcodeServerRequest, zcodeSessionEvent, } from "./ZcodeAdapterV2.testkit.ts"; +import type { ZcodeAdapterV2Options } from "@t3tools/provider-zcode/testing"; const INSTANCE_ID = ProviderInstanceId.make("zcode_frame_test"); const THREAD_ID = ThreadId.make("thread-zcode-frame-test"); @@ -51,6 +53,7 @@ const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ const makeTurnInput = Effect.fnUntraced(function* ( providerThread: OrchestrationV2ProviderThread, text = TURN_TEXT, + model = MODEL_SELECTION.model, ): Effect.fn.Return { const now = yield* DateTime.now; const threadId = providerThread.appThreadId ?? THREAD_ID; @@ -61,7 +64,7 @@ const makeTurnInput = Effect.fnUntraced(function* ( createdBy: "user", creationSource: "web", providerInstanceId: providerThread.providerInstanceId, - modelSelection: MODEL_SELECTION, + modelSelection: { instanceId: INSTANCE_ID, model }, runtimeMode: runtimePolicy.runtimeMode, interactionMode: runtimePolicy.interactionMode, branch: null, @@ -95,16 +98,17 @@ const makeTurnInput = Effect.fnUntraced(function* ( scheduledTaskId: undefined, senderThreadId: undefined, }, - modelSelection: MODEL_SELECTION, + modelSelection: { instanceId: INSTANCE_ID, model }, runtimePolicy, }; }); -const makeHarness = Effect.fnUntraced(function* () { +const makeHarness = Effect.fnUntraced(function* (override: Partial = {}) { const scenario = yield* makeFakeZcodeConnection(); const adapter = yield* makeZcodeAdapterV2({ ...makeZcodeAdapterOptions(), makeConnection: () => Effect.succeed(scenario.connection), + ...override, }); const runtime = yield* adapter.openSession({ threadId: THREAD_ID, @@ -158,9 +162,9 @@ const makeHarness = Effect.fnUntraced(function* () { allEvents, takeEvent, offerTurnEvent, - startTurn: (text?: string) => + startTurn: (text?: string, model?: string) => Effect.gen(function* () { - const input = yield* makeTurnInput(providerThread, text); + const input = yield* makeTurnInput(providerThread, text, model); return yield* runtime.startTurn(input); }), }; @@ -293,6 +297,54 @@ describe("ZcodeAdapterV2 (frame-driven)", () => { }).pipe(Effect.scoped, Effect.provide(ZcodeAdapterTestLayer)), ); + it.effect( + "sends account-model turns with the advertised reasoning level and serves runtime headers", + () => + Effect.gen(function* () { + const harness = yield* makeHarness({ makeRequestAuth: fakeZcodeRequestAuth() }); + // "default" omits the model entirely; account slugs select per turn. + yield* harness.startTurn(TURN_TEXT, "account:zai-individual-coding-plan/GLM-5.3-Flash"); + const send = requestFor(harness.scenario, "session/send"); + assert.isDefined(send); + assert.deepEqual(send!.params["modelSelection"], { + providerId: "account:zai-individual-coding-plan", + modelId: "GLM-5.3-Flash", + options: { reasoningLevel: "max" }, + }); + + // Before an account-model request the CLI asks the host for project runtime headers; + // the injected request auth's response must reach the wire verbatim. + const broker = zcodeServerRequest("interaction/requestProviderRuntimeHeaders", { + sessionId: harness.scenario.createdSessionId(), + modelSelection: { + providerId: "account:zai-individual-coding-plan", + modelId: "GLM-5.3-Flash", + options: { reasoningLevel: "max" }, + }, + }); + yield* harness.scenario.offerEvent(broker); + // The first two responds are the create-time runtime preferences and MCP auth headers. + yield* harness.scenario.takeRespond; + yield* harness.scenario.takeRespond; + const answered = yield* harness.scenario.takeRespond; + assert.equal(answered.id, broker.id); + assert.deepEqual(answered.result, { + headersApplied: true, + requestAuth: { apiKey: "test", apiKeyId: "k", accountScope: "a" }, + }); + + yield* harness.offerTurnEvent("turn.completed", { + response: "pong", + tokenCount: 3, + toolCallCount: 0, + duration: 60, + resultType: "success", + }); + yield* harness.takeEvent("turn.terminal", (event) => event.status === "completed"); + assert.deepEqual(harness.scenario.unexpectedMethods(), []); + }).pipe(Effect.scoped, Effect.provide(ZcodeAdapterTestLayer)), + ); + it.effect("maps shell tool lifecycle into command execution items", () => Effect.gen(function* () { const harness = yield* makeHarness(); diff --git a/apps/server/src/orchestration-v2/Adapters/ZcodeAdapterV2.testkit.ts b/apps/server/src/orchestration-v2/Adapters/ZcodeAdapterV2.testkit.ts index e15b1027afbe8..eb4b41c8746a5 100644 --- a/apps/server/src/orchestration-v2/Adapters/ZcodeAdapterV2.testkit.ts +++ b/apps/server/src/orchestration-v2/Adapters/ZcodeAdapterV2.testkit.ts @@ -4,6 +4,7 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { ProviderInstanceId } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; +import * as FetchHttpClient from "effect/http/FetchHttpClient"; import * as Layer from "effect/Layer"; import * as Queue from "effect/Queue"; @@ -99,7 +100,17 @@ function zcodeSessionCreateResult(sessionId: string, params: ZcodeRecord): Zcode }, settings: { mode: { current: "build" }, - model: { available: [] }, + model: { + available: [ + { + ref: { providerId: "account:zai-individual-coding-plan", modelId: "GLM-5.3-Flash" }, + label: "GLM-5.3 Flash", + contextWindow: 200000, + maxOutputTokens: 128000, + reasoning: { levels: ["low", "high", "max"], defaultLevel: "max" }, + }, + ], + }, permission: { mode: "build" }, thoughtLevel: { available: [], enabled: false }, }, @@ -251,6 +262,19 @@ export const makeZcodeAdapterOptions = (): ZcodeAdapterV2Options => ({ environment: {}, }); +/** + * Request auth the adapter can drive without touching the local credential store or network: + * every runtime-headers request is answered with the fixed successful exchange. + */ +export const fakeZcodeRequestAuth = + (): NonNullable => () => ({ + answerRuntimeHeaders: () => + Effect.succeed({ + headersApplied: true, + requestAuth: { apiKey: "test", apiKeyId: "k", accountScope: "a" }, + }), + }); + /** The driver kind the package driver and the hand-built adapter options must agree on. */ export const zcodeDriverKind: typeof ZCODE_PROVIDER = ZCODE_PROVIDER; @@ -263,4 +287,6 @@ export const ZcodeAdapterTestLayer = Layer.mergeAll( NodeServices.layer, IdAllocator.layer, McpProviderSessions.layer, + // The adapter captures an HttpClient for the runtime-headers exchange; fakes never fetch. + FetchHttpClient.layer, ); diff --git a/docs/user/providers-zcode.md b/docs/user/providers-zcode.md index b4befa0289b7b..27591f6b56f28 100644 --- a/docs/user/providers-zcode.md +++ b/docs/user/providers-zcode.md @@ -7,8 +7,8 @@ configuration. 1. Install ZCode on the machine running the T3 Code server. The ZCode app installs the `zcode` CLI; make sure it is on that machine's `PATH`, or set its **Binary path** in provider settings. -2. Run `zcode login` once in a terminal on that machine. Login opens a browser authorization for - your Z.AI account. +2. Run `zcode login` once in a terminal on that machine. Login starts an OAuth authorization for + your Z.AI account, which works on headless servers too: finish it from a browser on any device. 3. Open **Settings → Providers**, enable ZCode, and refresh it. ZCode runs on the server's machine only. A desktop ZCode app elsewhere does not share its sign-in @@ -16,9 +16,10 @@ with T3 Code; the server's own `zcode login` is what counts. ## Models -The model picker lists the GLM models your Z.AI plan offers. New threads start on `default`, -which defers to the model your ZCode session resolves on its own. A model your account cannot -use stops the turn with an error instead of running on another model. +After signing in, the model picker lists the GLM models your Z.AI plan offers — no API key or +model configuration needed. Reasoning follows each model's own default level unless you pick one. +New threads start on `default`, which defers to the model your ZCode session resolves on its own. +A model your account cannot use stops the turn with an error instead of running on another model. ## Permission Modes diff --git a/packages/provider-zcode/src/server/adapter.ts b/packages/provider-zcode/src/server/adapter.ts index 64c470e57d4b0..67607585b263f 100644 --- a/packages/provider-zcode/src/server/adapter.ts +++ b/packages/provider-zcode/src/server/adapter.ts @@ -1,5 +1,6 @@ import { AgentScope } from "@t3tools/shared/AgentScope"; import * as HostProcess from "@t3tools/shared/HostProcess"; +import { causeErrorTag } from "@t3tools/shared/observability"; import * as Predicate from "effect/Predicate"; import { type OrchestrationV2ConversationMessage, @@ -27,6 +28,7 @@ import * as DateTime from "effect/DateTime"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; +import * as HttpClient from "effect/http/HttpClient"; import * as Option from "effect/Option"; import * as Queue from "effect/Queue"; import * as Schema from "effect/Schema"; @@ -63,13 +65,17 @@ import { } from "./zcodeProtocol.ts"; import { resolveZcodeCatalogFiles, - zcodeAccountConfigPush, resolveZcodeLaunchCommand, zcodeModelSelectionFromSlug, zcodeRuntimeEnvironment, zcodeSpawnArgs, type ZcodeCatalogFileSet, } from "./zcodeRuntime.ts"; +import { + makeZcodeRequestAuth, + zcodeAccountConfigPushFromLadder, + zcodeFamilyFromProviderId, +} from "./zcodeRequestAuth.ts"; export const ZCODE_PROVIDER: ProviderDriverKind = ProviderDriverKind.make("zcode"); const DEFAULT_ZCODE_SETTINGS = Schema.decodeSync(ZcodeSettings)({}); @@ -186,6 +192,8 @@ export interface ZcodeAdapterV2Options { readonly environment: NodeJS.ProcessEnv; /** Testkit injection point; production spawns the real `zcode app-server`. */ readonly makeConnection?: typeof makeZcodeConnection; + /** Testkit injection point; production mints OAuth project tokens from the local store. */ + readonly makeRequestAuth?: typeof makeZcodeRequestAuth; } function providerRef( @@ -222,15 +230,33 @@ function contentText(content: unknown): string { .join(""); } -/** - * The CLI rejects a pinned model with this registry error when its account provisioning could - * not make any model executable — as of CLI 0.17.1 this happens for every push over this - * protocol, while the CLI's own headless runs resolve models fine. - */ -function isRegistryModelGapError(result: unknown): boolean { - if (!Predicate.isObject(result) || !Predicate.isObject(result["error"])) return false; - const message = zcodeRecordString(result["error"], "message") ?? ""; - return message.includes("Provider Registry") && message.includes("Model"); +interface ZcodeAvailableModel { + readonly providerId: string; + readonly modelId: string; + readonly defaultReasoningLevel?: string; +} + +/** Parses the create result's `settings.model.available` descriptors. */ +function parseZcodeAvailableModels(input: unknown): ReadonlyArray { + if (!Array.isArray(input)) return []; + const out: Array = []; + for (const entry of input) { + if (!Predicate.isObject(entry)) continue; + const ref = entry["ref"]; + const providerId = zcodeRecordString(ref, "providerId"); + const modelId = zcodeRecordString(ref, "modelId"); + if (providerId === undefined || modelId === undefined) continue; + const reasoning = entry["reasoning"]; + const defaultLevel = Predicate.isObject(reasoning) + ? zcodeRecordString(reasoning, "defaultLevel") + : undefined; + out.push({ + providerId, + modelId, + ...(defaultLevel === undefined ? {} : { defaultReasoningLevel: defaultLevel }), + }); + } + return out; } function toolResultOutput(result: unknown): string | undefined { @@ -392,6 +418,7 @@ export const makeZcodeAdapterV2 = Effect.fn("makeZcodeAdapterV2")(function* ( const idAllocator = yield* IdAllocator.IdAllocatorV2; const host = yield* ProviderHost.ProviderHost; const mcpSessions = yield* McpProviderSessions.McpProviderSessions; + const httpClient = yield* HttpClient.HttpClient; const protocolError = (detail: string, payload?: unknown) => new ProviderAdapter.ProviderAdapterProtocolError({ @@ -400,6 +427,13 @@ export const makeZcodeAdapterV2 = Effect.fn("makeZcodeAdapterV2")(function* ( ...(payload === undefined ? {} : { payload }), }); + const requestAuth = (options.makeRequestAuth ?? makeZcodeRequestAuth)({ + fileSystem, + httpClient, + environment: options.environment, + clientVersion: "0.0.0", + }); + const zcodeMcpServers = ( session: McpProviderSessionConfig | undefined, ): ReadonlyArray | undefined => @@ -501,6 +535,9 @@ export const makeZcodeAdapterV2 = Effect.fn("makeZcodeAdapterV2")(function* ( // overtake the runtime request resolution they correlate with. const sessionEventPermit = yield* Semaphore.make(1); let threadState: ZcodeThreadState | null = null; + // Session models come from the CLI's own create-result advertisement; per-session, so a + // resume re-populates them. + let availableModels: ReadonlyArray = []; let transportDown = false; yield* Effect.addFinalizer(() => @@ -1399,11 +1436,46 @@ export const makeZcodeAdapterV2 = Effect.fn("makeZcodeAdapterV2")(function* ( }); return; } - case "interaction/requestOfficialMcpAuthHeaders": - case "interaction/requestProviderRuntimeHeaders": { + case "interaction/requestOfficialMcpAuthHeaders": { yield* connection.respond(respondId, {}); return; } + case "interaction/requestProviderRuntimeHeaders": { + const params = zcodeRecordField(record, "params"); + const providerId = + zcodeRecordString(params ?? {}, "providerId") ?? + zcodeRecordString( + zcodeRecordField(params ?? {}, "modelSelection") ?? {}, + "providerId", + ); + const family = zcodeFamilyFromProviderId(providerId); + if (family === undefined) { + yield* connection.respond(respondId, { + headersApplied: false, + errorMessage: "Unsupported ZCode account provider.", + }); + return; + } + const rejectedProjectTokenFingerprint = zcodeRecordString( + params ?? {}, + "rejectedProjectTokenFingerprint", + ); + const answer = requestAuth.answerRuntimeHeaders({ + family, + ...(rejectedProjectTokenFingerprint === undefined + ? {} + : { rejectedProjectTokenFingerprint }), + }); + yield* answer.pipe( + Effect.flatMap((response) => connection.respond(respondId, response)), + Effect.catchCause((cause) => + Effect.logWarning("ZCode runtime headers answer failed.", { + errorTag: causeErrorTag(cause), + }), + ), + ); + return; + } case "interaction/requestPermission": { const params = zcodeRecordField(record, "params"); const nativeRequestId = @@ -1544,26 +1616,30 @@ export const makeZcodeAdapterV2 = Effect.fn("makeZcodeAdapterV2")(function* ( Effect.forkIn(scope), ); - // Provisioning must precede session/create: the probe showed create resolves the model - // against the registry immediately. Best-effort — a rejected push surfaces honestly as a + // Provisioning must precede session/create: the CLI resolves models against the registry + // it builds from this push. Best-effort — a rejected push surfaces honestly as a // "Select a model" turn failure instead of failing the session open. const bootstrapProviderRegistry = Effect.fn("ZcodeAdapter.bootstrapProviderRegistry")( function* () { - const push = - catalogFiles.builtinCatalog === undefined - ? undefined - : zcodeAccountConfigPush( - catalogFiles.builtinCatalog, - `t3code-${input.providerSessionId}`, - ); - if (push === undefined) { + if (catalogFiles.builtinCatalog === undefined || catalogFiles.builtinPath === undefined) { yield* Effect.logWarning( "ZCode builtin catalog unavailable; account models will report as missing.", { builtinPath: catalogFiles.builtinPath ?? null }, ); return; } - yield* request("provider/updateAccountConfig", push as unknown as ZcodeRecord).pipe( + const push = yield* zcodeAccountConfigPushFromLadder(fileSystem, { + builtinPath: catalogFiles.builtinPath, + builtinCatalog: catalogFiles.builtinCatalog, + environment: options.environment, + }); + if (push === undefined) { + yield* Effect.logWarning( + "ZCode catalog declares no account providers; account models will report as missing.", + ); + return; + } + yield* request("provider/updateAccountConfig", push).pipe( Effect.tapError((error) => Effect.logWarning("ZCode account config push failed.", { operation: error.operation, @@ -1571,14 +1647,6 @@ export const makeZcodeAdapterV2 = Effect.fn("makeZcodeAdapterV2")(function* ( ), Effect.ignore, ); - yield* request("provider/refreshRegistry", { reason: "t3code-session" }).pipe( - Effect.tapError((error) => - Effect.logWarning("ZCode registry refresh failed.", { - operation: error.operation, - }), - ), - Effect.ignore, - ); }, ); yield* bootstrapProviderRegistry(); @@ -1602,31 +1670,25 @@ export const makeZcodeAdapterV2 = Effect.fn("makeZcodeAdapterV2")(function* ( titleGenerationEnabled: false, ...(mcpServers === undefined ? {} : { mcpServers }), }; - const modelSelection = zcodeModelSelectionFromSlug(threadInput.modelSelection.model); - if (modelSelection !== undefined) createParams["model"] = modelSelection; - let result = yield* request("session/create", createParams, SESSION_CREATE_TIMEOUT_MS); - if ( - result !== undefined && - isRegistryModelGapError(result) && - modelSelection !== undefined - ) { - // CLI 0.17.1 cannot make pushed account models executable over this protocol, so the - // pinned model is rejected at create. Retry without it: turns then fail with the CLI's - // own "Select a model" error, and a CLI that fixes provisioning lights up unchanged. - yield* Effect.logWarning( - "ZCode rejected the pinned model; retrying session create without it.", - { providerId: modelSelection.providerId, modelId: modelSelection.modelId }, - ); - const fallbackParams: ZcodeRecord = { ...createParams }; - delete fallbackParams["model"]; - result = yield* request("session/create", fallbackParams, SESSION_CREATE_TIMEOUT_MS); - } + // The model rides on session/send instead: pinning it at create breaks whenever the + // CLI's registry lacks the model, while the send-time selection resolves per turn and + // carries the reasoning level the CLI requires. + const result = yield* request("session/create", createParams, SESSION_CREATE_TIMEOUT_MS); const resultRecord = Predicate.isObject(result) ? result : {}; const sessionRecord = zcodeRecordField(resultRecord, "session"); const nativeSessionId = zcodeRecordString(sessionRecord ?? {}, "sessionId"); if (nativeSessionId === undefined) { return yield* protocolError("session/create returned no sessionId", result); } + availableModels = parseZcodeAvailableModels( + zcodeRecordField( + zcodeRecordField( + Predicate.isObject(resultRecord["settings"]) ? resultRecord["settings"] : {}, + "model", + ), + "available", + ), + ); yield* subscribeSession(nativeSessionId); return nativeSessionId; }); @@ -1851,7 +1913,21 @@ export const makeZcodeAdapterV2 = Effect.fn("makeZcodeAdapterV2")(function* ( content, }; const modelSelection = zcodeModelSelectionFromSlug(turnInput.modelSelection.model); - if (modelSelection !== undefined) sendParams["modelSelection"] = modelSelection; + if (modelSelection !== undefined) { + // The registry requires a reasoning level whenever the model advertises one; the + // CLI's own default fills the gap for T3 selections that carry none. + const advertised = availableModels.find( + (entry) => + entry.providerId === modelSelection.providerId && + entry.modelId === modelSelection.modelId, + ); + const reasoningLevel = + modelSelection.options?.reasoningLevel ?? advertised?.defaultReasoningLevel; + sendParams["modelSelection"] = { + ...modelSelection, + ...(reasoningLevel === undefined ? {} : { options: { reasoningLevel } }), + }; + } yield* sessionEventPermit.withPermits(1)( Effect.gen(function* () { const turn = yield* installTurn(state, turnInput, nativeTurnId); @@ -2210,6 +2286,7 @@ export const makeZcodeAdapterV2 = Effect.fn("makeZcodeAdapterV2")(function* ( export type ZcodeAdapterV2DriverEnv = | ChildProcessSpawner.ChildProcessSpawner | FileSystem.FileSystem + | HttpClient.HttpClient | IdAllocator.IdAllocatorV2 | McpProviderSessions.McpProviderSessions | ProviderHost.ProviderHost; @@ -2221,11 +2298,12 @@ export const ZcodeAdapterV2Driver: ProviderAdapterDriver) { const hostEnvironment = yield* HostProcess.Environment; + const httpClient = yield* HttpClient.HttpClient; return yield* makeZcodeAdapterV2({ instanceId: input.instanceId, settings: { ...input.config, enabled: input.enabled }, environment: yield* mergeProviderInstanceEnvironment(input.environment, hostEnvironment), - }); + }).pipe(Effect.provideService(HttpClient.HttpClient, httpClient)); }, (effect, input) => effect.pipe( diff --git a/packages/provider-zcode/src/server/zcodeRequestAuth.test.ts b/packages/provider-zcode/src/server/zcodeRequestAuth.test.ts new file mode 100644 index 0000000000000..aea158326c3c8 --- /dev/null +++ b/packages/provider-zcode/src/server/zcodeRequestAuth.test.ts @@ -0,0 +1,117 @@ +// @effect-diagnostics nodeBuiltinImport:off -- tests craft the CLI's own AES-GCM store values with node crypto +import { assert, describe, it } from "@effect/vitest"; +import * as NodeCrypto from "node:crypto"; + +import { + decryptZcodeCredential, + resolveZcodeCredentialSecret, + zcodeAccountScope, + zcodeFamilyFromProviderId, +} from "./zcodeRequestAuth.ts"; + +/** Produces store values the CLI's fallback cipher would hold, keyed the same way. */ +const encryptZcodeCredential = (value: string, secret: string): string => { + const iv = NodeCrypto.randomBytes(12); + const cipher = NodeCrypto.createCipheriv( + "aes-256-gcm", + NodeCrypto.createHash("sha256").update(secret).digest(), + iv, + ); + const ciphertext = Buffer.concat([cipher.update(value, "utf8"), cipher.final()]); + return `enc:v1:${iv.toString("base64url")}.${cipher.getAuthTag().toString("base64url")}.${ciphertext.toString("base64url")}`; +}; + +describe("resolveZcodeCredentialSecret", () => { + it("prefers ZCODE_CREDENTIAL_SECRET and trims it", () => { + assert.equal( + resolveZcodeCredentialSecret({ ZCODE_CREDENTIAL_SECRET: " custom-secret " }, "linux"), + "custom-secret", + ); + assert.equal( + resolveZcodeCredentialSecret( + { ZCODE_CREDENTIAL_SECRET: " ", USER: "jame", HOME: "/home/dev" }, + "linux", + ), + "zcode-credential-fallback:linux:/home/dev:jame", + ); + }); + + it("derives the CLI's machine-bound fallback from USER then USERNAME and HOME", () => { + assert.equal( + resolveZcodeCredentialSecret({ USER: "jame", HOME: "/home/dev" }, "darwin"), + "zcode-credential-fallback:darwin:/home/dev:jame", + ); + assert.equal( + resolveZcodeCredentialSecret({ USERNAME: "jdoe", HOME: "/home/dev" }, "win32"), + "zcode-credential-fallback:win32:/home/dev:jdoe", + ); + assert.equal( + resolveZcodeCredentialSecret({}, "linux"), + "zcode-credential-fallback:linux::unknown", + ); + }); +}); + +describe("decryptZcodeCredential", () => { + it("round-trips the CLI's encrypted store values", () => { + const secret = "zcode-credential-fallback:linux:/home/dev:jame"; + for (const plaintext of ["oauth-login-token", "值-with-unicode 🔐", ""]) { + assert.equal( + decryptZcodeCredential(encryptZcodeCredential(plaintext, secret), secret), + plaintext, + ); + } + }); + + it("passes plain store values through and rejects malformed ciphertext", () => { + assert.equal(decryptZcodeCredential("plain-token", "s"), "plain-token"); + assert.throws(() => decryptZcodeCredential("enc:v1:not-a-cipher", "s")); + assert.throws(() => decryptZcodeCredential("enc:v1:only.two", "s")); + }); +}); + +describe("zcodeFamilyFromProviderId", () => { + it("maps account plan provider ids to their auth family", () => { + assert.equal(zcodeFamilyFromProviderId("account:zai-individual-coding-plan"), "zai"); + assert.equal(zcodeFamilyFromProviderId("account:bigmodel-individual-coding-plan"), "bigmodel"); + assert.equal(zcodeFamilyFromProviderId("account:zai"), "zai"); + }); + + it("returns undefined for provider ids outside the account namespace", () => { + assert.equal(zcodeFamilyFromProviderId(undefined), undefined); + assert.equal(zcodeFamilyFromProviderId("account:"), undefined); + assert.equal(zcodeFamilyFromProviderId(""), undefined); + }); +}); + +describe("zcodeAccountScope", () => { + it("digests the plan identity, login token, and JWT exactly like the CLI", () => { + assert.equal( + zcodeAccountScope("zai", "login-token", undefined), + NodeCrypto.createHash("sha256") + .update(JSON.stringify(["zai", "individual-coding-plan", null, "login-token", null])) + .digest("hex"), + ); + assert.equal( + zcodeAccountScope("zai", "login-token", "jwt"), + NodeCrypto.createHash("sha256") + .update(JSON.stringify(["zai", "individual-coding-plan", null, "login-token", "jwt"])) + .digest("hex"), + ); + }); + + it("is deterministic and changes with any input", () => { + assert.equal( + zcodeAccountScope("zai", "tok", undefined), + zcodeAccountScope("zai", "tok", undefined), + ); + assert.notEqual( + zcodeAccountScope("zai", "tok", undefined), + zcodeAccountScope("bigmodel", "tok", undefined), + ); + assert.notEqual( + zcodeAccountScope("zai", "tok", undefined), + zcodeAccountScope("zai", "tok2", undefined), + ); + }); +}); diff --git a/packages/provider-zcode/src/server/zcodeRequestAuth.ts b/packages/provider-zcode/src/server/zcodeRequestAuth.ts new file mode 100644 index 0000000000000..9245dc7ea0fc3 --- /dev/null +++ b/packages/provider-zcode/src/server/zcodeRequestAuth.ts @@ -0,0 +1,491 @@ +// @effect-diagnostics nodeBuiltinImport:off -- the push revision hashes the injected catalog path with sha256 +// Account provisioning and request auth for the ZCode CLI: the entitlement ladder behind the +// `provider/updateAccountConfig` push, and the OAuth project-token exchange that answers +// `interaction/requestProviderRuntimeHeaders` before every account-model request. Verified +// end-to-end against zcode 3.17.2 (PROTOCOL-NOTES.md); the desktop host performs the same +// exchanges. +import * as NodeCrypto from "node:crypto"; +import * as NodePath from "node:path"; + +import { causeErrorTag } from "@t3tools/shared/observability"; +import * as HostProcess from "@t3tools/shared/HostProcess"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as HttpClient from "effect/http/HttpClient"; +import * as HttpClientRequest from "effect/http/HttpClientRequest"; +import * as HttpClientResponse from "effect/http/HttpClientResponse"; +import * as Option from "effect/Option"; +import * as Predicate from "effect/Predicate"; +import * as Result from "effect/Result"; +import * as Schema from "effect/Schema"; + +import { + zcodeJsonStringify, + zcodeRecordArray, + zcodeRecordField, + zcodeRecordString, + type ZcodeRecord, +} from "./zcodeProtocol.ts"; +import { zcodeDataRoot } from "./zcodeRuntime.ts"; + +const ENCRYPTED_PREFIX = "enc:v1:"; +const CIPHER_ALGORITHM = "aes-256-gcm"; +const ZCODE_JWT_CREDENTIAL_KEY = "zcodejwttoken"; +const ZCODE_API_KEY_NAME = "zcode-api-key"; +const TEAM_PROJECT_TYPE = "2"; +const DEFAULT_ORGANIZATION_NAME = "默认机构"; +const DEFAULT_PROJECT_NAME = "默认项目"; +const REQUEST_TIMEOUT_MS = 15_000; +const TOKEN_TTL_MS = 10 * 60_000; +/** Refresh the cached project token this long before expiry so a queued request never stalls. */ +const TOKEN_REFRESH_MARGIN_MS = 120_000; + +const FAMILY_ORIGIN: Record = { + zai: "https://api.z.ai", + bigmodel: "https://bigmodel.cn", +}; + +const sha256Hex = (value: string): string => + NodeCrypto.createHash("sha256").update(value).digest("hex"); + +/** + * The CLI's machine-bound fallback cipher secret: sha256 of this string keys the store's + * AES-256-GCM values. ZCODE_CREDENTIAL_SECRET overrides it for custom installs. + */ +export const resolveZcodeCredentialSecret = ( + environment: NodeJS.ProcessEnv, + platform: NodeJS.Platform, +): string => { + const configured = environment.ZCODE_CREDENTIAL_SECRET?.trim(); + if (configured !== undefined && configured.length > 0) return configured; + const username = environment.USER ?? environment.USERNAME ?? "unknown"; + const home = environment.HOME ?? ""; + return `zcode-credential-fallback:${platform}:${home}:${username}`; +}; + +export const decryptZcodeCredential = (value: string, secret: string): string => { + if (!value.startsWith(ENCRYPTED_PREFIX)) return value; + const [ivRaw, tagRaw, cipherRaw] = value.slice(ENCRYPTED_PREFIX.length).split("."); + if (ivRaw === undefined || tagRaw === undefined || cipherRaw === undefined) { + throw new Error("Encrypted credential is not ..."); + } + const decipher = NodeCrypto.createDecipheriv( + CIPHER_ALGORITHM, + NodeCrypto.createHash("sha256").update(secret).digest(), + Buffer.from(ivRaw, "base64url"), + ); + decipher.setAuthTag(Buffer.from(tagRaw, "base64url")); + return Buffer.concat([ + decipher.update(Buffer.from(cipherRaw, "base64url")), + decipher.final(), + ]).toString("utf8"); +}; + +const decodeJsonText = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Unknown)); + +const readDecryptedCredentialStore = Effect.fn("ZcodeRequestAuth.readCredentialStore")(function* ( + fileSystem: FileSystem.FileSystem, + credentialsPath: string, + environment: NodeJS.ProcessEnv, + platform: NodeJS.Platform, +): Effect.fn.Return> { + const raw = yield* fileSystem.readFileString(credentialsPath).pipe(Effect.option); + const parsed = Option.isNone(raw) ? undefined : Option.getOrUndefined(decodeJsonText(raw.value)); + if (!Predicate.isObject(parsed)) return {}; + const secret = resolveZcodeCredentialSecret(environment, platform); + const entries: Record = {}; + for (const [key, value] of Object.entries(parsed)) { + if (typeof value !== "string") continue; + const decrypted = yield* Effect.try({ + try: () => decryptZcodeCredential(value, secret), + catch: () => undefined, + }).pipe(Effect.option); + if (Option.isSome(decrypted)) entries[key] = decrypted.value; + } + return entries; +}); + +/** Families whose 3.15+ connection is an active OAuth individual coding plan. */ +const readOAuthSelectedFamilies = Effect.fn("ZcodeRequestAuth.readOAuthFamilies")(function* ( + fileSystem: FileSystem.FileSystem, + settingPath: string, + store: Record, +): Effect.fn.Return> { + const raw = yield* fileSystem.readFileString(settingPath).pipe(Effect.option); + const parsed = Option.isNone(raw) ? undefined : Option.getOrUndefined(decodeJsonText(raw.value)); + if (!Predicate.isObject(parsed)) return new Set(); + const selections = parsed["providerFamilyConnectionSelections"]; + const modes = parsed["modelProviderFamilyModes"]; + const families = new Set(); + if (!Predicate.isObject(selections)) return families; + for (const [family, selection] of Object.entries(selections)) { + const kind = Predicate.isObject(selection) ? selection["kind"] : undefined; + if (kind !== "individual-coding-plan") continue; + if (Predicate.isObject(modes) && modes[family] === "apiKey") continue; + if (!store[`oauth:${family}:access_token`]) continue; + families.add(family); + } + return families; +}); + +export type ZcodeAccountProviderPush = { + readonly builtinModelIds: ReadonlyArray; + readonly access: { readonly type: "zhipu-account"; readonly entitled: boolean }; +}; + +export type ZcodeAccountStatePush = { + readonly availability: "available" | "unavailable"; + readonly entitled: boolean; + readonly current: boolean; +}; + +export type ZcodeAccountConfigPush = { + readonly revision: string; + readonly basedOnZCodeBuiltinRevision: string; + readonly providers: Record; + readonly states: Record; +}; + +const zcodeRuleAccess = (rule: unknown): ZcodeRecord | undefined => { + const access = zcodeRecordField(zcodeRecordField(rule, "config"), "access"); + return Predicate.isObject(access) ? access : undefined; +}; + +const zcodeBuiltinAccountRules = (builtinCatalog: unknown): ReadonlyArray => { + const providerRules = zcodeRecordArray( + zcodeRecordField(zcodeRecordField(builtinCatalog, "config"), "providerConfigRules"), + "providerRules", + ); + return (providerRules ?? []).filter( + (rule): rule is ZcodeRecord => + Predicate.isObject(rule) && + zcodeRecordString(zcodeRuleAccess(rule), "type") === "zhipu-account", + ); +}; + +const zcodeBuiltinModelIds = (rule: ZcodeRecord): ReadonlyArray => { + const ids = zcodeRecordField(zcodeRecordField(rule, "config"), "builtinModelIds"); + return (Array.isArray(ids) ? ids : []).filter( + (id): id is string => typeof id === "string" && id.length > 0, + ); +}; + +/** The registry keys the snapshot to `zcode-builtin::` of the catalog + * file the CLI booted from; any other value is accepted and silently ignored. */ +const zcodeBuiltinBasedOnRevision = (builtinPath: string, builtinCatalog: unknown): string => { + const revision = zcodeRecordField(builtinCatalog, "revision"); + const revisionText = + typeof revision === "number" && Number.isFinite(revision) + ? String(Math.trunc(revision)) + : typeof revision === "string" && revision.length > 0 + ? revision + : "0"; + return `zcode-builtin:${revisionText}:${sha256Hex(NodePath.resolve(builtinPath))}`; +}; + +/** + * Builds the `provider/updateAccountConfig` push from the builtin catalog's zhipu-account rules + * and the entitlement ladder: a family is entitled only when the user picked an OAuth + * individual coding plan for it and the credential store decrypts to its login token. + */ +export const zcodeAccountConfigPushFromLadder = Effect.fn("ZcodeRequestAuth.buildAccountPush")( + function* ( + fileSystem: FileSystem.FileSystem, + input: { + readonly builtinPath: string; + readonly builtinCatalog: unknown; + readonly environment: NodeJS.ProcessEnv; + }, + ): Effect.fn.Return { + const rules = zcodeBuiltinAccountRules(input.builtinCatalog); + if (rules.length === 0) return undefined; + const root = zcodeDataRoot(input.environment); + if (root === undefined) return undefined; + const store = yield* readDecryptedCredentialStore( + fileSystem, + `${root}/.zcode/v2/credentials.json`, + input.environment, + yield* HostProcess.Platform, + ); + const families = yield* readOAuthSelectedFamilies( + fileSystem, + `${root}/.zcode/v2/setting.json`, + store, + ); + const providers: Record = {}; + const states: Record = {}; + for (const rule of rules) { + const providerId = zcodeRecordString(rule, "providerId"); + const modelIds = zcodeBuiltinModelIds(rule); + if (providerId === undefined || modelIds.length === 0) continue; + const entitled = + zcodeRecordString(zcodeRuleAccess(rule), "mode") === "individual-coding-plan" && + families.has(zcodeRecordString(zcodeRuleAccess(rule), "accountType") ?? ""); + providers[providerId] = { + builtinModelIds: modelIds, + access: { type: "zhipu-account", entitled }, + }; + states[providerId] = { + availability: entitled ? "available" : "unavailable", + entitled, + current: entitled, + }; + } + if (Object.keys(providers).length === 0) return undefined; + return { + revision: `account:t3code:${DateTime.toEpochMillis(yield* DateTime.now)}`, + basedOnZCodeBuiltinRevision: zcodeBuiltinBasedOnRevision( + input.builtinPath, + input.builtinCatalog, + ), + providers, + states, + }; + }, +); + +/** `account:-individual-coding-plan` → the plan family the auth exchange needs. */ +export const zcodeFamilyFromProviderId = (providerId: string | undefined): string | undefined => { + const family = providerId?.replace(/^account:/, "").replace(/-individual-coding-plan$/, ""); + return family === undefined || family.length === 0 ? undefined : family; +}; + +/** Mirrors the CLI's account-scope digest over the plan identity and login tokens. */ +export const zcodeAccountScope = ( + family: string, + loginToken: string, + jwtToken: string | undefined, +): string => + sha256Hex( + zcodeJsonStringify([family, "individual-coding-plan", null, loginToken, jwtToken ?? null]), + ); + +class ZcodeRequestAuthError extends Schema.TaggedError()( + "ZcodeRequestAuthError", + { message: Schema.String }, +) {} + +const declineMessage = (hint: string): string => `ZCode request auth failed (${hint}).`; + +const failAuth = (hint: string) => new ZcodeRequestAuthError({ message: declineMessage(hint) }); + +interface CachedProjectToken { + readonly token: string; + readonly apiKeyId: string; + readonly accountScope: string; + readonly fingerprint: string; + readonly refreshAtMillis: number; +} + +export interface ZcodeRequestAuth { + /** The `interaction/requestProviderRuntimeHeaders` response for an entitled account model. */ + readonly answerRuntimeHeaders: (input: { + readonly family: string; + readonly rejectedProjectTokenFingerprint?: string; + }) => Effect.Effect; +} + +export interface ZcodeRequestAuthOptions { + readonly fileSystem: FileSystem.FileSystem; + readonly httpClient: HttpClient.HttpClient; + readonly environment: NodeJS.ProcessEnv; + readonly clientVersion: string; +} + +/** + * Serves OAuth project tokens for entitled individual coding plans, mirroring the desktop + * host's exchange: customer info → personal default project → the account's `zcode-api-key` → + * a short-lived access token. Read-only towards the account; a missing key surfaces as a + * decline the turn reports honestly. + */ +export const makeZcodeRequestAuth = (options: ZcodeRequestAuthOptions): ZcodeRequestAuth => { + const root = zcodeDataRoot(options.environment) ?? ""; + const credentialsPath = `${root}/.zcode/v2/credentials.json`; + const cache = new Map(); + + const requestJson = (request: HttpClientRequest.HttpClientRequest) => + options.httpClient.execute(request).pipe( + Effect.flatMap(HttpClientResponse.filterStatusOk), + Effect.flatMap((response) => response.json), + Effect.timeoutOption(REQUEST_TIMEOUT_MS), + Effect.map((answered) => (Option.isSome(answered) ? answered.value : undefined)), + Effect.orElseSucceed(() => undefined), + ); + + const envelopeData = Effect.fnUntraced(function* ( + raw: unknown, + exchange: string, + ): Effect.fn.Return { + const code = Predicate.isObject(raw) ? raw["code"] : undefined; + if (!(code === 0 || code === 200 || code === "0" || code === "200")) { + return yield* failAuth(`${exchange} code=${String(code)}`); + } + return Predicate.isObject(raw) ? raw["data"] : undefined; + }); + + const mint = Effect.fn("ZcodeRequestAuth.mint")(function* ( + family: string, + ): Effect.fn.Return { + const store = yield* readDecryptedCredentialStore( + options.fileSystem, + credentialsPath, + options.environment, + yield* HostProcess.Platform, + ); + const loginToken = store[`oauth:${family}:access_token`]; + if (loginToken === undefined || loginToken.length === 0) { + return yield* failAuth("no OAuth login for family"); + } + const origin = FAMILY_ORIGIN[family]; + if (origin === undefined) { + return yield* failAuth(`unknown family ${family}`); + } + const customer = yield* envelopeData( + yield* requestJson( + HttpClientRequest.get(`${origin}/api/biz/customer/getCustomerInfo`, { + headers: { Authorization: loginToken }, + }), + ), + "getCustomerInfo", + ); + const organizations: Array<{ + organizationId: string; + organizationName?: string; + projects: Array<{ projectId: string; projectName?: string }>; + }> = []; + const rawOrganizations = Predicate.isObject(customer) ? customer["organizations"] : undefined; + for (const rawOrg of Array.isArray(rawOrganizations) ? rawOrganizations : []) { + if (!Predicate.isObject(rawOrg)) continue; + const organizationId = zcodeRecordString(rawOrg, "organizationId"); + if (organizationId === undefined || organizationId.length === 0) continue; + const projects: Array<{ projectId: string; projectName?: string }> = []; + const rawProjects = zcodeRecordField(rawOrg, "projects"); + for (const rawProject of Array.isArray(rawProjects) ? rawProjects : []) { + if (!Predicate.isObject(rawProject)) continue; + const projectId = zcodeRecordString(rawProject, "projectId"); + if (projectId === undefined || projectId.length === 0) continue; + if (String(zcodeRecordField(rawProject, "projectType") ?? "").trim() === TEAM_PROJECT_TYPE) + continue; + projects.push({ + projectId, + ...(typeof rawProject["projectName"] === "string" + ? { projectName: rawProject["projectName"] } + : {}), + }); + } + if (projects.length === 0) continue; + organizations.push({ + organizationId, + ...(typeof rawOrg["organizationName"] === "string" + ? { organizationName: rawOrg["organizationName"] } + : {}), + projects, + }); + } + const org = + organizations.find((entry) => entry.organizationName?.includes(DEFAULT_ORGANIZATION_NAME)) ?? + organizations[0]; + const project = + org?.projects.find((entry) => entry.projectName?.includes(DEFAULT_PROJECT_NAME)) ?? + org?.projects[0]; + if (org === undefined || project === undefined) { + return yield* failAuth("no personal project"); + } + const keysPath = `${origin}/api/biz/v1/organization/${encodeURIComponent(org.organizationId)}/projects/${encodeURIComponent(project.projectId)}/api_keys`; + const keys = yield* envelopeData( + yield* requestJson( + HttpClientRequest.get(keysPath, { + headers: { + Authorization: family === "zai" ? `Bearer ${loginToken}` : loginToken, + }, + }), + ), + "api_keys", + ); + const keyList = Array.isArray(keys) ? keys : []; + const key = keyList.find( + (entry) => Predicate.isObject(entry) && entry["name"] === ZCODE_API_KEY_NAME, + ); + const apiKeyId = + Predicate.isObject(key) && typeof key["apiKey"] === "string" ? key["apiKey"] : undefined; + if (apiKeyId === undefined || apiKeyId.length === 0) { + return yield* failAuth("account has no zcode-api-key; open the ZCode app once"); + } + const exchangeRequest = HttpClientRequest.post( + `${keysPath}/${encodeURIComponent(apiKeyId)}/access_tokens`, + { headers: { Authorization: `Bearer ${loginToken}` } }, + ).pipe( + HttpClientRequest.bodyText( + zcodeJsonStringify({ clientType: "zcode", clientVersion: options.clientVersion }), + "application/json", + ), + ); + const exchange = yield* envelopeData(yield* requestJson(exchangeRequest), "access_tokens"); + const token = + Predicate.isObject(exchange) && typeof exchange["accessToken"] === "string" + ? exchange["accessToken"] + : undefined; + if (token === undefined || token.length === 0) { + return yield* failAuth("token exchange returned no token"); + } + const cachedToken: CachedProjectToken = { + token, + apiKeyId, + accountScope: zcodeAccountScope(family, loginToken, store[ZCODE_JWT_CREDENTIAL_KEY]), + fingerprint: sha256Hex(token), + refreshAtMillis: + DateTime.toEpochMillis(yield* DateTime.now) + TOKEN_TTL_MS - TOKEN_REFRESH_MARGIN_MS, + }; + cache.set(family, cachedToken); + return cachedToken; + }); + + const answer = Effect.fn("ZcodeRequestAuth.answer")(function* (input: { + readonly family: string; + readonly rejectedProjectTokenFingerprint?: string; + }): Effect.fn.Return { + const hit = cache.get(input.family); + const cached = + hit !== undefined && + DateTime.toEpochMillis(yield* DateTime.now) < hit.refreshAtMillis && + (input.rejectedProjectTokenFingerprint === undefined || + input.rejectedProjectTokenFingerprint !== hit.fingerprint) + ? hit + : undefined; + if (cached !== undefined) { + return { + headersApplied: true, + requestAuth: { + apiKey: cached.token, + apiKeyId: cached.apiKeyId, + accountScope: cached.accountScope, + }, + }; + } + const minted = yield* Effect.result(mint(input.family)); + return Result.isFailure(minted) + ? { headersApplied: false, errorMessage: minted.failure.message } + : { + headersApplied: true, + requestAuth: { + apiKey: minted.success.token, + apiKeyId: minted.success.apiKeyId, + accountScope: minted.success.accountScope, + }, + }; + }); + + return { + answerRuntimeHeaders: (input) => + answer(input).pipe( + // Any defect here would strand the CLI's request; decline instead. + Effect.catchCause((cause) => + Effect.succeed({ + headersApplied: false, + errorMessage: declineMessage(causeErrorTag(cause)), + } satisfies ZcodeRecord), + ), + ), + }; +}; diff --git a/packages/provider-zcode/src/server/zcodeRuntime.test.ts b/packages/provider-zcode/src/server/zcodeRuntime.test.ts index 04c91b5e85b17..25242e099f87f 100644 --- a/packages/provider-zcode/src/server/zcodeRuntime.test.ts +++ b/packages/provider-zcode/src/server/zcodeRuntime.test.ts @@ -1,14 +1,19 @@ +// @effect-diagnostics nodeBuiltinImport:off -- tests craft the CLI's own AES-GCM store values with node crypto import * as NodeServices from "@effect/platform-node/NodeServices"; import * as HostProcess from "@t3tools/shared/HostProcess"; import { assert, describe, it } from "@effect/vitest"; +import * as NodeCrypto from "node:crypto"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; +import { + resolveZcodeCredentialSecret, + zcodeAccountConfigPushFromLadder, +} from "./zcodeRequestAuth.ts"; import { resolveZcodeCatalogFiles, readZcodeAppVersion, resolveZcodeLaunchCommand, - zcodeAccountConfigPush, zcodeBuiltinCatalogPath, zcodeBundledCatalogPath, zcodeDataRoot, @@ -37,17 +42,35 @@ const inlineCatalog = { { providerId: "account:zai-individual-coding-plan", config: { - access: { type: "zhipu-account", accountType: "zai" }, + access: { type: "zhipu-account", mode: "individual-coding-plan", accountType: "zai" }, builtinModelIds: ["GLM-5.3", "GLM-5.3-Flash"], }, }, { providerId: "account:zai-team-coding-plan", config: { - access: { type: "zhipu-account", accountType: "zai" }, + access: { type: "zhipu-account", mode: "team-coding-plan", accountType: "zai" }, + builtinModelIds: ["GLM-5.3"], + }, + }, + { + providerId: "account:bigmodel-individual-coding-plan", + config: { + access: { + type: "zhipu-account", + mode: "individual-coding-plan", + accountType: "bigmodel", + }, builtinModelIds: ["GLM-5.3"], }, }, + { + providerId: "account:start-individual-coding-plan", + config: { + access: { type: "zhipu-account", mode: "individual-coding-plan", accountType: "start" }, + builtinModelIds: ["GLM-5.3-Flash"], + }, + }, { providerId: "kimi-api", config: { access: { type: "api-key" }, builtinModelIds: ["kimi-k3"] }, @@ -61,6 +84,18 @@ const inlineCatalog = { }, }; +/** Produces store values the CLI's fallback cipher would hold, keyed the same way. */ +const encryptZcodeCredential = (value: string, secret: string): string => { + const iv = NodeCrypto.randomBytes(12); + const cipher = NodeCrypto.createCipheriv( + "aes-256-gcm", + NodeCrypto.createHash("sha256").update(secret).digest(), + iv, + ); + const ciphertext = Buffer.concat([cipher.update(value, "utf8"), cipher.final()]); + return `enc:v1:${iv.toString("base64url")}.${cipher.getAuthTag().toString("base64url")}.${ciphertext.toString("base64url")}`; +}; + describe("zcodePlatformDir", () => { it("maps zcode's own platform spellings", () => { assert.equal(zcodePlatformDir("linux", "x64"), "linux-x86_64"); @@ -199,62 +234,114 @@ describe("zcodeRuntimeEnvironment", () => { }); }); -describe("zcodeAccountConfigPush", () => { - it("pushes only catalog zhipu-account providers with entitled access and states", () => { - const push = zcodeAccountConfigPush(inlineCatalog, "t3code-test"); - assert.isDefined(push); - assert.deepEqual(Object.keys(push.providers), [ - "account:zai-individual-coding-plan", - "account:zai-team-coding-plan", - ]); - assert.deepEqual(push.providers["account:zai-individual-coding-plan"]!, { - builtinModelIds: ["GLM-5.3", "GLM-5.3-Flash"], - access: { type: "zhipu-account", entitled: true }, - }); - assert.equal(push.basedOnZCodeBuiltinRevision, "43"); - assert.equal(push.revision, "t3code-test"); - const states = push.states; - assert.equal(states["account:zai-individual-coding-plan"]!.current, true); - assert.equal(states["account:zai-team-coding-plan"]!.current, false); - assert.equal(states["account:zai-team-coding-plan"]!.availability, "available"); - assert.equal(states["account:zai-team-coding-plan"]!.entitled, true); - }); +describe("zcodeAccountConfigPushFromLadder", () => { + it.effect( + "keys the push to the catalog path hash and maps the entitlement ladder onto providers and states", + () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "zcode-ladder-test-" }); + const platform = yield* HostProcess.Platform; + const environment = { ZCODE_DATA_BASE_DIR: root, HOME: "/home/dev", USER: "dev" }; + yield* fs.makeDirectory(`${root}/.zcode/v2`, { recursive: true }); + yield* fs.writeFileString( + `${root}/.zcode/v2/setting.json`, + JSON.stringify({ + providerFamilyConnectionSelections: { + zai: { kind: "individual-coding-plan" }, + bigmodel: { kind: "individual-coding-plan" }, + start: { kind: "individual-coding-plan" }, + }, + modelProviderFamilyModes: { bigmodel: "apiKey" }, + }), + ); + yield* fs.writeFileString( + `${root}/.zcode/v2/credentials.json`, + JSON.stringify({ + "oauth:zai:access_token": encryptZcodeCredential( + "zai-login-token", + resolveZcodeCredentialSecret(environment, platform), + ), + }), + ); + const builtinPath = `${root}/catalog/zcode-builtin.json`; + const push = yield* zcodeAccountConfigPushFromLadder(fs, { + builtinPath, + builtinCatalog: inlineCatalog, + environment, + }); + assert.isDefined(push); + // builtinPath is absolute, so the CLI-side path.resolve is a no-op here. + assert.equal( + push!.basedOnZCodeBuiltinRevision, + `zcode-builtin:43:${NodeCrypto.createHash("sha256").update(builtinPath).digest("hex")}`, + ); + assert.equal(push!.revision.startsWith("account:t3code:"), true); + assert.deepEqual(Object.keys(push!.providers), [ + "account:zai-individual-coding-plan", + "account:zai-team-coding-plan", + "account:bigmodel-individual-coding-plan", + "account:start-individual-coding-plan", + ]); + // OAuth selection + decrypted login token + plan-mode rule → entitled. + assert.deepEqual(push!.providers["account:zai-individual-coding-plan"], { + builtinModelIds: ["GLM-5.3", "GLM-5.3-Flash"], + access: { type: "zhipu-account", entitled: true }, + }); + assert.deepEqual(push!.states["account:zai-individual-coding-plan"], { + availability: "available", + entitled: true, + current: true, + }); + // Team-plan rules never entitle. + assert.equal(push!.states["account:zai-team-coding-plan"]!.availability, "unavailable"); + assert.equal(push!.states["account:zai-team-coding-plan"]!.entitled, false); + assert.equal(push!.states["account:zai-team-coding-plan"]!.current, false); + // A family switched to apiKey mode is excluded even with a selection. + assert.equal( + push!.states["account:bigmodel-individual-coding-plan"]!.availability, + "unavailable", + ); + // A selected family without a decryptable login token stays unentitled. + assert.equal(push!.states["account:start-individual-coding-plan"]!.entitled, false); + }), + ).pipe(Effect.provide(NodeServices.layer)), + ); - it("returns undefined without a usable catalog", () => { - assert.equal(zcodeAccountConfigPush(undefined, "r"), undefined); - assert.equal(zcodeAccountConfigPush({}, "r"), undefined); - assert.equal( - zcodeAccountConfigPush( - { - config: { - providerConfigRules: { - providerRules: [{ providerId: "kimi-api", config: { access: { type: "api-key" } } }], + it.effect("returns undefined without a usable catalog or data root", () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const noRules = yield* zcodeAccountConfigPushFromLadder(fs, { + builtinPath: "/tmp/builtin.json", + builtinCatalog: { config: { providerConfigRules: { providerRules: [] } } }, + environment: { HOME: "/home/dev" }, + }); + assert.equal(noRules, undefined); + const noRoot = yield* zcodeAccountConfigPushFromLadder(fs, { + builtinPath: "/tmp/builtin.json", + builtinCatalog: inlineCatalog, + environment: {}, + }); + assert.equal(noRoot, undefined); + const apiOnly = yield* zcodeAccountConfigPushFromLadder(fs, { + builtinPath: "/tmp/builtin.json", + builtinCatalog: { + config: { + providerConfigRules: { + providerRules: [ + { providerId: "kimi-api", config: { access: { type: "api-key" } } }, + ], + }, }, }, - }, - "r", - ), - undefined, - ); - }); - - it("tolerates a flattened providerRules list", () => { - const push = zcodeAccountConfigPush( - { - revision: 7, - providerRules: [ - { - providerId: "account:zai-start-plan", - config: { access: { type: "zhipu-account" }, builtinModelIds: ["GLM-5.3-Flash"] }, - }, - ], - }, - "r", - ); - assert.isDefined(push); - assert.equal(push.basedOnZCodeBuiltinRevision, "7"); - assert.deepEqual(Object.keys(push.providers), ["account:zai-start-plan"]); - }); + environment: { HOME: "/home/dev" }, + }); + assert.equal(apiOnly, undefined); + }), + ).pipe(Effect.provide(NodeServices.layer)), + ); }); describe("zcodeModelSelectionFromSlug", () => { @@ -322,11 +409,19 @@ it.layer(NodeServices.layer)("ZcodeRuntime catalog resolution", (it) => { assert.equal(resolved.builtinPath, `${builtinDir}/zcode-builtin.json`); assert.equal(resolved.bundledPath, undefined); assert.equal(resolved.personalPath, personalPath); - const push = zcodeAccountConfigPush(resolved.builtinCatalog, "r"); + // No credentials or setting selection in this root: every rule pushes unentitled. + const push = yield* zcodeAccountConfigPushFromLadder(fs, { + builtinPath: resolved.builtinPath!, + builtinCatalog: resolved.builtinCatalog, + environment: { ZCODE_DATA_BASE_DIR: root }, + }); assert.deepEqual(Object.keys(push?.providers ?? {}), [ "account:zai-individual-coding-plan", "account:zai-team-coding-plan", + "account:bigmodel-individual-coding-plan", + "account:start-individual-coding-plan", ]); + assert.equal(push?.states["account:zai-individual-coding-plan"]?.entitled, false); }), ), ); diff --git a/packages/provider-zcode/src/server/zcodeRuntime.ts b/packages/provider-zcode/src/server/zcodeRuntime.ts index dcd8be9a3ea86..56279573274cb 100644 --- a/packages/provider-zcode/src/server/zcodeRuntime.ts +++ b/packages/provider-zcode/src/server/zcodeRuntime.ts @@ -8,36 +8,12 @@ import * as HostProcess from "@t3tools/shared/HostProcess"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Option from "effect/Option"; -import * as Predicate from "effect/Predicate"; import * as Schema from "effect/Schema"; -import { - zcodeRecordArray, - zcodeRecordField, - zcodeRecordString, - type ZcodeRecord, -} from "./zcodeProtocol.ts"; - const ZCODE_BASE_URL = "https://zcode.z.ai"; const DEFAULT_ZCODE_BINARY = "zcode"; -/** The CLI filters account providers to ids present in this catalog; inventing ids breaks selection. */ -export interface ZcodeAccountProviderPush { - readonly builtinModelIds: ReadonlyArray; - readonly access: { readonly type: "zhipu-account"; readonly entitled: true }; -} - -export interface ZcodeAccountConfigPush { - readonly revision: string; - readonly basedOnZCodeBuiltinRevision: string; - readonly providers: Record; - readonly states: Record< - string, - { readonly availability: "available"; readonly entitled: true; readonly current: boolean } - >; -} - /** Wire shape of `model` in session/create and `modelSelection` in session/send. */ export interface ZcodeModelSelection { readonly providerId: string; @@ -186,79 +162,6 @@ export function zcodeRuntimeEnvironment( return next; } -/** - * Builds the `provider/updateAccountConfig` params from the builtin catalog's zhipu-account - * providers. `entitled: true` in both providers.access and states is what makes the registry - * treat a model as executable; `current` marks the first provider so a default exists. - */ -export function zcodeAccountConfigPush( - catalog: unknown, - revision: string, -): ZcodeAccountConfigPush | undefined { - const rules = zcodeProviderRules(catalog); - if (rules === undefined) return undefined; - const providers: Record = {}; - const states: ZcodeAccountConfigPush["states"] = {}; - // Real catalogs carry a numeric revision; the wire expects the string form. - const rawRevision = zcodeRecordField(catalog ?? {}, "revision"); - const basedOnRevision = - typeof rawRevision === "string" && rawRevision.length > 0 - ? rawRevision - : typeof rawRevision === "number" && Number.isFinite(rawRevision) - ? String(Math.trunc(rawRevision)) - : "0"; - for (const rule of rules) { - const providerId = zcodeRecordString(rule, "providerId"); - const config = zcodeRecordField(rule, "config"); - const access = Predicate.isObject(config) ? zcodeRecordField(config, "access") : undefined; - const accessType = zcodeRecordString(access ?? {}, "type"); - const builtinModelIds = zcodeRecordStringArray( - Predicate.isObject(config) ? zcodeRecordField(config, "builtinModelIds") : undefined, - ); - if ( - providerId === undefined || - accessType !== "zhipu-account" || - builtinModelIds.length === 0 - ) { - continue; - } - providers[providerId] = { - builtinModelIds, - access: { type: "zhipu-account", entitled: true }, - }; - states[providerId] = { - availability: "available", - entitled: true, - current: Object.keys(providers).length === 1, - }; - } - if (Object.keys(providers).length === 0) return undefined; - return { - revision, - basedOnZCodeBuiltinRevision: basedOnRevision, - providers, - states, - }; -} - -/** providerRules sits at config.providerConfigRules.providerRules; tolerate a flattened copy. */ -function zcodeProviderRules(catalog: unknown): ReadonlyArray | undefined { - if (!Predicate.isObject(catalog)) return undefined; - const direct = zcodeRecordArray(catalog, "providerRules"); - if (direct !== undefined) return direct.filter(Predicate.isObject); - const config = zcodeRecordField(catalog, "config"); - if (!Predicate.isObject(config)) return undefined; - const nested = zcodeRecordField(config, "providerConfigRules"); - if (!Predicate.isObject(nested)) return undefined; - const rules = zcodeRecordArray(nested, "providerRules"); - return rules === undefined ? undefined : rules.filter(Predicate.isObject); -} - -function zcodeRecordStringArray(value: unknown): ReadonlyArray { - if (!Array.isArray(value)) return []; - return value.filter((entry): entry is string => typeof entry === "string" && entry.length > 0); -} - /** * T3 model slug → zcode ModelSelection. "default" and "default$" defer to the CLI's own * default (undefined means omit `model`), and "/$" maps to the From 03f25f87f4b10949847315fd5b81b2e65935ee3d Mon Sep 17 00:00:00 2001 From: panudetjt <3482285+panudetjt@users.noreply.github.com> Date: Tue, 10 Nov 2026 09:10:53 +0700 Subject: [PATCH 06/23] fix(provider-zcode): map the CLI's real model.streaming event shape MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Live transcripts show the CLI streams assistant text as model.streaming {assistantMessageId, delta, kind, done} — not the part.* shapes the shared schema advertises — and turn.completed carries the authoritative full response. The adapter was dropping the stream entirely, so turns completed with no visible assistant message. Map model.streaming (text/reasoning deltas -> streamed items) and materialize the final assistant message from the completed payload. Model: GLM-5.3-Flash via ZCode (account:zai-individual-coding-plan). Harness: ZCode. --- .../Adapters/ZcodeAdapterV2.test.ts | 30 +++++------ packages/provider-zcode/src/server/adapter.ts | 53 ++++++++++++++++++- 2 files changed, 66 insertions(+), 17 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/ZcodeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ZcodeAdapterV2.test.ts index 3816a0e27674d..841ccd088f4ce 100644 --- a/apps/server/src/orchestration-v2/Adapters/ZcodeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ZcodeAdapterV2.test.ts @@ -246,26 +246,24 @@ describe("ZcodeAdapterV2 (frame-driven)", () => { (event) => event.providerTurn.status === "running", ); yield* harness.offerTurnEvent("turn.started", {}); - yield* harness.offerTurnEvent("part.delta", { - messageId: "msg_a", - partId: "p1", - field: "text", + // The CLI's real stream: model.streaming deltas keyed by assistantMessageId. + yield* harness.offerTurnEvent("model.streaming", { + assistantMessageId: "msg_a", delta: "Hello", + kind: "text_delta", + done: false, }); - yield* harness.offerTurnEvent("part.delta", { - messageId: "msg_a", - partId: "p1", - field: "text", + yield* harness.offerTurnEvent("model.streaming", { + assistantMessageId: "msg_a", delta: " world", + kind: "text_delta", + done: false, }); - yield* harness.offerTurnEvent("part.upserted", { - part: { - partId: "p1", - sessionId: "sess_1", - messageId: "msg_a", - type: "text", - text: "Hello world", - }, + yield* harness.offerTurnEvent("model.streaming", { + assistantMessageId: "msg_a", + delta: "", + kind: "text_delta", + done: true, }); yield* harness.offerTurnEvent("turn.completed", { response: "Hello world", diff --git a/packages/provider-zcode/src/server/adapter.ts b/packages/provider-zcode/src/server/adapter.ts index 67607585b263f..ac21bcaa468ea 100644 --- a/packages/provider-zcode/src/server/adapter.ts +++ b/packages/provider-zcode/src/server/adapter.ts @@ -1233,6 +1233,38 @@ export const makeZcodeAdapterV2 = Effect.fn("makeZcodeAdapterV2")(function* ( yield* completeStreamItem(turn, item, text); return; } + case "model.streaming": { + // The CLI's real text/reasoning stream: {assistantMessageId, delta, kind, done}. + if (turn === null) return; + const messageId = zcodeRecordString(payload, "assistantMessageId"); + const delta = zcodeRecordString(payload, "delta"); + if (messageId === undefined || delta === undefined) return; + const kind = zcodeRecordString(payload, "kind") ?? "text_delta"; + if (kind !== "text_delta" && kind !== "reasoning_delta") return; + const item = yield* streamItemFor( + turn, + kind === "text_delta" ? "assistant_message" : "reasoning", + messageId, + messageId, + ); + item.text += delta; + yield* emit({ + type: "command_output.delta", + driver: ZCODE_PROVIDER, + threadId: turn.turnInput.threadId, + itemId: idAllocator.derive.turnItemFromProviderItem({ + driver: ZCODE_PROVIDER, + nativeItemId: item.nativeItemId, + }), + chunk: delta, + }); + if (payload["done"] === true) { + yield* completeStreamItem(turn, item); + return; + } + yield* scheduleStreamFlush(turn, item); + return; + } case "tool.updated": { if (turn === null) return; // Subagent-attributed calls belong to child sessions; projecting them as parent @@ -1373,6 +1405,26 @@ export const makeZcodeAdapterV2 = Effect.fn("makeZcodeAdapterV2")(function* ( tokenUsage: { usedTokens: tokenCount, updatedAt: DateTime.formatIso(updatedAt) }, }; } + // The completed payload carries the authoritative full response; make sure an + // assistant message exists even when the stream produced no mapped items. + const response = zcodeRecordString(payload, "response"); + if (response !== undefined && response.length > 0) { + const textItems = Array.from(turn.streamItems.entries()).filter( + ([, item]) => item.kind === "assistant_message", + ); + const last = textItems.at(-1); + if (last === undefined) { + const item = yield* streamItemFor( + turn, + "assistant_message", + `final:${turn.providerTurn.id}`, + "final", + ); + yield* completeStreamItem(turn, item, response); + } else { + yield* completeStreamItem(turn, last[1], response); + } + } yield* finalizeTurn(state); return; } @@ -1405,7 +1457,6 @@ export const makeZcodeAdapterV2 = Effect.fn("makeZcodeAdapterV2")(function* ( } case "turn.steerQueued": case "turn.steerDrained": - case "model.streaming": case "session.created": case "session.resumed": case "session.updated": From c97d2f1041143cd249c05786becc2660b30a56d4 Mon Sep 17 00:00:00 2001 From: panudetjt <3482285+panudetjt@users.noreply.github.com> Date: Tue, 10 Nov 2026 09:17:26 +0700 Subject: [PATCH 07/23] fix(provider-zcode): refresh advertised models on session resume too A server restart replays threads through session/resume, whose subscribe snapshot also advertises the session's executable models. Parsing it there (not only in the create result) keeps the registry-required reasoning level attached to sends after restarts. Model: GLM-5.3-Flash via ZCode (account:zai-individual-coding-plan). Harness: ZCode. --- packages/provider-zcode/src/server/adapter.ts | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/packages/provider-zcode/src/server/adapter.ts b/packages/provider-zcode/src/server/adapter.ts index ac21bcaa468ea..a3f13fab6ad4a 100644 --- a/packages/provider-zcode/src/server/adapter.ts +++ b/packages/provider-zcode/src/server/adapter.ts @@ -1702,12 +1702,21 @@ export const makeZcodeAdapterV2 = Effect.fn("makeZcodeAdapterV2")(function* ( ); yield* bootstrapProviderRegistry(); - const subscribeSession = (sessionId: string) => - request("session/subscribe", { + const subscribeSession = Effect.fnUntraced(function* (sessionId: string) { + const result = yield* request("session/subscribe", { sessionId, deliveryKind: "desktop-continuous", includeSnapshot: true, }); + // The snapshot advertises the session's executable models; resume needs it as much as + // create, since a restart replays threads through resume without a fresh create result. + const snapshot = zcodeRecordField(Predicate.isObject(result) ? result : {}, "snapshot"); + const settings = zcodeRecordField(Predicate.isObject(snapshot) ? snapshot : {}, "settings"); + const model = zcodeRecordField(Predicate.isObject(settings) ? settings : {}, "model"); + const models = parseZcodeAvailableModels(model); + if (models.length > 0) availableModels = models; + return result; + }); const createNativeSession = Effect.fnUntraced(function* ( threadInput: ProviderAdapter.ProviderAdapterV2EnsureThreadInput, From 9b3ea4d08037339cbc65087fab0c048df1dd05b6 Mon Sep 17 00:00:00 2001 From: panudetjt <3482285+panudetjt@users.noreply.github.com> Date: Tue, 10 Nov 2026 10:06:24 +0700 Subject: [PATCH 08/23] feat(provider-zcode): enumerate the picker from the CLI catalog and ladder The model picker was a hardcoded static list with no effort levels. The status probe now enumerates models from the CLI's injected zcode-builtin catalog filtered by the same entitlement ladder the account push uses, attaches a Reasoning select built from the catalog's modelMatch rules (last matching rule wins, mirroring the runtime composition), and keeps 'ZCode default' as the plan-default slug. New GLM models appear on provider refresh without an adapter change. Sends prefer the user's Effort pick over slug suffixes over advertised defaults, and the credential cipher resolves the username via os.userInfo like the CLI instead of env names. Model: GLM-5.3-Flash via ZCode (account:zai-individual-coding-plan). Harness: ZCode. --- packages/provider-zcode/src/server/adapter.ts | 13 ++- packages/provider-zcode/src/server/status.ts | 95 +++++++++++++++---- .../src/server/zcodeRequestAuth.test.ts | 30 +++--- .../src/server/zcodeRequestAuth.ts | 91 +++++++++++++++++- 4 files changed, 187 insertions(+), 42 deletions(-) diff --git a/packages/provider-zcode/src/server/adapter.ts b/packages/provider-zcode/src/server/adapter.ts index a3f13fab6ad4a..b94e8affe6453 100644 --- a/packages/provider-zcode/src/server/adapter.ts +++ b/packages/provider-zcode/src/server/adapter.ts @@ -1974,15 +1974,22 @@ export const makeZcodeAdapterV2 = Effect.fn("makeZcodeAdapterV2")(function* ( }; const modelSelection = zcodeModelSelectionFromSlug(turnInput.modelSelection.model); if (modelSelection !== undefined) { - // The registry requires a reasoning level whenever the model advertises one; the - // CLI's own default fills the gap for T3 selections that carry none. + // Level priority: the user's Effort pick (the "reasoningLevel" descriptor lands in + // model selections) beats the slug suffix beats the CLI-advertised default. + const pickedLevel = turnInput.modelSelection.options?.find( + (selection) => selection.id === "reasoningLevel" || selection.id === "effort", + )?.value; const advertised = availableModels.find( (entry) => entry.providerId === modelSelection.providerId && entry.modelId === modelSelection.modelId, ); const reasoningLevel = - modelSelection.options?.reasoningLevel ?? advertised?.defaultReasoningLevel; + (typeof pickedLevel === "string" && pickedLevel.length > 0 + ? pickedLevel + : undefined) ?? + modelSelection.options?.reasoningLevel ?? + advertised?.defaultReasoningLevel; sendParams["modelSelection"] = { ...modelSelection, ...(reasoningLevel === undefined ? {} : { options: { reasoningLevel } }), diff --git a/packages/provider-zcode/src/server/status.ts b/packages/provider-zcode/src/server/status.ts index dcd2572dae708..e11aadb42ac5f 100644 --- a/packages/provider-zcode/src/server/status.ts +++ b/packages/provider-zcode/src/server/status.ts @@ -1,8 +1,14 @@ -import { type CustomModelSetting, type ServerProviderModel } from "@t3tools/contracts"; +import { + type CustomModelSetting, + type ModelCapabilities, + type ServerProviderModel, +} from "@t3tools/contracts"; import type { ZcodeSettings } from "../settings.ts"; -import { resolveZcodeLaunchCommand } from "./zcodeRuntime.ts"; +import { resolveZcodeCatalogFiles, resolveZcodeLaunchCommand } from "./zcodeRuntime.ts"; +import { zcodeEntitledModels } from "./zcodeRequestAuth.ts"; import { resolveSpawnCommand } from "@t3tools/shared/shell"; import { compareSemverVersions } from "@t3tools/shared/semver"; +import { createModelCapabilities } from "@t3tools/shared/model"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; @@ -42,26 +48,71 @@ const decodeDoctorOutput = Schema.decodeUnknownOption( ), ); -const ZCODE_FALLBACK_MODELS: ReadonlyArray = [ - { slug: "default", name: "ZCode default", isCustom: false, capabilities: {} }, - { - slug: "account:zai-individual-coding-plan/GLM-5.3", - name: "GLM-5.3", - isCustom: false, - capabilities: {}, - }, - { - slug: "account:zai-individual-coding-plan/GLM-5.3-Flash", - name: "GLM-5.3 Flash", - isCustom: false, - capabilities: {}, - }, -]; +/** + * "default" defers to the model the signed-in ZCode CLI resolves on its own; the rest of the + * picker is enumerated from the CLI's injected catalog + entitlement ladder at probe time, so + * models the plan adds later appear without an adapter change. + */ +const ZCODE_DEFAULT_MODEL: ServerProviderModel = { + slug: "default", + name: "ZCode default", + isCustom: false, + isDefault: true, + capabilities: {}, +}; + +function zcodeReasoningCapabilities(levels: ReadonlyArray): ModelCapabilities { + if (levels.length === 0) return {}; + return createModelCapabilities({ + optionDescriptors: [ + { + id: "reasoningLevel", + label: "Reasoning", + type: "select", + role: "reasoning", + options: levels.map((level) => ({ + id: level, + label: level.charAt(0).toUpperCase() + level.slice(1), + })), + }, + ], + }); +} + +const zcodeEntitledPickerModels = Effect.fn("ZcodeProviderStatus.entitledModels")(function* ( + fileSystem: FileSystem.FileSystem, + environment: NodeJS.ProcessEnv, +) { + const catalogFiles = yield* resolveZcodeCatalogFiles(fileSystem, environment); + if (catalogFiles.builtinCatalog === undefined || catalogFiles.builtinPath === undefined) { + return []; + } + const entitled = yield* zcodeEntitledModels(fileSystem, { + builtinPath: catalogFiles.builtinPath, + builtinCatalog: catalogFiles.builtinCatalog, + environment, + }); + return entitled.map((model): ServerProviderModel => { + const capabilities = zcodeReasoningCapabilities(model.reasoningLevels); + return { + slug: `${model.providerId}/${model.modelId}`, + name: model.modelId, + subProvider: model.providerId, + isCustom: false, + capabilities: Object.keys(capabilities).length === 0 ? {} : capabilities, + }; + }); +}); function zcodeModelsFromSettings( customModels: ReadonlyArray | undefined, + entitledModels: ReadonlyArray = [], ): ReadonlyArray { - return providerModelsFromSettings(ZCODE_FALLBACK_MODELS, customModels ?? [], {}); + return providerModelsFromSettings( + [ZCODE_DEFAULT_MODEL, ...entitledModels], + customModels ?? [], + {}, + ); } const runZcodeDoctor = Effect.fn("ZcodeProviderStatus.runDoctor")(function* ( @@ -289,11 +340,17 @@ export const checkZcodeProviderStatus = Effect.fn("checkZcodeProviderStatus")(fu fileSystem, resolveZcodeCredentialsPath(environment), ); + // Enumerate the picker from the CLI's own catalog + entitlement ladder; fall back to the + // default-only list when the catalog files are absent (fresh installs before first login). + const entitledModels = yield* zcodeEntitledPickerModels(fileSystem, environment).pipe( + Effect.orElseSucceed(() => [] as ReadonlyArray), + ); + const models = zcodeModelsFromSettings(zcodeSettings.customModels, entitledModels); return buildServerProvider({ presentation: ZCODE_PRESENTATION, enabled: zcodeSettings.enabled, checkedAt, - models: fallbackModels, + models, probe: { installed: true, version, diff --git a/packages/provider-zcode/src/server/zcodeRequestAuth.test.ts b/packages/provider-zcode/src/server/zcodeRequestAuth.test.ts index aea158326c3c8..c7245f908ee55 100644 --- a/packages/provider-zcode/src/server/zcodeRequestAuth.test.ts +++ b/packages/provider-zcode/src/server/zcodeRequestAuth.test.ts @@ -27,28 +27,22 @@ describe("resolveZcodeCredentialSecret", () => { resolveZcodeCredentialSecret({ ZCODE_CREDENTIAL_SECRET: " custom-secret " }, "linux"), "custom-secret", ); - assert.equal( - resolveZcodeCredentialSecret( - { ZCODE_CREDENTIAL_SECRET: " ", USER: "jame", HOME: "/home/dev" }, - "linux", - ), - "zcode-credential-fallback:linux:/home/dev:jame", + const blankOverride = resolveZcodeCredentialSecret( + { ZCODE_CREDENTIAL_SECRET: " ", HOME: "/home/dev" }, + "linux", ); + assert.match(blankOverride, /^zcode-credential-fallback:linux:\/home\/dev:/); }); - it("derives the CLI's machine-bound fallback from USER then USERNAME and HOME", () => { - assert.equal( - resolveZcodeCredentialSecret({ USER: "jame", HOME: "/home/dev" }, "darwin"), - "zcode-credential-fallback:darwin:/home/dev:jame", - ); - assert.equal( - resolveZcodeCredentialSecret({ USERNAME: "jdoe", HOME: "/home/dev" }, "win32"), - "zcode-credential-fallback:win32:/home/dev:jdoe", - ); - assert.equal( - resolveZcodeCredentialSecret({}, "linux"), - "zcode-credential-fallback:linux::unknown", + it("derives the CLI's machine-bound fallback with the HOME from the environment", () => { + // Username comes from os.userInfo() where resolvable (env names never win); on runtimes + // where it throws, the env fallback speaks. Only the stable parts are asserted here. + const secret = resolveZcodeCredentialSecret( + { USER: "someoneElse", HOME: "/home/dev" }, + "darwin", ); + assert.match(secret, /^zcode-credential-fallback:darwin:\/home\/dev:/); + assert.match(resolveZcodeCredentialSecret({}, "linux"), /^zcode-credential-fallback:linux:/); }); }); diff --git a/packages/provider-zcode/src/server/zcodeRequestAuth.ts b/packages/provider-zcode/src/server/zcodeRequestAuth.ts index 9245dc7ea0fc3..1a3bdcbaa63a0 100644 --- a/packages/provider-zcode/src/server/zcodeRequestAuth.ts +++ b/packages/provider-zcode/src/server/zcodeRequestAuth.ts @@ -5,6 +5,7 @@ // end-to-end against zcode 3.17.2 (PROTOCOL-NOTES.md); the desktop host performs the same // exchanges. import * as NodeCrypto from "node:crypto"; +import * as NodeOS from "node:os"; import * as NodePath from "node:path"; import { causeErrorTag } from "@t3tools/shared/observability"; @@ -59,8 +60,15 @@ export const resolveZcodeCredentialSecret = ( ): string => { const configured = environment.ZCODE_CREDENTIAL_SECRET?.trim(); if (configured !== undefined && configured.length > 0) return configured; - const username = environment.USER ?? environment.USERNAME ?? "unknown"; - const home = environment.HOME ?? ""; + // The CLI binds the fallback to os.userInfo().username — env USER/USERNAME may be absent in + // server processes and must not silently derive a different key. + let username = "unknown"; + try { + username = NodeOS.userInfo().username; + } catch { + username = environment.USER ?? environment.USERNAME ?? "unknown"; + } + const home = environment.HOME ?? NodeOS.homedir(); return `zcode-credential-fallback:${platform}:${home}:${username}`; }; @@ -245,6 +253,85 @@ export const zcodeAccountConfigPushFromLadder = Effect.fn("ZcodeRequestAuth.buil }, ); +/** + * Entitled individual-coding-plan models for the model picker: enumerated from the injected + * catalog plus the entitlement ladder, so a catalog refresh by the CLI adds models without an + * adapter change. Reasoning levels come from the catalog's modelRules (modelMatch regexes, + * matched case-insensitively against the model id). + */ +export interface ZcodeEntitledModel { + readonly providerId: string; + readonly modelId: string; + readonly reasoningLevels: ReadonlyArray; +} + +export const zcodeEntitledModels = Effect.fn("ZcodeRequestAuth.entitledModels")(function* ( + fileSystem: FileSystem.FileSystem, + input: { + readonly builtinPath: string; + readonly builtinCatalog: unknown; + readonly environment: NodeJS.ProcessEnv; + }, +): Effect.fn.Return, never, never> { + const rules = zcodeBuiltinAccountRules(input.builtinCatalog); + const root = zcodeDataRoot(input.environment); + if (rules.length === 0 || root === undefined) return []; + const store = yield* readDecryptedCredentialStore( + fileSystem, + `${root}/.zcode/v2/credentials.json`, + input.environment, + yield* HostProcess.Platform, + ); + const families = yield* readOAuthSelectedFamilies( + fileSystem, + `${root}/.zcode/v2/setting.json`, + store, + ); + const modelRules = zcodeRecordArray( + zcodeRecordField(zcodeRecordField(input.builtinCatalog, "config"), "modelConfigRules"), + "modelRules", + ); + const levelsFor = (modelId: string): ReadonlyArray => { + // Later matching rules compose over earlier ones (the runtime's advertised levels are the + // last match: Flash hits glm-5.* then glm-5\.3, landing on [low, high, max]). + let levels: ReadonlyArray = []; + for (const rule of modelRules ?? []) { + const match = zcodeRecordString(rule, "modelMatch"); + if (match === undefined) continue; + let regex: RegExp; + try { + regex = new RegExp(match, "i"); + } catch { + continue; + } + if (!regex.test(modelId)) continue; + const specs = zcodeRecordField(zcodeRecordField(rule, "config"), "optionSpecs"); + const reasoning = zcodeRecordField(Predicate.isObject(specs) ? specs : {}, "reasoningLevel"); + const values = zcodeRecordArray(reasoning, "values")?.filter( + (value): value is string => typeof value === "string", + ); + if (values !== undefined && values.length > 0) levels = values; + } + return levels; + }; + const out: Array = []; + for (const rule of rules) { + const providerId = zcodeRecordString(rule, "providerId"); + const access = zcodeRuleAccess(rule); + if ( + providerId === undefined || + zcodeRecordString(access, "mode") !== "individual-coding-plan" || + !families.has(zcodeRecordString(access, "accountType") ?? "") + ) { + continue; + } + for (const modelId of zcodeBuiltinModelIds(rule)) { + out.push({ providerId, modelId, reasoningLevels: levelsFor(modelId) }); + } + } + return out; +}); + /** `account:-individual-coding-plan` → the plan family the auth exchange needs. */ export const zcodeFamilyFromProviderId = (providerId: string | undefined): string | undefined => { const family = providerId?.replace(/^account:/, "").replace(/-individual-coding-plan$/, ""); From d60519a15a99c2de43f05d2fc95511c4c6763d5e Mon Sep 17 00:00:00 2001 From: panudetjt <3482285+panudetjt@users.noreply.github.com> Date: Tue, 10 Nov 2026 14:07:44 +0700 Subject: [PATCH 09/23] fix(provider-zcode): recheck the token cache against current credentials A cache hit was accepted on family and expiry alone, so after a CLI login switched accounts the same family kept receiving the previous account's apiKey, apiKeyId, and accountScope until the token expired. Answers now reread the credential store and require the scope digest to match the cached entry; the derivation is shared with mint so the two cannot drift. Also exports the provisioning predicate for the provider status probe. Model: GLM-5.3-Flash via ZCode. Harness: ZCode. --- .../src/server/zcodeRequestAuth.test.ts | 107 ++++++++++++++++++ .../src/server/zcodeRequestAuth.ts | 79 +++++++++---- 2 files changed, 162 insertions(+), 24 deletions(-) diff --git a/packages/provider-zcode/src/server/zcodeRequestAuth.test.ts b/packages/provider-zcode/src/server/zcodeRequestAuth.test.ts index c7245f908ee55..15f040460578b 100644 --- a/packages/provider-zcode/src/server/zcodeRequestAuth.test.ts +++ b/packages/provider-zcode/src/server/zcodeRequestAuth.test.ts @@ -1,13 +1,21 @@ // @effect-diagnostics nodeBuiltinImport:off -- tests craft the CLI's own AES-GCM store values with node crypto +import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import { assert, describe, it } from "@effect/vitest"; import * as NodeCrypto from "node:crypto"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as HttpClient from "effect/http/HttpClient"; +import * as HttpClientResponse from "effect/http/HttpClientResponse"; import { decryptZcodeCredential, + makeZcodeRequestAuth, resolveZcodeCredentialSecret, zcodeAccountScope, zcodeFamilyFromProviderId, } from "./zcodeRequestAuth.ts"; +import { zcodeRecordField, zcodeRecordString } from "./zcodeProtocol.ts"; /** Produces store values the CLI's fallback cipher would hold, keyed the same way. */ const encryptZcodeCredential = (value: string, secret: string): string => { @@ -109,3 +117,102 @@ describe("zcodeAccountScope", () => { ); }); }); + +describe("makeZcodeRequestAuth", () => { + /** A temp data root with the zai login on disk plus an HTTP fake answering the mint chain. */ + const harness = Effect.fnUntraced(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const root = yield* fileSystem.makeTempDirectoryScoped({ prefix: "zcode-request-auth-test-" }); + yield* fileSystem.makeDirectory(`${root}/.zcode/v2`, { recursive: true }); + const platform = yield* HostProcess.Platform; + const environment = { ZCODE_DATA_BASE_DIR: root, HOME: "/home/dev", USER: "dev" }; + const secret = resolveZcodeCredentialSecret(environment, platform); + const writeLogin = (loginToken: string | undefined) => + fileSystem.writeFileString( + `${root}/.zcode/v2/credentials.json`, + JSON.stringify( + loginToken === undefined + ? {} + : { "oauth:zai:access_token": encryptZcodeCredential(loginToken, secret) }, + ), + ); + yield* writeLogin("login-a"); + const exchangeLogins: Array = []; + const httpClient = HttpClient.make((request, url) => { + const ok = (data: unknown) => + Effect.succeed(HttpClientResponse.fromWeb(request, Response.json({ code: 200, data }))); + if (url.pathname.endsWith("/api/biz/customer/getCustomerInfo")) { + return ok({ + organizations: [ + { + organizationId: "org-1", + organizationName: "默认机构", + projects: [{ projectId: "proj-1", projectName: "默认项目" }], + }, + ], + }); + } + if (url.pathname.endsWith("/api_keys")) { + return ok([{ name: "zcode-api-key", apiKey: "key-1" }]); + } + exchangeLogins.push((request.headers.authorization ?? "").replace(/^Bearer /, "")); + return ok({ accessToken: `access-token-${exchangeLogins.length}` }); + }); + return { + auth: makeZcodeRequestAuth({ fileSystem, httpClient, environment, clientVersion: "test" }), + writeLogin, + exchangeLogins, + }; + }); + + const requestAuthOf = (response: unknown): Record => + (zcodeRecordField(response, "requestAuth") ?? {}) as Record; + + it.effect("serves a cache hit while the family's credentials stay the same", () => + Effect.gen(function* () { + const h = yield* harness(); + const first = yield* h.auth.answerRuntimeHeaders({ family: "zai" }); + assert.equal(zcodeRecordString(requestAuthOf(first), "apiKey"), "access-token-1"); + assert.equal( + zcodeRecordString(requestAuthOf(first), "accountScope"), + zcodeAccountScope("zai", "login-a", undefined), + ); + // The CLI may rewrite the store with a fresh IV for the same token; the credential scope, + // not the ciphertext, must decide validity. + yield* h.writeLogin("login-a"); + const second = yield* h.auth.answerRuntimeHeaders({ family: "zai" }); + assert.equal(zcodeRecordString(requestAuthOf(second), "apiKey"), "access-token-1"); + assert.deepEqual(h.exchangeLogins, ["login-a"]); + }).pipe(Effect.provide(NodeServices.layer)), + ); + + it.effect("re-mints against the new account when the family's login changes", () => + Effect.gen(function* () { + const h = yield* harness(); + const first = yield* h.auth.answerRuntimeHeaders({ family: "zai" }); + assert.equal(zcodeRecordString(requestAuthOf(first), "apiKey"), "access-token-1"); + yield* h.writeLogin("login-b"); + const second = yield* h.auth.answerRuntimeHeaders({ family: "zai" }); + assert.equal(zcodeRecordString(requestAuthOf(second), "apiKey"), "access-token-2"); + assert.equal( + zcodeRecordString(requestAuthOf(second), "accountScope"), + zcodeAccountScope("zai", "login-b", undefined), + ); + assert.equal(zcodeRecordString(requestAuthOf(second), "apiKeyId"), "key-1"); + assert.deepEqual(h.exchangeLogins, ["login-a", "login-b"]); + }).pipe(Effect.provide(NodeServices.layer)), + ); + + it.effect("declines once the family's login disappears", () => + Effect.gen(function* () { + const h = yield* harness(); + const first = yield* h.auth.answerRuntimeHeaders({ family: "zai" }); + assert.equal(zcodeRecordString(requestAuthOf(first), "apiKey"), "access-token-1"); + yield* h.writeLogin(undefined); + const second = yield* h.auth.answerRuntimeHeaders({ family: "zai" }); + assert.equal(zcodeRecordField(second, "headersApplied"), false); + assert.match(zcodeRecordString(second, "errorMessage") ?? "", /no OAuth login for family/); + assert.deepEqual(h.exchangeLogins, ["login-a"]); + }).pipe(Effect.provide(NodeServices.layer)), + ); +}); diff --git a/packages/provider-zcode/src/server/zcodeRequestAuth.ts b/packages/provider-zcode/src/server/zcodeRequestAuth.ts index 1a3bdcbaa63a0..ce5cf2e22f5f2 100644 --- a/packages/provider-zcode/src/server/zcodeRequestAuth.ts +++ b/packages/provider-zcode/src/server/zcodeRequestAuth.ts @@ -114,27 +114,41 @@ const readDecryptedCredentialStore = Effect.fn("ZcodeRequestAuth.readCredentialS return entries; }); -/** Families whose 3.15+ connection is an active OAuth individual coding plan. */ -const readOAuthSelectedFamilies = Effect.fn("ZcodeRequestAuth.readOAuthFamilies")(function* ( - fileSystem: FileSystem.FileSystem, - settingPath: string, - store: Record, -): Effect.fn.Return> { - const raw = yield* fileSystem.readFileString(settingPath).pipe(Effect.option); - const parsed = Option.isNone(raw) ? undefined : Option.getOrUndefined(decodeJsonText(raw.value)); - if (!Predicate.isObject(parsed)) return new Set(); - const selections = parsed["providerFamilyConnectionSelections"]; - const modes = parsed["modelProviderFamilyModes"]; +/** + * Families the provisioning ladder treats as signed-in OAuth individual coding plans: setting.json + * selects the plan, no apiKey override, and the credential store holds a login token for the + * family. The provider status probe derives its auth display from this same predicate so the + * probe and provisioning cannot drift. + */ +export const zcodeOAuthSelectedFamilies = ( + setting: unknown, + store: Record, +): ReadonlySet => { + if (!Predicate.isObject(setting)) return new Set(); + const selections = setting["providerFamilyConnectionSelections"]; + const modes = setting["modelProviderFamilyModes"]; const families = new Set(); if (!Predicate.isObject(selections)) return families; for (const [family, selection] of Object.entries(selections)) { const kind = Predicate.isObject(selection) ? selection["kind"] : undefined; if (kind !== "individual-coding-plan") continue; if (Predicate.isObject(modes) && modes[family] === "apiKey") continue; - if (!store[`oauth:${family}:access_token`]) continue; + const token = store[`oauth:${family}:access_token`]; + if (typeof token !== "string" || token.length === 0) continue; families.add(family); } return families; +}; + +/** Families whose 3.15+ connection is an active OAuth individual coding plan. */ +const readOAuthSelectedFamilies = Effect.fn("ZcodeRequestAuth.readOAuthFamilies")(function* ( + fileSystem: FileSystem.FileSystem, + settingPath: string, + store: Record, +): Effect.fn.Return> { + const raw = yield* fileSystem.readFileString(settingPath).pipe(Effect.option); + const parsed = Option.isNone(raw) ? undefined : Option.getOrUndefined(decodeJsonText(raw.value)); + return zcodeOAuthSelectedFamilies(parsed, store); }); export type ZcodeAccountProviderPush = { @@ -391,6 +405,24 @@ export const makeZcodeRequestAuth = (options: ZcodeRequestAuthOptions): ZcodeReq const credentialsPath = `${root}/.zcode/v2/credentials.json`; const cache = new Map(); + /** The family's current login with the credential scope digesting it; undefined once gone. */ + const currentLogin = Effect.fn("ZcodeRequestAuth.currentLogin")(function* ( + family: string, + ): Effect.fn.Return<{ loginToken: string; accountScope: string } | undefined> { + const store = yield* readDecryptedCredentialStore( + options.fileSystem, + credentialsPath, + options.environment, + yield* HostProcess.Platform, + ); + const loginToken = store[`oauth:${family}:access_token`]; + if (loginToken === undefined || loginToken.length === 0) return undefined; + return { + loginToken, + accountScope: zcodeAccountScope(family, loginToken, store[ZCODE_JWT_CREDENTIAL_KEY]), + }; + }); + const requestJson = (request: HttpClientRequest.HttpClientRequest) => options.httpClient.execute(request).pipe( Effect.flatMap(HttpClientResponse.filterStatusOk), @@ -414,14 +446,8 @@ export const makeZcodeRequestAuth = (options: ZcodeRequestAuthOptions): ZcodeReq const mint = Effect.fn("ZcodeRequestAuth.mint")(function* ( family: string, ): Effect.fn.Return { - const store = yield* readDecryptedCredentialStore( - options.fileSystem, - credentialsPath, - options.environment, - yield* HostProcess.Platform, - ); - const loginToken = store[`oauth:${family}:access_token`]; - if (loginToken === undefined || loginToken.length === 0) { + const login = yield* currentLogin(family); + if (login === undefined) { return yield* failAuth("no OAuth login for family"); } const origin = FAMILY_ORIGIN[family]; @@ -431,7 +457,7 @@ export const makeZcodeRequestAuth = (options: ZcodeRequestAuthOptions): ZcodeReq const customer = yield* envelopeData( yield* requestJson( HttpClientRequest.get(`${origin}/api/biz/customer/getCustomerInfo`, { - headers: { Authorization: loginToken }, + headers: { Authorization: login.loginToken }, }), ), "getCustomerInfo", @@ -484,7 +510,7 @@ export const makeZcodeRequestAuth = (options: ZcodeRequestAuthOptions): ZcodeReq yield* requestJson( HttpClientRequest.get(keysPath, { headers: { - Authorization: family === "zai" ? `Bearer ${loginToken}` : loginToken, + Authorization: family === "zai" ? `Bearer ${login.loginToken}` : login.loginToken, }, }), ), @@ -501,7 +527,7 @@ export const makeZcodeRequestAuth = (options: ZcodeRequestAuthOptions): ZcodeReq } const exchangeRequest = HttpClientRequest.post( `${keysPath}/${encodeURIComponent(apiKeyId)}/access_tokens`, - { headers: { Authorization: `Bearer ${loginToken}` } }, + { headers: { Authorization: `Bearer ${login.loginToken}` } }, ).pipe( HttpClientRequest.bodyText( zcodeJsonStringify({ clientType: "zcode", clientVersion: options.clientVersion }), @@ -519,7 +545,7 @@ export const makeZcodeRequestAuth = (options: ZcodeRequestAuthOptions): ZcodeReq const cachedToken: CachedProjectToken = { token, apiKeyId, - accountScope: zcodeAccountScope(family, loginToken, store[ZCODE_JWT_CREDENTIAL_KEY]), + accountScope: login.accountScope, fingerprint: sha256Hex(token), refreshAtMillis: DateTime.toEpochMillis(yield* DateTime.now) + TOKEN_TTL_MS - TOKEN_REFRESH_MARGIN_MS, @@ -533,8 +559,13 @@ export const makeZcodeRequestAuth = (options: ZcodeRequestAuthOptions): ZcodeReq readonly rejectedProjectTokenFingerprint?: string; }): Effect.fn.Return { const hit = cache.get(input.family); + // A CLI login can swap accounts or log out under the same family; the cached scope digests + // the credentials, so reread them and drop the hit when they no longer match. + const currentScope = + hit === undefined ? undefined : (yield* currentLogin(input.family))?.accountScope; const cached = hit !== undefined && + currentScope === hit.accountScope && DateTime.toEpochMillis(yield* DateTime.now) < hit.refreshAtMillis && (input.rejectedProjectTokenFingerprint === undefined || input.rejectedProjectTokenFingerprint !== hit.fingerprint) From b369954b6e345f1eab0ee4a84585650770689b20 Mon Sep 17 00:00:00 2001 From: panudetjt <3482285+panudetjt@users.noreply.github.com> Date: Tue, 10 Nov 2026 14:08:45 +0700 Subject: [PATCH 10/23] fix(provider-zcode): derive provider status from provisioning credentials The status probe required an oauth:active_provider marker that provisioning never sets, so a credential object with an individual-coding-plan selection was reported unauthenticated with a spurious 'zcode login' instruction. Auth now derives from the same predicate provisioning uses. Data paths also fall back to the OS home (USERPROFILE on Windows) when HOME is unset, so the probe reads the store ZCode actually uses. Model: GLM-5.3-Flash via ZCode. Harness: ZCode. --- .../provider-zcode/src/server/status.test.ts | 145 ++++++++++++++++-- packages/provider-zcode/src/server/status.ts | 38 +++-- 2 files changed, 159 insertions(+), 24 deletions(-) diff --git a/packages/provider-zcode/src/server/status.test.ts b/packages/provider-zcode/src/server/status.test.ts index 395b448b2ed42..decbbb7e669cb 100644 --- a/packages/provider-zcode/src/server/status.test.ts +++ b/packages/provider-zcode/src/server/status.test.ts @@ -1,14 +1,26 @@ +import * as NodeOS from "node:os"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, describe, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Schema from "effect/Schema"; +import { vi } from "vite-plus/test"; import { writeFakeCli } from "@t3tools/provider-testing/fakeCli"; import { ZcodeSettings } from "../settings.ts"; import { checkZcodeProviderStatus, zcodeAuthFromCredentials } from "./status.ts"; +// os.homedir()'s ESM namespace cannot be spied on, so tests stub it for the Windows-shaped env cases. +const homedirStub = vi.hoisted(() => ({ value: undefined as string | undefined })); +vi.mock("node:os", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + homedir: () => homedirStub.value ?? actual.homedir(), + }; +}); + const settings = Schema.decodeSync(ZcodeSettings); const BASE_ENV = { PATH: process.env.PATH }; @@ -17,6 +29,12 @@ const credentialsWithActiveProvider = JSON.stringify([ ["oauth:account:zai-individual-coding-plan", { accessToken: "redacted" }], ]); +const settingWithZaiPlan = JSON.stringify({ + providerFamilyConnectionSelections: { zai: { kind: "individual-coding-plan" } }, +}); + +const credentialsWithZaiToken = JSON.stringify({ "oauth:zai:access_token": "enc:v1:redacted" }); + const fakeDoctor = (source: string) => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -24,32 +42,60 @@ const fakeDoctor = (source: string) => return writeFakeCli({ directory, name: "zcode", source }); }); -const dataDirWithCredentials = (credentials: string | null) => +const dataDirWithCredentials = (credentials: string | null, setting?: string) => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const root = yield* fs.makeTempDirectoryScoped({ prefix: "zcode-credentials-test-" }); if (credentials !== null) { yield* fs.makeDirectory(`${root}/.zcode/v2`, { recursive: true }); yield* fs.writeFileString(`${root}/.zcode/v2/credentials.json`, credentials); + if (setting !== undefined) { + yield* fs.writeFileString(`${root}/.zcode/v2/setting.json`, setting); + } } return root; }); describe("zcodeAuthFromCredentials", () => { - it("reads the credential store's active provider", () => { + it("derives object-store auth from the provisioning entitlement ladder", () => { + assert.equal( + zcodeAuthFromCredentials(JSON.parse(credentialsWithActiveProvider), undefined), + "authenticated", + ); + assert.equal(zcodeAuthFromCredentials([], undefined), "unauthenticated"); + assert.equal(zcodeAuthFromCredentials([["other", "value"]], undefined), "unauthenticated"); + assert.equal( + zcodeAuthFromCredentials([["oauth:active_provider", ""]], undefined), + "unauthenticated", + ); + assert.equal(zcodeAuthFromCredentials(["oauth:active_provider"], undefined), "unauthenticated"); + assert.equal(zcodeAuthFromCredentials(undefined, undefined), "unknown"); + // Object stores are judged like provisioning: a family credential counts only with its + // individual-coding-plan selection; the oauth:active_provider marker is not consulted. + assert.equal(zcodeAuthFromCredentials({}, undefined), "unauthenticated"); + assert.equal(zcodeAuthFromCredentials({ oauth: true }, undefined), "unauthenticated"); + assert.equal( + zcodeAuthFromCredentials({ "oauth:active_provider": "zai" }, undefined), + "unauthenticated", + ); assert.equal( - zcodeAuthFromCredentials(JSON.parse(credentialsWithActiveProvider)), + zcodeAuthFromCredentials(JSON.parse(credentialsWithZaiToken), JSON.parse(settingWithZaiPlan)), "authenticated", ); - assert.equal(zcodeAuthFromCredentials([]), "unauthenticated"); - assert.equal(zcodeAuthFromCredentials([["other", "value"]]), "unauthenticated"); - assert.equal(zcodeAuthFromCredentials([["oauth:active_provider", ""]]), "unauthenticated"); - assert.equal(zcodeAuthFromCredentials(["oauth:active_provider"]), "unauthenticated"); - assert.equal(zcodeAuthFromCredentials(undefined), "unknown"); - // A readable store without an active provider is a not-signed-in store, whatever the shape. - assert.equal(zcodeAuthFromCredentials({}), "unauthenticated"); - assert.equal(zcodeAuthFromCredentials({ oauth: true }), "unauthenticated"); - assert.equal(zcodeAuthFromCredentials({ "oauth:active_provider": "zai" }), "authenticated"); + assert.equal( + zcodeAuthFromCredentials(JSON.parse(credentialsWithZaiToken), undefined), + "unauthenticated", + ); + assert.equal( + zcodeAuthFromCredentials( + JSON.parse(credentialsWithZaiToken), + JSON.parse( + '{"providerFamilyConnectionSelections":{"zai":{"kind":"individual-coding-plan"}},"modelProviderFamilyModes":{"zai":"apiKey"}}', + ), + ), + "unauthenticated", + ); + assert.equal(zcodeAuthFromCredentials({}, JSON.parse(settingWithZaiPlan)), "unauthenticated"); }); }); @@ -75,6 +121,61 @@ it.layer(NodeServices.layer)("Zcode status", (it) => { ), ); + it.effect("finds credentials through the OS home when only USERPROFILE is set", () => + Effect.scoped( + Effect.gen(function* () { + const binaryPath = yield* fakeDoctor( + 'process.stdout.write(\'{"cli":{"version":"3.17.2"}}\');process.exit(0);', + ); + const dataDir = yield* dataDirWithCredentials(credentialsWithActiveProvider); + yield* Effect.acquireRelease( + Effect.sync(() => { + homedirStub.value = dataDir; + }), + () => + Effect.sync(() => { + homedirStub.value = undefined; + }), + ); + const snapshot = yield* checkZcodeProviderStatus(settings({ enabled: true, binaryPath }), { + ...BASE_ENV, + USERPROFILE: dataDir, + }); + assert.equal(snapshot.installed, true); + assert.equal(snapshot.version, "3.17.2"); + assert.equal(snapshot.status, "ready"); + assert.equal(snapshot.auth.status, "authenticated"); + assert.equal(snapshot.message, undefined); + }), + ), + ); + + it.effect("still prefers HOME over the OS home", () => + Effect.scoped( + Effect.gen(function* () { + const binaryPath = yield* fakeDoctor( + 'process.stdout.write(\'{"cli":{"version":"3.17.2"}}\');process.exit(0);', + ); + const dataDir = yield* dataDirWithCredentials(credentialsWithActiveProvider); + yield* Effect.acquireRelease( + Effect.sync(() => { + homedirStub.value = `${dataDir}/elsewhere`; + }), + () => + Effect.sync(() => { + homedirStub.value = undefined; + }), + ); + const snapshot = yield* checkZcodeProviderStatus(settings({ enabled: true, binaryPath }), { + ...BASE_ENV, + HOME: dataDir, + }); + assert.equal(snapshot.status, "ready"); + assert.equal(snapshot.auth.status, "authenticated"); + }), + ), + ); + it.effect("flags a signed-out CLI as warning", () => Effect.scoped( Effect.gen(function* () { @@ -95,6 +196,26 @@ it.layer(NodeServices.layer)("Zcode status", (it) => { ), ); + it.effect("accepts the plan-selected family credential provisioning accepts", () => + Effect.scoped( + Effect.gen(function* () { + const binaryPath = yield* fakeDoctor( + 'process.stdout.write(\'{"cli":{"version":"3.17.2"}}\');process.exit(0);', + ); + const dataDir = yield* dataDirWithCredentials(credentialsWithZaiToken, settingWithZaiPlan); + const snapshot = yield* checkZcodeProviderStatus(settings({ enabled: true, binaryPath }), { + ...BASE_ENV, + ZCODE_DATA_BASE_DIR: dataDir, + }); + assert.equal(snapshot.installed, true); + assert.equal(snapshot.version, "3.17.2"); + assert.equal(snapshot.status, "ready"); + assert.equal(snapshot.auth.status, "authenticated"); + assert.equal(snapshot.message, undefined); + }), + ), + ); + it.effect("falls back to plain stdout when doctor output is not JSON", () => Effect.scoped( Effect.gen(function* () { diff --git a/packages/provider-zcode/src/server/status.ts b/packages/provider-zcode/src/server/status.ts index e11aadb42ac5f..7955e64ff444d 100644 --- a/packages/provider-zcode/src/server/status.ts +++ b/packages/provider-zcode/src/server/status.ts @@ -1,3 +1,4 @@ +import * as NodeOS from "node:os"; import { type CustomModelSetting, type ModelCapabilities, @@ -5,7 +6,7 @@ import { } from "@t3tools/contracts"; import type { ZcodeSettings } from "../settings.ts"; import { resolveZcodeCatalogFiles, resolveZcodeLaunchCommand } from "./zcodeRuntime.ts"; -import { zcodeEntitledModels } from "./zcodeRequestAuth.ts"; +import { zcodeEntitledModels, zcodeOAuthSelectedFamilies } from "./zcodeRequestAuth.ts"; import { resolveSpawnCommand } from "@t3tools/shared/shell"; import { compareSemverVersions } from "@t3tools/shared/semver"; import { createModelCapabilities } from "@t3tools/shared/model"; @@ -133,9 +134,14 @@ const runZcodeDoctor = Effect.fn("ZcodeProviderStatus.runDoctor")(function* ( ); }); -/** Auth is CLI-managed (`zcode login`); the probe only reads the credential store's active provider. */ +/** + * Auth is CLI-managed (`zcode login`); the probe derives status from the same credential-store + * keys and setting.json plan selections the provisioning ladder accepts, so an account that can + * make ZCode requests never shows a false `zcode login` warning. + */ export const zcodeAuthFromCredentials = ( data: unknown, + setting: unknown, ): "authenticated" | "unauthenticated" | "unknown" => { if (Array.isArray(data)) { const hasActiveProvider = data.some( @@ -147,32 +153,39 @@ export const zcodeAuthFromCredentials = ( ); return hasActiveProvider ? "authenticated" : "unauthenticated"; } - // The CLI store is a JSON object keyed by credential id. + // Object stores are judged like provisioning, which ignores any oauth:active_provider marker. if (typeof data === "object" && data !== null) { - const active = (data as Record)["oauth:active_provider"]; - if (active === undefined) return "unauthenticated"; - return typeof active === "string" && active.length > 0 ? "authenticated" : "unknown"; + return zcodeOAuthSelectedFamilies(setting, data as Record).size > 0 + ? "authenticated" + : "unauthenticated"; } return "unknown"; }; -const decodeCredentials = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Unknown)); +const decodeStoreJson = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Unknown)); const readZcodeAuthStatus = Effect.fn("ZcodeProviderStatus.readAuthStatus")(function* ( fileSystem: FileSystem.FileSystem, credentialsPath: string, + settingPath: string, ): Effect.fn.Return<"authenticated" | "unauthenticated" | "unknown", never, never> { const raw = yield* fileSystem.readFile(credentialsPath).pipe( Effect.map((bytes) => new TextDecoder().decode(bytes)), Effect.orElseSucceed(() => undefined), ); if (raw === undefined) return "unauthenticated" as const; - return zcodeAuthFromCredentials(Option.getOrUndefined(decodeCredentials(raw))); + const setting = yield* fileSystem.readFileString(settingPath).pipe(Effect.option); + return zcodeAuthFromCredentials( + Option.getOrUndefined(decodeStoreJson(raw)), + Option.isNone(setting) ? undefined : Option.getOrUndefined(decodeStoreJson(setting.value)), + ); }); -const resolveZcodeCredentialsPath = (environment: NodeJS.ProcessEnv): string => { - const dataBaseDir = environment.ZCODE_DATA_BASE_DIR?.trim() || environment.HOME || ""; - return `${dataBaseDir}/.zcode/v2/credentials.json`; +const resolveZcodeDataPath = (environment: NodeJS.ProcessEnv, file: string): string => { + // Windows runs without HOME; os.homedir() (USERPROFILE there) keeps the probe on the real store. + const dataBaseDir = + environment.ZCODE_DATA_BASE_DIR?.trim() || environment.HOME || NodeOS.homedir(); + return `${dataBaseDir}/.zcode/v2/${file}`; }; export const buildInitialZcodeProviderSnapshot = Effect.fn( @@ -338,7 +351,8 @@ export const checkZcodeProviderStatus = Effect.fn("checkZcodeProviderStatus")(fu const authStatus = yield* readZcodeAuthStatus( fileSystem, - resolveZcodeCredentialsPath(environment), + resolveZcodeDataPath(environment, "credentials.json"), + resolveZcodeDataPath(environment, "setting.json"), ); // Enumerate the picker from the CLI's own catalog + entitlement ladder; fall back to the // default-only list when the catalog files are absent (fresh installs before first login). From 149691083dc8e2e8c9dffbbc9618495883edb195 Mon Sep 17 00:00:00 2001 From: panudetjt <3482285+panudetjt@users.noreply.github.com> Date: Tue, 10 Nov 2026 14:13:22 +0700 Subject: [PATCH 11/23] fix(provider-zcode): resolve the CLI data root and PATH probe on every platform zcodeDataRoot returned undefined when both ZCODE_DATA_BASE_DIR and HOME were absent (the normal Windows case), skipping catalog discovery and the account push, so plan models went missing from the picker; it now falls back to os.homedir(). The PATH probe split on ':' on every platform, missing ';'-separated Windows entries and letting the desktop shim shadow the PATH installation; it now splits on the platform delimiter. Model: GLM-5.3-Flash via ZCode. Harness: ZCode. --- .../src/server/zcodeRuntime.test.ts | 53 ++++++++++++++++--- .../provider-zcode/src/server/zcodeRuntime.ts | 13 +++-- 2 files changed, 53 insertions(+), 13 deletions(-) diff --git a/packages/provider-zcode/src/server/zcodeRuntime.test.ts b/packages/provider-zcode/src/server/zcodeRuntime.test.ts index 25242e099f87f..a9bf0a175d4f2 100644 --- a/packages/provider-zcode/src/server/zcodeRuntime.test.ts +++ b/packages/provider-zcode/src/server/zcodeRuntime.test.ts @@ -3,6 +3,7 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import * as HostProcess from "@t3tools/shared/HostProcess"; import { assert, describe, it } from "@effect/vitest"; import * as NodeCrypto from "node:crypto"; +import * as NodeOS from "node:os"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; @@ -144,7 +145,9 @@ describe("zcodeBuiltinCatalogPath", () => { ); assert.equal(zcodeDataRoot({ HOME: "/home/dev" }), "/home/dev"); assert.equal(zcodeDataRoot({ ZCODE_DATA_BASE_DIR: " /tmp/z ", HOME: "/home/dev" }), "/tmp/z"); - assert.equal(zcodeDataRoot({}), undefined); + // Windows runs without HOME or ZCODE_DATA_BASE_DIR; the OS home keeps catalog discovery alive. + assert.equal(zcodeDataRoot({}), NodeOS.homedir()); + assert.equal(zcodeDataRoot({ ZCODE_DATA_BASE_DIR: " ", HOME: "" }), NodeOS.homedir()); }); }); @@ -201,6 +204,46 @@ describe("resolveZcodeLaunchCommand", () => { assert.equal(command, "zcode"); }).pipe(Effect.provide(NodeServices.layer)), ); + + it.effect("prefers a colon-separated PATH hit over the desktop shim on POSIX", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const home = yield* fileSystem.makeTempDirectory(); + yield* fileSystem.makeDirectory(`${home}/.zcode/server/bin`, { recursive: true }); + yield* fileSystem.writeFileString(`${home}/.zcode/server/bin/zcode`, "#!/bin/sh\n"); + const onPath = yield* fileSystem.makeTempDirectory(); + yield* fileSystem.writeFileString(`${onPath}/zcode`, "#!/bin/sh\n"); + const command = yield* resolveZcodeLaunchCommand( + { binaryPath: "zcode" }, + { HOME: home, PATH: `${onPath}:/nonexistent` }, + fileSystem, + ); + assert.equal(command, "zcode"); + }).pipe( + Effect.provideService(HostProcess.Platform, "linux"), + Effect.provide(NodeServices.layer), + ), + ); + + it.effect("splits PATH on ; on Windows so a PATH install beats the desktop shim", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const home = yield* fileSystem.makeTempDirectory(); + yield* fileSystem.makeDirectory(`${home}/.zcode/server/bin`, { recursive: true }); + yield* fileSystem.writeFileString(`${home}/.zcode/server/bin/zcode`, "#!/bin/sh\n"); + const onPath = yield* fileSystem.makeTempDirectory(); + yield* fileSystem.writeFileString(`${onPath}/zcode.exe`, "binary"); + const command = yield* resolveZcodeLaunchCommand( + { binaryPath: "zcode" }, + { HOME: home, PATH: `C:\\nonexistent;${onPath}` }, + fileSystem, + ); + assert.equal(command, "zcode"); + }).pipe( + Effect.provideService(HostProcess.Platform, "win32"), + Effect.provide(NodeServices.layer), + ), + ); }); describe("zcodeRuntimeEnvironment", () => { @@ -309,7 +352,7 @@ describe("zcodeAccountConfigPushFromLadder", () => { ).pipe(Effect.provide(NodeServices.layer)), ); - it.effect("returns undefined without a usable catalog or data root", () => + it.effect("returns undefined without a usable catalog", () => Effect.scoped( Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -319,12 +362,6 @@ describe("zcodeAccountConfigPushFromLadder", () => { environment: { HOME: "/home/dev" }, }); assert.equal(noRules, undefined); - const noRoot = yield* zcodeAccountConfigPushFromLadder(fs, { - builtinPath: "/tmp/builtin.json", - builtinCatalog: inlineCatalog, - environment: {}, - }); - assert.equal(noRoot, undefined); const apiOnly = yield* zcodeAccountConfigPushFromLadder(fs, { builtinPath: "/tmp/builtin.json", builtinCatalog: { diff --git a/packages/provider-zcode/src/server/zcodeRuntime.ts b/packages/provider-zcode/src/server/zcodeRuntime.ts index 56279573274cb..46a9c30bb90db 100644 --- a/packages/provider-zcode/src/server/zcodeRuntime.ts +++ b/packages/provider-zcode/src/server/zcodeRuntime.ts @@ -3,6 +3,7 @@ // account config push that makes catalog models selectable, and T3 model slug mapping. // Everything protocol-shaped here mirrors the Phase 0 probe notes (PROTOCOL-NOTES.md). import * as NodeCrypto from "node:crypto"; +import * as NodeOS from "node:os"; import * as HostProcess from "@t3tools/shared/HostProcess"; import * as Effect from "effect/Effect"; @@ -50,8 +51,10 @@ export function zcodePlatformDir(platform: NodeJS.Platform, arch: string): strin } export function zcodeDataRoot(environment: NodeJS.ProcessEnv): string | undefined { - const root = environment.ZCODE_DATA_BASE_DIR?.trim() || environment.HOME; - return root === undefined || root.length === 0 ? undefined : root; + // Windows often runs without HOME, so os.homedir() (USERPROFILE there) keeps catalog + // discovery and the shim lookup alive. + const root = environment.ZCODE_DATA_BASE_DIR?.trim() || environment.HOME || NodeOS.homedir(); + return root.length === 0 ? undefined : root; } export function zcodeBuiltinCatalogPath(input: { @@ -202,8 +205,8 @@ export const resolveZcodeLaunchCommand = Effect.fn("ZcodeRuntime.resolveLaunchCo if (explicit.length > 0 && explicit !== DEFAULT_ZCODE_BINARY) return explicit; const onPath = yield* zcodeOnPath(environment, fileSystem); if (onPath) return DEFAULT_ZCODE_BINARY; - const home = environment.ZCODE_DATA_BASE_DIR?.trim() || environment.HOME; - if (home === undefined || home === "") return DEFAULT_ZCODE_BINARY; + const home = zcodeDataRoot(environment); + if (home === undefined) return DEFAULT_ZCODE_BINARY; const desktopShim = `${home}/.zcode/server/bin/zcode`; const shimExists = yield* fileSystem.exists(desktopShim).pipe(Effect.orElseSucceed(() => false)); return shimExists ? desktopShim : DEFAULT_ZCODE_BINARY; @@ -216,7 +219,7 @@ const zcodeOnPath = Effect.fn("ZcodeRuntime.zcodeOnPath")(function* ( const pathValue = environment.PATH ?? ""; const platform = yield* HostProcess.Platform; const suffixes = platform === "win32" ? ["zcode.exe", "zcode.cmd"] : ["zcode"]; - for (const dir of pathValue.split(":")) { + for (const dir of pathValue.split(platform === "win32" ? ";" : ":")) { if (dir === "") continue; for (const suffix of suffixes) { const found = yield* fileSystem From 8caad4c7d1484e2c42eb1a27b5378102603334a5 Mon Sep 17 00:00:00 2001 From: panudetjt <3482285+panudetjt@users.noreply.github.com> Date: Tue, 10 Nov 2026 14:14:08 +0700 Subject: [PATCH 12/23] fix(provider-zcode): fail the transport after CLI exit and report request errors When a tool child kept stdout open after the CLI exited, the transport never failed and an active turn could hang forever; an exit drain-grace watcher now fails the transport independently of EOF. On POSIX, termination skipped the SIGKILL escalation once the leader exited during the grace window, leaving tool processes alive; the process group keeps being probed after exit. Request failures always reported the operation as "request" because response records carry no method; pending requests now carry it. Error details copied up to 600 chars of raw server payload into the message; detail is now bounded metadata and the payload lives only in cause. Model: GLM-5.3-Flash via ZCode. Harness: ZCode. --- .../src/server/zcodeProtocol.test.ts | 249 ++++++++++++++++++ .../src/server/zcodeProtocol.ts | 58 +++- 2 files changed, 295 insertions(+), 12 deletions(-) create mode 100644 packages/provider-zcode/src/server/zcodeProtocol.test.ts diff --git a/packages/provider-zcode/src/server/zcodeProtocol.test.ts b/packages/provider-zcode/src/server/zcodeProtocol.test.ts new file mode 100644 index 0000000000000..aa02b7350764e --- /dev/null +++ b/packages/provider-zcode/src/server/zcodeProtocol.test.ts @@ -0,0 +1,249 @@ +import { assert, describe, it } from "@effect/vitest"; +import { expect, vi } from "vite-plus/test"; +import * as Cause from "effect/Cause"; +import * as Deferred from "effect/Deferred"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Fiber from "effect/Fiber"; +import * as Option from "effect/Option"; +import * as Queue from "effect/Queue"; +import * as Sink from "effect/Sink"; +import * as Scope from "effect/Scope"; +import * as Stream from "effect/Stream"; +import * as TestClock from "effect/testing/TestClock"; +import { ChildProcessSpawner } from "effect/process"; +import * as HostProcess from "@t3tools/shared/HostProcess"; + +import { makeZcodeConnection, ZcodeProtocolError, type ZcodeRecord } from "./zcodeProtocol.ts"; + +// Mirrors the private EXIT_DRAIN_GRACE and TERMINATION_GRACE in zcodeProtocol.ts. +const EXIT_DRAIN_GRACE_MS = 1_000; +const TERMINATION_GRACE_MS = 1_000; + +const processGroupState = vi.hoisted(() => ({ + groupAlive: true, + signals: [] as Array, +})); + +vi.mock("@t3tools/provider-core/server/processGroup", () => ({ + signalProcessGroup: (pid: number, signal: NodeJS.Signals | 0) => { + if (!processGroupState.groupAlive) { + throw Object.assign(new Error(`kill ESRCH: no process group ${pid}`), { code: "ESRCH" }); + } + processGroupState.signals.push(signal); + }, +})); + +const setupProcessGroup = (groupAlive: boolean) => { + processGroupState.groupAlive = groupAlive; + processGroupState.signals = []; +}; + +const makeHeldOpenChild = Effect.fnUntraced(function* (scope?: Scope.Closeable) { + const exitCode = yield* Deferred.make(); + const stdout = yield* Queue.unbounded>(); + const stdinChunks = yield* Queue.unbounded(); + const handle = ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(55_000), + exitCode: Deferred.await(exitCode), + isRunning: Effect.succeed(true), + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + stdin: Sink.forEach((chunk: Uint8Array) => Queue.offer(stdinChunks, chunk).pipe(Effect.asVoid)), + stdout: Stream.fromQueue(stdout), + stderr: Stream.empty, + all: Stream.empty, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }); + const unscoped = makeZcodeConnection({ + command: "zcode", + args: ["app-server", "--stdio"], + cwd: undefined, + env: {}, + }).pipe( + Effect.provideService( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => Effect.succeed(handle)), + ), + Effect.provideService(HostProcess.Platform, "linux"), + ); + const connection = yield* scope === undefined ? unscoped : unscoped.pipe(Scope.provide(scope)); + return { connection, exitCode, stdout, stdinChunks }; +}); + +const takeSentRecord = Effect.fnUntraced(function* (stdinChunks: Queue.Dequeue) { + const line = new TextDecoder().decode(yield* Queue.take(stdinChunks)); + return JSON.parse(line) as ZcodeRecord; +}); + +describe("makeZcodeConnection", () => { + it.effect("fails the transport once the exit grace elapses with stdout still open", () => + Effect.scoped( + Effect.gen(function* () { + // Group modeled empty, so the scope-close termination below is a no-op. + setupProcessGroup(false); + const { connection, exitCode, stdout } = yield* makeHeldOpenChild(); + + yield* Deferred.succeed(exitCode, ChildProcessSpawner.ExitCode(0)); + assert.equal(yield* connection.exited, 0); + + // Records buffered when zcode died still drain while the grace runs. + yield* Queue.offer(stdout, new TextEncoder().encode('{"event":"turn.completed"}\n')); + const drained = yield* Queue.take(connection.events); + assert.equal(drained["event"], "turn.completed"); + + const down = yield* Deferred.make(); + yield* Queue.take(connection.events).pipe( + Effect.catch((error) => Deferred.succeed(down, error)), + Effect.forkScoped, + ); + yield* TestClock.adjust(Duration.millis(EXIT_DRAIN_GRACE_MS - 1)); + assert.isTrue(Option.isNone(yield* Deferred.poll(down))); + yield* TestClock.adjust(Duration.millis(1)); + const error = yield* Deferred.await(down); + assert.instanceOf(error, ZcodeProtocolError); + assert.equal(error.operation, "read"); + assert.equal(error.detail, "zcode app-server exited with code 0, but stdout never closed"); + }), + ), + ); + + it.effect("stdout closing after exit still reports the exit code", () => + Effect.scoped( + Effect.gen(function* () { + // Group modeled empty, so the scope-close termination below is a no-op. + setupProcessGroup(false); + const { connection, exitCode, stdout } = yield* makeHeldOpenChild(); + const down = yield* Deferred.make(); + yield* Queue.take(connection.events).pipe( + Effect.catch((error) => Deferred.succeed(down, error)), + Effect.forkScoped, + ); + + yield* Deferred.succeed(exitCode, ChildProcessSpawner.ExitCode(7)); + assert.equal(yield* connection.exited, 7); + yield* Queue.end(stdout); + + const error = yield* Deferred.await(down); + assert.equal(error.operation, "read"); + assert.equal(error.detail, "zcode app-server exited with code 7"); + }), + ), + ); + + it.effect("escalates to SIGKILL after the leader exits while tools keep the group alive", () => + Effect.scoped( + Effect.gen(function* () { + setupProcessGroup(true); + const scope = yield* Scope.make(); + const { connection, exitCode } = yield* makeHeldOpenChild(scope); + + yield* Deferred.succeed(exitCode, ChildProcessSpawner.ExitCode(0)); + assert.equal(yield* connection.exited, 0); + + // Detached tools keep the pgid alive after the leader is reaped; termination must + // still SIGTERM the group and, after the grace, SIGKILL the survivors. + const closed = yield* Scope.close(scope, Exit.void).pipe(Effect.forkScoped); + yield* TestClock.adjust(Duration.millis(TERMINATION_GRACE_MS)); + yield* Fiber.join(closed); + + // hasExited probes interleave signal 0; assert on the delivered signals only. + const sent = processGroupState.signals.filter((signal) => signal !== 0); + expect(sent).toEqual(["SIGTERM", "SIGKILL"]); + }), + ), + ); + + it.effect("skips group signalling once the leader exited and the group is empty", () => + Effect.scoped( + Effect.gen(function* () { + setupProcessGroup(false); + const scope = yield* Scope.make(); + const { connection, exitCode } = yield* makeHeldOpenChild(scope); + + yield* Deferred.succeed(exitCode, ChildProcessSpawner.ExitCode(0)); + assert.equal(yield* connection.exited, 0); + + // No survivors: termination returns without waiting the grace or signalling. + yield* Scope.close(scope, Exit.void); + + expect(processGroupState.signals).toEqual([]); + }), + ), + ); + + it.effect("a rejected request reports the real method with bounded detail", () => + Effect.scoped( + Effect.gen(function* () { + // Group modeled empty, so the scope-close termination below is a no-op. + setupProcessGroup(false); + const { connection, stdout, stdinChunks } = yield* makeHeldOpenChild(); + + const pending = yield* connection + .request("provider/updateAccountConfig", { accountId: "acct_1" }) + .pipe( + Effect.catch((error) => Effect.succeed(error)), + Effect.forkScoped, + ); + + const sent = yield* takeSentRecord(stdinChunks); + assert.equal(sent["method"], "provider/updateAccountConfig"); + + const payload = "SECRET_ZCODE_SERVER_PAYLOAD"; + const serverError = { code: "accountConfigRejected", message: payload }; + yield* Queue.offer( + stdout, + new TextEncoder().encode(`${JSON.stringify({ id: sent["id"], error: serverError })}\n`), + ); + + const error = yield* Fiber.join(pending); + assert.instanceOf(error, ZcodeProtocolError); + // Raw payload stays in cause only; message and detail carry bounded metadata. + assert.isFalse(`${error.detail}`.includes(payload)); + assert.isFalse(error.message.includes(payload)); + assert.equal( + error.detail, + `code accountConfigRejected, ${JSON.stringify(serverError).length}-char payload`, + ); + assert.include(JSON.stringify(error.cause), payload); + // The failure names the method that failed, not the generic "request". + assert.equal(error.operation, "provider/updateAccountConfig"); + }), + ), + ); + + it.effect("a string error payload also stays confined to cause", () => + Effect.scoped( + Effect.gen(function* () { + // Group modeled empty, so the scope-close termination below is a no-op. + setupProcessGroup(false); + const { connection, stdout, stdinChunks } = yield* makeHeldOpenChild(); + + const pending = yield* connection + .request("session/subscribe", { sessionId: "sess_1" }) + .pipe( + Effect.catch((error) => Effect.succeed(error)), + Effect.forkScoped, + ); + + const sent = yield* takeSentRecord(stdinChunks); + + const payload = "SECRET_BARE_ZCODE_PAYLOAD"; + yield* Queue.offer( + stdout, + new TextEncoder().encode(`${JSON.stringify({ id: sent["id"], error: payload })}\n`), + ); + + const error = yield* Fiber.join(pending); + assert.instanceOf(error, ZcodeProtocolError); + assert.isFalse(`${error.detail}`.includes(payload)); + assert.isFalse(error.message.includes(payload)); + assert.equal(error.detail, `${JSON.stringify(payload).length}-char payload`); + assert.include(JSON.stringify(error.cause), payload); + assert.equal(error.operation, "session/subscribe"); + }), + ), + ); +}); diff --git a/packages/provider-zcode/src/server/zcodeProtocol.ts b/packages/provider-zcode/src/server/zcodeProtocol.ts index e88401ca09e06..45a67c9a81588 100644 --- a/packages/provider-zcode/src/server/zcodeProtocol.ts +++ b/packages/provider-zcode/src/server/zcodeProtocol.ts @@ -87,9 +87,11 @@ export interface ZcodeConnection { const DEFAULT_REQUEST_TIMEOUT_MS = 30_000; const TERMINATION_GRACE = Duration.seconds(1); +const EXIT_DRAIN_GRACE = Duration.seconds(1); interface PendingZcodeRequest { readonly deferred: Deferred.Deferred; + readonly method: string; } const MAX_ZCODE_RECORD_CHARS = 16 * 1024 * 1024; @@ -149,14 +151,19 @@ export function zcodeJsonStringify(value: unknown): string { } } -const ZCODE_ERROR_DETAIL_MAX_CHARS = 600; - -function summarizeZcodeError(error: unknown): string { - const text = typeof error === "string" ? error : JSON.stringify(error); - if (text === undefined) return "unknown error"; - return text.length > ZCODE_ERROR_DETAIL_MAX_CHARS - ? `${text.slice(0, ZCODE_ERROR_DETAIL_MAX_CHARS)}…` - : text; +const ZCODE_SERVER_ERROR_CODE_MAX_CHARS = 100; + +// Bounded server-error metadata; the raw payload travels only in `cause`. +function describeZcodeServerError(error: unknown): string { + const code = zcodeRecordField(error, "code"); + const codeText = + typeof code === "string" || typeof code === "number" + ? `code ${String(code).slice(0, ZCODE_SERVER_ERROR_CODE_MAX_CHARS)}` + : undefined; + const payloadLength = JSON.stringify(error)?.length ?? 0; + return codeText === undefined + ? `${payloadLength}-char payload` + : `${codeText}, ${payloadLength}-char payload`; } function describeZcodeStdoutClosure(exitCode: number | undefined): string { @@ -224,7 +231,9 @@ export const makeZcodeConnection = Effect.fnUntraced(function* (options: ZcodeSp }; const hasExited = (): boolean => { - if (childExited) return true; + // A dead POSIX leader does not empty its group: detached tools keep the pgid alive and + // must stay reachable for the SIGKILL escalation. + if (childExited && platform === "win32") return true; try { if (platform === "win32") process.kill(Number(child.pid), 0); else signalProcessGroup(Number(child.pid), 0); @@ -288,8 +297,8 @@ export const makeZcodeConnection = Effect.fnUntraced(function* (options: ZcodeSp yield* Deferred.fail( pending.deferred, new ZcodeProtocolError({ - operation: String(record["method"] ?? "request"), - detail: summarizeZcodeError(record["error"]), + operation: pending.method, + detail: describeZcodeServerError(record["error"]), ...(record["error"] === undefined ? {} : { cause: record["error"] }), }), ); @@ -313,6 +322,31 @@ export const makeZcodeConnection = Effect.fnUntraced(function* (options: ZcodeSp Effect.forkIn(scope), ); + // A detached tool child can inherit stdout and keep it open after zcode exits, so stdout + // EOF never arrives and stdout closure cannot fail the transport. Give records buffered + // at death the grace window to drain, then fail the transport without EOF; the + // transportDown claim keeps a later stdout close from double-failing. + yield* Deferred.await(exitDeferred).pipe( + Effect.matchEffect({ + onFailure: (cause) => + Effect.sleep(EXIT_DRAIN_GRACE).pipe( + Effect.andThen(failTransport(new ZcodeProtocolError({ operation: "exit", cause }))), + ), + onSuccess: (exitCode) => + Effect.sleep(EXIT_DRAIN_GRACE).pipe( + Effect.andThen( + failTransport( + new ZcodeProtocolError({ + operation: "read", + detail: `zcode app-server exited with code ${exitCode}, but stdout never closed`, + }), + ), + ), + ), + }), + Effect.forkIn(scope), + ); + yield* Effect.gen(function* () { const frame = makeJsonlFramer(); yield* child.stdout.pipe( @@ -410,7 +444,7 @@ export const makeZcodeConnection = Effect.fnUntraced(function* (options: ZcodeSp ): Effect.fn.Return { const id = nextRequestId++; const deferred = yield* Deferred.make(); - pendingRequests.set(String(id), { deferred }); + pendingRequests.set(String(id), { deferred, method }); yield* send({ id, method, params }).pipe( Effect.tapError(() => Effect.sync(() => pendingRequests.delete(String(id)))), ); From 811236d2e504eb1a487284f984ad38b373b29322 Mon Sep 17 00:00:00 2001 From: panudetjt <3482285+panudetjt@users.noreply.github.com> Date: Tue, 10 Nov 2026 14:14:26 +0700 Subject: [PATCH 13/23] fix(provider-zcode): finalize failed turns and harden session and stream handling A session/send rejection cleared activeTurn in memory but left the persisted turn and session running once the start-error handler stopped the run subscriber; the error tap now records the failure and runs the full finalizeTurn sequence first. A session/compact rejection or timeout left the dead turn installed, blocking the thread with previous_turn_active; it now recovers into the existing failed-item and finalizeTurn path. interruptTurn kept the interrupted flag when session/stop rejected, misreporting the eventual terminal; it restores the previous flag on failure. Recovery turns (forceNewNativeThread) reused the cached native session, sending context into the old conversation; the cache hit and native-id fallback now bypass it. Text and reasoning deltas of one message shared a stream item key, so answer text streamed as reasoning; keys are per kind. The approval test assertion consumes the respond queue instead of racing the event loop. Model: GLM-5.3-Flash via ZCode. Harness: ZCode. --- .../Adapters/ZcodeAdapterV2.test.ts | 289 +++++++++++++++++- packages/provider-zcode/src/server/adapter.ts | 40 ++- 2 files changed, 314 insertions(+), 15 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/ZcodeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ZcodeAdapterV2.test.ts index 841ccd088f4ce..4589a145e1483 100644 --- a/apps/server/src/orchestration-v2/Adapters/ZcodeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ZcodeAdapterV2.test.ts @@ -18,6 +18,7 @@ import { } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; import * as Queue from "effect/Queue"; import * as Stream from "effect/Stream"; @@ -36,7 +37,11 @@ import { zcodeServerRequest, zcodeSessionEvent, } from "./ZcodeAdapterV2.testkit.ts"; -import type { ZcodeAdapterV2Options } from "@t3tools/provider-zcode/testing"; +import type { + ZcodeAdapterV2Options, + ZcodeConnection, + ZcodeProtocolError, +} from "@t3tools/provider-zcode/testing"; const INSTANCE_ID = ProviderInstanceId.make("zcode_frame_test"); const THREAD_ID = ThreadId.make("thread-zcode-frame-test"); @@ -103,11 +108,15 @@ const makeTurnInput = Effect.fnUntraced(function* ( }; }); -const makeHarness = Effect.fnUntraced(function* (override: Partial = {}) { +const makeHarness = Effect.fnUntraced(function* ( + override: Partial = {}, + // Lets a test intercept client→server methods on the injected fake connection. + wrapConnection: (connection: ZcodeConnection) => ZcodeConnection = (connection) => connection, +) { const scenario = yield* makeFakeZcodeConnection(); const adapter = yield* makeZcodeAdapterV2({ ...makeZcodeAdapterOptions(), - makeConnection: () => Effect.succeed(scenario.connection), + makeConnection: () => Effect.succeed(wrapConnection(scenario.connection)), ...override, }); const runtime = yield* adapter.openSession({ @@ -231,6 +240,35 @@ describe("ZcodeAdapterV2 (frame-driven)", () => { }).pipe(Effect.scoped, Effect.provide(ZcodeAdapterTestLayer)), ); + it.effect( + "forceNewNativeThread opens a fresh native session instead of resuming the cached one", + () => + Effect.gen(function* () { + const harness = yield* makeHarness(); + assert.equal(harness.providerThread.nativeThreadRef?.nativeId, "sess_1"); + // Recovery (ProviderTurnStartService) re-ensures the same provider thread row with the + // native ref cleared and demands a fresh native conversation. + const replacement = yield* harness.runtime.ensureThread({ + threadId: THREAD_ID, + modelSelection: MODEL_SELECTION, + runtimePolicy, + existingProviderThread: { ...harness.providerThread, nativeThreadRef: null }, + forceNewNativeThread: true, + }); + assert.equal(replacement.id, harness.providerThread.id); + assert.equal(replacement.nativeThreadRef?.nativeId, "sess_2"); + const creates = harness.scenario.requests.filter( + (call) => call.method === "session/create", + ); + assert.equal(creates.length, 2); + assert.isUndefined( + harness.scenario.requests.find((call) => call.method === "session/resume"), + ); + assert.equal(harness.scenario.createdSessionId(), "sess_2"); + assert.deepEqual(harness.scenario.unexpectedMethods(), []); + }).pipe(Effect.scoped, Effect.provide(ZcodeAdapterTestLayer)), + ); + it.effect("streams text deltas and completes the turn with token usage", () => Effect.gen(function* () { const harness = yield* makeHarness(); @@ -295,6 +333,74 @@ describe("ZcodeAdapterV2 (frame-driven)", () => { }).pipe(Effect.scoped, Effect.provide(ZcodeAdapterTestLayer)), ); + it.effect("keeps reasoning and answer deltas of one message as separate stream items", () => + Effect.gen(function* () { + const harness = yield* makeHarness(); + yield* harness.startTurn(); + yield* harness.takeEvent( + "provider_turn.updated", + (event) => event.providerTurn.status === "running", + ); + yield* harness.offerTurnEvent("turn.started", {}); + // One assistant message streams reasoning first, then the answer; each kind must own + // its own item and completion state. + yield* harness.offerTurnEvent("model.streaming", { + assistantMessageId: "msg_mix", + delta: "pondering", + kind: "reasoning_delta", + done: false, + }); + yield* harness.offerTurnEvent("model.streaming", { + assistantMessageId: "msg_mix", + delta: "", + kind: "reasoning_delta", + done: true, + }); + yield* harness.offerTurnEvent("model.streaming", { + assistantMessageId: "msg_mix", + delta: "the answer", + kind: "text_delta", + done: false, + }); + yield* harness.offerTurnEvent("model.streaming", { + assistantMessageId: "msg_mix", + delta: "", + kind: "text_delta", + done: true, + }); + yield* harness.offerTurnEvent("turn.completed", { + response: "the answer", + tokenCount: 8, + toolCallCount: 0, + duration: 90, + resultType: "success", + }); + + const reasoningChunk = yield* harness.takeEvent("command_output.delta"); + assert.equal(reasoningChunk.chunk, "pondering"); + // Takes discard non-matching events, so consume in emission order: the reasoning item + // completes (its done frame) before the answer delta streams. + yield* harness.takeEvent("turn_item.updated", (event) => + event.turnItem.type === "reasoning" + ? event.turnItem.streaming === false && event.turnItem.text === "pondering" + : false, + ); + const answerChunk = yield* harness.takeEvent( + "command_output.delta", + (event) => event.chunk === "the answer", + ); + assert.notEqual(answerChunk.itemId, reasoningChunk.itemId); + + yield* harness.takeEvent("turn_item.updated", (event) => + event.turnItem.type === "assistant_message" + ? event.turnItem.streaming === false && event.turnItem.text === "the answer" + : false, + ); + yield* harness.takeEvent("turn.terminal", (event) => event.status === "completed"); + assert.deepEqual(harness.scenario.unexpectedMethods(), []); + }).pipe(Effect.scoped, Effect.provide(ZcodeAdapterTestLayer)), + ); + it.effect( "sends account-model turns with the advertised reasoning level and serves runtime headers", () => @@ -444,9 +550,13 @@ describe("ZcodeAdapterV2 (frame-driven)", () => { decision: "accept", }); - const relayed = harness.scenario.responds.find((respond) => respond.id === broker.id); - assert.isDefined(relayed); - assert.deepEqual(relayed!.result, allowResponse); + // The respond queue synchronizes this with the adapter's pump: the answer may + // resolve while the broker record is still pending. + let relayed = yield* harness.scenario.takeRespond; + while (relayed.id !== broker.id) { + relayed = yield* harness.scenario.takeRespond; + } + assert.deepEqual(relayed.result, allowResponse); yield* harness.takeEvent( "runtime_request.updated", (event) => event.runtimeRequest.status === "resolved", @@ -489,4 +599,171 @@ describe("ZcodeAdapterV2 (frame-driven)", () => { assert.deepEqual(harness.scenario.unexpectedMethods(), []); }).pipe(Effect.scoped, Effect.provide(ZcodeAdapterTestLayer)), ); + + it.effect("keeps the real terminal when session/stop rejects", () => + Effect.gen(function* () { + const stopRejection = { + _tag: "ZcodeProtocolError", + operation: "session/stop", + detail: "session rejected the stop", + } as unknown as ZcodeProtocolError; + const attempted: Array = []; + const harness = yield* makeHarness({}, (connection) => ({ + ...connection, + request: (method, params, timeoutMs) => { + if (method !== "session/stop") return connection.request(method, params, timeoutMs); + attempted.push(method); + return Effect.fail(stopRejection); + }, + })); + yield* harness.startTurn(); + const running = yield* harness.takeEvent( + "provider_turn.updated", + (event) => event.providerTurn.status === "running", + ); + const stopError = yield* harness.runtime + .interruptTurn({ + providerThread: harness.providerThread, + providerTurnId: running.providerTurn.id, + }) + .pipe(Effect.flip); + assert.equal(stopError._tag, "ProviderAdapterInterruptError"); + assert.deepEqual(attempted, ["session/stop"]); + // With the stop rejected, the turn keeps its actual outcome instead of being + // reported as interrupted. + yield* harness.offerTurnEvent("turn.completed", { + response: "done", + tokenCount: 5, + toolCallCount: 0, + duration: 50, + resultType: "success", + }); + const terminal = yield* harness.takeEvent("turn.terminal"); + assert.equal(terminal.providerTurnId, running.providerTurn.id); + assert.equal(terminal.status, "completed"); + assert.deepEqual(harness.scenario.unexpectedMethods(), []); + }).pipe(Effect.scoped, Effect.provide(ZcodeAdapterTestLayer)), + ); + + it.effect("finalizes the failed turn and session when session/send is rejected", () => + Effect.gen(function* () { + // A server-side method rejection surfaces as a ZcodeProtocolError on the request + // channel; the testing export ships the shape as a type only, so tag it directly. + const sendRejection = { + _tag: "ZcodeProtocolError", + operation: "session/send", + detail: "session rejected the prompt", + } as unknown as ZcodeProtocolError; + const attempted: Array = []; + const harness = yield* makeHarness({}, (connection) => ({ + ...connection, + request: (method, params, timeoutMs) => { + if (method !== "session/send") return connection.request(method, params, timeoutMs); + attempted.push(method); + return Effect.fail(sendRejection); + }, + })); + + const startFiber = yield* Effect.forkChild(harness.startTurn().pipe(Effect.flip)); + // The whole terminal sequence must reach the event stream before the start error + // surfaces: the run subscriber stops as soon as the caller sees the start failure. + const running = yield* harness.takeEvent( + "provider_turn.updated", + (event) => event.providerTurn.status === "running", + ); + const failed = yield* harness.takeEvent( + "provider_turn.updated", + (event) => event.providerTurn.status === "failed", + ); + assert.equal(failed.providerTurn.id, running.providerTurn.id); + yield* harness.takeEvent( + "provider_thread.updated", + (event) => event.providerThread.status === "idle", + ); + const erroredSession = yield* harness.takeEvent( + "provider_session.updated", + (event) => event.providerSession.status === "error", + ); + assert.equal(erroredSession.providerSession.lastError, "ZCode failed to start the turn."); + yield* harness.takeEvent( + "turn_item.updated", + (event) => event.turnItem.status === "failed" && event.turnItem.type === "error", + ); + const terminal = yield* harness.takeEvent( + "turn.terminal", + (event) => event.status === "failed", + ); + assert.equal(terminal.providerTurnId, failed.providerTurn.id); + + const startError = yield* Fiber.join(startFiber); + assert.equal(startError._tag, "ProviderAdapterTurnStartError"); + assert.deepEqual(attempted, ["session/send"]); + assert.deepEqual(harness.scenario.unexpectedMethods(), []); + }).pipe(Effect.scoped, Effect.provide(ZcodeAdapterTestLayer)), + ); + + it.effect( + "finalizes the failed compact turn and keeps the thread usable when session/compact is rejected", + () => + Effect.gen(function* () { + const compactRejection = { + _tag: "ZcodeProtocolError", + operation: "session/compact", + detail: "session rejected the compaction", + } as unknown as ZcodeProtocolError; + const attempted: Array = []; + const harness = yield* makeHarness({}, (connection) => ({ + ...connection, + request: (method, params, timeoutMs) => { + if (method !== "session/compact") return connection.request(method, params, timeoutMs); + attempted.push(method); + return Effect.fail(compactRejection); + }, + })); + + const compactInput = yield* makeTurnInput(harness.providerThread, "/compact"); + yield* harness.runtime.compactThread!(compactInput); + + yield* harness.takeEvent( + "provider_turn.updated", + (event) => event.providerTurn.status === "running", + ); + yield* harness.takeEvent( + "turn_item.updated", + (event) => event.turnItem.type === "compaction" && event.turnItem.status === "failed", + ); + const failed = yield* harness.takeEvent( + "provider_turn.updated", + (event) => event.providerTurn.status === "failed", + ); + yield* harness.takeEvent( + "provider_thread.updated", + (event) => event.providerThread.status === "idle", + ); + const erroredSession = yield* harness.takeEvent( + "provider_session.updated", + (event) => event.providerSession.status === "error", + ); + assert.equal(erroredSession.providerSession.lastError, "ZCode context compaction failed."); + const terminal = yield* harness.takeEvent( + "turn.terminal", + (event) => event.status === "failed", + ); + assert.equal(terminal.providerTurnId, failed.providerTurn.id); + + // The dead compact turn must not hold the thread: a later startTurn works normally. + yield* harness.startTurn(); + assert.isDefined(requestFor(harness.scenario, "session/send")); + yield* harness.offerTurnEvent("turn.completed", { + response: "after compaction failure", + tokenCount: 4, + toolCallCount: 0, + duration: 30, + resultType: "success", + }); + yield* harness.takeEvent("turn.terminal", (event) => event.status === "completed"); + assert.deepEqual(attempted, ["session/compact"]); + assert.deepEqual(harness.scenario.unexpectedMethods(), []); + }).pipe(Effect.scoped, Effect.provide(ZcodeAdapterTestLayer)), + ); }); diff --git a/packages/provider-zcode/src/server/adapter.ts b/packages/provider-zcode/src/server/adapter.ts index b94e8affe6453..213d3a3e741bf 100644 --- a/packages/provider-zcode/src/server/adapter.ts +++ b/packages/provider-zcode/src/server/adapter.ts @@ -1245,7 +1245,7 @@ export const makeZcodeAdapterV2 = Effect.fn("makeZcodeAdapterV2")(function* ( turn, kind === "text_delta" ? "assistant_message" : "reasoning", messageId, - messageId, + kind === "text_delta" ? "text" : "reasoning", ); item.text += delta; yield* emit({ @@ -1779,13 +1779,18 @@ export const makeZcodeAdapterV2 = Effect.fn("makeZcodeAdapterV2")(function* ( } const existing = threadInput.existingProviderThread; if ( + !threadInput.forceNewNativeThread && threadState !== null && existing !== undefined && threadState.providerThread.id === existing.id ) { return threadState.providerThread; } - const nativeId = existing?.nativeThreadRef?.nativeId ?? threadState?.nativeSessionId; + // Recovery (forceNewNativeThread) must open a fresh conversation even though the + // provider thread row and threadState still reference the old native session. + const nativeId = threadInput.forceNewNativeThread + ? undefined + : (existing?.nativeThreadRef?.nativeId ?? threadState?.nativeSessionId); const nativeSessionId = nativeId !== undefined ? yield* resumeNativeSession(nativeId, threadInput) @@ -2000,15 +2005,23 @@ export const makeZcodeAdapterV2 = Effect.fn("makeZcodeAdapterV2")(function* ( const turn = yield* installTurn(state, turnInput, nativeTurnId); yield* beginTurnWork(state, turn); yield* request("session/send", sendParams).pipe( - Effect.tapError(() => - Effect.sync(() => { - if (state.activeTurn === turn) state.activeTurn = null; + Effect.tapError((cause) => + Effect.gen(function* () { + if (state.activeTurn !== turn) return; + turn.failure = makeProviderFailure({ + cause, + message: "ZCode failed to start the turn.", + class: "provider_error", + }); + // The rejection reaches the caller as a turn start error, whose handler + // stops the event subscriber; the terminal sequence must be queued first. + yield* finalizeTurn(state); }), ), ); }), ); - // session/send acks before turn events; rejections surface via turn.failed. + // session/send acks before turn events; rejections there surface via turn.failed. }, (effect, turnInput) => effect.pipe( @@ -2055,13 +2068,15 @@ export const makeZcodeAdapterV2 = Effect.fn("makeZcodeAdapterV2")(function* ( }, SESSION_CREATE_TIMEOUT_MS, ).pipe( - Effect.mapError((cause) => { + // Recover instead of re-failing: the failed-item + finalizeTurn sequence below + // must still run or state.activeTurn stays installed and blocks later turns. + Effect.catch((cause) => { turn.failure = makeProviderFailure({ cause, message: "ZCode context compaction failed.", class: "provider_error", }); - return cause; + return Effect.undefined; }), ); const resultRecord = Predicate.isObject(result) ? result : {}; @@ -2168,8 +2183,15 @@ export const makeZcodeAdapterV2 = Effect.fn("makeZcodeAdapterV2")(function* ( `ZCode turn ${interruptInput.providerTurnId} is not active`, ); } + const wasInterrupted = turn.interrupted; turn.interrupted = true; - yield* request("session/stop", { sessionId: state.nativeSessionId }); + yield* request("session/stop", { sessionId: state.nativeSessionId }).pipe( + Effect.onError(() => + Effect.sync(() => { + turn.interrupted = wasInterrupted; + }), + ), + ); }, (effect, interruptInput) => effect.pipe( From dfcf871863d690233c8b48d8850d5e0d244214f6 Mon Sep 17 00:00:00 2001 From: panudetjt <3482285+panudetjt@users.noreply.github.com> Date: Tue, 10 Nov 2026 14:14:41 +0700 Subject: [PATCH 14/23] fix(web): keep the google search term for the providers settings entry The ZCode terms replaced google instead of joining it, so searching google no longer surfaced Providers (Antigravity signs in with Google). Model: GLM-5.3-Flash via ZCode. Harness: ZCode. --- apps/web/src/components/settings/settingsSearch.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index 5b9bcf50c8bbb..52c1f65a166e7 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -941,7 +941,7 @@ export const SETTINGS_SEARCH_ITEMS = [ title: "Providers", to: "/settings/providers", searchTerms: [ - "agents cli codex claude cursor grok fx kiro opencode antigravity zcode z ai glm sign in sign out install subscription instances authentication api key models configuration binary path config directory codex_home shadow home endpoint arguments environment variables display name accent color custom favorite hidden auto compact native sessions import resume", + "agents cli codex claude cursor grok fx kiro opencode antigravity google zcode z ai glm sign in sign out install subscription instances authentication api key models configuration binary path config directory codex_home shadow home endpoint arguments environment variables display name accent color custom favorite hidden auto compact native sessions import resume", ], }, { From 65f9b87a33749618f4ca345759ee34e9093c1615 Mon Sep 17 00:00:00 2001 From: panudetjt <3482285+panudetjt@users.noreply.github.com> Date: Tue, 10 Nov 2026 14:14:58 +0700 Subject: [PATCH 15/23] fix(release): include provider-zcode in the release-smoke fixture The server and client-runtime manifests require @t3tools/provider-zcode, so lockfile regeneration in the smoke workspace failed without its manifest and blocked the required CI check. Model: GLM-5.3-Flash via ZCode. Harness: ZCode. --- scripts/release-smoke.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/release-smoke.ts b/scripts/release-smoke.ts index 9a97563e29de0..3d2435dc4ab74 100644 --- a/scripts/release-smoke.ts +++ b/scripts/release-smoke.ts @@ -40,6 +40,7 @@ const workspaceFiles = [ "packages/provider-kiro/package.json", "packages/provider-muse/package.json", "packages/provider-opencode/package.json", + "packages/provider-zcode/package.json", "packages/secret-provider-core/package.json", "packages/secret-provider-definitions/package.json", "packages/secret-provider-onepassword/package.json", From df270b3ccc6770a3e937f9281b234f630393a241 Mon Sep 17 00:00:00 2001 From: panudetjt <3482285+panudetjt@users.noreply.github.com> Date: Tue, 10 Nov 2026 14:15:16 +0700 Subject: [PATCH 16/23] docs(readme): add ZCode to the supported-provider sentence The install warning contradicted the intro and provider list, which both include ZCode. Model: GLM-5.3-Flash via ZCode. Harness: ZCode. --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index ec038533faf59..5c671943f55c1 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ We wanted something performant, remote-ready, and truly open. If we ever go the ## Installation > [!WARNING] -> T3 Code currently supports Codex, Claude, Cursor, Grok Build, OpenCode, Antigravity, and fx. Install and authenticate at least one provider before use: +> T3 Code currently supports Codex, Claude, Cursor, Grok Build, OpenCode, Antigravity, ZCode, and fx. Install and authenticate at least one provider before use: > > - Codex: install [Codex CLI](https://developers.openai.com/codex/cli) and run `codex login` > - Claude: install [Claude Code](https://claude.com/product/claude-code) and run `claude auth login` From e88386c290728a126b91ed3dd8a06b8035326781 Mon Sep 17 00:00:00 2001 From: panudetjt <3482285+panudetjt@users.noreply.github.com> Date: Tue, 10 Nov 2026 14:41:10 +0700 Subject: [PATCH 17/23] fix(provider-zcode): probe the Windows desktop shim as zcode.cmd The shim fallback hardcoded the bare zcode name, so on Windows with zcode off PATH only a zcode.cmd launcher in the data root was missed and the provider fell back to a binary that does not exist. Model: GLM-5.3-Flash via ZCode. Harness: ZCode. --- .../src/server/zcodeRuntime.test.ts | 38 +++++++++++++++++++ .../provider-zcode/src/server/zcodeRuntime.ts | 8 ++-- 2 files changed, 42 insertions(+), 4 deletions(-) diff --git a/packages/provider-zcode/src/server/zcodeRuntime.test.ts b/packages/provider-zcode/src/server/zcodeRuntime.test.ts index a9bf0a175d4f2..6602acda2dc2f 100644 --- a/packages/provider-zcode/src/server/zcodeRuntime.test.ts +++ b/packages/provider-zcode/src/server/zcodeRuntime.test.ts @@ -193,6 +193,44 @@ describe("resolveZcodeLaunchCommand", () => { }).pipe(Effect.provide(NodeServices.layer)), ); + it.effect("probes the Windows desktop shim as zcode.cmd when zcode is off PATH", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const home = yield* fileSystem.makeTempDirectory(); + yield* fileSystem.makeDirectory(`${home}/.zcode/server/bin`, { recursive: true }); + yield* fileSystem.writeFileString(`${home}/.zcode/server/bin/zcode.cmd`, "@echo off\n"); + const command = yield* resolveZcodeLaunchCommand( + { binaryPath: "zcode" }, + { HOME: home, PATH: "C:\\nonexistent" }, + fileSystem, + ); + assert.equal(command, `${home}/.zcode/server/bin/zcode.cmd`); + }).pipe( + Effect.provideService(HostProcess.Platform, "win32"), + Effect.provide(NodeServices.layer), + ), + ); + + it.effect( + "keeps the plain binary name on Windows when the shim dir only holds a bare zcode", + () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const home = yield* fileSystem.makeTempDirectory(); + yield* fileSystem.makeDirectory(`${home}/.zcode/server/bin`, { recursive: true }); + yield* fileSystem.writeFileString(`${home}/.zcode/server/bin/zcode`, "#!/bin/sh\n"); + const command = yield* resolveZcodeLaunchCommand( + { binaryPath: "" }, + { HOME: home, PATH: "C:\\nonexistent" }, + fileSystem, + ); + assert.equal(command, "zcode"); + }).pipe( + Effect.provideService(HostProcess.Platform, "win32"), + Effect.provide(NodeServices.layer), + ), + ); + it.effect("keeps the plain binary name when no fallback exists", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; diff --git a/packages/provider-zcode/src/server/zcodeRuntime.ts b/packages/provider-zcode/src/server/zcodeRuntime.ts index 46a9c30bb90db..824a08d47c20d 100644 --- a/packages/provider-zcode/src/server/zcodeRuntime.ts +++ b/packages/provider-zcode/src/server/zcodeRuntime.ts @@ -192,9 +192,9 @@ export function zcodeSpawnArgs(launchArgs: string): ReadonlyArray { } /** - * Falls back to the desktop shim (`~/.zcode/server/bin/zcode`) when PATH lacks `zcode`; the - * bare "zcode" is the schema's decoded default, not an explicit choice. PATH is probed - * manually: resolveSpawnCommand is a no-op on POSIX and defers to exec. + * Falls back to the desktop shim (`~/.zcode/server/bin/zcode`, `zcode.cmd` on Windows) when PATH + * lacks `zcode`; the bare "zcode" is the schema's decoded default, not an explicit choice. PATH is + * probed manually: resolveSpawnCommand is a no-op on POSIX and defers to exec. */ export const resolveZcodeLaunchCommand = Effect.fn("ZcodeRuntime.resolveLaunchCommand")(function* ( settings: { readonly binaryPath: string }, @@ -207,7 +207,7 @@ export const resolveZcodeLaunchCommand = Effect.fn("ZcodeRuntime.resolveLaunchCo if (onPath) return DEFAULT_ZCODE_BINARY; const home = zcodeDataRoot(environment); if (home === undefined) return DEFAULT_ZCODE_BINARY; - const desktopShim = `${home}/.zcode/server/bin/zcode`; + const desktopShim = `${home}/.zcode/server/bin/${(yield* HostProcess.Platform) === "win32" ? "zcode.cmd" : "zcode"}`; const shimExists = yield* fileSystem.exists(desktopShim).pipe(Effect.orElseSucceed(() => false)); return shimExists ? desktopShim : DEFAULT_ZCODE_BINARY; }); From 7915c7b2dac7a95d33b33d210210cf92cf0ac644 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Tue, 10 Nov 2026 08:07:55 +0000 Subject: [PATCH 18/23] fix(policy): let managed policies name the zcode driver --- apps/server/src/policy/managedProviders.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/server/src/policy/managedProviders.ts b/apps/server/src/policy/managedProviders.ts index 059f6aeb4b782..08b564651f476 100644 --- a/apps/server/src/policy/managedProviders.ts +++ b/apps/server/src/policy/managedProviders.ts @@ -23,6 +23,7 @@ import { kiroClient } from "@t3tools/provider-kiro/client"; import { museClient } from "@t3tools/provider-muse/client"; import { openCodeClient } from "@t3tools/provider-opencode/client"; import { piClient } from "@t3tools/provider-pi/client"; +import { zcodeClient } from "@t3tools/provider-zcode/client"; import type { ProviderClientDefinition } from "@t3tools/provider-core/client"; interface KnownDriver { @@ -50,6 +51,7 @@ const PACKAGED_CLIENTS: ReadonlyArray = [ museClient, openCodeClient, piClient, + zcodeClient, acpRegistryClient, ]; From 10236b7a85aecf12e86fa07bc0b2113c1c014569 Mon Sep 17 00:00:00 2001 From: panudetjt <3482285+panudetjt@users.noreply.github.com> Date: Wed, 11 Nov 2026 14:09:56 +0700 Subject: [PATCH 19/23] fix(provider-zcode): apply review fixes for mode, auth latency, and endpoint override A runtime-mode change detached the session but the resumed native session kept the mode it was created with, so Full access could stay `yolo` after switching to Supervised. The adapter now sends `session/setMode` after `session/resume` and re-syncs before `session/send`. `interaction/requestProviderRuntimeHeaders` was answered inline while holding the session event permit, so three sequential HTTPS calls stalled every stream and tool event. The answer is forked, and the credential store is cached by content digest instead of being decrypted on every model request. `ZCODE_BASE_URL` was always overwritten; the builtin catalog path and endpoint hash now follow the configured origin so the override works end to end. The token exchange reports the installed CLI version from `current.json` and omits it when unknown, instead of a hard-coded `0.0.0`, and the provider docs disclose that the server reads the CLI's credential store as ZCode's host. --- .../Adapters/ZcodeAdapterV2.test.ts | 50 ++++++++++++++++ .../Adapters/ZcodeAdapterV2.testkit.ts | 1 + docs/user/providers-zcode.md | 10 +++- packages/provider-zcode/src/server/adapter.ts | 44 ++++++++++++-- .../src/server/zcodeRequestAuth.test.ts | 28 +++++++-- .../src/server/zcodeRequestAuth.ts | 27 ++++++++- .../src/server/zcodeRuntime.test.ts | 60 +++++++++++++++++++ .../provider-zcode/src/server/zcodeRuntime.ts | 35 ++++++++--- 8 files changed, 234 insertions(+), 21 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/ZcodeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ZcodeAdapterV2.test.ts index 4589a145e1483..26ccb55a805ed 100644 --- a/apps/server/src/orchestration-v2/Adapters/ZcodeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ZcodeAdapterV2.test.ts @@ -55,6 +55,12 @@ const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ cwd: null, }); +const fullAccessRuntimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ + runtimeMode: "full-access", + interactionMode: "default", + cwd: null, +}); + const makeTurnInput = Effect.fnUntraced(function* ( providerThread: OrchestrationV2ProviderThread, text = TURN_TEXT, @@ -269,6 +275,50 @@ describe("ZcodeAdapterV2 (frame-driven)", () => { }).pipe(Effect.scoped, Effect.provide(ZcodeAdapterTestLayer)), ); + it.effect("reapplies the runtime mode when a detached session resumes", () => + Effect.gen(function* () { + const openRuntime = (scenario: FakeZcodeScenario, policy: typeof runtimePolicy) => + Effect.gen(function* () { + const adapter = yield* makeZcodeAdapterV2({ + ...makeZcodeAdapterOptions(), + makeConnection: () => Effect.succeed(scenario.connection), + }); + return yield* adapter.openSession({ + threadId: THREAD_ID, + providerSessionId: PROVIDER_SESSION_ID, + modelSelection: MODEL_SELECTION, + runtimePolicy: policy, + }); + }); + + const createdScenario = yield* makeFakeZcodeConnection(); + const created = yield* openRuntime(createdScenario, fullAccessRuntimePolicy); + const providerThread = yield* created.ensureThread({ + threadId: THREAD_ID, + modelSelection: MODEL_SELECTION, + runtimePolicy: fullAccessRuntimePolicy, + }); + assert.equal(requestFor(createdScenario, "session/create")?.params["mode"], "yolo"); + + // thread.runtime-mode.set detaches the session; the next turn reopens and resumes it. + const resumedScenario = yield* makeFakeZcodeConnection(); + const resumed = yield* openRuntime(resumedScenario, runtimePolicy); + yield* resumed.ensureThread({ + threadId: THREAD_ID, + modelSelection: MODEL_SELECTION, + runtimePolicy, + existingProviderThread: providerThread, + }); + assert.isUndefined(requestFor(resumedScenario, "session/create")); + assert.equal(requestFor(resumedScenario, "session/resume")?.params["sessionId"], "sess_1"); + assert.deepEqual(requestFor(resumedScenario, "session/setMode")?.params, { + sessionId: "sess_1", + mode: "guarded", + }); + assert.deepEqual(resumedScenario.unexpectedMethods(), []); + }).pipe(Effect.scoped, Effect.provide(ZcodeAdapterTestLayer)), + ); + it.effect("streams text deltas and completes the turn with token usage", () => Effect.gen(function* () { const harness = yield* makeHarness(); diff --git a/apps/server/src/orchestration-v2/Adapters/ZcodeAdapterV2.testkit.ts b/apps/server/src/orchestration-v2/Adapters/ZcodeAdapterV2.testkit.ts index eb4b41c8746a5..611850c8f76c6 100644 --- a/apps/server/src/orchestration-v2/Adapters/ZcodeAdapterV2.testkit.ts +++ b/apps/server/src/orchestration-v2/Adapters/ZcodeAdapterV2.testkit.ts @@ -52,6 +52,7 @@ const KNOWN_METHODS = new Set([ "session/read", "session/resume", "session/send", + "session/setMode", "session/stop", "session/subscribe", ]); diff --git a/docs/user/providers-zcode.md b/docs/user/providers-zcode.md index 27591f6b56f28..f24de6f6567df 100644 --- a/docs/user/providers-zcode.md +++ b/docs/user/providers-zcode.md @@ -14,6 +14,14 @@ configuration. ZCode runs on the server's machine only. A desktop ZCode app elsewhere does not share its sign-in with T3 Code; the server's own `zcode login` is what counts. +## Credentials + +T3 Code never stores your Z.AI sign-in. The ZCode agent asks its host for credentials before every +account-model request, so the server stands in as that host: it reads the sign-in `zcode login` +already wrote on the same machine, exchanges it for a short-lived access token through the same +Z.AI endpoints the ZCode app uses, and holds that token in memory only. The store is never +written, so signing out or signing in again from a terminal applies on the next refresh. + ## Models After signing in, the model picker lists the GLM models your Z.AI plan offers — no API key or @@ -33,5 +41,5 @@ ZCode gates tool calls by its own permission modes, and T3 Code answers by the p ## Steering, Forks and Compaction - A message you send while ZCode is working joins the running turn. -- Forking a thread creates a ZCode fork of the conversation from that point. +- Forking a ZCode thread is not available yet, and the fork command is hidden for these threads. - `/compact` uses ZCode's own compaction. diff --git a/packages/provider-zcode/src/server/adapter.ts b/packages/provider-zcode/src/server/adapter.ts index 213d3a3e741bf..6dfb85848796a 100644 --- a/packages/provider-zcode/src/server/adapter.ts +++ b/packages/provider-zcode/src/server/adapter.ts @@ -64,8 +64,10 @@ import { type ZcodeRecord, } from "./zcodeProtocol.ts"; import { + readZcodeAppVersion, resolveZcodeCatalogFiles, resolveZcodeLaunchCommand, + zcodeDataRoot, zcodeModelSelectionFromSlug, zcodeRuntimeEnvironment, zcodeSpawnArgs, @@ -208,7 +210,7 @@ function providerRef( * edit auto-accepts file changes, yolo never asks. Unoffered stored modes arrive as * Supervised, so anything unexpected falls back to the strictest mode. */ -function zcodeSessionMode(runtimeMode: RuntimeMode | undefined): "guarded" | "edit" | "yolo" { +function zcodeSessionMode(runtimeMode: RuntimeMode | undefined): ZcodeSessionMode { switch (runtimeMode) { case "auto-accept-edits": return "edit"; @@ -390,8 +392,11 @@ interface ZcodeThreadState { providerThread: OrchestrationV2ProviderThread; readonly nativeSessionId: string; activeTurn: ActiveZcodeTurn | null; + sessionMode: ZcodeSessionMode; } +type ZcodeSessionMode = "guarded" | "edit" | "yolo"; + function zcodeFailureFromErrorDetail(error: unknown): OrchestrationV2ProviderFailure { const record = Predicate.isObject(error) ? error : {}; const message = zcodeRecordString(record, "message") ?? "ZCode reported a turn error."; @@ -427,11 +432,18 @@ export const makeZcodeAdapterV2 = Effect.fn("makeZcodeAdapterV2")(function* ( ...(payload === undefined ? {} : { payload }), }); + // The CLI's own host reports the installed version in the token exchange; unknown stays omitted. + const dataRoot = zcodeDataRoot(options.environment); + const clientVersion = + dataRoot === undefined + ? undefined + : yield* readZcodeAppVersion(fileSystem, `${dataRoot}/.zcode/server/current.json`); + const requestAuth = (options.makeRequestAuth ?? makeZcodeRequestAuth)({ fileSystem, httpClient, environment: options.environment, - clientVersion: "0.0.0", + clientVersion, }); const zcodeMcpServers = ( @@ -1517,6 +1529,8 @@ export const makeZcodeAdapterV2 = Effect.fn("makeZcodeAdapterV2")(function* ( ? {} : { rejectedProjectTokenFingerprint }), }); + // Minting can take seconds on a cache miss; the CLI correlates the reply by id, so + // fork it instead of stalling every session event this permit serializes. yield* answer.pipe( Effect.flatMap((response) => connection.respond(respondId, response)), Effect.catchCause((cause) => @@ -1524,6 +1538,7 @@ export const makeZcodeAdapterV2 = Effect.fn("makeZcodeAdapterV2")(function* ( errorTag: causeErrorTag(cause), }), ), + Effect.forkIn(scope), ); return; } @@ -1724,8 +1739,6 @@ export const makeZcodeAdapterV2 = Effect.fn("makeZcodeAdapterV2")(function* ( const mcpServers = zcodeMcpServers(yield* mcpSessions.read(threadInput.threadId)); const createParams: ZcodeRecord = { workspace: { workspacePath: cwd, workspaceKey: cwd }, - // Mode changes restart the session (supportsRuntimeModeSwitchInSession: false), so - // the mode is set once here and zcode enforces it natively for the session's life. mode: zcodeSessionMode(threadInput.runtimePolicy?.runtimeMode), titleGenerationEnabled: false, ...(mcpServers === undefined ? {} : { mcpServers }), @@ -1767,6 +1780,12 @@ export const makeZcodeAdapterV2 = Effect.fn("makeZcodeAdapterV2")(function* ( }, SESSION_CREATE_TIMEOUT_MS, ); + // session/resume carries no mode, so a mode chosen while the session was detached (or + // before a host restart) must be applied explicitly. + yield* request("session/setMode", { + sessionId: nativeSessionId, + mode: zcodeSessionMode(threadInput.runtimePolicy?.runtimeMode), + }); yield* subscribeSession(nativeSessionId); return nativeSessionId; }); @@ -1826,7 +1845,12 @@ export const makeZcodeAdapterV2 = Effect.fn("makeZcodeAdapterV2")(function* ( createdAt, updatedAt: createdAt, }; - threadState = { providerThread, nativeSessionId, activeTurn: null }; + threadState = { + providerThread, + nativeSessionId, + activeTurn: null, + sessionMode: zcodeSessionMode(threadInput.runtimePolicy?.runtimeMode), + }; yield* emit({ type: "provider_thread.updated", driver: ZCODE_PROVIDER, @@ -2000,6 +2024,16 @@ export const makeZcodeAdapterV2 = Effect.fn("makeZcodeAdapterV2")(function* ( ...(reasoningLevel === undefined ? {} : { options: { reasoningLevel } }), }; } + // The orchestrator detaches on a mode change, but in-process reuse and CLI-side mode + // edits still leave the native session on an older mode. + const requestedMode = zcodeSessionMode(turnInput.runtimePolicy?.runtimeMode); + if (state.sessionMode !== requestedMode) { + yield* request("session/setMode", { + sessionId: state.nativeSessionId, + mode: requestedMode, + }); + state.sessionMode = requestedMode; + } yield* sessionEventPermit.withPermits(1)( Effect.gen(function* () { const turn = yield* installTurn(state, turnInput, nativeTurnId); diff --git a/packages/provider-zcode/src/server/zcodeRequestAuth.test.ts b/packages/provider-zcode/src/server/zcodeRequestAuth.test.ts index 15f040460578b..b85c402b959a7 100644 --- a/packages/provider-zcode/src/server/zcodeRequestAuth.test.ts +++ b/packages/provider-zcode/src/server/zcodeRequestAuth.test.ts @@ -120,7 +120,7 @@ describe("zcodeAccountScope", () => { describe("makeZcodeRequestAuth", () => { /** A temp data root with the zai login on disk plus an HTTP fake answering the mint chain. */ - const harness = Effect.fnUntraced(function* () { + const harness = Effect.fnUntraced(function* (clientVersion: string | undefined) { const fileSystem = yield* FileSystem.FileSystem; const root = yield* fileSystem.makeTempDirectoryScoped({ prefix: "zcode-request-auth-test-" }); yield* fileSystem.makeDirectory(`${root}/.zcode/v2`, { recursive: true }); @@ -138,6 +138,7 @@ describe("makeZcodeRequestAuth", () => { ); yield* writeLogin("login-a"); const exchangeLogins: Array = []; + const exchangeBodies: Array = []; const httpClient = HttpClient.make((request, url) => { const ok = (data: unknown) => Effect.succeed(HttpClientResponse.fromWeb(request, Response.json({ code: 200, data }))); @@ -156,12 +157,18 @@ describe("makeZcodeRequestAuth", () => { return ok([{ name: "zcode-api-key", apiKey: "key-1" }]); } exchangeLogins.push((request.headers.authorization ?? "").replace(/^Bearer /, "")); + exchangeBodies.push( + request.body._tag === "Uint8Array" + ? JSON.parse(new TextDecoder().decode(request.body.body)) + : undefined, + ); return ok({ accessToken: `access-token-${exchangeLogins.length}` }); }); return { - auth: makeZcodeRequestAuth({ fileSystem, httpClient, environment, clientVersion: "test" }), + auth: makeZcodeRequestAuth({ fileSystem, httpClient, environment, clientVersion }), writeLogin, exchangeLogins, + exchangeBodies, }; }); @@ -170,7 +177,7 @@ describe("makeZcodeRequestAuth", () => { it.effect("serves a cache hit while the family's credentials stay the same", () => Effect.gen(function* () { - const h = yield* harness(); + const h = yield* harness("test"); const first = yield* h.auth.answerRuntimeHeaders({ family: "zai" }); assert.equal(zcodeRecordString(requestAuthOf(first), "apiKey"), "access-token-1"); assert.equal( @@ -188,7 +195,7 @@ describe("makeZcodeRequestAuth", () => { it.effect("re-mints against the new account when the family's login changes", () => Effect.gen(function* () { - const h = yield* harness(); + const h = yield* harness("test"); const first = yield* h.auth.answerRuntimeHeaders({ family: "zai" }); assert.equal(zcodeRecordString(requestAuthOf(first), "apiKey"), "access-token-1"); yield* h.writeLogin("login-b"); @@ -205,7 +212,7 @@ describe("makeZcodeRequestAuth", () => { it.effect("declines once the family's login disappears", () => Effect.gen(function* () { - const h = yield* harness(); + const h = yield* harness("test"); const first = yield* h.auth.answerRuntimeHeaders({ family: "zai" }); assert.equal(zcodeRecordString(requestAuthOf(first), "apiKey"), "access-token-1"); yield* h.writeLogin(undefined); @@ -215,4 +222,15 @@ describe("makeZcodeRequestAuth", () => { assert.deepEqual(h.exchangeLogins, ["login-a"]); }).pipe(Effect.provide(NodeServices.layer)), ); + + it.effect("reports the installed CLI version in the token exchange only when known", () => + Effect.gen(function* () { + const known = yield* harness("3.17.2"); + yield* known.auth.answerRuntimeHeaders({ family: "zai" }); + const unknown = yield* harness(undefined); + yield* unknown.auth.answerRuntimeHeaders({ family: "zai" }); + assert.deepEqual(known.exchangeBodies, [{ clientType: "zcode", clientVersion: "3.17.2" }]); + assert.deepEqual(unknown.exchangeBodies, [{ clientType: "zcode" }]); + }).pipe(Effect.provide(NodeServices.layer)), + ); }); diff --git a/packages/provider-zcode/src/server/zcodeRequestAuth.ts b/packages/provider-zcode/src/server/zcodeRequestAuth.ts index ce5cf2e22f5f2..d54975e429918 100644 --- a/packages/provider-zcode/src/server/zcodeRequestAuth.ts +++ b/packages/provider-zcode/src/server/zcodeRequestAuth.ts @@ -92,14 +92,25 @@ export const decryptZcodeCredential = (value: string, secret: string): string => const decodeJsonText = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Unknown)); +interface CredentialStoreCache { + digest: string; + store: Record; +} + const readDecryptedCredentialStore = Effect.fn("ZcodeRequestAuth.readCredentialStore")(function* ( fileSystem: FileSystem.FileSystem, credentialsPath: string, environment: NodeJS.ProcessEnv, platform: NodeJS.Platform, + cache?: CredentialStoreCache, ): Effect.fn.Return> { const raw = yield* fileSystem.readFileString(credentialsPath).pipe(Effect.option); - const parsed = Option.isNone(raw) ? undefined : Option.getOrUndefined(decodeJsonText(raw.value)); + if (Option.isNone(raw)) return {}; + // Content, not mtime: a rewrite can land in the same timestamp tick, and a stale login token + // would mint project tokens against the previous account. + const digest = sha256Hex(raw.value); + if (cache !== undefined && cache.digest === digest) return cache.store; + const parsed = Option.getOrUndefined(decodeJsonText(raw.value)); if (!Predicate.isObject(parsed)) return {}; const secret = resolveZcodeCredentialSecret(environment, platform); const entries: Record = {}; @@ -111,6 +122,10 @@ const readDecryptedCredentialStore = Effect.fn("ZcodeRequestAuth.readCredentialS }).pipe(Effect.option); if (Option.isSome(decrypted)) entries[key] = decrypted.value; } + if (cache !== undefined) { + cache.digest = digest; + cache.store = entries; + } return entries; }); @@ -391,7 +406,8 @@ export interface ZcodeRequestAuthOptions { readonly fileSystem: FileSystem.FileSystem; readonly httpClient: HttpClient.HttpClient; readonly environment: NodeJS.ProcessEnv; - readonly clientVersion: string; + /** The installed CLI's version, as its own host reports it; omitted when unknown. */ + readonly clientVersion?: string | undefined; } /** @@ -404,6 +420,7 @@ export const makeZcodeRequestAuth = (options: ZcodeRequestAuthOptions): ZcodeReq const root = zcodeDataRoot(options.environment) ?? ""; const credentialsPath = `${root}/.zcode/v2/credentials.json`; const cache = new Map(); + const credentialCache: CredentialStoreCache = { digest: "", store: {} }; /** The family's current login with the credential scope digesting it; undefined once gone. */ const currentLogin = Effect.fn("ZcodeRequestAuth.currentLogin")(function* ( @@ -414,6 +431,7 @@ export const makeZcodeRequestAuth = (options: ZcodeRequestAuthOptions): ZcodeReq credentialsPath, options.environment, yield* HostProcess.Platform, + credentialCache, ); const loginToken = store[`oauth:${family}:access_token`]; if (loginToken === undefined || loginToken.length === 0) return undefined; @@ -530,7 +548,10 @@ export const makeZcodeRequestAuth = (options: ZcodeRequestAuthOptions): ZcodeReq { headers: { Authorization: `Bearer ${login.loginToken}` } }, ).pipe( HttpClientRequest.bodyText( - zcodeJsonStringify({ clientType: "zcode", clientVersion: options.clientVersion }), + zcodeJsonStringify({ + clientType: "zcode", + ...(options.clientVersion === undefined ? {} : { clientVersion: options.clientVersion }), + }), "application/json", ), ); diff --git a/packages/provider-zcode/src/server/zcodeRuntime.test.ts b/packages/provider-zcode/src/server/zcodeRuntime.test.ts index 6602acda2dc2f..b01f3c69e982e 100644 --- a/packages/provider-zcode/src/server/zcodeRuntime.test.ts +++ b/packages/provider-zcode/src/server/zcodeRuntime.test.ts @@ -17,6 +17,7 @@ import { resolveZcodeLaunchCommand, zcodeBuiltinCatalogPath, zcodeBundledCatalogPath, + zcodeBaseUrl, zcodeDataRoot, zcodeModelSelectionFromSlug, zcodePersonalProviderConfigPath, @@ -134,6 +135,23 @@ describe("zcodeBuiltinCatalogPath", () => { ); }); + it("keys an overridden endpoint's catalog on its normalized origin", () => { + const originHash = NodeCrypto.createHash("sha256") + .update("https://proxy.example") + .digest("hex") + .slice(0, 32); + assert.equal( + zcodeBuiltinCatalogPath({ + root: "/home/dev", + appVersion: "3.17.2", + platform: "linux", + arch: "x64", + baseUrl: "https://proxy.example/team/", + }), + `/home/dev/.zcode/v2/runtime/provider/linux-x86_64/3.17.2/endpoint-${originHash}/zcode-builtin.json`, + ); + }); + it("derives the bundled and personal paths from the data root", () => { assert.equal( zcodeBundledCatalogPath("/home/dev"), @@ -293,6 +311,16 @@ describe("zcodeRuntimeEnvironment", () => { assert.equal(env.ZCODE_PERSONAL_PROVIDER_CONFIG_FILE, undefined); }); + it("honors a configured endpoint and normalizes it to an origin", () => { + const env = zcodeRuntimeEnvironment( + { HOME: "/home/dev", ZCODE_BASE_URL: " https://proxy.example/team/ " }, + {}, + ); + assert.equal(env.ZCODE_BASE_URL, "https://proxy.example"); + // Unparseable values keep their text so the catalog lookup fails honestly. + assert.equal(zcodeBaseUrl({ ZCODE_BASE_URL: "not a url" }), "not a url"); + }); + it("emits catalog env vars only for files that exist", () => { const env = zcodeRuntimeEnvironment( {}, @@ -501,6 +529,38 @@ it.layer(NodeServices.layer)("ZcodeRuntime catalog resolution", (it) => { ), ); + it.effect("resolves the catalog under the overridden endpoint directory", () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "zcode-runtime-test-" }); + const platformDir = zcodePlatformDir( + yield* HostProcess.Platform, + yield* HostProcess.Architecture, + ); + if (platformDir === undefined) return; + const origin = "https://proxy.example"; + const endpointHash = NodeCrypto.createHash("sha256") + .update(origin) + .digest("hex") + .slice(0, 32); + yield* fs.makeDirectory(`${root}/.zcode/server`, { recursive: true }); + yield* fs.writeFileString(`${root}/.zcode/server/current.json`, '{"version":"3.17.1"}'); + const builtinDir = `${root}/.zcode/v2/runtime/provider/${platformDir}/3.17.1/endpoint-${endpointHash}`; + yield* fs.makeDirectory(builtinDir, { recursive: true }); + yield* fs.writeFileString( + `${builtinDir}/zcode-builtin.json`, + JSON.stringify(inlineCatalog), + ); + const resolved = yield* resolveZcodeCatalogFiles(fs, { + ZCODE_DATA_BASE_DIR: root, + ZCODE_BASE_URL: origin, + }); + assert.equal(resolved.builtinPath, `${builtinDir}/zcode-builtin.json`); + }), + ), + ); + it.effect("keeps an unreadable current.json from producing catalog env vars", () => Effect.scoped( Effect.gen(function* () { diff --git a/packages/provider-zcode/src/server/zcodeRuntime.ts b/packages/provider-zcode/src/server/zcodeRuntime.ts index 824a08d47c20d..e4418499e21af 100644 --- a/packages/provider-zcode/src/server/zcodeRuntime.ts +++ b/packages/provider-zcode/src/server/zcodeRuntime.ts @@ -15,6 +15,20 @@ const ZCODE_BASE_URL = "https://zcode.z.ai"; const DEFAULT_ZCODE_BINARY = "zcode"; +/** The configured endpoint, normalized to an origin exactly like the CLI's cache key. */ +const normalizeZcodeEndpoint = (raw: string | undefined): string => { + const trimmed = raw?.trim(); + if (trimmed === undefined || trimmed.length === 0) return ZCODE_BASE_URL; + try { + return new URL(trimmed).origin; + } catch { + return trimmed; + } +}; + +export const zcodeBaseUrl = (environment: NodeJS.ProcessEnv): string => + normalizeZcodeEndpoint(environment.ZCODE_BASE_URL); + /** Wire shape of `model` in session/create and `modelSelection` in session/send. */ export interface ZcodeModelSelection { readonly providerId: string; @@ -31,10 +45,13 @@ export interface ZcodeCatalogFileSet { } const ZCODE_ENDPOINT_HASH_LENGTH = 32; -const ZCODE_ENDPOINT_HASH = NodeCrypto.createHash("sha256") - .update(ZCODE_BASE_URL) - .digest("hex") - .slice(0, ZCODE_ENDPOINT_HASH_LENGTH); + +/** The CLI keys its endpoint-scoped catalog directory on sha256 of the endpoint origin. */ +const zcodeEndpointHash = (baseUrl: string): string => + NodeCrypto.createHash("sha256") + .update(baseUrl) + .digest("hex") + .slice(0, ZCODE_ENDPOINT_HASH_LENGTH); /** zcode stores CDN-refreshed catalogs under its own platform spelling, not process.platform. */ export function zcodePlatformDir(platform: NodeJS.Platform, arch: string): string | undefined { @@ -62,10 +79,12 @@ export function zcodeBuiltinCatalogPath(input: { readonly appVersion: string; readonly platform: NodeJS.Platform; readonly arch: string; + /** Endpoint origin the CLI would normalize; defaults to the production endpoint. */ + readonly baseUrl?: string; }): string | undefined { const platformDir = zcodePlatformDir(input.platform, input.arch); if (platformDir === undefined) return undefined; - return `${input.root}/.zcode/v2/runtime/provider/${platformDir}/${input.appVersion}/endpoint-${ZCODE_ENDPOINT_HASH}/zcode-builtin.json`; + return `${input.root}/.zcode/v2/runtime/provider/${platformDir}/${input.appVersion}/endpoint-${zcodeEndpointHash(normalizeZcodeEndpoint(input.baseUrl))}/zcode-builtin.json`; } export function zcodeBundledCatalogPath(root: string): string { @@ -127,6 +146,7 @@ export const resolveZcodeCatalogFiles = Effect.fn("ZcodeRuntime.resolveCatalogFi appVersion, platform: yield* HostProcess.Platform, arch: yield* HostProcess.Architecture, + baseUrl: zcodeBaseUrl(environment), }); if (builtinPath !== undefined && (yield* catalogFileExists(fileSystem, builtinPath))) { result.builtinPath = builtinPath; @@ -146,13 +166,14 @@ export const resolveZcodeCatalogFiles = Effect.fn("ZcodeRuntime.resolveCatalogFi /** * The CLI requires ZCODE_BUILTIN_PROVIDER_CONFIG_FILE and ZCODE_PERSONAL_PROVIDER_CONFIG_FILE * together; a builtin var without the personal one fails session startup, so the personal var - * is dropped when its file is missing. + * is dropped when its file is missing. ZCODE_BASE_URL stays overridable: its normalized origin + * also keys the catalog path, so a proxy endpoint resolves the matching catalog directory. */ export function zcodeRuntimeEnvironment( environment: NodeJS.ProcessEnv, files: ZcodeCatalogFileSet, ): NodeJS.ProcessEnv { - const next: NodeJS.ProcessEnv = { ...environment, ZCODE_BASE_URL }; + const next: NodeJS.ProcessEnv = { ...environment, ZCODE_BASE_URL: zcodeBaseUrl(environment) }; if (files.builtinPath !== undefined) { next.ZCODE_BUILTIN_PROVIDER_CONFIG_FILE = files.builtinPath; if (files.bundledPath !== undefined) { From f039c0ee6e4e454a9404cf8b6a5586422f91b0aa Mon Sep 17 00:00:00 2001 From: panudetjt <3482285+panudetjt@users.noreply.github.com> Date: Wed, 11 Nov 2026 20:02:35 +0700 Subject: [PATCH 20/23] feat(provider-zcode): attach to the zcode serve daemon instead of spawning app-server The provider spawned `zcode app-server --stdio` per turn and minted its own credentials, duplicating what the zcode CLI already owns and skipping the user-level daemon. T3 now attaches to `zcode serve`: it reads the daemon status file for the port and ingress token, starts the launcher detached only when no daemon is running, and streams the v4 conversation channel. Channel calls map onto the existing adapter surface and interactions resolve through `resolveInteraction`. Account-config pushing and the credential module are gone; the daemon owns the exchange. --- .../Adapters/ZcodeAdapterV2.test.ts | 68 +- .../Adapters/ZcodeAdapterV2.testkit.ts | 25 +- docs/user/providers-zcode.md | 14 +- packages/provider-zcode/src/server/adapter.ts | 174 +---- packages/provider-zcode/src/server/status.ts | 8 +- .../src/server/zcodeEntitlements.test.ts | 191 ++++++ .../src/server/zcodeEntitlements.ts | 229 +++++++ .../src/server/zcodeProtocol.test.ts | 249 ------- .../src/server/zcodeProtocol.ts | 410 +----------- .../src/server/zcodeRequestAuth.test.ts | 236 ------- .../src/server/zcodeRequestAuth.ts | 630 ------------------ .../src/server/zcodeRuntime.test.ts | 146 +--- .../provider-zcode/src/server/zcodeRuntime.ts | 8 +- .../src/server/zcodeServeConnection.ts | 458 +++++++++++++ .../src/server/zcodeServeConversation.test.ts | 450 +++++++++++++ .../src/server/zcodeServeConversation.ts | 528 +++++++++++++++ .../src/server/zcodeServeDaemon.ts | 209 ++++++ .../src/server/zcodeServeProtocol.test.ts | 126 ++++ .../src/server/zcodeServeProtocol.ts | 460 +++++++++++++ packages/provider-zcode/src/settings.ts | 3 +- packages/provider-zcode/src/testing.ts | 1 - 21 files changed, 2730 insertions(+), 1893 deletions(-) create mode 100644 packages/provider-zcode/src/server/zcodeEntitlements.test.ts create mode 100644 packages/provider-zcode/src/server/zcodeEntitlements.ts delete mode 100644 packages/provider-zcode/src/server/zcodeProtocol.test.ts delete mode 100644 packages/provider-zcode/src/server/zcodeRequestAuth.test.ts delete mode 100644 packages/provider-zcode/src/server/zcodeRequestAuth.ts create mode 100644 packages/provider-zcode/src/server/zcodeServeConnection.ts create mode 100644 packages/provider-zcode/src/server/zcodeServeConversation.test.ts create mode 100644 packages/provider-zcode/src/server/zcodeServeConversation.ts create mode 100644 packages/provider-zcode/src/server/zcodeServeDaemon.ts create mode 100644 packages/provider-zcode/src/server/zcodeServeProtocol.test.ts create mode 100644 packages/provider-zcode/src/server/zcodeServeProtocol.ts diff --git a/apps/server/src/orchestration-v2/Adapters/ZcodeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ZcodeAdapterV2.test.ts index 26ccb55a805ed..41a1e9c76ade6 100644 --- a/apps/server/src/orchestration-v2/Adapters/ZcodeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ZcodeAdapterV2.test.ts @@ -30,7 +30,6 @@ import { ZCODE_PROVIDER, makeZcodeAdapterV2 } from "@t3tools/provider-zcode/test import { type FakeZcodeScenario, ZcodeAdapterTestLayer, - fakeZcodeRequestAuth, makeFakeZcodeConnection, makeZcodeAdapterOptions, zcodeDriverKind, @@ -451,52 +450,29 @@ describe("ZcodeAdapterV2 (frame-driven)", () => { }).pipe(Effect.scoped, Effect.provide(ZcodeAdapterTestLayer)), ); - it.effect( - "sends account-model turns with the advertised reasoning level and serves runtime headers", - () => - Effect.gen(function* () { - const harness = yield* makeHarness({ makeRequestAuth: fakeZcodeRequestAuth() }); - // "default" omits the model entirely; account slugs select per turn. - yield* harness.startTurn(TURN_TEXT, "account:zai-individual-coding-plan/GLM-5.3-Flash"); - const send = requestFor(harness.scenario, "session/send"); - assert.isDefined(send); - assert.deepEqual(send!.params["modelSelection"], { - providerId: "account:zai-individual-coding-plan", - modelId: "GLM-5.3-Flash", - options: { reasoningLevel: "max" }, - }); - - // Before an account-model request the CLI asks the host for project runtime headers; - // the injected request auth's response must reach the wire verbatim. - const broker = zcodeServerRequest("interaction/requestProviderRuntimeHeaders", { - sessionId: harness.scenario.createdSessionId(), - modelSelection: { - providerId: "account:zai-individual-coding-plan", - modelId: "GLM-5.3-Flash", - options: { reasoningLevel: "max" }, - }, - }); - yield* harness.scenario.offerEvent(broker); - // The first two responds are the create-time runtime preferences and MCP auth headers. - yield* harness.scenario.takeRespond; - yield* harness.scenario.takeRespond; - const answered = yield* harness.scenario.takeRespond; - assert.equal(answered.id, broker.id); - assert.deepEqual(answered.result, { - headersApplied: true, - requestAuth: { apiKey: "test", apiKeyId: "k", accountScope: "a" }, - }); + it.effect("sends account-model turns with the advertised reasoning level", () => + Effect.gen(function* () { + const harness = yield* makeHarness(); + // "default" omits the model entirely; account slugs select per turn. + yield* harness.startTurn(TURN_TEXT, "account:zai-individual-coding-plan/GLM-5.3-Flash"); + const send = requestFor(harness.scenario, "session/send"); + assert.isDefined(send); + assert.deepEqual(send!.params["modelSelection"], { + providerId: "account:zai-individual-coding-plan", + modelId: "GLM-5.3-Flash", + options: { reasoningLevel: "max" }, + }); - yield* harness.offerTurnEvent("turn.completed", { - response: "pong", - tokenCount: 3, - toolCallCount: 0, - duration: 60, - resultType: "success", - }); - yield* harness.takeEvent("turn.terminal", (event) => event.status === "completed"); - assert.deepEqual(harness.scenario.unexpectedMethods(), []); - }).pipe(Effect.scoped, Effect.provide(ZcodeAdapterTestLayer)), + yield* harness.offerTurnEvent("turn.completed", { + response: "pong", + tokenCount: 3, + toolCallCount: 0, + duration: 60, + resultType: "success", + }); + yield* harness.takeEvent("turn.terminal", (event) => event.status === "completed"); + assert.deepEqual(harness.scenario.unexpectedMethods(), []); + }).pipe(Effect.scoped, Effect.provide(ZcodeAdapterTestLayer)), ); it.effect("maps shell tool lifecycle into command execution items", () => diff --git a/apps/server/src/orchestration-v2/Adapters/ZcodeAdapterV2.testkit.ts b/apps/server/src/orchestration-v2/Adapters/ZcodeAdapterV2.testkit.ts index 611850c8f76c6..9f1c78a7e07c2 100644 --- a/apps/server/src/orchestration-v2/Adapters/ZcodeAdapterV2.testkit.ts +++ b/apps/server/src/orchestration-v2/Adapters/ZcodeAdapterV2.testkit.ts @@ -46,8 +46,6 @@ export interface FakeZcodeScenario { } const KNOWN_METHODS = new Set([ - "provider/updateAccountConfig", - "provider/refreshRegistry", "session/create", "session/read", "session/resume", @@ -119,7 +117,7 @@ function zcodeSessionCreateResult(sessionId: string, params: ZcodeRecord): Zcode }; } -/** Builds the fake `zcode app-server` NDJSON peer the adapter talks to. */ +/** Builds the fake ZCode connection peer the adapter talks to. */ export const makeFakeZcodeConnection = Effect.fnUntraced(function* (): Effect.fn.Return< FakeZcodeScenario, never, @@ -182,14 +180,6 @@ export const makeFakeZcodeConnection = Effect.fnUntraced(function* (): Effect.fn }); return { sessionId: createdSessionId, accepted: true, stateRevision: 1 }; } - case "provider/updateAccountConfig": - return { - receivedRevision: String(params["revision"] ?? ""), - providerCount: Object.keys((params["providers"] ?? {}) as ZcodeRecord).length, - status: "received", - }; - case "provider/refreshRegistry": - return { refreshed: true }; case "session/read": return zcodeSessionCreateResult(createdSessionId ?? "sess_unknown", params); case "session/stop": @@ -263,19 +253,6 @@ export const makeZcodeAdapterOptions = (): ZcodeAdapterV2Options => ({ environment: {}, }); -/** - * Request auth the adapter can drive without touching the local credential store or network: - * every runtime-headers request is answered with the fixed successful exchange. - */ -export const fakeZcodeRequestAuth = - (): NonNullable => () => ({ - answerRuntimeHeaders: () => - Effect.succeed({ - headersApplied: true, - requestAuth: { apiKey: "test", apiKeyId: "k", accountScope: "a" }, - }), - }); - /** The driver kind the package driver and the hand-built adapter options must agree on. */ export const zcodeDriverKind: typeof ZCODE_PROVIDER = ZCODE_PROVIDER; diff --git a/docs/user/providers-zcode.md b/docs/user/providers-zcode.md index f24de6f6567df..88472a201b422 100644 --- a/docs/user/providers-zcode.md +++ b/docs/user/providers-zcode.md @@ -1,7 +1,8 @@ # ZCode -T3 Code runs the ZCode CLI as an agent, using your own Z.AI account, skills, plugins, and MCP -configuration. +T3 Code attaches to the ZCode daemon (`zcode serve`) as an agent, using your own Z.AI account, +skills, plugins, and MCP configuration. T3 Code starts the daemon when it is not already running +and leaves it running when the session ends, so other ZCode clients on the machine keep working. ## Set Up ZCode @@ -16,11 +17,10 @@ with T3 Code; the server's own `zcode login` is what counts. ## Credentials -T3 Code never stores your Z.AI sign-in. The ZCode agent asks its host for credentials before every -account-model request, so the server stands in as that host: it reads the sign-in `zcode login` -already wrote on the same machine, exchanges it for a short-lived access token through the same -Z.AI endpoints the ZCode app uses, and holds that token in memory only. The store is never -written, so signing out or signing in again from a terminal applies on the next refresh. +T3 Code never stores or reads your Z.AI sign-in. The ZCode daemon owns the credential exchange: +it reads the sign-in `zcode login` wrote on the same machine and mints the short-lived account +tokens its turns need. T3 Code attaches to that daemon and never handles the tokens. Signing out +or signing in again from a terminal applies to the next turn. ## Models diff --git a/packages/provider-zcode/src/server/adapter.ts b/packages/provider-zcode/src/server/adapter.ts index 6dfb85848796a..08d6a310d43f2 100644 --- a/packages/provider-zcode/src/server/adapter.ts +++ b/packages/provider-zcode/src/server/adapter.ts @@ -1,6 +1,4 @@ -import { AgentScope } from "@t3tools/shared/AgentScope"; import * as HostProcess from "@t3tools/shared/HostProcess"; -import { causeErrorTag } from "@t3tools/shared/observability"; import * as Predicate from "effect/Predicate"; import { type OrchestrationV2ConversationMessage, @@ -32,6 +30,7 @@ import * as HttpClient from "effect/http/HttpClient"; import * as Option from "effect/Option"; import * as Queue from "effect/Queue"; import * as Schema from "effect/Schema"; +import * as Scope from "effect/Scope"; import * as Semaphore from "effect/Semaphore"; import * as Stream from "effect/Stream"; import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; @@ -54,30 +53,21 @@ import { import { isServerRequest, - makeZcodeConnection, zcodeJsonStringify, zcodeRecordArray, zcodeRecordField, zcodeRecordNumber, zcodeRecordString, type ZcodeConnection, + type ZcodeProtocolError, + type ZcodeProtocolTimeoutError, type ZcodeRecord, } from "./zcodeProtocol.ts"; +import { zcodeModelSelectionFromSlug } from "./zcodeRuntime.ts"; import { - readZcodeAppVersion, - resolveZcodeCatalogFiles, - resolveZcodeLaunchCommand, - zcodeDataRoot, - zcodeModelSelectionFromSlug, - zcodeRuntimeEnvironment, - zcodeSpawnArgs, - type ZcodeCatalogFileSet, -} from "./zcodeRuntime.ts"; -import { - makeZcodeRequestAuth, - zcodeAccountConfigPushFromLadder, - zcodeFamilyFromProviderId, -} from "./zcodeRequestAuth.ts"; + makeZcodeServeConnection, + type ZcodeServeConnectionOptions, +} from "./zcodeServeConnection.ts"; export const ZCODE_PROVIDER: ProviderDriverKind = ProviderDriverKind.make("zcode"); const DEFAULT_ZCODE_SETTINGS = Schema.decodeSync(ZcodeSettings)({}); @@ -192,10 +182,14 @@ export interface ZcodeAdapterV2Options { readonly settings: ZcodeSettings; /** Instance environment already merged with the host process environment. */ readonly environment: NodeJS.ProcessEnv; - /** Testkit injection point; production spawns the real `zcode app-server`. */ - readonly makeConnection?: typeof makeZcodeConnection; - /** Testkit injection point; production mints OAuth project tokens from the local store. */ - readonly makeRequestAuth?: typeof makeZcodeRequestAuth; + /** Testkit injection point; production attaches to the local `zcode serve` daemon. */ + readonly makeConnection?: ( + options: ZcodeServeConnectionOptions, + ) => Effect.Effect< + ZcodeConnection, + ZcodeProtocolError | ZcodeProtocolTimeoutError, + ChildProcessSpawner.ChildProcessSpawner | Scope.Scope + >; } function providerRef( @@ -418,7 +412,6 @@ export const makeZcodeAdapterV2 = Effect.fn("makeZcodeAdapterV2")(function* ( options: ZcodeAdapterV2Options, ) { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const agentScope = yield* AgentScope; const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const host = yield* ProviderHost.ProviderHost; @@ -432,20 +425,6 @@ export const makeZcodeAdapterV2 = Effect.fn("makeZcodeAdapterV2")(function* ( ...(payload === undefined ? {} : { payload }), }); - // The CLI's own host reports the installed version in the token exchange; unknown stays omitted. - const dataRoot = zcodeDataRoot(options.environment); - const clientVersion = - dataRoot === undefined - ? undefined - : yield* readZcodeAppVersion(fileSystem, `${dataRoot}/.zcode/server/current.json`); - - const requestAuth = (options.makeRequestAuth ?? makeZcodeRequestAuth)({ - fileSystem, - httpClient, - environment: options.environment, - clientVersion, - }); - const zcodeMcpServers = ( session: McpProviderSessionConfig | undefined, ): ReadonlyArray | undefined => @@ -474,34 +453,14 @@ export const makeZcodeAdapterV2 = Effect.fn("makeZcodeAdapterV2")(function* ( ) { const scope = yield* Effect.scope; const cwd = input.runtimePolicy.cwd ?? host.paths.cwd; - // Catalog files must exist before spawn: the CLI fails closed on dangling env paths. - const catalogFiles: ZcodeCatalogFileSet = yield* resolveZcodeCatalogFiles( - fileSystem, - options.environment, - ); - const env = zcodeRuntimeEnvironment(options.environment, catalogFiles); - const launchCommand = yield* resolveZcodeLaunchCommand( - options.settings, - options.environment, - fileSystem, - ); - const scopedLaunch = yield* agentScope.wrap({ - command: launchCommand, - args: zcodeSpawnArgs(options.settings.launchArgs), - name: "zcode", - threadId: input.threadId, - env, - }); - // Finalizers run in reverse order: reap detached work after terminating zcode. - const scopeUnit = scopedLaunch.unit; - const stopScope = - scopeUnit === undefined ? Effect.void : yield* Effect.cached(agentScope.stop(scopeUnit)); - yield* Effect.addFinalizer(() => stopScope); - const connection: ZcodeConnection = yield* (options.makeConnection ?? makeZcodeConnection)({ - command: scopedLaunch.command, - args: scopedLaunch.args, + const connection: ZcodeConnection = yield* ( + options.makeConnection ?? makeZcodeServeConnection + )({ + environment: options.environment, + settings: options.settings, cwd, - env, + fileSystem, + httpClient, }).pipe( Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), Effect.mapError( @@ -513,13 +472,8 @@ export const makeZcodeAdapterV2 = Effect.fn("makeZcodeAdapterV2")(function* ( }), ), ); - // Detached children can keep stdout open after zcode exits. - if (scopeUnit !== undefined) { - yield* connection.exited.pipe( - Effect.matchEffect({ onFailure: () => stopScope, onSuccess: () => stopScope }), - Effect.forkIn(scope), - ); - } + // A daemon restart surfaces as a closed channel; the pump below fails with it. + yield* connection.exited.pipe(Effect.ignore, Effect.forkIn(scope)); const now = yield* DateTime.now; let sessionEntity: OrchestrationV2ProviderSession = { @@ -1503,45 +1457,6 @@ export const makeZcodeAdapterV2 = Effect.fn("makeZcodeAdapterV2")(function* ( yield* connection.respond(respondId, {}); return; } - case "interaction/requestProviderRuntimeHeaders": { - const params = zcodeRecordField(record, "params"); - const providerId = - zcodeRecordString(params ?? {}, "providerId") ?? - zcodeRecordString( - zcodeRecordField(params ?? {}, "modelSelection") ?? {}, - "providerId", - ); - const family = zcodeFamilyFromProviderId(providerId); - if (family === undefined) { - yield* connection.respond(respondId, { - headersApplied: false, - errorMessage: "Unsupported ZCode account provider.", - }); - return; - } - const rejectedProjectTokenFingerprint = zcodeRecordString( - params ?? {}, - "rejectedProjectTokenFingerprint", - ); - const answer = requestAuth.answerRuntimeHeaders({ - family, - ...(rejectedProjectTokenFingerprint === undefined - ? {} - : { rejectedProjectTokenFingerprint }), - }); - // Minting can take seconds on a cache miss; the CLI correlates the reply by id, so - // fork it instead of stalling every session event this permit serializes. - yield* answer.pipe( - Effect.flatMap((response) => connection.respond(respondId, response)), - Effect.catchCause((cause) => - Effect.logWarning("ZCode runtime headers answer failed.", { - errorTag: causeErrorTag(cause), - }), - ), - Effect.forkIn(scope), - ); - return; - } case "interaction/requestPermission": { const params = zcodeRecordField(record, "params"); const nativeRequestId = @@ -1656,7 +1571,7 @@ export const makeZcodeAdapterV2 = Effect.fn("makeZcodeAdapterV2")(function* ( ? null : makeProviderFailure({ cause, - message: "zcode app-server exited unexpectedly.", + message: "The ZCode server connection was lost.", class: "transport_error", }); yield* finalizeTurn(state); @@ -1666,8 +1581,8 @@ export const makeZcodeAdapterV2 = Effect.fn("makeZcodeAdapterV2")(function* ( yield* updateProviderSession( interrupted ? "stopped" : "error", interrupted - ? "zcode app-server was stopped." - : "zcode app-server exited unexpectedly.", + ? "The ZCode server connection was closed." + : "The ZCode server connection was lost.", ); yield* Queue.fail( events, @@ -1682,40 +1597,7 @@ export const makeZcodeAdapterV2 = Effect.fn("makeZcodeAdapterV2")(function* ( Effect.forkIn(scope), ); - // Provisioning must precede session/create: the CLI resolves models against the registry - // it builds from this push. Best-effort — a rejected push surfaces honestly as a - // "Select a model" turn failure instead of failing the session open. - const bootstrapProviderRegistry = Effect.fn("ZcodeAdapter.bootstrapProviderRegistry")( - function* () { - if (catalogFiles.builtinCatalog === undefined || catalogFiles.builtinPath === undefined) { - yield* Effect.logWarning( - "ZCode builtin catalog unavailable; account models will report as missing.", - { builtinPath: catalogFiles.builtinPath ?? null }, - ); - return; - } - const push = yield* zcodeAccountConfigPushFromLadder(fileSystem, { - builtinPath: catalogFiles.builtinPath, - builtinCatalog: catalogFiles.builtinCatalog, - environment: options.environment, - }); - if (push === undefined) { - yield* Effect.logWarning( - "ZCode catalog declares no account providers; account models will report as missing.", - ); - return; - } - yield* request("provider/updateAccountConfig", push).pipe( - Effect.tapError((error) => - Effect.logWarning("ZCode account config push failed.", { - operation: error.operation, - }), - ), - Effect.ignore, - ); - }, - ); - yield* bootstrapProviderRegistry(); + // The daemon owns provider/credential provisioning, so no account config is pushed here. const subscribeSession = Effect.fnUntraced(function* (sessionId: string) { const result = yield* request("session/subscribe", { diff --git a/packages/provider-zcode/src/server/status.ts b/packages/provider-zcode/src/server/status.ts index 7955e64ff444d..e1462d3c6ea66 100644 --- a/packages/provider-zcode/src/server/status.ts +++ b/packages/provider-zcode/src/server/status.ts @@ -6,7 +6,7 @@ import { } from "@t3tools/contracts"; import type { ZcodeSettings } from "../settings.ts"; import { resolveZcodeCatalogFiles, resolveZcodeLaunchCommand } from "./zcodeRuntime.ts"; -import { zcodeEntitledModels, zcodeOAuthSelectedFamilies } from "./zcodeRequestAuth.ts"; +import { zcodeEntitledModels, zcodeOAuthSelectedFamilies } from "./zcodeEntitlements.ts"; import { resolveSpawnCommand } from "@t3tools/shared/shell"; import { compareSemverVersions } from "@t3tools/shared/semver"; import { createModelCapabilities } from "@t3tools/shared/model"; @@ -136,8 +136,8 @@ const runZcodeDoctor = Effect.fn("ZcodeProviderStatus.runDoctor")(function* ( /** * Auth is CLI-managed (`zcode login`); the probe derives status from the same credential-store - * keys and setting.json plan selections the provisioning ladder accepts, so an account that can - * make ZCode requests never shows a false `zcode login` warning. + * keys and setting.json plan selections the model picker accepts, so an account that can make + * ZCode requests never shows a false `zcode login` warning. */ export const zcodeAuthFromCredentials = ( data: unknown, @@ -153,7 +153,7 @@ export const zcodeAuthFromCredentials = ( ); return hasActiveProvider ? "authenticated" : "unauthenticated"; } - // Object stores are judged like provisioning, which ignores any oauth:active_provider marker. + // Object stores are judged like the picker, which ignores any oauth:active_provider marker. if (typeof data === "object" && data !== null) { return zcodeOAuthSelectedFamilies(setting, data as Record).size > 0 ? "authenticated" diff --git a/packages/provider-zcode/src/server/zcodeEntitlements.test.ts b/packages/provider-zcode/src/server/zcodeEntitlements.test.ts new file mode 100644 index 0000000000000..3fa0674840e95 --- /dev/null +++ b/packages/provider-zcode/src/server/zcodeEntitlements.test.ts @@ -0,0 +1,191 @@ +// @effect-diagnostics nodeBuiltinImport:off -- tests craft the CLI's own AES-GCM store values with node crypto +import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as HostProcess from "@t3tools/shared/HostProcess"; +import { assert, describe, it } from "@effect/vitest"; +import * as NodeCrypto from "node:crypto"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; + +import { + decryptZcodeCredential, + resolveZcodeCredentialSecret, + zcodeEntitledModels, +} from "./zcodeEntitlements.ts"; + +/** Produces store values the CLI's fallback cipher would hold, keyed the same way. */ +const encryptZcodeCredential = (value: string, secret: string): string => { + const iv = NodeCrypto.randomBytes(12); + const cipher = NodeCrypto.createCipheriv( + "aes-256-gcm", + NodeCrypto.createHash("sha256").update(secret).digest(), + iv, + ); + const ciphertext = Buffer.concat([cipher.update(value, "utf8"), cipher.final()]); + return `enc:v1:${iv.toString("base64url")}.${cipher.getAuthTag().toString("base64url")}.${ciphertext.toString("base64url")}`; +}; + +const catalog = { + config: { + providerConfigRules: { + providerRules: [ + { + providerId: "account:zai-individual-coding-plan", + config: { + access: { type: "zhipu-account", mode: "individual-coding-plan", accountType: "zai" }, + builtinModelIds: ["GLM-5.3", "GLM-5.3-Flash"], + }, + }, + { + providerId: "account:zai-team-coding-plan", + config: { + access: { type: "zhipu-account", mode: "team-coding-plan", accountType: "zai" }, + builtinModelIds: ["GLM-5.3"], + }, + }, + { + providerId: "zai-api", + config: { access: { type: "api-key" }, builtinModelIds: ["GLM-5.3"] }, + }, + ], + }, + modelConfigRules: { + modelRules: [ + { + modelMatch: "^GLM-5\\.3$", + config: { optionSpecs: { reasoningLevel: { values: ["low", "high", "max"] } } }, + }, + { + modelMatch: "^GLM-5\\.3-Flash$", + config: { optionSpecs: { reasoningLevel: { values: ["low", "high"] } } }, + }, + ], + }, + }, +}; + +describe("resolveZcodeCredentialSecret", () => { + it("prefers ZCODE_CREDENTIAL_SECRET and trims it", () => { + assert.equal( + resolveZcodeCredentialSecret({ ZCODE_CREDENTIAL_SECRET: " custom-secret " }, "linux"), + "custom-secret", + ); + const blankOverride = resolveZcodeCredentialSecret( + { ZCODE_CREDENTIAL_SECRET: " ", HOME: "/home/dev" }, + "linux", + ); + assert.match(blankOverride, /^zcode-credential-fallback:linux:\/home\/dev:/); + }); + + it("derives the CLI's machine-bound fallback with the HOME from the environment", () => { + // Username comes from os.userInfo() where resolvable (env names never win); on runtimes + // where it throws, the env fallback speaks. Only the stable parts are asserted here. + const secret = resolveZcodeCredentialSecret( + { USER: "someoneElse", HOME: "/home/dev" }, + "darwin", + ); + assert.match(secret, /^zcode-credential-fallback:darwin:\/home\/dev:/); + assert.match(resolveZcodeCredentialSecret({}, "linux"), /^zcode-credential-fallback:linux:/); + }); +}); + +describe("decryptZcodeCredential", () => { + it("round-trips the CLI's encrypted store values", () => { + const secret = "zcode-credential-fallback:linux:/home/dev:jame"; + for (const plaintext of ["oauth-login-token", "值-with-unicode 🔐", ""]) { + assert.equal( + decryptZcodeCredential(encryptZcodeCredential(plaintext, secret), secret), + plaintext, + ); + } + }); + + it("passes plain store values through and rejects malformed ciphertext", () => { + assert.equal(decryptZcodeCredential("plain-token", "s"), "plain-token"); + assert.throws(() => decryptZcodeCredential("enc:v1:not-a-cipher", "s")); + assert.throws(() => decryptZcodeCredential("enc:v1:only.two", "s")); + }); +}); + +describe("zcodeEntitledModels", () => { + const harness = (input: { + readonly customModels?: unknown; + readonly setting?: unknown; + readonly loginToken?: string; + }) => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "zcode-entitlements-test-" }); + const platform = yield* HostProcess.Platform; + const environment = { ZCODE_DATA_BASE_DIR: root, HOME: "/home/dev", USER: "dev" }; + yield* fs.makeDirectory(`${root}/.zcode/v2`, { recursive: true }); + if (input.setting !== undefined) { + yield* fs.writeFileString( + `${root}/.zcode/v2/setting.json`, + JSON.stringify(input.setting), + ); + } + if (input.loginToken !== undefined) { + yield* fs.writeFileString( + `${root}/.zcode/v2/credentials.json`, + JSON.stringify({ + "oauth:zai:access_token": encryptZcodeCredential( + input.loginToken, + resolveZcodeCredentialSecret(environment, platform), + ), + }), + ); + } + return yield* zcodeEntitledModels(fs, { + builtinPath: `${root}/zcode-builtin.json`, + builtinCatalog: input.customModels ?? catalog, + environment, + }); + }), + ).pipe(Effect.provide(NodeServices.layer)); + + it.effect("enumerates selected individual-coding-plan models with catalog reasoning levels", () => + Effect.gen(function* () { + const models = yield* harness({ + setting: { + providerFamilyConnectionSelections: { zai: { kind: "individual-coding-plan" } }, + }, + loginToken: "zai-login-token", + }); + assert.deepEqual(models, [ + { + providerId: "account:zai-individual-coding-plan", + modelId: "GLM-5.3", + reasoningLevels: ["low", "high", "max"], + }, + { + providerId: "account:zai-individual-coding-plan", + modelId: "GLM-5.3-Flash", + reasoningLevels: ["low", "high"], + }, + ]); + }), + ); + + it.effect("drops a family switched to apiKey mode even with a login token", () => + Effect.gen(function* () { + const models = yield* harness({ + setting: { + providerFamilyConnectionSelections: { zai: { kind: "individual-coding-plan" } }, + modelProviderFamilyModes: { zai: "apiKey" }, + }, + loginToken: "zai-login-token", + }); + assert.deepEqual(models, []); + }), + ); + + it.effect("skips catalogs without zhipu-account rules", () => + Effect.gen(function* () { + const models = yield* harness({ + customModels: { config: { providerConfigRules: { providerRules: [] } } }, + }); + assert.deepEqual(models, []); + }), + ); +}); diff --git a/packages/provider-zcode/src/server/zcodeEntitlements.ts b/packages/provider-zcode/src/server/zcodeEntitlements.ts new file mode 100644 index 0000000000000..b987452d0b901 --- /dev/null +++ b/packages/provider-zcode/src/server/zcodeEntitlements.ts @@ -0,0 +1,229 @@ +// @effect-diagnostics nodeBuiltinImport:off -- the CLI's credential store is AES-256-GCM under a key only node:crypto can derive +// The entitlement ladder shared by the model picker and the auth probe: which individual-coding-plan +// families the user selected in setting.json and holds a decryptable login token for in +// credentials.json, joined against the CLI catalog's zhipu-account rules. +import * as NodeCrypto from "node:crypto"; +import * as NodeOS from "node:os"; + +import * as HostProcess from "@t3tools/shared/HostProcess"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Option from "effect/Option"; +import * as Predicate from "effect/Predicate"; +import * as Schema from "effect/Schema"; + +import { + zcodeRecordArray, + zcodeRecordField, + zcodeRecordString, + type ZcodeRecord, +} from "./zcodeProtocol.ts"; +import { zcodeDataRoot } from "./zcodeRuntime.ts"; + +const ENCRYPTED_PREFIX = "enc:v1:"; +const CIPHER_ALGORITHM = "aes-256-gcm"; + +/** + * The CLI's machine-bound fallback cipher secret: sha256 of this string keys the store's + * AES-256-GCM values. ZCODE_CREDENTIAL_SECRET overrides it for custom installs. + */ +export const resolveZcodeCredentialSecret = ( + environment: NodeJS.ProcessEnv, + platform: NodeJS.Platform, +): string => { + const configured = environment.ZCODE_CREDENTIAL_SECRET?.trim(); + if (configured !== undefined && configured.length > 0) return configured; + // The CLI binds the fallback to os.userInfo().username — env USER/USERNAME may be absent in + // server processes and must not silently derive a different key. + let username = "unknown"; + try { + username = NodeOS.userInfo().username; + } catch { + username = environment.USER ?? environment.USERNAME ?? "unknown"; + } + const home = environment.HOME ?? NodeOS.homedir(); + return `zcode-credential-fallback:${platform}:${home}:${username}`; +}; + +export const decryptZcodeCredential = (value: string, secret: string): string => { + if (!value.startsWith(ENCRYPTED_PREFIX)) return value; + const [ivRaw, tagRaw, cipherRaw] = value.slice(ENCRYPTED_PREFIX.length).split("."); + if (ivRaw === undefined || tagRaw === undefined || cipherRaw === undefined) { + throw new Error("Encrypted credential is not ..."); + } + const decipher = NodeCrypto.createDecipheriv( + CIPHER_ALGORITHM, + NodeCrypto.createHash("sha256").update(secret).digest(), + Buffer.from(ivRaw, "base64url"), + ); + decipher.setAuthTag(Buffer.from(tagRaw, "base64url")); + return Buffer.concat([ + decipher.update(Buffer.from(cipherRaw, "base64url")), + decipher.final(), + ]).toString("utf8"); +}; + +const decodeJsonText = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Unknown)); + +const readDecryptedCredentialStore = Effect.fn("ZcodeEntitlements.readCredentialStore")(function* ( + fileSystem: FileSystem.FileSystem, + credentialsPath: string, + environment: NodeJS.ProcessEnv, + platform: NodeJS.Platform, +): Effect.fn.Return> { + const raw = yield* fileSystem.readFileString(credentialsPath).pipe(Effect.option); + if (Option.isNone(raw)) return {}; + const parsed = Option.getOrUndefined(decodeJsonText(raw.value)); + if (!Predicate.isObject(parsed)) return {}; + const secret = resolveZcodeCredentialSecret(environment, platform); + const entries: Record = {}; + for (const [key, value] of Object.entries(parsed)) { + if (typeof value !== "string") continue; + const decrypted = yield* Effect.try({ + try: () => decryptZcodeCredential(value, secret), + catch: () => undefined, + }).pipe(Effect.option); + if (Option.isSome(decrypted)) entries[key] = decrypted.value; + } + return entries; +}); + +/** + * Families the entitlement ladder treats as signed-in OAuth individual coding plans: setting.json + * selects the plan, no apiKey override, and the credential store holds a login token for the + * family. The provider status probe derives its auth display from this same predicate so the + * probe and the picker cannot drift. + */ +export const zcodeOAuthSelectedFamilies = ( + setting: unknown, + store: Record, +): ReadonlySet => { + if (!Predicate.isObject(setting)) return new Set(); + const selections = setting["providerFamilyConnectionSelections"]; + const modes = setting["modelProviderFamilyModes"]; + const families = new Set(); + if (!Predicate.isObject(selections)) return families; + for (const [family, selection] of Object.entries(selections)) { + const kind = Predicate.isObject(selection) ? selection["kind"] : undefined; + if (kind !== "individual-coding-plan") continue; + if (Predicate.isObject(modes) && modes[family] === "apiKey") continue; + const token = store[`oauth:${family}:access_token`]; + if (typeof token !== "string" || token.length === 0) continue; + families.add(family); + } + return families; +}; + +/** Families whose 3.15+ connection is an active OAuth individual coding plan. */ +const readOAuthSelectedFamilies = Effect.fn("ZcodeEntitlements.readOAuthFamilies")(function* ( + fileSystem: FileSystem.FileSystem, + settingPath: string, + store: Record, +): Effect.fn.Return> { + const raw = yield* fileSystem.readFileString(settingPath).pipe(Effect.option); + const parsed = Option.isNone(raw) ? undefined : Option.getOrUndefined(decodeJsonText(raw.value)); + return zcodeOAuthSelectedFamilies(parsed, store); +}); + +const zcodeRuleAccess = (rule: unknown): ZcodeRecord | undefined => { + const access = zcodeRecordField(zcodeRecordField(rule, "config"), "access"); + return Predicate.isObject(access) ? access : undefined; +}; + +const zcodeBuiltinAccountRules = (builtinCatalog: unknown): ReadonlyArray => { + const providerRules = zcodeRecordArray( + zcodeRecordField(zcodeRecordField(builtinCatalog, "config"), "providerConfigRules"), + "providerRules", + ); + return (providerRules ?? []).filter( + (rule): rule is ZcodeRecord => + Predicate.isObject(rule) && + zcodeRecordString(zcodeRuleAccess(rule), "type") === "zhipu-account", + ); +}; + +const zcodeBuiltinModelIds = (rule: ZcodeRecord): ReadonlyArray => { + const ids = zcodeRecordField(zcodeRecordField(rule, "config"), "builtinModelIds"); + return (Array.isArray(ids) ? ids : []).filter( + (id): id is string => typeof id === "string" && id.length > 0, + ); +}; + +/** + * Entitled individual-coding-plan models for the model picker: enumerated from the injected + * catalog plus the entitlement ladder, so a catalog refresh by the CLI adds models without an + * adapter change. Reasoning levels come from the catalog's modelRules (modelMatch regexes, + * matched case-insensitively against the model id). + */ +export interface ZcodeEntitledModel { + readonly providerId: string; + readonly modelId: string; + readonly reasoningLevels: ReadonlyArray; +} + +export const zcodeEntitledModels = Effect.fn("ZcodeEntitlements.entitledModels")(function* ( + fileSystem: FileSystem.FileSystem, + input: { + readonly builtinPath: string; + readonly builtinCatalog: unknown; + readonly environment: NodeJS.ProcessEnv; + }, +): Effect.fn.Return, never, never> { + const rules = zcodeBuiltinAccountRules(input.builtinCatalog); + const root = zcodeDataRoot(input.environment); + if (rules.length === 0 || root === undefined) return []; + const store = yield* readDecryptedCredentialStore( + fileSystem, + `${root}/.zcode/v2/credentials.json`, + input.environment, + yield* HostProcess.Platform, + ); + const families = yield* readOAuthSelectedFamilies( + fileSystem, + `${root}/.zcode/v2/setting.json`, + store, + ); + const modelRules = zcodeRecordArray( + zcodeRecordField(zcodeRecordField(input.builtinCatalog, "config"), "modelConfigRules"), + "modelRules", + ); + const levelsFor = (modelId: string): ReadonlyArray => { + // Later matching rules compose over earlier ones (the runtime's advertised levels are the + // last match: Flash hits glm-5.* then glm-5\.3, landing on [low, high, max]). + let levels: ReadonlyArray = []; + for (const rule of modelRules ?? []) { + const match = zcodeRecordString(rule, "modelMatch"); + if (match === undefined) continue; + let regex: RegExp; + try { + regex = new RegExp(match, "i"); + } catch { + continue; + } + if (!regex.test(modelId)) continue; + const specs = zcodeRecordField(zcodeRecordField(rule, "config"), "optionSpecs"); + const reasoning = zcodeRecordField(Predicate.isObject(specs) ? specs : {}, "reasoningLevel"); + const values = zcodeRecordArray(reasoning, "values")?.filter( + (value): value is string => typeof value === "string", + ); + if (values !== undefined && values.length > 0) levels = values; + } + return levels; + }; + const out: Array = []; + for (const rule of rules) { + const providerId = zcodeRecordString(rule, "providerId"); + const access = zcodeRuleAccess(rule); + if ( + providerId === undefined || + zcodeRecordString(access, "mode") !== "individual-coding-plan" || + !families.has(zcodeRecordString(access, "accountType") ?? "") + ) { + continue; + } + for (const modelId of zcodeBuiltinModelIds(rule)) { + out.push({ providerId, modelId, reasoningLevels: levelsFor(modelId) }); + } + } + return out; +}); diff --git a/packages/provider-zcode/src/server/zcodeProtocol.test.ts b/packages/provider-zcode/src/server/zcodeProtocol.test.ts deleted file mode 100644 index aa02b7350764e..0000000000000 --- a/packages/provider-zcode/src/server/zcodeProtocol.test.ts +++ /dev/null @@ -1,249 +0,0 @@ -import { assert, describe, it } from "@effect/vitest"; -import { expect, vi } from "vite-plus/test"; -import * as Cause from "effect/Cause"; -import * as Deferred from "effect/Deferred"; -import * as Duration from "effect/Duration"; -import * as Effect from "effect/Effect"; -import * as Exit from "effect/Exit"; -import * as Fiber from "effect/Fiber"; -import * as Option from "effect/Option"; -import * as Queue from "effect/Queue"; -import * as Sink from "effect/Sink"; -import * as Scope from "effect/Scope"; -import * as Stream from "effect/Stream"; -import * as TestClock from "effect/testing/TestClock"; -import { ChildProcessSpawner } from "effect/process"; -import * as HostProcess from "@t3tools/shared/HostProcess"; - -import { makeZcodeConnection, ZcodeProtocolError, type ZcodeRecord } from "./zcodeProtocol.ts"; - -// Mirrors the private EXIT_DRAIN_GRACE and TERMINATION_GRACE in zcodeProtocol.ts. -const EXIT_DRAIN_GRACE_MS = 1_000; -const TERMINATION_GRACE_MS = 1_000; - -const processGroupState = vi.hoisted(() => ({ - groupAlive: true, - signals: [] as Array, -})); - -vi.mock("@t3tools/provider-core/server/processGroup", () => ({ - signalProcessGroup: (pid: number, signal: NodeJS.Signals | 0) => { - if (!processGroupState.groupAlive) { - throw Object.assign(new Error(`kill ESRCH: no process group ${pid}`), { code: "ESRCH" }); - } - processGroupState.signals.push(signal); - }, -})); - -const setupProcessGroup = (groupAlive: boolean) => { - processGroupState.groupAlive = groupAlive; - processGroupState.signals = []; -}; - -const makeHeldOpenChild = Effect.fnUntraced(function* (scope?: Scope.Closeable) { - const exitCode = yield* Deferred.make(); - const stdout = yield* Queue.unbounded>(); - const stdinChunks = yield* Queue.unbounded(); - const handle = ChildProcessSpawner.makeHandle({ - pid: ChildProcessSpawner.ProcessId(55_000), - exitCode: Deferred.await(exitCode), - isRunning: Effect.succeed(true), - kill: () => Effect.void, - unref: Effect.succeed(Effect.void), - stdin: Sink.forEach((chunk: Uint8Array) => Queue.offer(stdinChunks, chunk).pipe(Effect.asVoid)), - stdout: Stream.fromQueue(stdout), - stderr: Stream.empty, - all: Stream.empty, - getInputFd: () => Sink.drain, - getOutputFd: () => Stream.empty, - }); - const unscoped = makeZcodeConnection({ - command: "zcode", - args: ["app-server", "--stdio"], - cwd: undefined, - env: {}, - }).pipe( - Effect.provideService( - ChildProcessSpawner.ChildProcessSpawner, - ChildProcessSpawner.make(() => Effect.succeed(handle)), - ), - Effect.provideService(HostProcess.Platform, "linux"), - ); - const connection = yield* scope === undefined ? unscoped : unscoped.pipe(Scope.provide(scope)); - return { connection, exitCode, stdout, stdinChunks }; -}); - -const takeSentRecord = Effect.fnUntraced(function* (stdinChunks: Queue.Dequeue) { - const line = new TextDecoder().decode(yield* Queue.take(stdinChunks)); - return JSON.parse(line) as ZcodeRecord; -}); - -describe("makeZcodeConnection", () => { - it.effect("fails the transport once the exit grace elapses with stdout still open", () => - Effect.scoped( - Effect.gen(function* () { - // Group modeled empty, so the scope-close termination below is a no-op. - setupProcessGroup(false); - const { connection, exitCode, stdout } = yield* makeHeldOpenChild(); - - yield* Deferred.succeed(exitCode, ChildProcessSpawner.ExitCode(0)); - assert.equal(yield* connection.exited, 0); - - // Records buffered when zcode died still drain while the grace runs. - yield* Queue.offer(stdout, new TextEncoder().encode('{"event":"turn.completed"}\n')); - const drained = yield* Queue.take(connection.events); - assert.equal(drained["event"], "turn.completed"); - - const down = yield* Deferred.make(); - yield* Queue.take(connection.events).pipe( - Effect.catch((error) => Deferred.succeed(down, error)), - Effect.forkScoped, - ); - yield* TestClock.adjust(Duration.millis(EXIT_DRAIN_GRACE_MS - 1)); - assert.isTrue(Option.isNone(yield* Deferred.poll(down))); - yield* TestClock.adjust(Duration.millis(1)); - const error = yield* Deferred.await(down); - assert.instanceOf(error, ZcodeProtocolError); - assert.equal(error.operation, "read"); - assert.equal(error.detail, "zcode app-server exited with code 0, but stdout never closed"); - }), - ), - ); - - it.effect("stdout closing after exit still reports the exit code", () => - Effect.scoped( - Effect.gen(function* () { - // Group modeled empty, so the scope-close termination below is a no-op. - setupProcessGroup(false); - const { connection, exitCode, stdout } = yield* makeHeldOpenChild(); - const down = yield* Deferred.make(); - yield* Queue.take(connection.events).pipe( - Effect.catch((error) => Deferred.succeed(down, error)), - Effect.forkScoped, - ); - - yield* Deferred.succeed(exitCode, ChildProcessSpawner.ExitCode(7)); - assert.equal(yield* connection.exited, 7); - yield* Queue.end(stdout); - - const error = yield* Deferred.await(down); - assert.equal(error.operation, "read"); - assert.equal(error.detail, "zcode app-server exited with code 7"); - }), - ), - ); - - it.effect("escalates to SIGKILL after the leader exits while tools keep the group alive", () => - Effect.scoped( - Effect.gen(function* () { - setupProcessGroup(true); - const scope = yield* Scope.make(); - const { connection, exitCode } = yield* makeHeldOpenChild(scope); - - yield* Deferred.succeed(exitCode, ChildProcessSpawner.ExitCode(0)); - assert.equal(yield* connection.exited, 0); - - // Detached tools keep the pgid alive after the leader is reaped; termination must - // still SIGTERM the group and, after the grace, SIGKILL the survivors. - const closed = yield* Scope.close(scope, Exit.void).pipe(Effect.forkScoped); - yield* TestClock.adjust(Duration.millis(TERMINATION_GRACE_MS)); - yield* Fiber.join(closed); - - // hasExited probes interleave signal 0; assert on the delivered signals only. - const sent = processGroupState.signals.filter((signal) => signal !== 0); - expect(sent).toEqual(["SIGTERM", "SIGKILL"]); - }), - ), - ); - - it.effect("skips group signalling once the leader exited and the group is empty", () => - Effect.scoped( - Effect.gen(function* () { - setupProcessGroup(false); - const scope = yield* Scope.make(); - const { connection, exitCode } = yield* makeHeldOpenChild(scope); - - yield* Deferred.succeed(exitCode, ChildProcessSpawner.ExitCode(0)); - assert.equal(yield* connection.exited, 0); - - // No survivors: termination returns without waiting the grace or signalling. - yield* Scope.close(scope, Exit.void); - - expect(processGroupState.signals).toEqual([]); - }), - ), - ); - - it.effect("a rejected request reports the real method with bounded detail", () => - Effect.scoped( - Effect.gen(function* () { - // Group modeled empty, so the scope-close termination below is a no-op. - setupProcessGroup(false); - const { connection, stdout, stdinChunks } = yield* makeHeldOpenChild(); - - const pending = yield* connection - .request("provider/updateAccountConfig", { accountId: "acct_1" }) - .pipe( - Effect.catch((error) => Effect.succeed(error)), - Effect.forkScoped, - ); - - const sent = yield* takeSentRecord(stdinChunks); - assert.equal(sent["method"], "provider/updateAccountConfig"); - - const payload = "SECRET_ZCODE_SERVER_PAYLOAD"; - const serverError = { code: "accountConfigRejected", message: payload }; - yield* Queue.offer( - stdout, - new TextEncoder().encode(`${JSON.stringify({ id: sent["id"], error: serverError })}\n`), - ); - - const error = yield* Fiber.join(pending); - assert.instanceOf(error, ZcodeProtocolError); - // Raw payload stays in cause only; message and detail carry bounded metadata. - assert.isFalse(`${error.detail}`.includes(payload)); - assert.isFalse(error.message.includes(payload)); - assert.equal( - error.detail, - `code accountConfigRejected, ${JSON.stringify(serverError).length}-char payload`, - ); - assert.include(JSON.stringify(error.cause), payload); - // The failure names the method that failed, not the generic "request". - assert.equal(error.operation, "provider/updateAccountConfig"); - }), - ), - ); - - it.effect("a string error payload also stays confined to cause", () => - Effect.scoped( - Effect.gen(function* () { - // Group modeled empty, so the scope-close termination below is a no-op. - setupProcessGroup(false); - const { connection, stdout, stdinChunks } = yield* makeHeldOpenChild(); - - const pending = yield* connection - .request("session/subscribe", { sessionId: "sess_1" }) - .pipe( - Effect.catch((error) => Effect.succeed(error)), - Effect.forkScoped, - ); - - const sent = yield* takeSentRecord(stdinChunks); - - const payload = "SECRET_BARE_ZCODE_PAYLOAD"; - yield* Queue.offer( - stdout, - new TextEncoder().encode(`${JSON.stringify({ id: sent["id"], error: payload })}\n`), - ); - - const error = yield* Fiber.join(pending); - assert.instanceOf(error, ZcodeProtocolError); - assert.isFalse(`${error.detail}`.includes(payload)); - assert.isFalse(error.message.includes(payload)); - assert.equal(error.detail, `${JSON.stringify(payload).length}-char payload`); - assert.include(JSON.stringify(error.cause), payload); - assert.equal(error.operation, "session/subscribe"); - }), - ), - ); -}); diff --git a/packages/provider-zcode/src/server/zcodeProtocol.ts b/packages/provider-zcode/src/server/zcodeProtocol.ts index 45a67c9a81588..51804049d217e 100644 --- a/packages/provider-zcode/src/server/zcodeProtocol.ts +++ b/packages/provider-zcode/src/server/zcodeProtocol.ts @@ -1,21 +1,10 @@ -// ZCode app-server speaks NDJSON on stdio; responses carry {id, result|error} while server→client -// requests repeat the {id, method, params} shape and must be answered via respond(). -import * as Clock from "effect/Clock"; -import * as Deferred from "effect/Deferred"; -import * as Duration from "effect/Duration"; -import * as Effect from "effect/Effect"; -import * as Option from "effect/Option"; +// Shared wire-shape types and errors for the ZCode provider. The transport itself lives in +// `zcodeServeProtocol.ts` (channel frames over the daemon's WebSocket) and +// `zcodeServeConnection.ts` (daemon requests mapped onto this connection surface). import * as Predicate from "effect/Predicate"; -import * as Queue from "effect/Queue"; -import * as Scope from "effect/Scope"; import * as Schema from "effect/Schema"; -import * as Stream from "effect/Stream"; -import * as ChildProcess from "effect/process/ChildProcess"; -import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; -import * as HostProcess from "@t3tools/shared/HostProcess"; -import { resolveSpawnCommand } from "@t3tools/shared/shell"; - -import { signalProcessGroup } from "@t3tools/provider-core/server/processGroup"; +import type * as Queue from "effect/Queue"; +import type * as Effect from "effect/Effect"; export class ZcodeProtocolError extends Schema.TaggedError()( "ZcodeProtocolError", @@ -66,13 +55,6 @@ export function zcodeRecordArray(input: unknown, key: string): ReadonlyArray record["id"] !== undefined && Predicate.isString(record["method"]); -export interface ZcodeSpawnOptions { - readonly command: string; - readonly args: ReadonlyArray; - readonly cwd: string | undefined; - readonly env: NodeJS.ProcessEnv; -} - export interface ZcodeConnection { readonly request: ( method: string, @@ -85,61 +67,7 @@ export interface ZcodeConnection { readonly terminate: Effect.Effect; } -const DEFAULT_REQUEST_TIMEOUT_MS = 30_000; -const TERMINATION_GRACE = Duration.seconds(1); -const EXIT_DRAIN_GRACE = Duration.seconds(1); - -interface PendingZcodeRequest { - readonly deferred: Deferred.Deferred; - readonly method: string; -} - -const MAX_ZCODE_RECORD_CHARS = 16 * 1024 * 1024; - -function makeJsonlFramer() { - let buffer = ""; - let dropping = false; - return (chunk: string) => { - const lines: string[] = []; - const droppedLengths: number[] = []; - let start = 0; - while (start < chunk.length) { - const newline = chunk.indexOf("\n", start); - const end = newline < 0 ? chunk.length : newline; - if (!dropping) { - if (buffer.length + end - start > MAX_ZCODE_RECORD_CHARS) { - droppedLengths.push(buffer.length + end - start); - buffer = ""; - dropping = true; - } else { - buffer += chunk.slice(start, end); - } - } - if (newline < 0) break; - if (!dropping && buffer.length > 0) { - lines.push(buffer.endsWith("\r") ? buffer.slice(0, -1) : buffer); - } - buffer = ""; - dropping = false; - start = newline + 1; - } - return { lines, droppedLengths }; - }; -} - -const logDroppedZcodeRecords = (droppedLengths: ReadonlyArray) => - Effect.forEach( - droppedLengths, - (recordLength) => - Effect.logWarning("Dropping oversized zcode stdout record.", { - recordLength, - maxRecordLength: MAX_ZCODE_RECORD_CHARS, - }), - { discard: true }, - ); - const UnknownFromJsonString = Schema.fromJsonString(Schema.Unknown); -const decodeJsonLine = Schema.decodeSync(UnknownFromJsonString); const encodeJsonLine = Schema.encodeSync(UnknownFromJsonString); /** JSON text for wire params/diagnostics; pure, so it may fall back to an empty string. */ @@ -150,331 +78,3 @@ export function zcodeJsonStringify(value: unknown): string { return ""; } } - -const ZCODE_SERVER_ERROR_CODE_MAX_CHARS = 100; - -// Bounded server-error metadata; the raw payload travels only in `cause`. -function describeZcodeServerError(error: unknown): string { - const code = zcodeRecordField(error, "code"); - const codeText = - typeof code === "string" || typeof code === "number" - ? `code ${String(code).slice(0, ZCODE_SERVER_ERROR_CODE_MAX_CHARS)}` - : undefined; - const payloadLength = JSON.stringify(error)?.length ?? 0; - return codeText === undefined - ? `${payloadLength}-char payload` - : `${codeText}, ${payloadLength}-char payload`; -} - -function describeZcodeStdoutClosure(exitCode: number | undefined): string { - if (exitCode === undefined || !Number.isFinite(exitCode)) { - return "zcode app-server closed stdout"; - } - return `zcode app-server exited with code ${exitCode}`; -} - -function parseZcodeRecord(line: string): ZcodeRecord | undefined { - try { - const parsed: unknown = decodeJsonLine(line); - return Predicate.isObject(parsed) ? parsed : undefined; - } catch { - return undefined; - } -} - -const terminateZcodeProcess = Effect.fn("ZcodeProtocol.terminateZcodeProcess")(function* ( - kill: (signal: NodeJS.Signals) => boolean, - hasExited: () => boolean, -) { - if (hasExited()) return; - if (!kill("SIGTERM")) return; - yield* Effect.sleep(TERMINATION_GRACE); - if (hasExited()) return; - kill("SIGKILL"); -}); - -export const makeZcodeConnection = Effect.fnUntraced(function* (options: ZcodeSpawnOptions) { - const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const platform = yield* HostProcess.Platform; - const scope = yield* Effect.scope; - - const spawnCommand = yield* resolveSpawnCommand(options.command, [...options.args], { - env: options.env, - }).pipe(Effect.mapError((cause) => new ZcodeProtocolError({ operation: "spawn", cause }))); - const child = yield* spawner - .spawn( - ChildProcess.make(spawnCommand.command, spawnCommand.args, { - ...(options.cwd === undefined ? {} : { cwd: options.cwd }), - env: options.env, - extendEnv: false, - shell: spawnCommand.shell, - detached: platform !== "win32", - stdin: { stream: "pipe", endOnDone: false }, - }), - ) - .pipe(Effect.mapError((cause) => new ZcodeProtocolError({ operation: "spawn", cause }))); - - let childExited = false; - let diagnosingStdoutClose = false; - - const killProcessGroup = (signal: NodeJS.Signals): boolean => { - try { - if (platform === "win32") { - process.kill(Number(child.pid), signal); - } else { - signalProcessGroup(Number(child.pid), signal); - } - return true; - } catch { - return false; - } - }; - - const hasExited = (): boolean => { - // A dead POSIX leader does not empty its group: detached tools keep the pgid alive and - // must stay reachable for the SIGKILL escalation. - if (childExited && platform === "win32") return true; - try { - if (platform === "win32") process.kill(Number(child.pid), 0); - else signalProcessGroup(Number(child.pid), 0); - return false; - } catch { - return true; - } - }; - - const terminateWindowsTree = Effect.gen(function* () { - if (hasExited()) return; - const taskkill = yield* spawner.spawn( - ChildProcess.make("taskkill", ["/PID", String(child.pid), "/T", "/F"]), - ); - yield* taskkill.exitCode; - }).pipe(Effect.scoped, Effect.ignore); - - const terminateProcess = - platform === "win32" - ? terminateWindowsTree - : terminateZcodeProcess(killProcessGroup, hasExited); - - yield* Scope.addFinalizer(scope, terminateProcess.pipe(Effect.ignore, Effect.uninterruptible)); - - const pendingRequests = new Map(); - const events = yield* Queue.unbounded(); - const outgoing = yield* Queue.unbounded(); - const transportDown = yield* Deferred.make(); - const exitDeferred = yield* Deferred.make(); - let nextRequestId = 1; - - const failTransport = Effect.fn("ZcodeProtocol.failTransport")(function* ( - error: ZcodeProtocolError, - ) { - const claimed = yield* Deferred.fail(transportDown, error); - if (!claimed) return; - for (const [key, pending] of pendingRequests) { - pendingRequests.delete(key); - yield* Deferred.fail(pending.deferred, error); - } - yield* Queue.fail(outgoing, error); - yield* Queue.fail(events, error); - }); - - const routeRecord = - /* Streaming deltas ride through here; avoid a span per record. */ - Effect.fnUntraced(function* (record: ZcodeRecord) { - if (isServerRequest(record)) { - // Server→client requests are surfaced as events; the adapter answers via respond(). - yield* Queue.offer(events, record); - return; - } - if (record["id"] !== undefined) { - const key = String(record["id"]); - const pending = pendingRequests.get(key); - if (pending !== undefined) { - pendingRequests.delete(key); - if (record["error"] === undefined) { - yield* Deferred.succeed(pending.deferred, record["result"]); - } else { - yield* Deferred.fail( - pending.deferred, - new ZcodeProtocolError({ - operation: pending.method, - detail: describeZcodeServerError(record["error"]), - ...(record["error"] === undefined ? {} : { cause: record["error"] }), - }), - ); - } - return; - } - } - yield* Queue.offer(events, record); - }); - - yield* child.exitCode.pipe( - Effect.matchEffect({ - onFailure: (cause) => - Deferred.fail(exitDeferred, new ZcodeProtocolError({ operation: "exit", cause })), - onSuccess: (code) => - Effect.suspend(() => { - childExited = true; - return Deferred.succeed(exitDeferred, Number(code)); - }), - }), - Effect.forkIn(scope), - ); - - // A detached tool child can inherit stdout and keep it open after zcode exits, so stdout - // EOF never arrives and stdout closure cannot fail the transport. Give records buffered - // at death the grace window to drain, then fail the transport without EOF; the - // transportDown claim keeps a later stdout close from double-failing. - yield* Deferred.await(exitDeferred).pipe( - Effect.matchEffect({ - onFailure: (cause) => - Effect.sleep(EXIT_DRAIN_GRACE).pipe( - Effect.andThen(failTransport(new ZcodeProtocolError({ operation: "exit", cause }))), - ), - onSuccess: (exitCode) => - Effect.sleep(EXIT_DRAIN_GRACE).pipe( - Effect.andThen( - failTransport( - new ZcodeProtocolError({ - operation: "read", - detail: `zcode app-server exited with code ${exitCode}, but stdout never closed`, - }), - ), - ), - ), - }), - Effect.forkIn(scope), - ); - - yield* Effect.gen(function* () { - const frame = makeJsonlFramer(); - yield* child.stdout.pipe( - Stream.decodeText(), - Stream.runForEach((chunk) => - Effect.gen(function* () { - const { lines, droppedLengths } = frame(chunk); - yield* logDroppedZcodeRecords(droppedLengths); - for (const line of lines) { - const record = parseZcodeRecord(line); - if (record === undefined) { - yield* Effect.logDebug("Dropping non-JSON zcode stdout line.", { - lineLength: line.length, - }); - continue; - } - yield* routeRecord(record); - } - }), - ), - ); - const flushed = frame("\n"); - yield* logDroppedZcodeRecords(flushed.droppedLengths); - for (const line of flushed.lines) { - const trailing = parseZcodeRecord(line); - if (trailing !== undefined) yield* routeRecord(trailing); - } - }).pipe( - Effect.matchCauseEffect({ - onFailure: (cause) => failTransport(new ZcodeProtocolError({ operation: "read", cause })), - onSuccess: Effect.fnUntraced(function* () { - diagnosingStdoutClose = true; - const polled = yield* Deferred.poll(exitDeferred); - const maybeExitCode = Option.isSome(polled) - ? yield* polled.value.pipe( - Effect.asSome, - Effect.orElseSucceed(() => Option.none()), - ) - : yield* Deferred.await(exitDeferred).pipe( - Effect.timeoutOption(Duration.millis(250)), - // Stdout-close waits need wall time; TestClock would strand Stop-with-restart without an exit. - Effect.provideService(Clock.Clock, Clock.Clock.defaultValue()), - Effect.orElseSucceed(() => Option.none()), - ); - return yield* failTransport( - new ZcodeProtocolError({ - operation: "read", - detail: describeZcodeStdoutClosure( - Option.isSome(maybeExitCode) ? maybeExitCode.value : undefined, - ), - }), - ); - }), - }), - Effect.forkIn(scope), - ); - - yield* child.stderr.pipe( - Stream.decodeText(), - Stream.runForEach((chunk) => - chunk.trim().length === 0 - ? Effect.void - : Effect.logDebug("zcode app-server stderr", { stderrLength: chunk.length }), - ), - Effect.ignore, - Effect.forkIn(scope), - ); - - yield* Stream.fromQueue(outgoing).pipe( - Stream.run(child.stdin), - Effect.catchCause((cause) => - diagnosingStdoutClose - ? Effect.void - : failTransport(new ZcodeProtocolError({ operation: "write", cause })), - ), - Effect.forkIn(scope), - ); - - const send = - /* Streaming deltas ride through here; avoid a span per record. */ - Effect.fnUntraced(function* (record: ZcodeRecord): Effect.fn.Return { - const accepted = yield* Queue.offer( - outgoing, - new TextEncoder().encode(`${encodeJsonLine(record)}\n`), - ); - if (!accepted) { - return yield* Deferred.await(transportDown); - } - }); - - const request = Effect.fn("ZcodeProtocol.request")(function* ( - method: string, - params: ZcodeRecord, - timeoutMs = DEFAULT_REQUEST_TIMEOUT_MS, - ): Effect.fn.Return { - const id = nextRequestId++; - const deferred = yield* Deferred.make(); - pendingRequests.set(String(id), { deferred, method }); - yield* send({ id, method, params }).pipe( - Effect.tapError(() => Effect.sync(() => pendingRequests.delete(String(id)))), - ); - return yield* Effect.raceFirst(Deferred.await(deferred), Deferred.await(transportDown)).pipe( - Effect.timeoutOrElse({ - duration: Duration.millis(timeoutMs), - orElse: () => Effect.fail(new ZcodeProtocolTimeoutError({ operation: method, timeoutMs })), - }), - Effect.onInterrupt(() => Effect.sync(() => pendingRequests.delete(String(id)))), - Effect.onError(() => Effect.sync(() => pendingRequests.delete(String(id)))), - ); - }); - - const respond = Effect.fnUntraced(function* ( - id: string, - result: unknown, - ): Effect.fn.Return { - yield* send({ id, result }); - }); - - return { - request, - respond, - events, - exited: Deferred.await(exitDeferred), - terminate: failTransport( - new ZcodeProtocolError({ - operation: "terminate", - detail: "zcode app-server was stopped", - }), - ).pipe(Effect.andThen(terminateProcess), Effect.ignore, Effect.uninterruptible), - } satisfies ZcodeConnection; -}); diff --git a/packages/provider-zcode/src/server/zcodeRequestAuth.test.ts b/packages/provider-zcode/src/server/zcodeRequestAuth.test.ts deleted file mode 100644 index b85c402b959a7..0000000000000 --- a/packages/provider-zcode/src/server/zcodeRequestAuth.test.ts +++ /dev/null @@ -1,236 +0,0 @@ -// @effect-diagnostics nodeBuiltinImport:off -- tests craft the CLI's own AES-GCM store values with node crypto -import * as NodeServices from "@effect/platform-node/NodeServices"; -import * as HostProcess from "@t3tools/shared/HostProcess"; -import { assert, describe, it } from "@effect/vitest"; -import * as NodeCrypto from "node:crypto"; -import * as Effect from "effect/Effect"; -import * as FileSystem from "effect/FileSystem"; -import * as HttpClient from "effect/http/HttpClient"; -import * as HttpClientResponse from "effect/http/HttpClientResponse"; - -import { - decryptZcodeCredential, - makeZcodeRequestAuth, - resolveZcodeCredentialSecret, - zcodeAccountScope, - zcodeFamilyFromProviderId, -} from "./zcodeRequestAuth.ts"; -import { zcodeRecordField, zcodeRecordString } from "./zcodeProtocol.ts"; - -/** Produces store values the CLI's fallback cipher would hold, keyed the same way. */ -const encryptZcodeCredential = (value: string, secret: string): string => { - const iv = NodeCrypto.randomBytes(12); - const cipher = NodeCrypto.createCipheriv( - "aes-256-gcm", - NodeCrypto.createHash("sha256").update(secret).digest(), - iv, - ); - const ciphertext = Buffer.concat([cipher.update(value, "utf8"), cipher.final()]); - return `enc:v1:${iv.toString("base64url")}.${cipher.getAuthTag().toString("base64url")}.${ciphertext.toString("base64url")}`; -}; - -describe("resolveZcodeCredentialSecret", () => { - it("prefers ZCODE_CREDENTIAL_SECRET and trims it", () => { - assert.equal( - resolveZcodeCredentialSecret({ ZCODE_CREDENTIAL_SECRET: " custom-secret " }, "linux"), - "custom-secret", - ); - const blankOverride = resolveZcodeCredentialSecret( - { ZCODE_CREDENTIAL_SECRET: " ", HOME: "/home/dev" }, - "linux", - ); - assert.match(blankOverride, /^zcode-credential-fallback:linux:\/home\/dev:/); - }); - - it("derives the CLI's machine-bound fallback with the HOME from the environment", () => { - // Username comes from os.userInfo() where resolvable (env names never win); on runtimes - // where it throws, the env fallback speaks. Only the stable parts are asserted here. - const secret = resolveZcodeCredentialSecret( - { USER: "someoneElse", HOME: "/home/dev" }, - "darwin", - ); - assert.match(secret, /^zcode-credential-fallback:darwin:\/home\/dev:/); - assert.match(resolveZcodeCredentialSecret({}, "linux"), /^zcode-credential-fallback:linux:/); - }); -}); - -describe("decryptZcodeCredential", () => { - it("round-trips the CLI's encrypted store values", () => { - const secret = "zcode-credential-fallback:linux:/home/dev:jame"; - for (const plaintext of ["oauth-login-token", "值-with-unicode 🔐", ""]) { - assert.equal( - decryptZcodeCredential(encryptZcodeCredential(plaintext, secret), secret), - plaintext, - ); - } - }); - - it("passes plain store values through and rejects malformed ciphertext", () => { - assert.equal(decryptZcodeCredential("plain-token", "s"), "plain-token"); - assert.throws(() => decryptZcodeCredential("enc:v1:not-a-cipher", "s")); - assert.throws(() => decryptZcodeCredential("enc:v1:only.two", "s")); - }); -}); - -describe("zcodeFamilyFromProviderId", () => { - it("maps account plan provider ids to their auth family", () => { - assert.equal(zcodeFamilyFromProviderId("account:zai-individual-coding-plan"), "zai"); - assert.equal(zcodeFamilyFromProviderId("account:bigmodel-individual-coding-plan"), "bigmodel"); - assert.equal(zcodeFamilyFromProviderId("account:zai"), "zai"); - }); - - it("returns undefined for provider ids outside the account namespace", () => { - assert.equal(zcodeFamilyFromProviderId(undefined), undefined); - assert.equal(zcodeFamilyFromProviderId("account:"), undefined); - assert.equal(zcodeFamilyFromProviderId(""), undefined); - }); -}); - -describe("zcodeAccountScope", () => { - it("digests the plan identity, login token, and JWT exactly like the CLI", () => { - assert.equal( - zcodeAccountScope("zai", "login-token", undefined), - NodeCrypto.createHash("sha256") - .update(JSON.stringify(["zai", "individual-coding-plan", null, "login-token", null])) - .digest("hex"), - ); - assert.equal( - zcodeAccountScope("zai", "login-token", "jwt"), - NodeCrypto.createHash("sha256") - .update(JSON.stringify(["zai", "individual-coding-plan", null, "login-token", "jwt"])) - .digest("hex"), - ); - }); - - it("is deterministic and changes with any input", () => { - assert.equal( - zcodeAccountScope("zai", "tok", undefined), - zcodeAccountScope("zai", "tok", undefined), - ); - assert.notEqual( - zcodeAccountScope("zai", "tok", undefined), - zcodeAccountScope("bigmodel", "tok", undefined), - ); - assert.notEqual( - zcodeAccountScope("zai", "tok", undefined), - zcodeAccountScope("zai", "tok2", undefined), - ); - }); -}); - -describe("makeZcodeRequestAuth", () => { - /** A temp data root with the zai login on disk plus an HTTP fake answering the mint chain. */ - const harness = Effect.fnUntraced(function* (clientVersion: string | undefined) { - const fileSystem = yield* FileSystem.FileSystem; - const root = yield* fileSystem.makeTempDirectoryScoped({ prefix: "zcode-request-auth-test-" }); - yield* fileSystem.makeDirectory(`${root}/.zcode/v2`, { recursive: true }); - const platform = yield* HostProcess.Platform; - const environment = { ZCODE_DATA_BASE_DIR: root, HOME: "/home/dev", USER: "dev" }; - const secret = resolveZcodeCredentialSecret(environment, platform); - const writeLogin = (loginToken: string | undefined) => - fileSystem.writeFileString( - `${root}/.zcode/v2/credentials.json`, - JSON.stringify( - loginToken === undefined - ? {} - : { "oauth:zai:access_token": encryptZcodeCredential(loginToken, secret) }, - ), - ); - yield* writeLogin("login-a"); - const exchangeLogins: Array = []; - const exchangeBodies: Array = []; - const httpClient = HttpClient.make((request, url) => { - const ok = (data: unknown) => - Effect.succeed(HttpClientResponse.fromWeb(request, Response.json({ code: 200, data }))); - if (url.pathname.endsWith("/api/biz/customer/getCustomerInfo")) { - return ok({ - organizations: [ - { - organizationId: "org-1", - organizationName: "默认机构", - projects: [{ projectId: "proj-1", projectName: "默认项目" }], - }, - ], - }); - } - if (url.pathname.endsWith("/api_keys")) { - return ok([{ name: "zcode-api-key", apiKey: "key-1" }]); - } - exchangeLogins.push((request.headers.authorization ?? "").replace(/^Bearer /, "")); - exchangeBodies.push( - request.body._tag === "Uint8Array" - ? JSON.parse(new TextDecoder().decode(request.body.body)) - : undefined, - ); - return ok({ accessToken: `access-token-${exchangeLogins.length}` }); - }); - return { - auth: makeZcodeRequestAuth({ fileSystem, httpClient, environment, clientVersion }), - writeLogin, - exchangeLogins, - exchangeBodies, - }; - }); - - const requestAuthOf = (response: unknown): Record => - (zcodeRecordField(response, "requestAuth") ?? {}) as Record; - - it.effect("serves a cache hit while the family's credentials stay the same", () => - Effect.gen(function* () { - const h = yield* harness("test"); - const first = yield* h.auth.answerRuntimeHeaders({ family: "zai" }); - assert.equal(zcodeRecordString(requestAuthOf(first), "apiKey"), "access-token-1"); - assert.equal( - zcodeRecordString(requestAuthOf(first), "accountScope"), - zcodeAccountScope("zai", "login-a", undefined), - ); - // The CLI may rewrite the store with a fresh IV for the same token; the credential scope, - // not the ciphertext, must decide validity. - yield* h.writeLogin("login-a"); - const second = yield* h.auth.answerRuntimeHeaders({ family: "zai" }); - assert.equal(zcodeRecordString(requestAuthOf(second), "apiKey"), "access-token-1"); - assert.deepEqual(h.exchangeLogins, ["login-a"]); - }).pipe(Effect.provide(NodeServices.layer)), - ); - - it.effect("re-mints against the new account when the family's login changes", () => - Effect.gen(function* () { - const h = yield* harness("test"); - const first = yield* h.auth.answerRuntimeHeaders({ family: "zai" }); - assert.equal(zcodeRecordString(requestAuthOf(first), "apiKey"), "access-token-1"); - yield* h.writeLogin("login-b"); - const second = yield* h.auth.answerRuntimeHeaders({ family: "zai" }); - assert.equal(zcodeRecordString(requestAuthOf(second), "apiKey"), "access-token-2"); - assert.equal( - zcodeRecordString(requestAuthOf(second), "accountScope"), - zcodeAccountScope("zai", "login-b", undefined), - ); - assert.equal(zcodeRecordString(requestAuthOf(second), "apiKeyId"), "key-1"); - assert.deepEqual(h.exchangeLogins, ["login-a", "login-b"]); - }).pipe(Effect.provide(NodeServices.layer)), - ); - - it.effect("declines once the family's login disappears", () => - Effect.gen(function* () { - const h = yield* harness("test"); - const first = yield* h.auth.answerRuntimeHeaders({ family: "zai" }); - assert.equal(zcodeRecordString(requestAuthOf(first), "apiKey"), "access-token-1"); - yield* h.writeLogin(undefined); - const second = yield* h.auth.answerRuntimeHeaders({ family: "zai" }); - assert.equal(zcodeRecordField(second, "headersApplied"), false); - assert.match(zcodeRecordString(second, "errorMessage") ?? "", /no OAuth login for family/); - assert.deepEqual(h.exchangeLogins, ["login-a"]); - }).pipe(Effect.provide(NodeServices.layer)), - ); - - it.effect("reports the installed CLI version in the token exchange only when known", () => - Effect.gen(function* () { - const known = yield* harness("3.17.2"); - yield* known.auth.answerRuntimeHeaders({ family: "zai" }); - const unknown = yield* harness(undefined); - yield* unknown.auth.answerRuntimeHeaders({ family: "zai" }); - assert.deepEqual(known.exchangeBodies, [{ clientType: "zcode", clientVersion: "3.17.2" }]); - assert.deepEqual(unknown.exchangeBodies, [{ clientType: "zcode" }]); - }).pipe(Effect.provide(NodeServices.layer)), - ); -}); diff --git a/packages/provider-zcode/src/server/zcodeRequestAuth.ts b/packages/provider-zcode/src/server/zcodeRequestAuth.ts deleted file mode 100644 index d54975e429918..0000000000000 --- a/packages/provider-zcode/src/server/zcodeRequestAuth.ts +++ /dev/null @@ -1,630 +0,0 @@ -// @effect-diagnostics nodeBuiltinImport:off -- the push revision hashes the injected catalog path with sha256 -// Account provisioning and request auth for the ZCode CLI: the entitlement ladder behind the -// `provider/updateAccountConfig` push, and the OAuth project-token exchange that answers -// `interaction/requestProviderRuntimeHeaders` before every account-model request. Verified -// end-to-end against zcode 3.17.2 (PROTOCOL-NOTES.md); the desktop host performs the same -// exchanges. -import * as NodeCrypto from "node:crypto"; -import * as NodeOS from "node:os"; -import * as NodePath from "node:path"; - -import { causeErrorTag } from "@t3tools/shared/observability"; -import * as HostProcess from "@t3tools/shared/HostProcess"; -import * as DateTime from "effect/DateTime"; -import * as Effect from "effect/Effect"; -import * as FileSystem from "effect/FileSystem"; -import * as HttpClient from "effect/http/HttpClient"; -import * as HttpClientRequest from "effect/http/HttpClientRequest"; -import * as HttpClientResponse from "effect/http/HttpClientResponse"; -import * as Option from "effect/Option"; -import * as Predicate from "effect/Predicate"; -import * as Result from "effect/Result"; -import * as Schema from "effect/Schema"; - -import { - zcodeJsonStringify, - zcodeRecordArray, - zcodeRecordField, - zcodeRecordString, - type ZcodeRecord, -} from "./zcodeProtocol.ts"; -import { zcodeDataRoot } from "./zcodeRuntime.ts"; - -const ENCRYPTED_PREFIX = "enc:v1:"; -const CIPHER_ALGORITHM = "aes-256-gcm"; -const ZCODE_JWT_CREDENTIAL_KEY = "zcodejwttoken"; -const ZCODE_API_KEY_NAME = "zcode-api-key"; -const TEAM_PROJECT_TYPE = "2"; -const DEFAULT_ORGANIZATION_NAME = "默认机构"; -const DEFAULT_PROJECT_NAME = "默认项目"; -const REQUEST_TIMEOUT_MS = 15_000; -const TOKEN_TTL_MS = 10 * 60_000; -/** Refresh the cached project token this long before expiry so a queued request never stalls. */ -const TOKEN_REFRESH_MARGIN_MS = 120_000; - -const FAMILY_ORIGIN: Record = { - zai: "https://api.z.ai", - bigmodel: "https://bigmodel.cn", -}; - -const sha256Hex = (value: string): string => - NodeCrypto.createHash("sha256").update(value).digest("hex"); - -/** - * The CLI's machine-bound fallback cipher secret: sha256 of this string keys the store's - * AES-256-GCM values. ZCODE_CREDENTIAL_SECRET overrides it for custom installs. - */ -export const resolveZcodeCredentialSecret = ( - environment: NodeJS.ProcessEnv, - platform: NodeJS.Platform, -): string => { - const configured = environment.ZCODE_CREDENTIAL_SECRET?.trim(); - if (configured !== undefined && configured.length > 0) return configured; - // The CLI binds the fallback to os.userInfo().username — env USER/USERNAME may be absent in - // server processes and must not silently derive a different key. - let username = "unknown"; - try { - username = NodeOS.userInfo().username; - } catch { - username = environment.USER ?? environment.USERNAME ?? "unknown"; - } - const home = environment.HOME ?? NodeOS.homedir(); - return `zcode-credential-fallback:${platform}:${home}:${username}`; -}; - -export const decryptZcodeCredential = (value: string, secret: string): string => { - if (!value.startsWith(ENCRYPTED_PREFIX)) return value; - const [ivRaw, tagRaw, cipherRaw] = value.slice(ENCRYPTED_PREFIX.length).split("."); - if (ivRaw === undefined || tagRaw === undefined || cipherRaw === undefined) { - throw new Error("Encrypted credential is not ..."); - } - const decipher = NodeCrypto.createDecipheriv( - CIPHER_ALGORITHM, - NodeCrypto.createHash("sha256").update(secret).digest(), - Buffer.from(ivRaw, "base64url"), - ); - decipher.setAuthTag(Buffer.from(tagRaw, "base64url")); - return Buffer.concat([ - decipher.update(Buffer.from(cipherRaw, "base64url")), - decipher.final(), - ]).toString("utf8"); -}; - -const decodeJsonText = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Unknown)); - -interface CredentialStoreCache { - digest: string; - store: Record; -} - -const readDecryptedCredentialStore = Effect.fn("ZcodeRequestAuth.readCredentialStore")(function* ( - fileSystem: FileSystem.FileSystem, - credentialsPath: string, - environment: NodeJS.ProcessEnv, - platform: NodeJS.Platform, - cache?: CredentialStoreCache, -): Effect.fn.Return> { - const raw = yield* fileSystem.readFileString(credentialsPath).pipe(Effect.option); - if (Option.isNone(raw)) return {}; - // Content, not mtime: a rewrite can land in the same timestamp tick, and a stale login token - // would mint project tokens against the previous account. - const digest = sha256Hex(raw.value); - if (cache !== undefined && cache.digest === digest) return cache.store; - const parsed = Option.getOrUndefined(decodeJsonText(raw.value)); - if (!Predicate.isObject(parsed)) return {}; - const secret = resolveZcodeCredentialSecret(environment, platform); - const entries: Record = {}; - for (const [key, value] of Object.entries(parsed)) { - if (typeof value !== "string") continue; - const decrypted = yield* Effect.try({ - try: () => decryptZcodeCredential(value, secret), - catch: () => undefined, - }).pipe(Effect.option); - if (Option.isSome(decrypted)) entries[key] = decrypted.value; - } - if (cache !== undefined) { - cache.digest = digest; - cache.store = entries; - } - return entries; -}); - -/** - * Families the provisioning ladder treats as signed-in OAuth individual coding plans: setting.json - * selects the plan, no apiKey override, and the credential store holds a login token for the - * family. The provider status probe derives its auth display from this same predicate so the - * probe and provisioning cannot drift. - */ -export const zcodeOAuthSelectedFamilies = ( - setting: unknown, - store: Record, -): ReadonlySet => { - if (!Predicate.isObject(setting)) return new Set(); - const selections = setting["providerFamilyConnectionSelections"]; - const modes = setting["modelProviderFamilyModes"]; - const families = new Set(); - if (!Predicate.isObject(selections)) return families; - for (const [family, selection] of Object.entries(selections)) { - const kind = Predicate.isObject(selection) ? selection["kind"] : undefined; - if (kind !== "individual-coding-plan") continue; - if (Predicate.isObject(modes) && modes[family] === "apiKey") continue; - const token = store[`oauth:${family}:access_token`]; - if (typeof token !== "string" || token.length === 0) continue; - families.add(family); - } - return families; -}; - -/** Families whose 3.15+ connection is an active OAuth individual coding plan. */ -const readOAuthSelectedFamilies = Effect.fn("ZcodeRequestAuth.readOAuthFamilies")(function* ( - fileSystem: FileSystem.FileSystem, - settingPath: string, - store: Record, -): Effect.fn.Return> { - const raw = yield* fileSystem.readFileString(settingPath).pipe(Effect.option); - const parsed = Option.isNone(raw) ? undefined : Option.getOrUndefined(decodeJsonText(raw.value)); - return zcodeOAuthSelectedFamilies(parsed, store); -}); - -export type ZcodeAccountProviderPush = { - readonly builtinModelIds: ReadonlyArray; - readonly access: { readonly type: "zhipu-account"; readonly entitled: boolean }; -}; - -export type ZcodeAccountStatePush = { - readonly availability: "available" | "unavailable"; - readonly entitled: boolean; - readonly current: boolean; -}; - -export type ZcodeAccountConfigPush = { - readonly revision: string; - readonly basedOnZCodeBuiltinRevision: string; - readonly providers: Record; - readonly states: Record; -}; - -const zcodeRuleAccess = (rule: unknown): ZcodeRecord | undefined => { - const access = zcodeRecordField(zcodeRecordField(rule, "config"), "access"); - return Predicate.isObject(access) ? access : undefined; -}; - -const zcodeBuiltinAccountRules = (builtinCatalog: unknown): ReadonlyArray => { - const providerRules = zcodeRecordArray( - zcodeRecordField(zcodeRecordField(builtinCatalog, "config"), "providerConfigRules"), - "providerRules", - ); - return (providerRules ?? []).filter( - (rule): rule is ZcodeRecord => - Predicate.isObject(rule) && - zcodeRecordString(zcodeRuleAccess(rule), "type") === "zhipu-account", - ); -}; - -const zcodeBuiltinModelIds = (rule: ZcodeRecord): ReadonlyArray => { - const ids = zcodeRecordField(zcodeRecordField(rule, "config"), "builtinModelIds"); - return (Array.isArray(ids) ? ids : []).filter( - (id): id is string => typeof id === "string" && id.length > 0, - ); -}; - -/** The registry keys the snapshot to `zcode-builtin::` of the catalog - * file the CLI booted from; any other value is accepted and silently ignored. */ -const zcodeBuiltinBasedOnRevision = (builtinPath: string, builtinCatalog: unknown): string => { - const revision = zcodeRecordField(builtinCatalog, "revision"); - const revisionText = - typeof revision === "number" && Number.isFinite(revision) - ? String(Math.trunc(revision)) - : typeof revision === "string" && revision.length > 0 - ? revision - : "0"; - return `zcode-builtin:${revisionText}:${sha256Hex(NodePath.resolve(builtinPath))}`; -}; - -/** - * Builds the `provider/updateAccountConfig` push from the builtin catalog's zhipu-account rules - * and the entitlement ladder: a family is entitled only when the user picked an OAuth - * individual coding plan for it and the credential store decrypts to its login token. - */ -export const zcodeAccountConfigPushFromLadder = Effect.fn("ZcodeRequestAuth.buildAccountPush")( - function* ( - fileSystem: FileSystem.FileSystem, - input: { - readonly builtinPath: string; - readonly builtinCatalog: unknown; - readonly environment: NodeJS.ProcessEnv; - }, - ): Effect.fn.Return { - const rules = zcodeBuiltinAccountRules(input.builtinCatalog); - if (rules.length === 0) return undefined; - const root = zcodeDataRoot(input.environment); - if (root === undefined) return undefined; - const store = yield* readDecryptedCredentialStore( - fileSystem, - `${root}/.zcode/v2/credentials.json`, - input.environment, - yield* HostProcess.Platform, - ); - const families = yield* readOAuthSelectedFamilies( - fileSystem, - `${root}/.zcode/v2/setting.json`, - store, - ); - const providers: Record = {}; - const states: Record = {}; - for (const rule of rules) { - const providerId = zcodeRecordString(rule, "providerId"); - const modelIds = zcodeBuiltinModelIds(rule); - if (providerId === undefined || modelIds.length === 0) continue; - const entitled = - zcodeRecordString(zcodeRuleAccess(rule), "mode") === "individual-coding-plan" && - families.has(zcodeRecordString(zcodeRuleAccess(rule), "accountType") ?? ""); - providers[providerId] = { - builtinModelIds: modelIds, - access: { type: "zhipu-account", entitled }, - }; - states[providerId] = { - availability: entitled ? "available" : "unavailable", - entitled, - current: entitled, - }; - } - if (Object.keys(providers).length === 0) return undefined; - return { - revision: `account:t3code:${DateTime.toEpochMillis(yield* DateTime.now)}`, - basedOnZCodeBuiltinRevision: zcodeBuiltinBasedOnRevision( - input.builtinPath, - input.builtinCatalog, - ), - providers, - states, - }; - }, -); - -/** - * Entitled individual-coding-plan models for the model picker: enumerated from the injected - * catalog plus the entitlement ladder, so a catalog refresh by the CLI adds models without an - * adapter change. Reasoning levels come from the catalog's modelRules (modelMatch regexes, - * matched case-insensitively against the model id). - */ -export interface ZcodeEntitledModel { - readonly providerId: string; - readonly modelId: string; - readonly reasoningLevels: ReadonlyArray; -} - -export const zcodeEntitledModels = Effect.fn("ZcodeRequestAuth.entitledModels")(function* ( - fileSystem: FileSystem.FileSystem, - input: { - readonly builtinPath: string; - readonly builtinCatalog: unknown; - readonly environment: NodeJS.ProcessEnv; - }, -): Effect.fn.Return, never, never> { - const rules = zcodeBuiltinAccountRules(input.builtinCatalog); - const root = zcodeDataRoot(input.environment); - if (rules.length === 0 || root === undefined) return []; - const store = yield* readDecryptedCredentialStore( - fileSystem, - `${root}/.zcode/v2/credentials.json`, - input.environment, - yield* HostProcess.Platform, - ); - const families = yield* readOAuthSelectedFamilies( - fileSystem, - `${root}/.zcode/v2/setting.json`, - store, - ); - const modelRules = zcodeRecordArray( - zcodeRecordField(zcodeRecordField(input.builtinCatalog, "config"), "modelConfigRules"), - "modelRules", - ); - const levelsFor = (modelId: string): ReadonlyArray => { - // Later matching rules compose over earlier ones (the runtime's advertised levels are the - // last match: Flash hits glm-5.* then glm-5\.3, landing on [low, high, max]). - let levels: ReadonlyArray = []; - for (const rule of modelRules ?? []) { - const match = zcodeRecordString(rule, "modelMatch"); - if (match === undefined) continue; - let regex: RegExp; - try { - regex = new RegExp(match, "i"); - } catch { - continue; - } - if (!regex.test(modelId)) continue; - const specs = zcodeRecordField(zcodeRecordField(rule, "config"), "optionSpecs"); - const reasoning = zcodeRecordField(Predicate.isObject(specs) ? specs : {}, "reasoningLevel"); - const values = zcodeRecordArray(reasoning, "values")?.filter( - (value): value is string => typeof value === "string", - ); - if (values !== undefined && values.length > 0) levels = values; - } - return levels; - }; - const out: Array = []; - for (const rule of rules) { - const providerId = zcodeRecordString(rule, "providerId"); - const access = zcodeRuleAccess(rule); - if ( - providerId === undefined || - zcodeRecordString(access, "mode") !== "individual-coding-plan" || - !families.has(zcodeRecordString(access, "accountType") ?? "") - ) { - continue; - } - for (const modelId of zcodeBuiltinModelIds(rule)) { - out.push({ providerId, modelId, reasoningLevels: levelsFor(modelId) }); - } - } - return out; -}); - -/** `account:-individual-coding-plan` → the plan family the auth exchange needs. */ -export const zcodeFamilyFromProviderId = (providerId: string | undefined): string | undefined => { - const family = providerId?.replace(/^account:/, "").replace(/-individual-coding-plan$/, ""); - return family === undefined || family.length === 0 ? undefined : family; -}; - -/** Mirrors the CLI's account-scope digest over the plan identity and login tokens. */ -export const zcodeAccountScope = ( - family: string, - loginToken: string, - jwtToken: string | undefined, -): string => - sha256Hex( - zcodeJsonStringify([family, "individual-coding-plan", null, loginToken, jwtToken ?? null]), - ); - -class ZcodeRequestAuthError extends Schema.TaggedError()( - "ZcodeRequestAuthError", - { message: Schema.String }, -) {} - -const declineMessage = (hint: string): string => `ZCode request auth failed (${hint}).`; - -const failAuth = (hint: string) => new ZcodeRequestAuthError({ message: declineMessage(hint) }); - -interface CachedProjectToken { - readonly token: string; - readonly apiKeyId: string; - readonly accountScope: string; - readonly fingerprint: string; - readonly refreshAtMillis: number; -} - -export interface ZcodeRequestAuth { - /** The `interaction/requestProviderRuntimeHeaders` response for an entitled account model. */ - readonly answerRuntimeHeaders: (input: { - readonly family: string; - readonly rejectedProjectTokenFingerprint?: string; - }) => Effect.Effect; -} - -export interface ZcodeRequestAuthOptions { - readonly fileSystem: FileSystem.FileSystem; - readonly httpClient: HttpClient.HttpClient; - readonly environment: NodeJS.ProcessEnv; - /** The installed CLI's version, as its own host reports it; omitted when unknown. */ - readonly clientVersion?: string | undefined; -} - -/** - * Serves OAuth project tokens for entitled individual coding plans, mirroring the desktop - * host's exchange: customer info → personal default project → the account's `zcode-api-key` → - * a short-lived access token. Read-only towards the account; a missing key surfaces as a - * decline the turn reports honestly. - */ -export const makeZcodeRequestAuth = (options: ZcodeRequestAuthOptions): ZcodeRequestAuth => { - const root = zcodeDataRoot(options.environment) ?? ""; - const credentialsPath = `${root}/.zcode/v2/credentials.json`; - const cache = new Map(); - const credentialCache: CredentialStoreCache = { digest: "", store: {} }; - - /** The family's current login with the credential scope digesting it; undefined once gone. */ - const currentLogin = Effect.fn("ZcodeRequestAuth.currentLogin")(function* ( - family: string, - ): Effect.fn.Return<{ loginToken: string; accountScope: string } | undefined> { - const store = yield* readDecryptedCredentialStore( - options.fileSystem, - credentialsPath, - options.environment, - yield* HostProcess.Platform, - credentialCache, - ); - const loginToken = store[`oauth:${family}:access_token`]; - if (loginToken === undefined || loginToken.length === 0) return undefined; - return { - loginToken, - accountScope: zcodeAccountScope(family, loginToken, store[ZCODE_JWT_CREDENTIAL_KEY]), - }; - }); - - const requestJson = (request: HttpClientRequest.HttpClientRequest) => - options.httpClient.execute(request).pipe( - Effect.flatMap(HttpClientResponse.filterStatusOk), - Effect.flatMap((response) => response.json), - Effect.timeoutOption(REQUEST_TIMEOUT_MS), - Effect.map((answered) => (Option.isSome(answered) ? answered.value : undefined)), - Effect.orElseSucceed(() => undefined), - ); - - const envelopeData = Effect.fnUntraced(function* ( - raw: unknown, - exchange: string, - ): Effect.fn.Return { - const code = Predicate.isObject(raw) ? raw["code"] : undefined; - if (!(code === 0 || code === 200 || code === "0" || code === "200")) { - return yield* failAuth(`${exchange} code=${String(code)}`); - } - return Predicate.isObject(raw) ? raw["data"] : undefined; - }); - - const mint = Effect.fn("ZcodeRequestAuth.mint")(function* ( - family: string, - ): Effect.fn.Return { - const login = yield* currentLogin(family); - if (login === undefined) { - return yield* failAuth("no OAuth login for family"); - } - const origin = FAMILY_ORIGIN[family]; - if (origin === undefined) { - return yield* failAuth(`unknown family ${family}`); - } - const customer = yield* envelopeData( - yield* requestJson( - HttpClientRequest.get(`${origin}/api/biz/customer/getCustomerInfo`, { - headers: { Authorization: login.loginToken }, - }), - ), - "getCustomerInfo", - ); - const organizations: Array<{ - organizationId: string; - organizationName?: string; - projects: Array<{ projectId: string; projectName?: string }>; - }> = []; - const rawOrganizations = Predicate.isObject(customer) ? customer["organizations"] : undefined; - for (const rawOrg of Array.isArray(rawOrganizations) ? rawOrganizations : []) { - if (!Predicate.isObject(rawOrg)) continue; - const organizationId = zcodeRecordString(rawOrg, "organizationId"); - if (organizationId === undefined || organizationId.length === 0) continue; - const projects: Array<{ projectId: string; projectName?: string }> = []; - const rawProjects = zcodeRecordField(rawOrg, "projects"); - for (const rawProject of Array.isArray(rawProjects) ? rawProjects : []) { - if (!Predicate.isObject(rawProject)) continue; - const projectId = zcodeRecordString(rawProject, "projectId"); - if (projectId === undefined || projectId.length === 0) continue; - if (String(zcodeRecordField(rawProject, "projectType") ?? "").trim() === TEAM_PROJECT_TYPE) - continue; - projects.push({ - projectId, - ...(typeof rawProject["projectName"] === "string" - ? { projectName: rawProject["projectName"] } - : {}), - }); - } - if (projects.length === 0) continue; - organizations.push({ - organizationId, - ...(typeof rawOrg["organizationName"] === "string" - ? { organizationName: rawOrg["organizationName"] } - : {}), - projects, - }); - } - const org = - organizations.find((entry) => entry.organizationName?.includes(DEFAULT_ORGANIZATION_NAME)) ?? - organizations[0]; - const project = - org?.projects.find((entry) => entry.projectName?.includes(DEFAULT_PROJECT_NAME)) ?? - org?.projects[0]; - if (org === undefined || project === undefined) { - return yield* failAuth("no personal project"); - } - const keysPath = `${origin}/api/biz/v1/organization/${encodeURIComponent(org.organizationId)}/projects/${encodeURIComponent(project.projectId)}/api_keys`; - const keys = yield* envelopeData( - yield* requestJson( - HttpClientRequest.get(keysPath, { - headers: { - Authorization: family === "zai" ? `Bearer ${login.loginToken}` : login.loginToken, - }, - }), - ), - "api_keys", - ); - const keyList = Array.isArray(keys) ? keys : []; - const key = keyList.find( - (entry) => Predicate.isObject(entry) && entry["name"] === ZCODE_API_KEY_NAME, - ); - const apiKeyId = - Predicate.isObject(key) && typeof key["apiKey"] === "string" ? key["apiKey"] : undefined; - if (apiKeyId === undefined || apiKeyId.length === 0) { - return yield* failAuth("account has no zcode-api-key; open the ZCode app once"); - } - const exchangeRequest = HttpClientRequest.post( - `${keysPath}/${encodeURIComponent(apiKeyId)}/access_tokens`, - { headers: { Authorization: `Bearer ${login.loginToken}` } }, - ).pipe( - HttpClientRequest.bodyText( - zcodeJsonStringify({ - clientType: "zcode", - ...(options.clientVersion === undefined ? {} : { clientVersion: options.clientVersion }), - }), - "application/json", - ), - ); - const exchange = yield* envelopeData(yield* requestJson(exchangeRequest), "access_tokens"); - const token = - Predicate.isObject(exchange) && typeof exchange["accessToken"] === "string" - ? exchange["accessToken"] - : undefined; - if (token === undefined || token.length === 0) { - return yield* failAuth("token exchange returned no token"); - } - const cachedToken: CachedProjectToken = { - token, - apiKeyId, - accountScope: login.accountScope, - fingerprint: sha256Hex(token), - refreshAtMillis: - DateTime.toEpochMillis(yield* DateTime.now) + TOKEN_TTL_MS - TOKEN_REFRESH_MARGIN_MS, - }; - cache.set(family, cachedToken); - return cachedToken; - }); - - const answer = Effect.fn("ZcodeRequestAuth.answer")(function* (input: { - readonly family: string; - readonly rejectedProjectTokenFingerprint?: string; - }): Effect.fn.Return { - const hit = cache.get(input.family); - // A CLI login can swap accounts or log out under the same family; the cached scope digests - // the credentials, so reread them and drop the hit when they no longer match. - const currentScope = - hit === undefined ? undefined : (yield* currentLogin(input.family))?.accountScope; - const cached = - hit !== undefined && - currentScope === hit.accountScope && - DateTime.toEpochMillis(yield* DateTime.now) < hit.refreshAtMillis && - (input.rejectedProjectTokenFingerprint === undefined || - input.rejectedProjectTokenFingerprint !== hit.fingerprint) - ? hit - : undefined; - if (cached !== undefined) { - return { - headersApplied: true, - requestAuth: { - apiKey: cached.token, - apiKeyId: cached.apiKeyId, - accountScope: cached.accountScope, - }, - }; - } - const minted = yield* Effect.result(mint(input.family)); - return Result.isFailure(minted) - ? { headersApplied: false, errorMessage: minted.failure.message } - : { - headersApplied: true, - requestAuth: { - apiKey: minted.success.token, - apiKeyId: minted.success.apiKeyId, - accountScope: minted.success.accountScope, - }, - }; - }); - - return { - answerRuntimeHeaders: (input) => - answer(input).pipe( - // Any defect here would strand the CLI's request; decline instead. - Effect.catchCause((cause) => - Effect.succeed({ - headersApplied: false, - errorMessage: declineMessage(causeErrorTag(cause)), - } satisfies ZcodeRecord), - ), - ), - }; -}; diff --git a/packages/provider-zcode/src/server/zcodeRuntime.test.ts b/packages/provider-zcode/src/server/zcodeRuntime.test.ts index b01f3c69e982e..6367c7b7bf50d 100644 --- a/packages/provider-zcode/src/server/zcodeRuntime.test.ts +++ b/packages/provider-zcode/src/server/zcodeRuntime.test.ts @@ -7,10 +7,6 @@ import * as NodeOS from "node:os"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; -import { - resolveZcodeCredentialSecret, - zcodeAccountConfigPushFromLadder, -} from "./zcodeRequestAuth.ts"; import { resolveZcodeCatalogFiles, readZcodeAppVersion, @@ -23,7 +19,7 @@ import { zcodePersonalProviderConfigPath, zcodePlatformDir, zcodeRuntimeEnvironment, - zcodeSpawnArgs, + zcodeServeArgs, } from "./zcodeRuntime.ts"; // Verified against the on-disk zcode 3.17.2 runtime catalog directory for https://zcode.z.ai. @@ -86,18 +82,6 @@ const inlineCatalog = { }, }; -/** Produces store values the CLI's fallback cipher would hold, keyed the same way. */ -const encryptZcodeCredential = (value: string, secret: string): string => { - const iv = NodeCrypto.randomBytes(12); - const cipher = NodeCrypto.createCipheriv( - "aes-256-gcm", - NodeCrypto.createHash("sha256").update(secret).digest(), - iv, - ); - const ciphertext = Buffer.concat([cipher.update(value, "utf8"), cipher.final()]); - return `enc:v1:${iv.toString("base64url")}.${cipher.getAuthTag().toString("base64url")}.${ciphertext.toString("base64url")}`; -}; - describe("zcodePlatformDir", () => { it("maps zcode's own platform spellings", () => { assert.equal(zcodePlatformDir("linux", "x64"), "linux-x86_64"); @@ -169,12 +153,11 @@ describe("zcodeBuiltinCatalogPath", () => { }); }); -describe("zcodeSpawnArgs", () => { - it("launches app-server --stdio with launch args split on whitespace", () => { - assert.deepEqual(zcodeSpawnArgs(""), ["app-server", "--stdio"]); - assert.deepEqual(zcodeSpawnArgs("--verbose --trace file "), [ - "app-server", - "--stdio", +describe("zcodeServeArgs", () => { + it("launches serve with launch args split on whitespace", () => { + assert.deepEqual(zcodeServeArgs(""), ["serve"]); + assert.deepEqual(zcodeServeArgs("--verbose --trace file "), [ + "serve", "--verbose", "--trace", "file", @@ -343,110 +326,6 @@ describe("zcodeRuntimeEnvironment", () => { }); }); -describe("zcodeAccountConfigPushFromLadder", () => { - it.effect( - "keys the push to the catalog path hash and maps the entitlement ladder onto providers and states", - () => - Effect.scoped( - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const root = yield* fs.makeTempDirectoryScoped({ prefix: "zcode-ladder-test-" }); - const platform = yield* HostProcess.Platform; - const environment = { ZCODE_DATA_BASE_DIR: root, HOME: "/home/dev", USER: "dev" }; - yield* fs.makeDirectory(`${root}/.zcode/v2`, { recursive: true }); - yield* fs.writeFileString( - `${root}/.zcode/v2/setting.json`, - JSON.stringify({ - providerFamilyConnectionSelections: { - zai: { kind: "individual-coding-plan" }, - bigmodel: { kind: "individual-coding-plan" }, - start: { kind: "individual-coding-plan" }, - }, - modelProviderFamilyModes: { bigmodel: "apiKey" }, - }), - ); - yield* fs.writeFileString( - `${root}/.zcode/v2/credentials.json`, - JSON.stringify({ - "oauth:zai:access_token": encryptZcodeCredential( - "zai-login-token", - resolveZcodeCredentialSecret(environment, platform), - ), - }), - ); - const builtinPath = `${root}/catalog/zcode-builtin.json`; - const push = yield* zcodeAccountConfigPushFromLadder(fs, { - builtinPath, - builtinCatalog: inlineCatalog, - environment, - }); - assert.isDefined(push); - // builtinPath is absolute, so the CLI-side path.resolve is a no-op here. - assert.equal( - push!.basedOnZCodeBuiltinRevision, - `zcode-builtin:43:${NodeCrypto.createHash("sha256").update(builtinPath).digest("hex")}`, - ); - assert.equal(push!.revision.startsWith("account:t3code:"), true); - assert.deepEqual(Object.keys(push!.providers), [ - "account:zai-individual-coding-plan", - "account:zai-team-coding-plan", - "account:bigmodel-individual-coding-plan", - "account:start-individual-coding-plan", - ]); - // OAuth selection + decrypted login token + plan-mode rule → entitled. - assert.deepEqual(push!.providers["account:zai-individual-coding-plan"], { - builtinModelIds: ["GLM-5.3", "GLM-5.3-Flash"], - access: { type: "zhipu-account", entitled: true }, - }); - assert.deepEqual(push!.states["account:zai-individual-coding-plan"], { - availability: "available", - entitled: true, - current: true, - }); - // Team-plan rules never entitle. - assert.equal(push!.states["account:zai-team-coding-plan"]!.availability, "unavailable"); - assert.equal(push!.states["account:zai-team-coding-plan"]!.entitled, false); - assert.equal(push!.states["account:zai-team-coding-plan"]!.current, false); - // A family switched to apiKey mode is excluded even with a selection. - assert.equal( - push!.states["account:bigmodel-individual-coding-plan"]!.availability, - "unavailable", - ); - // A selected family without a decryptable login token stays unentitled. - assert.equal(push!.states["account:start-individual-coding-plan"]!.entitled, false); - }), - ).pipe(Effect.provide(NodeServices.layer)), - ); - - it.effect("returns undefined without a usable catalog", () => - Effect.scoped( - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const noRules = yield* zcodeAccountConfigPushFromLadder(fs, { - builtinPath: "/tmp/builtin.json", - builtinCatalog: { config: { providerConfigRules: { providerRules: [] } } }, - environment: { HOME: "/home/dev" }, - }); - assert.equal(noRules, undefined); - const apiOnly = yield* zcodeAccountConfigPushFromLadder(fs, { - builtinPath: "/tmp/builtin.json", - builtinCatalog: { - config: { - providerConfigRules: { - providerRules: [ - { providerId: "kimi-api", config: { access: { type: "api-key" } } }, - ], - }, - }, - }, - environment: { HOME: "/home/dev" }, - }); - assert.equal(apiOnly, undefined); - }), - ).pipe(Effect.provide(NodeServices.layer)), - ); -}); - describe("zcodeModelSelectionFromSlug", () => { it("defers default slugs to the CLI's own default", () => { assert.equal(zcodeModelSelectionFromSlug("default"), undefined); @@ -512,19 +391,6 @@ it.layer(NodeServices.layer)("ZcodeRuntime catalog resolution", (it) => { assert.equal(resolved.builtinPath, `${builtinDir}/zcode-builtin.json`); assert.equal(resolved.bundledPath, undefined); assert.equal(resolved.personalPath, personalPath); - // No credentials or setting selection in this root: every rule pushes unentitled. - const push = yield* zcodeAccountConfigPushFromLadder(fs, { - builtinPath: resolved.builtinPath!, - builtinCatalog: resolved.builtinCatalog, - environment: { ZCODE_DATA_BASE_DIR: root }, - }); - assert.deepEqual(Object.keys(push?.providers ?? {}), [ - "account:zai-individual-coding-plan", - "account:zai-team-coding-plan", - "account:bigmodel-individual-coding-plan", - "account:start-individual-coding-plan", - ]); - assert.equal(push?.states["account:zai-individual-coding-plan"]?.entitled, false); }), ), ); diff --git a/packages/provider-zcode/src/server/zcodeRuntime.ts b/packages/provider-zcode/src/server/zcodeRuntime.ts index e4418499e21af..a9d0781824f0e 100644 --- a/packages/provider-zcode/src/server/zcodeRuntime.ts +++ b/packages/provider-zcode/src/server/zcodeRuntime.ts @@ -1,6 +1,6 @@ // @effect-diagnostics nodeBuiltinImport:off -- Catalog paths key on sha256 of the endpoint origin; Effect's crypto service adds a dependency the driver does not carry. -// Provisioning for `zcode app-server --stdio`: spawn args, catalog-backed environment, the -// account config push that makes catalog models selectable, and T3 model slug mapping. +// ZCode CLI locations and catalog discovery: the data root, the installed catalog files the +// model picker enumerates, the launch command and `serve` args, and T3 model slug mapping. // Everything protocol-shaped here mirrors the Phase 0 probe notes (PROTOCOL-NOTES.md). import * as NodeCrypto from "node:crypto"; import * as NodeOS from "node:os"; @@ -208,8 +208,8 @@ export function zcodeModelSelectionFromSlug(slug: string): ZcodeModelSelection | return { providerId, modelId, options: { reasoningLevel } }; } -export function zcodeSpawnArgs(launchArgs: string): ReadonlyArray { - return ["app-server", "--stdio", ...launchArgs.split(/\s+/).filter((arg) => arg.length > 0)]; +export function zcodeServeArgs(launchArgs: string): ReadonlyArray { + return ["serve", ...launchArgs.split(/\s+/).filter((arg) => arg.length > 0)]; } /** diff --git a/packages/provider-zcode/src/server/zcodeServeConnection.ts b/packages/provider-zcode/src/server/zcodeServeConnection.ts new file mode 100644 index 0000000000000..da0e5a2bd2958 --- /dev/null +++ b/packages/provider-zcode/src/server/zcodeServeConnection.ts @@ -0,0 +1,458 @@ +// Transport shim: presents the `zcode serve` daemon through the request/event surface the +// session layer already speaks. Requests map to daemon channel calls; events come from the +// row-based v4 conversation projection, folded by `zcodeServeConversation`. +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as HttpClient from "effect/http/HttpClient"; +import * as Predicate from "effect/Predicate"; +import * as Queue from "effect/Queue"; +import * as Schema from "effect/Schema"; +import * as Clock from "effect/Clock"; +import * as SecureRandom from "effect/crypto/SecureRandom"; + +import { + ZcodeProtocolError, + type ZcodeConnection, + type ZcodeProtocolTimeoutError, + type ZcodeRecord, +} from "./zcodeProtocol.ts"; +import { zcodeChannelUrl, connectZcodeServer } from "./zcodeServeDaemon.ts"; +import { + applyConversationPayload, + initialConversationState, + type ZcodeConversationState, +} from "./zcodeServeConversation.ts"; +import { makeZcodeChannelClient, type ZcodeChannelClient } from "./zcodeServeProtocol.ts"; + +const CLIENT_ID = "t3-code"; +const DEFAULT_TIMEOUT_MS = 30_000; +const CREATE_TIMEOUT_MS = 150_000; + +// Command ids only need to be unique, so draw from the process's own WebCrypto rather than +// threading a service through the adapter's fixed environment requirements. +const random = SecureRandom.make((size) => globalThis.crypto.getRandomValues(new Uint8Array(size))); + +export interface ZcodeServeConnectionOptions { + readonly environment: NodeJS.ProcessEnv; + readonly settings: { readonly binaryPath: string; readonly launchArgs: string }; + readonly cwd: string; + readonly fileSystem: FileSystem.FileSystem; + readonly httpClient: HttpClient.HttpClient; +} + +interface ZcodeWorkspaceParams { + readonly workspacePath: string; + readonly workspaceIdentity: string; + readonly projectId: string; +} + +const recordField = (record: unknown, key: string): unknown => + Predicate.isObject(record) ? record[key] : undefined; + +const recordString = (record: unknown, key: string): string | undefined => { + const value = recordField(record, key); + return Predicate.isString(value) ? value : undefined; +}; + +/** + * The daemon refuses to start a session for a workspace it has no project record for. The host + * channel here is a remote host, so `reuseExistingRoot` is rejected; match the root ourselves so + * repeated attaches do not mint duplicate projects. + */ +const resolveProjectId = Effect.fn("ZcodeServe.resolveProject")(function* ( + channel: ZcodeChannelClient, + workspacePath: string, +) { + const existing = yield* channel.call("project", "readProjects", { includeDeleted: false }); + const match = Array.isArray(existing) + ? existing.find((project) => { + if (!Predicate.isObject(project) || project["deleted"] === true) return false; + const roots = project["roots"]; + return Array.isArray(roots) && recordString(roots[0], "rootPath") === workspacePath; + }) + : undefined; + const existingId = recordString(match, "id"); + if (existingId !== undefined) return existingId; + + const saved = yield* channel.call("project", "saveProject", { + roots: [workspacePath], + primaryRoot: workspacePath, + defaultName: workspacePath.split(/[\\/]/).filter(Boolean).at(-1) ?? "zcode", + }); + const id = recordString(saved, "id"); + if (id === undefined) { + return yield* new ZcodeProtocolError({ + operation: "resolveProject", + detail: "zcode server did not return a project id for the workspace.", + }); + } + return id; +}); + +const isZcodeProtocolError = Schema.is(ZcodeProtocolError); + +const errorMessage = (error: unknown): string => + isZcodeProtocolError(error) + ? (error.detail ?? error.operation) + : error instanceof Error + ? error.message + : String(error); + +/** The daemon advertises its session model on the snapshot; the session layer only needs it as a default. */ +const availableModelDescriptors = ( + state: ZcodeConversationState, + settings: unknown, +): ReadonlyArray => { + const model = recordField(recordField(settings, "model"), "available"); + if (Array.isArray(model) && model.length > 0) return model.filter(Predicate.isObject); + const selection = state.modelSelection; + if (selection === undefined) return []; + return [ + { + ref: { providerId: selection.providerId, modelId: selection.modelId }, + ...(state.thoughtLevel === undefined + ? {} + : { reasoning: { defaultLevel: state.thoughtLevel, levels: state.thoughtLevels } }), + }, + ]; +}; + +const readLegacySettings = Effect.fn("ZcodeServe.readSettings")(function* ( + channel: ZcodeChannelClient, + workspace: ZcodeWorkspaceParams, + sessionId: string, +) { + const snapshot = yield* channel.call("zcode-agent", "readSession", { + ...workspace, + sessionId, + deliveryKind: "desktop-continuous", + }); + return recordField(snapshot, "settings"); +}); + +export const makeZcodeServeConnection = Effect.fn("ZcodeServeConnection.make")(function* ( + options: ZcodeServeConnectionOptions, +) { + const scope = yield* Effect.scope; + const server = yield* connectZcodeServer({ + settings: options.settings, + environment: options.environment, + fileSystem: options.fileSystem, + httpClient: options.httpClient, + }); + const channel = yield* makeZcodeChannelClient({ + url: zcodeChannelUrl(server.status, server.token), + headers: { "x-zcode-rpc-host-capability": server.capability }, + }); + const projectId = yield* resolveProjectId(channel, options.cwd); + const workspace: ZcodeWorkspaceParams = { + workspacePath: options.cwd, + workspaceIdentity: projectId, + projectId, + }; + yield* channel.call("zcode-agent", "initialize", workspace).pipe( + Effect.tap((initialized) => + recordField(initialized, "available") === true + ? Effect.void + : Effect.logWarning("ZCode agent reports no usable provider for this workspace.", { + reason: recordField(initialized, "reason") ?? null, + }), + ), + Effect.ignore, + ); + + const events = yield* Queue.unbounded(); + const exitDeferred = yield* Deferred.make(); + let conversation: ZcodeConversationState = initialConversationState(); + let sessionId: string | undefined; + let seeding = false; + + const foldFrame = (frame: unknown): void => { + if (recordField(frame, "kind") !== "complete") return; + if (sessionId === undefined) return; + const topic = recordString(frame, "topic"); + // The listener is workspace-scoped; the topic names the conversation this client subscribed. + if (topic !== undefined && topic !== `conversation/${sessionId}`) return; + const payload = recordField(recordField(frame, "frame"), "payload"); + if (!Predicate.isObject(payload)) return; + const seedRows = seeding && recordString(payload, "kind") === "snapshot"; + if (seedRows) seeding = false; + for (const event of applyConversationPayload(conversation, payload, { seedRows })) { + if (event.type === "server.request") { + const requestParams = recordField(event.payload, "params"); + Queue.offerUnsafe(events, { + id: recordString(requestParams, "requestId") ?? "", + method: recordString(event.payload, "method") ?? "", + params: Predicate.isObject(requestParams) ? requestParams : {}, + }); + continue; + } + Queue.offerUnsafe(events, { method: "session/event", params: event }); + } + }; + + yield* channel.listen("zcode-agent", "onDynamicConversationFrame", workspace, foldFrame); + yield* channel.listen("zcode-agent", "onDynamicSessionLifecycle", workspace, (snapshot) => { + if (!Predicate.isObject(snapshot)) return; + const eventSessionId = recordString(snapshot, "sessionId"); + if (sessionId !== undefined && eventSessionId !== undefined && eventSessionId !== sessionId) { + return; + } + Queue.offerUnsafe(events, { method: "session/lifecycle/changed", params: snapshot }); + }); + + yield* channel.closed.pipe( + Effect.andThen( + Deferred.fail( + exitDeferred, + new ZcodeProtocolError({ + operation: "exit", + detail: "zcode server channel closed", + }), + ), + ), + Effect.andThen( + Queue.fail( + events, + new ZcodeProtocolError({ operation: "exit", detail: "zcode server channel closed" }), + ), + ), + Effect.ignore, + Effect.forkIn(scope), + ); + + const subscribe = Effect.fn("ZcodeServe.subscribe")(function* (nativeSessionId: string) { + conversation = initialConversationState(); + sessionId = nativeSessionId; + seeding = true; + return yield* channel.call("zcode-agent", "subscribeConversationV4", { + ...workspace, + sessionId: nativeSessionId, + visibility: "foreground", + }); + }); + + const commitCommand = Effect.fn("ZcodeServe.commitCommand")(function* ( + type: string, + nativeSessionId: string, + payload: ZcodeRecord, + ) { + const commandId = yield* random.uuidV4(); + const issuedAt = yield* Clock.currentTimeMillis; + return yield* channel.call("zcode-agent", "sendConversationCommandV4", { + ...workspace, + envelope: { + commandId, + clientId: CLIENT_ID, + sessionId: nativeSessionId, + type, + payload, + issuedAt, + }, + }); + }); + + /** Translates a session-layer answer into the daemon's `resolveInteraction` shape. */ + const interactionAnswer = ( + interaction: ZcodeRecord | undefined, + result: unknown, + ): ZcodeRecord | undefined => { + const wanted = Predicate.isObject(result) ? result : {}; + const optionId = recordString(wanted, "optionId"); + if (optionId !== undefined) return { optionId }; + if (recordField(wanted, "cancelled") === true) return { action: "cancel" }; + const value = recordString(wanted, "value"); + if (value !== undefined) { + const payload = recordField(interaction ?? {}, "payload"); + const questions = recordField(payload, "questions"); + const question = Array.isArray(questions) ? questions[0] : undefined; + // AskUserQuestion interactions resolve through the elicitation path: the daemon expects + // the tool's answers map, not a permission option id. + const questionText = recordString(question, "question"); + if (questionText !== undefined) { + return { action: "accept", content: { answers: { [questionText]: value } } }; + } + const candidates = recordField(payload, "options"); + const match = Array.isArray(candidates) + ? candidates.find((candidate) => recordString(candidate, "label") === value) + : undefined; + const matchedId = recordString(match, "optionId") ?? recordString(match, "value"); + return matchedId === undefined ? { freeText: value } : { optionId: matchedId }; + } + if (recordString(wanted, "decision") === "deny") return { action: "decline" }; + return undefined; + }; + + const respond = Effect.fnUntraced(function* (id: string, result: unknown) { + const interaction = conversation.pendingInteractions.get(id); + const answer = interactionAnswer(interaction, result); + if (answer === undefined || sessionId === undefined) return; + conversation.pendingInteractions.delete(id); + yield* commitCommand("resolveInteraction", sessionId, { + interactionId: id, + answer, + }).pipe( + Effect.catch((error) => + Effect.logWarning("ZCode interaction answer failed.", { detail: errorMessage(error) }), + ), + ); + }); + + const request = Effect.fn("ZcodeServe.request")(function* ( + method: string, + params: ZcodeRecord, + timeoutMs = DEFAULT_TIMEOUT_MS, + ): Effect.fn.Return { + switch (method) { + case "session/create": { + const workspaceRef = recordField(params, "workspace"); + const requestedCwd = recordString(workspaceRef, "workspacePath"); + const mode = recordString(params, "mode"); + const mcpServers = recordField(params, "mcpServers"); + const created = yield* channel.call( + "zcode-session", + "createSession", + { + ...workspace, + ...(requestedCwd === undefined ? {} : { workspacePath: requestedCwd }), + ...(mode === undefined ? {} : { mode }), + ...(Array.isArray(mcpServers) ? { mcpServers } : {}), + }, + timeoutMs, + ); + const nativeSessionId = recordString(recordField(created, "session"), "sessionId"); + if (nativeSessionId === undefined) { + return yield* new ZcodeProtocolError({ + operation: method, + detail: "zcode server returned no session id.", + }); + } + const settings = recordField(created, "settings"); + return { + session: { sessionId: nativeSessionId }, + settings: { + model: { available: availableModelDescriptors(conversation, settings) }, + }, + }; + } + case "session/subscribe": { + const nativeSessionId = recordString(params, "sessionId"); + if (nativeSessionId === undefined) return { snapshot: {} }; + yield* subscribe(nativeSessionId); + const settings = yield* readLegacySettings(channel, workspace, nativeSessionId).pipe( + Effect.orElseSucceed(() => undefined), + ); + return { + snapshot: { + settings: { + model: { available: availableModelDescriptors(conversation, settings) }, + }, + }, + }; + } + case "session/resume": { + const nativeSessionId = recordString(params, "sessionId"); + if (nativeSessionId === undefined) { + return yield* new ZcodeProtocolError({ + operation: method, + detail: "session/resume needs a sessionId", + }); + } + const mcpServers = recordField(params, "mcpServers"); + yield* channel.call( + "zcode-agent", + "resumeSession", + { + ...workspace, + sessionId: nativeSessionId, + ...(Array.isArray(mcpServers) ? { mcpServers } : {}), + }, + CREATE_TIMEOUT_MS, + ); + sessionId = nativeSessionId; + return {}; + } + case "session/setMode": { + const nativeSessionId = recordString(params, "sessionId"); + const mode = recordString(params, "mode"); + if (nativeSessionId === undefined || mode === undefined) return {}; + yield* channel.call("zcode-agent", "setMode", { + ...workspace, + sessionId: nativeSessionId, + mode, + }); + return {}; + } + case "session/send": { + const nativeSessionId = recordString(params, "sessionId"); + const content = recordString(params, "content"); + if (nativeSessionId === undefined || content === undefined) { + return yield* new ZcodeProtocolError({ + operation: method, + detail: "session/send needs a sessionId and content.", + }); + } + const modelSelection = recordField(params, "modelSelection"); + const inputId = yield* random.uuidV4(); + return yield* channel.call("zcode-agent", "sendPrompt", { + ...workspace, + sessionId: nativeSessionId, + inputId, + content, + ...(Predicate.isObject(modelSelection) ? { modelSelection } : {}), + }); + } + case "session/compact": { + const nativeSessionId = recordString(params, "sessionId"); + if (nativeSessionId === undefined) return {}; + const instructions = recordString(params, "instructions"); + const inputId = yield* random.uuidV4(); + return yield* channel.call( + "zcode-agent", + "compactSession", + { + ...workspace, + sessionId: nativeSessionId, + inputId, + ...(instructions === undefined ? {} : { instructions }), + }, + CREATE_TIMEOUT_MS, + ); + } + case "session/stop": { + const nativeSessionId = recordString(params, "sessionId") ?? sessionId; + if (nativeSessionId === undefined) return {}; + const executionId = conversation.foregroundExecutionId; + yield* commitCommand( + "stop", + nativeSessionId, + executionId === undefined ? {} : { expectedForegroundExecutionId: executionId }, + ); + return {}; + } + case "session/read": { + const nativeSessionId = recordString(params, "sessionId"); + if (nativeSessionId === undefined) return {}; + return yield* channel.call("zcode-agent", "readSession", { + ...workspace, + sessionId: nativeSessionId, + deliveryKind: "desktop-continuous", + }); + } + default: + return yield* new ZcodeProtocolError({ + operation: method, + detail: "unsupported zcode server request", + }); + } + }); + + return { + request, + respond, + events, + exited: Deferred.await(exitDeferred), + terminate: Effect.void, + } satisfies ZcodeConnection; +}); diff --git a/packages/provider-zcode/src/server/zcodeServeConversation.test.ts b/packages/provider-zcode/src/server/zcodeServeConversation.test.ts new file mode 100644 index 0000000000000..778a6d9204968 --- /dev/null +++ b/packages/provider-zcode/src/server/zcodeServeConversation.test.ts @@ -0,0 +1,450 @@ +import { assert, describe, it } from "@effect/vitest"; + +import { + applyConversationPayload, + initialConversationState, + type ZcodeConversationState, +} from "./zcodeServeConversation.ts"; + +const deltas = (state: ZcodeConversationState, entries: ReadonlyArray) => + applyConversationPayload(state, { kind: "deltas", deltas: entries }); + +describe("applyConversationPayload", () => { + it("seeds config, rows, and streamed text from a snapshot", () => { + const state = initialConversationState(); + const events = applyConversationPayload(state, { + kind: "snapshot", + snapshot: { + revision: 3, + config: { + modelSelection: { + providerId: "account:zai-individual-coding-plan", + modelId: "GLM-5.3", + }, + thought: "max", + thoughtLevels: ["low", "high", "max"], + }, + control: { + phase: "draft", + activeWorks: [{ kind: "primaryTurn", foregroundExecutionId: "runtime_command_1" }], + }, + rows: { + window: [ + { rowId: 1, kind: "turnHeader", state: "running" }, + { + rowId: 2, + kind: "assistantText", + assistantResponseId: "msg_a", + text: "earlier", + state: "complete", + }, + ], + totalCount: 2, + firstRowId: 1, + }, + pendingInteractions: [], + }, + }); + + assert.equal(state.revision, 3); + assert.deepEqual(state.modelSelection, { + providerId: "account:zai-individual-coding-plan", + modelId: "GLM-5.3", + }); + assert.deepEqual(state.thoughtLevels, ["low", "high", "max"]); + assert.equal(state.thoughtLevel, "max"); + assert.equal(state.phase, "draft"); + assert.equal(state.foregroundExecutionId, "runtime_command_1"); + assert.equal(state.lastAssistantText, "earlier"); + assert.deepEqual(events, [ + { + type: "model.streaming", + payload: { + assistantMessageId: "msg_a", + delta: "earlier", + kind: "text_delta", + done: true, + }, + }, + ]); + }); + + it("replays snapshot rows silently when subscribed for history", () => { + const state = initialConversationState(); + const events = applyConversationPayload( + state, + { + kind: "snapshot", + snapshot: { + revision: 1, + rows: { + window: [ + { rowId: 1, kind: "assistantText", assistantResponseId: "msg_a", text: "old" }, + ], + }, + }, + }, + { seedRows: true }, + ); + assert.deepEqual(events, []); + assert.equal(state.lastAssistantText, "old"); + }); + + it("streams text deltas and completes the turn with the last assistant text", () => { + const state = initialConversationState(); + + assert.deepEqual( + deltas(state, [ + { + op: "state.updated", + patch: { + revision: 1, + control: { + phase: "running", + activeWorks: [{ kind: "primaryTurn", foregroundExecutionId: "runtime_command_4" }], + }, + }, + }, + ]), + [{ type: "turn.started", payload: {} }], + ); + assert.equal(state.foregroundExecutionId, "runtime_command_4"); + + assert.deepEqual( + deltas(state, [ + { + op: "row.appended", + row: { + rowId: 6, + kind: "assistantText", + assistantResponseId: "msg_b", + text: "", + state: "streaming", + }, + }, + ]), + [], + ); + + assert.deepEqual(deltas(state, [{ op: "row.delta", rowId: 6, append: "do" }]), [ + { + type: "model.streaming", + payload: { assistantMessageId: "msg_b", delta: "do", kind: "text_delta" }, + }, + ]); + + // The finishing upsert carries the whole text; only the suffix is new. + assert.deepEqual( + deltas(state, [ + { + op: "row.upserted", + row: { + rowId: 6, + kind: "assistantText", + assistantResponseId: "msg_b", + text: "done", + state: "complete", + }, + }, + { op: "row.upserted", row: { rowId: 1, kind: "turnHeader", outputTokens: 31 } }, + ]), + [ + { + type: "model.streaming", + payload: { + assistantMessageId: "msg_b", + delta: "ne", + kind: "text_delta", + done: true, + }, + }, + ], + ); + + assert.deepEqual( + deltas(state, [{ op: "state.updated", patch: { control: { phase: "completedSuccess" } } }]), + [ + { + type: "turn.completed", + payload: { resultType: "success", response: "done", tokenCount: 31 }, + }, + ], + ); + assert.equal(state.lastAssistantText, "done"); + }); + + it("reports interruption and failure as their own terminal kinds", () => { + const interrupted = initialConversationState(); + deltas(interrupted, [{ op: "state.updated", patch: { control: { phase: "running" } } }]); + assert.deepEqual( + deltas(interrupted, [ + { op: "state.updated", patch: { control: { phase: "completedInterrupted" } } }, + ]), + [{ type: "turn.completed", payload: { resultType: "cancelled" } }], + ); + + const failed = initialConversationState(); + deltas(failed, [{ op: "state.updated", patch: { control: { phase: "running" } } }]); + assert.deepEqual( + deltas(failed, [ + { + op: "state.updated", + patch: { control: { phase: "error", lastError: { message: "model exploded" } } }, + }, + ]), + [{ type: "turn.failed", payload: { error: { message: "model exploded" } } }], + ); + }); + + it("follows a tool call from started through streamed output to its result", () => { + const state = initialConversationState(); + const toolRow = { + rowId: 4, + kind: "toolCall", + toolCallId: "call_1", + toolName: "Bash", + status: "inputStreaming", + input: { command: "echo hello-t3" }, + }; + + assert.deepEqual(deltas(state, [{ op: "row.appended", row: toolRow }]), [ + { + type: "tool.updated", + payload: { + toolCallId: "call_1", + toolName: "Bash", + input: { command: "echo hello-t3" }, + kind: "started", + }, + }, + ]); + + assert.deepEqual( + deltas(state, [ + { op: "row.upserted", row: { ...toolRow, status: "running" } }, + { op: "row.delta", rowId: 4, path: "output.text", append: "hel" }, + ]), + [ + { + type: "tool.updated", + payload: { + toolCallId: "call_1", + toolName: "Bash", + input: { command: "echo hello-t3" }, + kind: "started", + }, + }, + { + type: "tool.updated", + payload: { + toolCallId: "call_1", + toolName: "Bash", + input: { command: "echo hello-t3" }, + kind: "progress", + outputPreview: { text: "hel" }, + }, + }, + ], + ); + + assert.deepEqual( + deltas(state, [ + { + op: "row.upserted", + row: { ...toolRow, status: "success", output: { text: "hello-t3" } }, + }, + ]), + [ + { + type: "tool.updated", + payload: { + toolCallId: "call_1", + toolName: "Bash", + input: { command: "echo hello-t3" }, + kind: "result", + result: { output: "hello-t3" }, + }, + }, + ], + ); + + assert.deepEqual( + deltas(state, [ + { + op: "row.appended", + row: { + rowId: 5, + kind: "toolCall", + toolCallId: "call_2", + toolName: "Bash", + status: "error", + error: { message: "permission denied" }, + }, + }, + ]), + [ + { + type: "tool.updated", + payload: { + toolCallId: "call_2", + toolName: "Bash", + input: undefined, + kind: "error", + error: { message: "permission denied" }, + }, + }, + ], + ); + }); + + it("announces a permission interaction once and resolves it when the daemon drops it", () => { + const state = initialConversationState(); + const interaction = { + interactionId: "int_1", + kind: "permission", + payload: { + toolCallId: "call_1", + toolName: "Bash", + summary: "Needs approval", + options: [ + { optionId: "o1", kind: "allowOnce", label: "Allow once" }, + { optionId: "o2", kind: "deny", label: "Deny" }, + ], + fullAccessOption: { optionId: "o3", kind: "custom", label: "Full access" }, + }, + }; + const patch = { op: "state.updated", patch: { pendingInteractions: [interaction] } }; + + assert.deepEqual(deltas(state, [patch]), [ + { + type: "permission.requested", + payload: { + requestId: "int_1", + toolCallId: "call_1", + toolName: "Bash", + reason: "Needs approval", + options: [ + { + optionId: "o1", + kind: "allow_once", + name: "Allow once", + response: { optionId: "o1", decision: "allow" }, + }, + { + optionId: "o2", + kind: "deny", + name: "Deny", + response: { optionId: "o2", decision: "deny" }, + }, + { + optionId: "o3", + kind: "custom", + name: "Full access", + response: { optionId: "o3", decision: "allow" }, + }, + ], + }, + }, + { + type: "server.request", + payload: { + method: "interaction/requestPermission", + params: { requestId: "int_1", toolCallId: "call_1", toolName: "Bash" }, + }, + }, + ]); + + // A resubscribe replays the same pending interaction; it must not ask twice. + assert.deepEqual(deltas(state, [patch]), []); + + assert.deepEqual(deltas(state, [{ op: "state.updated", patch: { pendingInteractions: [] } }]), [ + { type: "permission.resolved", payload: { requestId: "int_1" } }, + ]); + assert.equal(state.pendingInteractions.size, 0); + }); + + it("surfaces an AskUserQuestion as choices and as free text", () => { + const state = initialConversationState(); + assert.deepEqual( + deltas(state, [ + { + op: "state.updated", + patch: { + pendingInteractions: [ + { + interactionId: "int_2", + kind: "userInput", + payload: { + questions: [ + { + question: "Proceed with the migration?", + header: "Confirm", + options: [ + { value: "yes", label: "Yes" }, + { value: "no", label: "No" }, + ], + }, + ], + }, + }, + ], + }, + }, + ]), + [ + { + type: "userInput.requested", + payload: { + requestId: "int_2", + prompt: "Proceed with the migration?", + inputType: "choice", + choices: ["Yes", "No"], + }, + }, + { + type: "server.request", + payload: { + method: "interaction/requestUserInput", + params: { requestId: "int_2" }, + }, + }, + ], + ); + + const freeText = initialConversationState(); + assert.deepEqual( + deltas(freeText, [ + { + op: "state.updated", + patch: { + pendingInteractions: [ + { + interactionId: "int_3", + kind: "userInput", + payload: { prompt: "Explain the change", freeText: true }, + }, + ], + }, + }, + ]), + [ + { + type: "userInput.requested", + payload: { + requestId: "int_3", + prompt: "Explain the change", + inputType: "text", + choices: [], + }, + }, + { + type: "server.request", + payload: { + method: "interaction/requestUserInput", + params: { requestId: "int_3" }, + }, + }, + ], + ); + }); +}); diff --git a/packages/provider-zcode/src/server/zcodeServeConversation.ts b/packages/provider-zcode/src/server/zcodeServeConversation.ts new file mode 100644 index 0000000000000..96f6c77368a98 --- /dev/null +++ b/packages/provider-zcode/src/server/zcodeServeConversation.ts @@ -0,0 +1,528 @@ +// Adapter between the `zcode serve` daemon's row-based conversation projection and the event +// vocabulary this provider's session layer already speaks. The daemon streams row deltas, +// state patches and pending interactions; folding them belongs here so the transport stays +// thin and the mapping stays testable. +import * as Predicate from "effect/Predicate"; + +export type ZcodeRecord = Record; + +export interface ZcodeConversationEvent { + readonly type: string; + readonly payload: ZcodeRecord; +} + +export interface ZcodeModelSelection { + readonly providerId: string; + readonly modelId: string; +} + +export interface ZcodeConversationState { + readonly rows: Map; + revision: number; + phase: string | undefined; + /** The daemon rejects a stale `stop` without this id, so track it off the live control patch. */ + foregroundExecutionId: string | undefined; + readonly pendingInteractions: Map; + /** Interactions already surfaced as requests; resubscribes replay the snapshot. */ + readonly announcedInteractions: Set; + /** Streamed `output.text` per tool row; tool rows only carry the full text at the end. */ + readonly toolOutput: Map; + modelSelection: ZcodeModelSelection | undefined; + thoughtLevels: ReadonlyArray; + thoughtLevel: string | undefined; + /** Latest finished assistant text, used as the turn result summary. */ + lastAssistantText: string | undefined; +} + +export const initialConversationState = (): ZcodeConversationState => ({ + rows: new Map(), + revision: 0, + phase: undefined, + foregroundExecutionId: undefined, + pendingInteractions: new Map(), + announcedInteractions: new Set(), + toolOutput: new Map(), + modelSelection: undefined, + thoughtLevels: [], + thoughtLevel: undefined, + lastAssistantText: undefined, +}); + +const field = (record: unknown, key: string): unknown => + Predicate.isObject(record) ? record[key] : undefined; + +const text = (record: unknown, key: string): string | undefined => { + const value = field(record, key); + return Predicate.isString(value) ? value : undefined; +}; + +const number = (record: unknown, key: string): number | undefined => { + const value = field(record, key); + return Predicate.isNumber(value) && Number.isFinite(value) ? value : undefined; +}; + +const readConfig = (state: ZcodeConversationState, config: unknown): void => { + const selection = field(config, "modelSelection"); + const providerId = text(selection, "providerId"); + const modelId = text(selection, "modelId"); + if (providerId !== undefined && modelId !== undefined) { + state.modelSelection = { providerId, modelId }; + } + const levels = field(config, "thoughtLevels"); + if (Array.isArray(levels)) { + state.thoughtLevels = levels.filter(Predicate.isString); + } + const thought = text(config, "thought"); + if (thought !== undefined) state.thoughtLevel = thought; +}; + +const readControl = (state: ZcodeConversationState, control: unknown): void => { + const phase = text(control, "phase"); + if (phase !== undefined) state.phase = phase; + const activeWorks = field(control, "activeWorks"); + if (Array.isArray(activeWorks)) { + const primary = + activeWorks.find((work) => text(work, "kind") === "primaryTurn") ?? activeWorks[0]; + state.foregroundExecutionId = text(primary, "foregroundExecutionId"); + } +}; + +/** Deltas carry the suffix; full-row upserts carry the whole text, so diff against the last row. */ +const appendedText = (previous: string, next: string): string => { + if (previous.length === 0) return next; + if (next.startsWith(previous)) return next.slice(previous.length); + let shared = 0; + while (shared < previous.length && shared < next.length && previous[shared] === next[shared]) { + shared += 1; + } + return next.slice(shared); +}; + +const streamTextEvent = ( + messageId: string, + delta: string, + kind: "text_delta" | "reasoning_delta", + done: boolean, +): ZcodeConversationEvent => ({ + type: "model.streaming", + payload: { + assistantMessageId: messageId, + delta, + kind, + ...(done ? { done: true } : {}), + }, +}); + +const rowEvents = ( + state: ZcodeConversationState, + row: ZcodeRecord, + previous: ZcodeRecord | undefined, + append: string | undefined, +): ReadonlyArray => { + const kind = text(row, "kind"); + + if (kind === "assistantText" || kind === "reasoning") { + const messageId = text(row, "assistantResponseId") ?? text(row, "entityId"); + if (messageId === undefined) return []; + const streamKind = kind === "assistantText" ? "text_delta" : "reasoning_delta"; + const nextText = text(row, "text") ?? ""; + const delta = + append ?? + (previous === undefined ? nextText : appendedText(text(previous, "text") ?? "", nextText)); + const rowState = text(row, "state"); + const finishing = + rowState !== undefined && + rowState !== "streaming" && + text(previous ?? {}, "state") !== rowState; + const events: ZcodeConversationEvent[] = []; + if (delta.length > 0 || finishing) { + events.push(streamTextEvent(messageId, delta, streamKind, finishing)); + } + if (kind === "assistantText" && nextText.length > 0 && rowState !== "streaming") { + state.lastAssistantText = nextText; + } + return events; + } + + if (kind === "toolCall") { + const toolCallId = text(row, "toolCallId") ?? `row-${number(row, "rowId") ?? 0}`; + const status = text(row, "status"); + const output = text(field(row, "output"), "text"); + const input = field(row, "input"); + const payload: ZcodeRecord = { + toolCallId, + toolName: text(row, "toolName"), + input, + }; + const previousStatus = text(previous ?? {}, "status"); + if (status === previousStatus) { + const preview = + text(field(row, "outputPreview"), "text") ?? text(field(row, "progress"), "previewLine"); + const previousPreview = + text(field(previous ?? {}, "outputPreview"), "text") ?? + text(field(previous ?? {}, "progress"), "previewLine"); + if (preview !== undefined && preview.length > 0 && preview !== previousPreview) { + return [ + { + type: "tool.updated", + payload: { ...payload, kind: "progress", outputPreview: { text: preview } }, + }, + ]; + } + if (input !== undefined && field(previous ?? {}, "input") === undefined) { + return [{ type: "tool.updated", payload: { ...payload, kind: "started" } }]; + } + return []; + } + if (status === "error" || status === "cancelled") { + const message = text(field(row, "error"), "message") ?? output ?? "Tool failed."; + return [{ type: "tool.updated", payload: { ...payload, kind: "error", error: { message } } }]; + } + if (status === "success") { + return [ + { + type: "tool.updated", + payload: { + ...payload, + kind: "result", + result: output === undefined ? {} : { output }, + }, + }, + ]; + } + return [{ type: "tool.updated", payload: { ...payload, kind: "started" } }]; + } + + return []; +}; + +const INTERACTION_OPTION_KINDS: Record = { + allowOnce: { kind: "allow_once", decision: "allow" }, + allowAlways: { kind: "allow_always", decision: "allow" }, + deny: { kind: "deny", decision: "deny" }, + custom: { kind: "custom", decision: "allow" }, +}; + +interface PermissionOption { + readonly optionId: string; + readonly kind: string; + readonly name: string; + readonly response: ZcodeRecord; +} + +const permissionOptions = (payload: unknown): ReadonlyArray => { + const raw = field(payload, "options"); + const options = Array.isArray(raw) ? [...raw] : []; + const fullAccess = field(payload, "fullAccessOption"); + if (Predicate.isObject(fullAccess)) options.push(fullAccess); + return options.flatMap((option): Array => { + const optionId = text(option, "optionId"); + if (optionId === undefined) return []; + const mapped = INTERACTION_OPTION_KINDS[text(option, "kind") ?? ""] ?? { + kind: "custom", + decision: "allow", + }; + return [ + { + optionId, + kind: mapped.kind, + name: text(option, "label") ?? optionId, + // The session layer echoes this object back untouched; the transport turns it into + // the daemon's `resolveInteraction` answer. + response: { optionId, decision: mapped.decision }, + }, + ]; + }); +}; + +interface UserInputView { + readonly prompt: string; + readonly inputType: "text" | "choice"; + readonly choices: ReadonlyArray; +} + +const userInputView = (payload: unknown): UserInputView => { + const questions = field(payload, "questions"); + const question = Array.isArray(questions) ? questions[0] : undefined; + const questionOptions = field(question, "options"); + const flatOptions = field(payload, "options"); + const labels = (candidates: unknown): ReadonlyArray => + Array.isArray(candidates) + ? candidates.flatMap((option): Array => { + const label = text(option, "label"); + return label === undefined ? [] : [label]; + }) + : []; + const choices = labels(questionOptions ?? flatOptions); + const freeText = + field(payload, "freeText") === true || field(question, "multiSelect") !== undefined; + return { + prompt: text(question, "question") ?? text(payload, "prompt") ?? "The agent asked a question", + inputType: freeText || choices.length === 0 ? "text" : "choice", + choices, + }; +}; + +/** + * Interaction events in this provider's vocabulary plus the server request that carries the + * user's answer back to the daemon. Both key on the daemon interaction id. + */ +const interactionEvents = ( + state: ZcodeConversationState, + interaction: ZcodeRecord, +): ReadonlyArray => { + const interactionId = text(interaction, "interactionId"); + if (interactionId === undefined || state.announcedInteractions.has(interactionId)) return []; + const payload = field(interaction, "payload"); + const kind = text(interaction, "kind") ?? text(payload, "kind"); + const events: ZcodeConversationEvent[] = []; + if (kind === "permission") { + events.push({ + type: "permission.requested", + payload: { + requestId: interactionId, + toolCallId: text(payload, "toolCallId"), + toolName: text(payload, "toolName"), + reason: text(payload, "summary"), + options: permissionOptions(payload), + }, + }); + events.push({ + type: "server.request", + payload: { + method: "interaction/requestPermission", + params: { + requestId: interactionId, + toolCallId: text(payload, "toolCallId"), + toolName: text(payload, "toolName"), + }, + }, + }); + } else if (kind === "userInput") { + const view = userInputView(payload); + events.push({ + type: "userInput.requested", + payload: { + requestId: interactionId, + prompt: view.prompt, + inputType: view.inputType, + choices: view.choices, + }, + }); + events.push({ + type: "server.request", + payload: { + method: "interaction/requestUserInput", + params: { requestId: interactionId }, + }, + }); + } + state.announcedInteractions.add(interactionId); + return events; +}; + +const interactionResolvedEvents = ( + interaction: ZcodeRecord, +): ReadonlyArray => { + const interactionId = text(interaction, "interactionId"); + if (interactionId === undefined) return []; + const kind = text(interaction, "kind") ?? text(field(interaction, "payload"), "kind"); + return [ + { + type: kind === "userInput" ? "userInput.resolved" : "permission.resolved", + payload: { requestId: interactionId }, + }, + ]; +}; + +const turnOutputTokens = (state: ZcodeConversationState): number | undefined => { + let best: { rowId: number; tokens: number } | undefined; + for (const [rowId, row] of state.rows) { + if (text(row, "kind") !== "turnHeader") continue; + const tokens = number(row, "outputTokens"); + if (tokens === undefined) continue; + if (best === undefined || rowId > best.rowId) best = { rowId, tokens }; + } + return best?.tokens; +}; + +const controlEvents = ( + state: ZcodeConversationState, + control: unknown, +): ReadonlyArray => { + const phase = text(control, "phase"); + if (phase === undefined) return []; + const previousPhase = state.phase; + readControl(state, control); + if (phase === previousPhase) return []; + if (phase === "running" || phase === "prewarming") { + state.lastAssistantText = undefined; + return [{ type: "turn.started", payload: {} }]; + } + if (previousPhase !== "running" && previousPhase !== "prewarming") return []; + const tokenCount = turnOutputTokens(state); + switch (phase) { + case "completedSuccess": + return [ + { + type: "turn.completed", + payload: { + resultType: "success", + ...(state.lastAssistantText === undefined ? {} : { response: state.lastAssistantText }), + ...(tokenCount === undefined ? {} : { tokenCount }), + }, + }, + ]; + case "completedInterrupted": + return [{ type: "turn.completed", payload: { resultType: "cancelled" } }]; + case "error": + return [ + { + type: "turn.failed", + payload: { + error: { + message: text(field(control, "lastError"), "message") ?? "ZCode turn failed.", + }, + }, + }, + ]; + default: + return []; + } +}; + +/** + * Folds one v4 conversation payload into `state` and returns the session events it produces. + * Mutates `state`; callers keep exactly one state per native session. + */ +export function applyConversationPayload( + state: ZcodeConversationState, + payload: unknown, + options: { readonly seedRows?: boolean } = {}, +): ReadonlyArray { + const kind = text(payload, "kind"); + if (kind === "snapshot") { + const snapshot = field(payload, "snapshot"); + const revision = number(snapshot, "revision"); + if (revision !== undefined) state.revision = revision; + readConfig(state, field(snapshot, "config")); + readControl(state, field(snapshot, "control")); + const events: ZcodeConversationEvent[] = []; + const rows = field(field(snapshot, "rows"), "window"); + if (Array.isArray(rows)) { + for (const entry of rows) { + const row = entry as ZcodeRecord; + const rowId = number(row, "rowId"); + if (rowId === undefined) continue; + state.rows.set(rowId, row); + if (options.seedRows !== true) { + events.push(...rowEvents(state, row, undefined, undefined)); + } else if (text(row, "kind") === "assistantText") { + const rowText = text(row, "text"); + if (rowText !== undefined && rowText.length > 0) state.lastAssistantText = rowText; + } + } + } + const pending = field(snapshot, "pendingInteractions"); + if (Array.isArray(pending)) { + for (const interaction of pending) { + if (!Predicate.isObject(interaction)) continue; + const id = text(interaction, "interactionId"); + if (id === undefined) continue; + state.pendingInteractions.set(id, interaction); + events.push(...interactionEvents(state, interaction)); + } + } + return events; + } + + if (kind !== "deltas") return []; + const deltas = field(payload, "deltas"); + if (!Array.isArray(deltas)) return []; + const events: ZcodeConversationEvent[] = []; + + for (const entry of deltas) { + const op = text(entry, "op"); + if (op === undefined) continue; + if (op === "state.updated") { + const patch = field(entry, "patch"); + const revision = number(patch, "revision"); + if (revision !== undefined) state.revision = revision; + readConfig(state, field(patch, "config")); + const control = field(patch, "control"); + if (control !== undefined) events.push(...controlEvents(state, control)); + const pending = field(patch, "pendingInteractions"); + if (Array.isArray(pending)) { + const next = new Map(); + for (const interaction of pending) { + if (!Predicate.isObject(interaction)) continue; + const id = text(interaction, "interactionId"); + if (id === undefined) continue; + next.set(id, interaction); + const known = state.pendingInteractions.get(id); + if (known === undefined) events.push(...interactionEvents(state, interaction)); + } + for (const [id, previous] of state.pendingInteractions) { + if (next.has(id)) continue; + events.push(...interactionResolvedEvents(previous)); + } + state.pendingInteractions.clear(); + for (const [id, interaction] of next) state.pendingInteractions.set(id, interaction); + } + continue; + } + if (op === "row.delta") { + const rowId = number(entry, "rowId"); + const append = text(entry, "append"); + const path = text(entry, "path") ?? "text"; + if (rowId === undefined || append === undefined) continue; + const row = state.rows.get(rowId); + if (row === undefined) continue; + const rowKind = text(row, "kind"); + if (rowKind === "toolCall") { + if (path !== "output.text") continue; + const accumulated = (state.toolOutput.get(rowId) ?? "") + append; + state.toolOutput.set(rowId, accumulated); + events.push({ + type: "tool.updated", + payload: { + toolCallId: text(row, "toolCallId") ?? `row-${rowId}`, + toolName: text(row, "toolName"), + input: field(row, "input"), + kind: "progress", + outputPreview: { text: accumulated }, + }, + }); + continue; + } + if (path !== "text") continue; + const streamed = { ...row, text: (text(row, "text") ?? "") + append }; + state.rows.set(rowId, streamed); + events.push(...rowEvents(state, streamed, row, append)); + continue; + } + if (op === "row.appended" || op === "row.upserted") { + const row = field(entry, "row"); + if (!Predicate.isObject(row)) continue; + const rowId = number(row, "rowId"); + if (rowId === undefined) continue; + const previous = state.rows.get(rowId); + state.rows.set(rowId, row); + events.push( + ...rowEvents(state, row, op === "row.appended" ? undefined : previous, undefined), + ); + continue; + } + if (op === "row.removed") { + const fromRowId = number(entry, "fromRowId"); + if (fromRowId === undefined) continue; + const removed: number[] = []; + for (const rowId of state.rows.keys()) { + if (rowId >= fromRowId) removed.push(rowId); + } + for (const rowId of removed) state.rows.delete(rowId); + } + } + + return events; +} diff --git a/packages/provider-zcode/src/server/zcodeServeDaemon.ts b/packages/provider-zcode/src/server/zcodeServeDaemon.ts new file mode 100644 index 0000000000000..d16202009a69a --- /dev/null +++ b/packages/provider-zcode/src/server/zcodeServeDaemon.ts @@ -0,0 +1,209 @@ +// `zcode serve` is a user-level daemon: it owns CLI credentials, runs the agent runtime and +// exposes the channel WebSocket T3 attaches to. T3 never spawns a per-session agent and never +// reads the credential store; it only makes sure the daemon is up and asks for a host grant. +import * as HostProcess from "@t3tools/shared/HostProcess"; +import { resolveSpawnCommand } from "@t3tools/shared/shell"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as HttpClient from "effect/http/HttpClient"; +import * as HttpClientRequest from "effect/http/HttpClientRequest"; +import * as HttpClientResponse from "effect/http/HttpClientResponse"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import * as ChildProcess from "effect/process/ChildProcess"; +import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; + +import { ZcodeProtocolError } from "./zcodeProtocol.ts"; +import { resolveZcodeLaunchCommand, zcodeDataRoot, zcodeServeArgs } from "./zcodeRuntime.ts"; + +const STATUS_FILE = "status.json"; +const TOKEN_FILE = "ingress-token"; +const STATUS_READY = "ready"; +const STARTUP_TIMEOUT = Duration.seconds(30); +const STARTUP_POLL = Duration.millis(250); + +const ZcodeServerStatus = Schema.Struct({ + state: Schema.String, + host: Schema.String, + port: Schema.Int, + version: Schema.optional(Schema.String), + pid: Schema.optional(Schema.Int), +}); +export type ZcodeServerStatus = typeof ZcodeServerStatus.Type; +const decodeStatus = Schema.decodeUnknownOption(Schema.fromJsonString(ZcodeServerStatus)); + +const ZcodeHostCapability = Schema.Struct({ + capability: Schema.String, + expiresAt: Schema.optional(Schema.Number), +}); + +function zcodeServerRoot(environment: NodeJS.ProcessEnv): string | undefined { + const root = zcodeDataRoot(environment); + return root === undefined ? undefined : `${root}/.zcode/server`; +} + +const readZcodeServerStatus = Effect.fn("ZcodeServe.readStatus")(function* ( + fileSystem: FileSystem.FileSystem, + serverRoot: string, +) { + const raw = yield* fileSystem + .readFileString(`${serverRoot}/run/${STATUS_FILE}`) + .pipe(Effect.option); + if (Option.isNone(raw)) return Option.none(); + const status = decodeStatus(raw.value); + if (Option.isNone(status)) return Option.none(); + return status.value.state === STATUS_READY ? Option.some(status.value) : Option.none(); +}); + +const readIngressToken = Effect.fn("ZcodeServe.readIngressToken")(function* ( + fileSystem: FileSystem.FileSystem, + serverRoot: string, +) { + const raw = yield* fileSystem + .readFileString(`${serverRoot}/run/${TOKEN_FILE}`) + .pipe(Effect.option); + if (Option.isNone(raw)) return undefined; + const token = raw.value.trim(); + return token.length === 0 ? undefined : token; +}); + +export const zcodeChannelUrl = (status: ZcodeServerStatus, token: string): string => + `ws://${status.host}:${status.port}/ws/host?token=${encodeURIComponent(token)}`; + +const issueZcodeHostCapability = Effect.fn("ZcodeServe.issueHostCapability")(function* ( + httpClient: HttpClient.HttpClient, + status: ZcodeServerStatus, + token: string, +) { + const request = HttpClientRequest.post( + `http://${status.host}:${status.port}/api/rpc-host-capability`, + { headers: { authorization: `Bearer ${token}` } }, + ); + const response = yield* httpClient + .execute(request) + .pipe( + Effect.mapError((cause) => new ZcodeProtocolError({ operation: "hostCapability", cause })), + ); + if (response.status < 200 || response.status >= 300) { + return yield* new ZcodeProtocolError({ + operation: "hostCapability", + detail: `zcode server refused the host grant (HTTP ${response.status})`, + }); + } + const body = yield* HttpClientResponse.schemaBodyJson(ZcodeHostCapability)(response).pipe( + Effect.mapError((cause) => new ZcodeProtocolError({ operation: "hostCapability", cause })), + ); + return body.capability; +}); + +const spawnZcodeServer = Effect.fn("ZcodeServe.spawn")(function* (input: { + readonly launchCommand: string; + readonly launchArgs: string; + readonly environment: NodeJS.ProcessEnv; +}) { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const platform = yield* HostProcess.Platform; + const command = yield* resolveSpawnCommand( + input.launchCommand, + zcodeServeArgs(input.launchArgs), + { + env: input.environment, + }, + ).pipe(Effect.mapError((cause) => new ZcodeProtocolError({ operation: "serve", cause }))); + // `serve` forks the supervisor and exits, so the handle belongs to a detached fiber: the + // daemon outlives this agent scope and must never be reaped by it. + yield* Effect.forkDetach( + Effect.scoped( + Effect.gen(function* () { + const child = yield* spawner + .spawn( + ChildProcess.make(command.command, command.args, { + env: input.environment, + extendEnv: false, + shell: command.shell, + detached: platform !== "win32", + stdin: "ignore", + stdout: "ignore", + stderr: "ignore", + }), + ) + .pipe(Effect.mapError((cause) => new ZcodeProtocolError({ operation: "serve", cause }))); + yield* child.exitCode.pipe(Effect.ignore); + }), + ), + ); +}); + +/** Attaches to the running daemon, starting it when this machine has none. */ +const ensureZcodeServer = Effect.fn("ZcodeServe.ensure")(function* (input: { + readonly settings: { readonly binaryPath: string; readonly launchArgs: string }; + readonly environment: NodeJS.ProcessEnv; + readonly fileSystem: FileSystem.FileSystem; +}) { + const serverRoot = zcodeServerRoot(input.environment); + if (serverRoot === undefined) { + return yield* new ZcodeProtocolError({ + operation: "serve", + detail: "ZCode has no data directory to host a server.", + }); + } + const running = yield* readZcodeServerStatus(input.fileSystem, serverRoot); + if (Option.isSome(running)) return { serverRoot, status: running.value }; + + const launchCommand = yield* resolveZcodeLaunchCommand( + input.settings, + input.environment, + input.fileSystem, + ).pipe(Effect.mapError((cause) => new ZcodeProtocolError({ operation: "serve", cause }))); + yield* spawnZcodeServer({ + launchCommand, + launchArgs: input.settings.launchArgs, + environment: input.environment, + }).pipe( + Effect.catch((error) => + Effect.logWarning("ZCode server did not start.", { operation: error.operation }), + ), + ); + + const started = yield* Effect.gen(function* () { + for (;;) { + const status = yield* readZcodeServerStatus(input.fileSystem, serverRoot); + if (Option.isSome(status)) return status.value; + yield* Effect.sleep(STARTUP_POLL); + } + }).pipe(Effect.timeoutOption(STARTUP_TIMEOUT)); + + if (Option.isNone(started)) { + return yield* new ZcodeProtocolError({ + operation: "serve", + detail: "zcode serve did not report a ready daemon.", + }); + } + return { serverRoot, status: started.value }; +}); + +export interface ZcodeServerConnectionInfo { + readonly serverRoot: string; + readonly status: ZcodeServerStatus; + readonly token: string; + readonly capability: string; +} + +export const connectZcodeServer = Effect.fn("ZcodeServe.connect")(function* (input: { + readonly settings: { readonly binaryPath: string; readonly launchArgs: string }; + readonly environment: NodeJS.ProcessEnv; + readonly fileSystem: FileSystem.FileSystem; + readonly httpClient: HttpClient.HttpClient; +}) { + const { serverRoot, status } = yield* ensureZcodeServer(input); + const token = yield* readIngressToken(input.fileSystem, serverRoot); + if (token === undefined) { + return yield* new ZcodeProtocolError({ + operation: "serve", + detail: "zcode server has no ingress token.", + }); + } + const capability = yield* issueZcodeHostCapability(input.httpClient, status, token); + return { serverRoot, status, token, capability } satisfies ZcodeServerConnectionInfo; +}); diff --git a/packages/provider-zcode/src/server/zcodeServeProtocol.test.ts b/packages/provider-zcode/src/server/zcodeServeProtocol.test.ts new file mode 100644 index 0000000000000..52c01551281dd --- /dev/null +++ b/packages/provider-zcode/src/server/zcodeServeProtocol.test.ts @@ -0,0 +1,126 @@ +import { assert, describe, it } from "@effect/vitest"; + +import { + decodeChannelFrameBody, + encodeChannelFrame, + makeChannelFramer, +} from "./zcodeServeProtocol.ts"; + +const HEADER_BYTES = 13; + +/** A server→client frame the client would receive, built the same way the daemon builds it. */ +const incomingFrame = (kind: number, body: Uint8Array): Uint8Array => { + const frame = new Uint8Array(HEADER_BYTES + body.byteLength); + frame[0] = kind; + new DataView(frame.buffer).setUint32(9, body.byteLength); + frame.set(body, HEADER_BYTES); + return frame; +}; + +const messageBody = (header: ReadonlyArray, arg: unknown): Uint8Array => { + const frame = encodeChannelFrame(header, arg); + return frame.slice(HEADER_BYTES); +}; + +describe("channel frame codec", () => { + it("round-trips a call's header and argument", () => { + const arg = [{ workspacePath: "/w", includeDeleted: false }]; + const frame = encodeChannelFrame([100, 3, "zcode-session", "createSession"], arg); + assert.equal(frame[0], 1); + assert.equal(new DataView(frame.buffer).getUint32(9), frame.byteLength - HEADER_BYTES); + assert.deepEqual(decodeChannelFrameBody(frame.slice(HEADER_BYTES)), { + type: 100, + id: 3, + data: arg, + }); + }); + + it("round-trips every serialized value type", () => { + const arg = { + text: "héllo", + count: 300, + list: [1, "two", null], + nested: { deep: [null, { ok: true }] }, + }; + const decoded = decodeChannelFrameBody(messageBody([204, 7], arg)); + assert.deepEqual(decoded, { type: 204, id: 7, data: arg }); + + const buffer = new Uint8Array([1, 2, 3]); + const withBuffer = decodeChannelFrameBody(messageBody([201, 1], buffer)); + assert.ok(withBuffer !== undefined); + assert.deepEqual([...(withBuffer.data as Uint8Array)], [1, 2, 3]); + + const withUndefined = decodeChannelFrameBody(messageBody([201, 2], undefined)); + assert.deepEqual(withUndefined, { type: 201, id: 2, data: undefined }); + }); + + it("reads an init frame without an argument", () => { + assert.deepEqual(decodeChannelFrameBody(messageBody([200, 0], undefined)), { + type: 200, + id: 0, + data: undefined, + }); + }); + + it("rejects an unknown tag or a truncated body", () => { + assert.throws(() => decodeChannelFrameBody(Uint8Array.of(9)), /unknown tag 9/); + assert.throws(() => decodeChannelFrameBody(Uint8Array.of(1, 5, 65)), /truncated/); + }); +}); + +describe("channel framer", () => { + it("reassembles frames split across arbitrary chunks", () => { + const first = encodeChannelFrame([201, 1], ["first"]); + const second = encodeChannelFrame([204, 2], { delta: "second" }); + const joined = new Uint8Array(first.byteLength + second.byteLength); + joined.set(first, 0); + joined.set(second, first.byteLength); + + const framer = makeChannelFramer(); + const bodies = [ + ...framer(joined.slice(0, 5)), + ...framer(joined.slice(5, HEADER_BYTES + 2)), + ...framer(joined.slice(HEADER_BYTES + 2, joined.byteLength - 1)), + ...framer(joined.slice(joined.byteLength - 1)), + ]; + assert.equal(bodies.length, 2); + assert.deepEqual(decodeChannelFrameBody(bodies[0]!), { type: 201, id: 1, data: ["first"] }); + assert.deepEqual(decodeChannelFrameBody(bodies[1]!), { + type: 204, + id: 2, + data: { delta: "second" }, + }); + }); + + it("emits every complete frame carried by one chunk", () => { + const first = encodeChannelFrame([204, 1], "a"); + const second = encodeChannelFrame([204, 2], "b"); + const joined = new Uint8Array(first.byteLength + second.byteLength); + joined.set(first, 0); + joined.set(second, first.byteLength); + assert.equal(makeChannelFramer()(joined).length, 2); + }); + + it("drops frames whose kind is not a message and resumes at the next header", () => { + const other = incomingFrame(7, new Uint8Array([0])); + const message = encodeChannelFrame([204, 4], "payload"); + const joined = new Uint8Array(other.byteLength + message.byteLength); + joined.set(other, 0); + joined.set(message, other.byteLength); + + const bodies = makeChannelFramer()(joined); + assert.equal(bodies.length, 1); + assert.deepEqual(decodeChannelFrameBody(bodies[0]!), { + type: 204, + id: 4, + data: "payload", + }); + }); + + it("keeps a partial frame buffered", () => { + const framer = makeChannelFramer(); + const frame = encodeChannelFrame([204, 5], "later"); + assert.deepEqual(framer(frame.slice(0, HEADER_BYTES - 1)), []); + assert.equal(framer(frame.slice(HEADER_BYTES - 1)).length, 1); + }); +}); diff --git a/packages/provider-zcode/src/server/zcodeServeProtocol.ts b/packages/provider-zcode/src/server/zcodeServeProtocol.ts new file mode 100644 index 0000000000000..625884141998c --- /dev/null +++ b/packages/provider-zcode/src/server/zcodeServeProtocol.ts @@ -0,0 +1,460 @@ +// `zcode serve` hosts a daemon that owns CLI credentials and multiplexes sessions over a +// binary WebSocket channel protocol. This module is the wire half: frame codec plus a +// request/event client. The frame layout was reverse-engineered from the shipped desktop +// client (runtime 3.17.2); it is undocumented and pinned to that runtime. +import * as Deferred from "effect/Deferred"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Queue from "effect/Queue"; +import * as Scope from "effect/Scope"; + +import { ZcodeProtocolError, ZcodeProtocolTimeoutError } from "./zcodeProtocol.ts"; + +/** 1 byte frame kind, u32 request id, u32 ack, u32 body length. */ +const FRAME_HEADER_BYTES = 13; +const FRAME_KIND_MESSAGE = 1; + +const CALL = 100; +const LISTEN = 102; +const INITIALIZED = 200; +const RESULT = 201; +const ERROR = 202; +const REJECTED = 203; +const EVENT = 204; + +const TAG_UNDEFINED = 0; +const TAG_STRING = 1; +const TAG_BUFFER = 2; +const TAG_ARRAY = 4; +const TAG_OBJECT = 5; +const TAG_INT = 6; + +const textEncoder = new TextEncoder(); +const textDecoder = new TextDecoder(); + +const writeVarint = (bytes: number[], value: number): void => { + if (value === 0) { + bytes.push(0); + return; + } + for (let rest = value; rest !== 0; rest = Math.floor(rest / 128)) { + const byte = rest & 0x7f; + bytes.push(rest > 0x7f ? byte | 0x80 : byte); + } +}; + +const appendBytes = (bytes: number[], chunk: Uint8Array): void => { + for (const byte of chunk) bytes.push(byte); +}; + +const serializeInto = (bytes: number[], value: unknown): void => { + if (value === undefined) { + bytes.push(TAG_UNDEFINED); + return; + } + if (typeof value === "string") { + const encoded = textEncoder.encode(value); + bytes.push(TAG_STRING); + writeVarint(bytes, encoded.byteLength); + appendBytes(bytes, encoded); + return; + } + if (value instanceof Uint8Array) { + bytes.push(TAG_BUFFER); + writeVarint(bytes, value.byteLength); + appendBytes(bytes, value); + return; + } + if (Array.isArray(value)) { + bytes.push(TAG_ARRAY); + writeVarint(bytes, value.length); + for (const element of value) serializeInto(bytes, element); + return; + } + if (typeof value === "number" && Number.isInteger(value)) { + bytes.push(TAG_INT); + writeVarint(bytes, value); + return; + } + const encoded = textEncoder.encode(JSON.stringify(value) ?? "null"); + bytes.push(TAG_OBJECT); + writeVarint(bytes, encoded.byteLength); + appendBytes(bytes, encoded); +}; + +class Reader { + private offset = 0; + private readonly bytes: Uint8Array; + + constructor(bytes: Uint8Array) { + this.bytes = bytes; + } + + read(count: number): Uint8Array { + const out = this.bytes.subarray(this.offset, this.offset + count); + if (out.byteLength !== count) throw new Error("zcode channel frame truncated"); + this.offset += count; + return out; + } +} + +const readVarint = (reader: Reader): number => { + let value = 0; + for (let shift = 0; ; shift += 7) { + const [byte = 0] = reader.read(1); + value += (byte & 0x7f) * 2 ** shift; + if ((byte & 0x80) === 0) return value; + } +}; + +const deserializeFrom = (reader: Reader): unknown => { + const [tag = -1] = reader.read(1); + switch (tag) { + case TAG_UNDEFINED: + return undefined; + case TAG_STRING: + return textDecoder.decode(reader.read(readVarint(reader))); + case TAG_BUFFER: + return reader.read(readVarint(reader)); + case TAG_ARRAY: { + const length = readVarint(reader); + return Array.from({ length }, () => deserializeFrom(reader)); + } + case TAG_OBJECT: + return JSON.parse(textDecoder.decode(reader.read(readVarint(reader)))); + case TAG_INT: + return readVarint(reader); + default: + throw new Error(`zcode channel frame has unknown tag ${tag}`); + } +}; + +/** One outbound message frame: `` then the serialized body. */ +export function encodeChannelFrame( + header: ReadonlyArray, + arg: unknown, +): Uint8Array { + const body: number[] = []; + serializeInto(body, header); + serializeInto(body, arg); + const buffer = new ArrayBuffer(FRAME_HEADER_BYTES + body.length); + const frame = new Uint8Array(buffer); + frame[0] = FRAME_KIND_MESSAGE; + const view = new DataView(buffer); + view.setUint32(1, 0); + view.setUint32(5, 0); + view.setUint32(9, body.length); + frame.set(Uint8Array.from(body), FRAME_HEADER_BYTES); + return frame; +} + +export interface ZcodeChannelFrame { + readonly type: number; + readonly id: number; + readonly data: unknown; +} + +/** Parses a message body; frames other than messages carry nothing we consume. */ +export function decodeChannelFrameBody(body: Uint8Array): ZcodeChannelFrame | undefined { + const reader = new Reader(body); + const header = deserializeFrom(reader); + if (!Array.isArray(header)) return undefined; + const [type, id] = header; + if (typeof type !== "number") return undefined; + if (type === INITIALIZED) return { type, id: 0, data: undefined }; + return { type, id: typeof id === "number" ? id : 0, data: deserializeFrom(reader) }; +} + +/** Reassembles WebSocket frames into channel message bodies. */ +export function makeChannelFramer(): (chunk: Uint8Array) => ReadonlyArray { + const chunks: Uint8Array[] = []; + let length = 0; + + const take = (count: number): Uint8Array => { + const out = new Uint8Array(count); + let offset = 0; + for (const chunk of chunks) { + const slice = chunk.subarray(0, Math.min(chunk.byteLength, count - offset)); + out.set(slice, offset); + offset += slice.byteLength; + if (offset === count) break; + } + let remaining = count; + while (remaining > 0) { + const head = chunks[0]; + if (head === undefined) break; + if (head.byteLength <= remaining) { + remaining -= head.byteLength; + chunks.shift(); + } else { + chunks[0] = head.subarray(remaining); + remaining = 0; + } + } + length -= count; + return out; + }; + + const peekHeader = (): Uint8Array => { + const out = new Uint8Array(FRAME_HEADER_BYTES); + let offset = 0; + for (const chunk of chunks) { + const slice = chunk.subarray(0, Math.min(chunk.byteLength, FRAME_HEADER_BYTES - offset)); + out.set(slice, offset); + offset += slice.byteLength; + if (offset === FRAME_HEADER_BYTES) break; + } + return out; + }; + + return (chunk) => { + if (chunk.byteLength > 0) { + chunks.push(chunk); + length += chunk.byteLength; + } + const bodies: Uint8Array[] = []; + for (;;) { + if (length < FRAME_HEADER_BYTES) break; + const header = peekHeader(); + const bodyLength = + ((header[9] ?? 0) * 0x1000000 + + ((header[10] ?? 0) << 16) + + ((header[11] ?? 0) << 8) + + (header[12] ?? 0)) >>> + 0; + if (length < FRAME_HEADER_BYTES + bodyLength) break; + take(FRAME_HEADER_BYTES); + const body = bodyLength === 0 ? new Uint8Array(0) : take(bodyLength); + if (header[0] === FRAME_KIND_MESSAGE) bodies.push(body); + } + return bodies; + }; +} + +export interface ZcodeChannelClient { + readonly call: ( + channel: string, + method: string, + arg: unknown, + timeoutMs?: number, + ) => Effect.Effect; + readonly listen: ( + channel: string, + event: string, + arg: unknown, + onEvent: (payload: unknown) => void, + ) => Effect.Effect; + /** Resolves when the socket closes; never fails, so finalizers stay quiet. */ + readonly closed: Effect.Effect; +} + +export interface ZcodeChannelClientOptions { + readonly url: string; + readonly headers?: Readonly>; +} + +interface PendingCall { + readonly deferred: Deferred.Deferred; + readonly method: string; +} + +const DEFAULT_CALL_TIMEOUT_MS = 60_000; +const INITIALIZE_TIMEOUT_MS = 15_000; + +/** + * Node's global WebSocket (undici) accepts request headers; the DOM lib typing does not. + * `/ws/host` rejects the handshake without the capability header, so the cast is required. + */ +const openSocket = (url: string, headers: Readonly>): WebSocket => + new (WebSocket as unknown as new ( + url: string, + init: { headers: Record }, + ) => WebSocket)(url, { headers: { ...headers } }); + +export const makeZcodeChannelClient = Effect.fnUntraced(function* ( + options: ZcodeChannelClientOptions, +) { + const scope = yield* Effect.scope; + const pending = new Map(); + const listeners = new Map void>(); + const initialized = yield* Deferred.make(); + const closed = yield* Deferred.make(); + const initQueue = yield* Queue.unbounded(); + const results = yield* Queue.unbounded<{ + readonly call: PendingCall; + readonly data: unknown; + readonly failed: boolean; + }>(); + const failures = yield* Queue.unbounded(); + const socketClosures = yield* Queue.unbounded(); + const frame = makeChannelFramer(); + let nextId = 0; + + const transportError = (operation: string, cause: unknown) => + new ZcodeProtocolError({ operation, cause }); + + const failAll = Effect.fn("ZcodeChannel.failAll")(function* (error: ZcodeProtocolError) { + yield* Deferred.fail(initialized, error).pipe(Effect.ignore); + for (const [id, call] of pending) { + pending.delete(id); + yield* Deferred.fail(call.deferred, error).pipe(Effect.ignore); + } + yield* Deferred.succeed(closed, undefined).pipe(Effect.ignore); + }); + + const socket = openSocket(options.url, options.headers ?? {}); + socket.binaryType = "arraybuffer"; + + const route = (parsed: ZcodeChannelFrame): void => { + if (parsed.type === INITIALIZED) { + Queue.offerUnsafe(initQueue, undefined); + return; + } + if (parsed.type === EVENT) { + listeners.get(parsed.id)?.(parsed.data); + return; + } + const call = pending.get(parsed.id); + if (call === undefined) return; + pending.delete(parsed.id); + if (parsed.type === RESULT) { + Queue.offerUnsafe(results, { call, data: parsed.data, failed: false }); + return; + } + if (parsed.type === ERROR || parsed.type === REJECTED) { + Queue.offerUnsafe(results, { call, data: parsed.data, failed: true }); + } + }; + + const onMessage = (event: MessageEvent) => { + const data: unknown = event.data; + const bytes = + data instanceof ArrayBuffer + ? new Uint8Array(data) + : ArrayBuffer.isView(data) + ? new Uint8Array(data.buffer, data.byteOffset, data.byteLength) + : undefined; + if (bytes === undefined) return; + for (const body of frame(bytes)) { + try { + const parsed = decodeChannelFrameBody(body); + if (parsed !== undefined) route(parsed); + } catch (cause) { + Queue.offerUnsafe(failures, cause); + } + } + }; + + socket.addEventListener("message", onMessage); + socket.addEventListener("close", (event) => + Queue.offerUnsafe(socketClosures, `code ${event.code}`), + ); + socket.addEventListener("error", () => Queue.offerUnsafe(socketClosures, "socket error")); + + yield* Scope.addFinalizer( + scope, + Effect.sync(() => { + socket.removeEventListener("message", onMessage); + socket.close(); + }), + ); + + // Settlement and closure handling run off the callback path so socket callbacks stay cheap. + yield* Effect.gen(function* () { + for (;;) { + const settled = yield* Queue.take(results); + yield* settled.failed + ? Deferred.fail( + settled.call.deferred, + transportError(settled.call.method, settled.data), + ).pipe(Effect.ignore) + : Deferred.succeed(settled.call.deferred, settled.data).pipe(Effect.ignore); + } + }).pipe(Effect.forkIn(scope), Effect.asVoid); + + yield* Effect.gen(function* () { + for (;;) { + const reason = yield* Queue.take(socketClosures); + yield* failAll(transportError("connect", `zcode server channel closed (${reason})`)); + } + }).pipe(Effect.forkIn(scope), Effect.asVoid); + + yield* Effect.gen(function* () { + for (;;) { + const cause = yield* Queue.take(failures); + yield* failAll(transportError("decode", cause)); + } + }).pipe(Effect.forkIn(scope), Effect.asVoid); + + yield* Effect.raceFirst( + Queue.take(initQueue).pipe( + Effect.timeoutOption(Duration.millis(INITIALIZE_TIMEOUT_MS)), + Effect.flatMap((ready) => + Option.isSome(ready) + ? Deferred.succeed(initialized, true).pipe(Effect.ignore) + : failAll(transportError("initialize", "zcode server channel handshake timed out")), + ), + ), + Deferred.await(closed), + ).pipe(Effect.ignore, Effect.forkIn(scope), Effect.asVoid); + + const awaitInitialized = Deferred.await(initialized); + + const send = (header: ReadonlyArray, arg: unknown) => + Effect.suspend(() => { + if (socket.readyState !== WebSocket.OPEN) { + return Effect.fail(transportError("send", "zcode server channel is not open")); + } + socket.send(encodeChannelFrame(header, arg)); + return Effect.void; + }); + + const call = Effect.fn("ZcodeChannel.call")(function* ( + channel: string, + method: string, + arg: unknown, + timeoutMs = DEFAULT_CALL_TIMEOUT_MS, + ): Effect.fn.Return { + yield* awaitInitialized; + const id = nextId++; + const deferred = yield* Deferred.make(); + pending.set(id, { deferred, method: `${channel}.${method}` }); + yield* send([CALL, id, channel, method], [arg]).pipe( + Effect.onError(() => Effect.sync(() => pending.delete(id))), + ); + return yield* Deferred.await(deferred).pipe( + Effect.timeoutOrElse({ + duration: Duration.millis(timeoutMs), + orElse: () => + Effect.fail( + new ZcodeProtocolTimeoutError({ operation: `${channel}.${method}`, timeoutMs }), + ), + }), + Effect.onInterrupt(() => Effect.sync(() => pending.delete(id))), + Effect.onError(() => Effect.sync(() => pending.delete(id))), + ); + }); + + const listen = Effect.fn("ZcodeChannel.listen")(function* ( + channel: string, + event: string, + arg: unknown, + onEvent: (payload: unknown) => void, + ) { + yield* awaitInitialized; + const id = nextId++; + listeners.set(id, onEvent); + yield* Scope.addFinalizer( + scope, + Effect.sync(() => void listeners.delete(id)), + ); + yield* send([LISTEN, id, channel, event], arg); + }); + + return { + call, + listen, + closed: Deferred.await(closed), + } satisfies ZcodeChannelClient; +}); diff --git a/packages/provider-zcode/src/settings.ts b/packages/provider-zcode/src/settings.ts index 824acf95f7c13..3b3be92cbd386 100644 --- a/packages/provider-zcode/src/settings.ts +++ b/packages/provider-zcode/src/settings.ts @@ -25,7 +25,8 @@ export const ZcodeSettings = makeProviderSettingsSchema( Schema.withDecodingDefault(Effect.succeed("")), Schema.annotateKey({ title: "Launch arguments", - description: "Additional CLI arguments passed to zcode app-server on session start.", + description: + "Additional CLI arguments passed to `zcode serve` when T3 Code starts the ZCode daemon.", providerSettingsForm: { clearWhenEmpty: "omit" }, }), ), diff --git a/packages/provider-zcode/src/testing.ts b/packages/provider-zcode/src/testing.ts index 9f8c2d5e1d8a5..7112dc6486e2d 100644 --- a/packages/provider-zcode/src/testing.ts +++ b/packages/provider-zcode/src/testing.ts @@ -9,5 +9,4 @@ export type { ZcodeProtocolError, ZcodeProtocolTimeoutError, ZcodeRecord, - ZcodeSpawnOptions, } from "./server/zcodeProtocol.ts"; From be6923408c286c6061077b4827212c83f6cf99f5 Mon Sep 17 00:00:00 2001 From: panudetjt <3482285+panudetjt@users.noreply.github.com> Date: Wed, 11 Nov 2026 20:53:17 +0700 Subject: [PATCH 21/23] fix(provider-zcode): keep the advertised reasoning level after resume A resumed session advertises its models under snapshot.settings.model.available, but subscribeSession parsed the model object itself, so availableModels stayed empty. The next send then omitted reasoningLevel, which GLM-5.3 requires. Parse the available list and cover the resumed send in the adapter test. --- .../Adapters/ZcodeAdapterV2.test.ts | 17 ++++++++++++++++- .../Adapters/ZcodeAdapterV2.testkit.ts | 9 ++++++++- packages/provider-zcode/src/server/adapter.ts | 2 +- 3 files changed, 25 insertions(+), 3 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/ZcodeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ZcodeAdapterV2.test.ts index 41a1e9c76ade6..47a0195506d96 100644 --- a/apps/server/src/orchestration-v2/Adapters/ZcodeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ZcodeAdapterV2.test.ts @@ -302,7 +302,7 @@ describe("ZcodeAdapterV2 (frame-driven)", () => { // thread.runtime-mode.set detaches the session; the next turn reopens and resumes it. const resumedScenario = yield* makeFakeZcodeConnection(); const resumed = yield* openRuntime(resumedScenario, runtimePolicy); - yield* resumed.ensureThread({ + const resumedThread = yield* resumed.ensureThread({ threadId: THREAD_ID, modelSelection: MODEL_SELECTION, runtimePolicy, @@ -314,6 +314,21 @@ describe("ZcodeAdapterV2 (frame-driven)", () => { sessionId: "sess_1", mode: "guarded", }); + + // A resumed session advertises its models only through session/subscribe; the next turn + // still needs the advertised reasoning level. + yield* resumed.startTurn( + yield* makeTurnInput( + resumedThread, + TURN_TEXT, + "account:zai-individual-coding-plan/GLM-5.3-Flash", + ), + ); + assert.deepEqual(requestFor(resumedScenario, "session/send")?.params["modelSelection"], { + providerId: "account:zai-individual-coding-plan", + modelId: "GLM-5.3-Flash", + options: { reasoningLevel: "max" }, + }); assert.deepEqual(resumedScenario.unexpectedMethods(), []); }).pipe(Effect.scoped, Effect.provide(ZcodeAdapterTestLayer)), ); diff --git a/apps/server/src/orchestration-v2/Adapters/ZcodeAdapterV2.testkit.ts b/apps/server/src/orchestration-v2/Adapters/ZcodeAdapterV2.testkit.ts index 9f1c78a7e07c2..2f15ec56ac716 100644 --- a/apps/server/src/orchestration-v2/Adapters/ZcodeAdapterV2.testkit.ts +++ b/apps/server/src/orchestration-v2/Adapters/ZcodeAdapterV2.testkit.ts @@ -166,7 +166,14 @@ export const makeFakeZcodeConnection = Effect.fnUntraced(function* (): Effect.fn return zcodeSessionCreateResult(createdSessionId, params); } case "session/subscribe": - return { sessionId: createdSessionId, eventSeq: 1, events: [] }; + return { + sessionId: createdSessionId, + eventSeq: 1, + events: [], + snapshot: { + settings: zcodeSessionCreateResult(createdSessionId ?? "sess_1", params).settings, + }, + }; case "session/send": { yield* Queue.offer(events, { method: "state.updated", diff --git a/packages/provider-zcode/src/server/adapter.ts b/packages/provider-zcode/src/server/adapter.ts index 08d6a310d43f2..45d038c5fb548 100644 --- a/packages/provider-zcode/src/server/adapter.ts +++ b/packages/provider-zcode/src/server/adapter.ts @@ -1610,7 +1610,7 @@ export const makeZcodeAdapterV2 = Effect.fn("makeZcodeAdapterV2")(function* ( const snapshot = zcodeRecordField(Predicate.isObject(result) ? result : {}, "snapshot"); const settings = zcodeRecordField(Predicate.isObject(snapshot) ? snapshot : {}, "settings"); const model = zcodeRecordField(Predicate.isObject(settings) ? settings : {}, "model"); - const models = parseZcodeAvailableModels(model); + const models = parseZcodeAvailableModels(zcodeRecordField(model, "available")); if (models.length > 0) availableModels = models; return result; }); From 43666f54505b8177ee2b83aedfe9992fa9022671 Mon Sep 17 00:00:00 2001 From: panudetjt <3482285+panudetjt@users.noreply.github.com> Date: Thu, 12 Nov 2026 10:58:21 +0700 Subject: [PATCH 22/23] fix(provider-zcode): harden daemon attach and interaction answers Probe a saved ready status before trusting it, keep failed interaction answers pending, preserve every question in multi-question prompts, refresh tool input from later updates, and read token usage from the newest turn header. --- packages/provider-zcode/src/server/adapter.ts | 78 +++++++++------ .../src/server/zcodeProtocol.ts | 5 +- .../src/server/zcodeServeConnection.ts | 52 ++-------- .../src/server/zcodeServeConversation.test.ts | 93 ++++++++++++++++-- .../src/server/zcodeServeConversation.ts | 94 +++++++++++++------ .../src/server/zcodeServeDaemon.ts | 29 +++++- 6 files changed, 243 insertions(+), 108 deletions(-) diff --git a/packages/provider-zcode/src/server/adapter.ts b/packages/provider-zcode/src/server/adapter.ts index 45d038c5fb548..8ad21b28b6ce5 100644 --- a/packages/provider-zcode/src/server/adapter.ts +++ b/packages/provider-zcode/src/server/adapter.ts @@ -357,11 +357,13 @@ interface ZcodePendingApproval { interface ZcodePendingQuestion { readonly nativeRequestId: string; - readonly questionId: string; runtimeRequest: OrchestrationV2RuntimeRequest; readonly node: OrchestrationV2ExecutionNode; readonly turnItem: OrchestrationV2TurnItem; - answer: { readonly value: string } | { readonly cancelled: true } | null; + answer: + | { readonly answers: Readonly>> } + | { readonly cancelled: true } + | null; } interface ZcodeAwaitingRequest { @@ -759,6 +761,9 @@ export const makeZcodeAdapterV2 = Effect.fn("makeZcodeAdapterV2")(function* ( if (existing !== undefined) { const name = zcodeRecordString(payload, "toolName"); if (name !== undefined) existing.name = name; + // The daemon may send the tool input only in a later update; keep the newest one. + const input = zcodeRecordField(payload, "input"); + if (input !== undefined) existing.input = input; return existing; } const state: ZcodeToolState = { @@ -935,7 +940,6 @@ export const makeZcodeAdapterV2 = Effect.fn("makeZcodeAdapterV2")(function* ( if (questions !== undefined) { pendingQuestions.set(String(requestId), { nativeRequestId, - questionId: questions[0]?.id ?? nativeRequestId, runtimeRequest, node, turnItem, @@ -1122,7 +1126,8 @@ export const makeZcodeAdapterV2 = Effect.fn("makeZcodeAdapterV2")(function* ( const answeredPermissions = new Map(); const answeredUserInputs = new Map< string, - { readonly value: string } | { readonly cancelled: true } + | { readonly answers: Readonly>> } + | { readonly cancelled: true } >(); const handleSessionEvent = Effect.fnUntraced(function* ( @@ -1320,30 +1325,38 @@ export const makeZcodeAdapterV2 = Effect.fn("makeZcodeAdapterV2")(function* ( case "userInput.requested": { const nativeRequestId = zcodeRecordString(payload, "requestId"); if (nativeRequestId === undefined) return; - const prompt = zcodeRecordString(payload, "prompt") ?? "The agent asked a question"; - const inputType = zcodeRecordString(payload, "inputType") ?? "text"; - const choices = (zcodeRecordArray(payload, "choices") ?? []).filter( - (choice): choice is string => typeof choice === "string" && choice.length > 0, + const views = zcodeRecordArray(payload, "questions") ?? []; + const questions = views.flatMap( + (view, index): Array => { + const prompt = zcodeRecordString(view, "prompt"); + if (prompt === undefined) return []; + const inputType = zcodeRecordString(view, "inputType") ?? "text"; + const choices = (zcodeRecordArray(view, "choices") ?? []).filter( + (choice): choice is string => typeof choice === "string" && choice.length > 0, + ); + return [ + { + id: `${nativeRequestId}:${index}`, + header: zcodeRecordString(view, "header") ?? `Question ${index + 1}`, + question: prompt, + options: choices.map((choice) => ({ + label: choice, + description: choice, + value: choice, + })), + // Free-text asks keep a custom answer even with preset choices. + ...(inputType === "text" || choices.length === 0 + ? { allowCustomAnswer: true } + : {}), + }, + ]; + }, ); + if (questions.length === 0) return; yield* emitRuntimeRequestShell( nativeRequestId, "user_input", - [ - { - id: nativeRequestId, - header: "Question", - question: prompt, - options: choices.map((choice) => ({ - label: choice, - description: choice, - value: choice, - })), - // Free-text asks keep a custom answer even with preset choices. - ...(inputType === "text" || choices.length === 0 - ? { allowCustomAnswer: true } - : {}), - }, - ], + questions, undefined, state, turn, @@ -2153,11 +2166,22 @@ export const makeZcodeAdapterV2 = Effect.fn("makeZcodeAdapterV2")(function* ( return; } const pendingQuestion = question!; - const answerValue = requestInput.answers?.[pendingQuestion.questionId]; + const answers = requestInput.answers ?? {}; + const answered: Array<[string, string | ReadonlyArray]> = []; + if (pendingQuestion.turnItem.type === "user_input_request") { + for (const item of pendingQuestion.turnItem.questions) { + const raw = answers[item.id]; + const values = (Array.isArray(raw) ? raw : [raw]).filter( + (value): value is string => typeof value === "string" && value.length > 0, + ); + if (values.length === 0) continue; + answered.push([item.question, values.length === 1 ? values[0]! : values]); + } + } pendingQuestion.answer = - requestInput.decision === "cancel" || typeof answerValue !== "string" + requestInput.decision === "cancel" || answered.length === 0 ? { cancelled: true } - : { value: answerValue }; + : { answers: Object.fromEntries(answered) }; pendingQuestions.delete(key); answeredUserInputs.set(pendingQuestion.nativeRequestId, pendingQuestion.answer); for (const [respondId, awaiting] of awaitingUserInputRequests) { diff --git a/packages/provider-zcode/src/server/zcodeProtocol.ts b/packages/provider-zcode/src/server/zcodeProtocol.ts index 51804049d217e..bb70a651bbb03 100644 --- a/packages/provider-zcode/src/server/zcodeProtocol.ts +++ b/packages/provider-zcode/src/server/zcodeProtocol.ts @@ -61,7 +61,10 @@ export interface ZcodeConnection { params: ZcodeRecord, timeoutMs?: number, ) => Effect.Effect; - readonly respond: (id: string, result: unknown) => Effect.Effect; + readonly respond: ( + id: string, + result: unknown, + ) => Effect.Effect; readonly events: Queue.Queue; readonly exited: Effect.Effect; readonly terminate: Effect.Effect; diff --git a/packages/provider-zcode/src/server/zcodeServeConnection.ts b/packages/provider-zcode/src/server/zcodeServeConnection.ts index da0e5a2bd2958..15e18ca8b1ddd 100644 --- a/packages/provider-zcode/src/server/zcodeServeConnection.ts +++ b/packages/provider-zcode/src/server/zcodeServeConnection.ts @@ -7,7 +7,6 @@ import * as FileSystem from "effect/FileSystem"; import * as HttpClient from "effect/http/HttpClient"; import * as Predicate from "effect/Predicate"; import * as Queue from "effect/Queue"; -import * as Schema from "effect/Schema"; import * as Clock from "effect/Clock"; import * as SecureRandom from "effect/crypto/SecureRandom"; @@ -21,6 +20,7 @@ import { zcodeChannelUrl, connectZcodeServer } from "./zcodeServeDaemon.ts"; import { applyConversationPayload, initialConversationState, + interactionAnswer, type ZcodeConversationState, } from "./zcodeServeConversation.ts"; import { makeZcodeChannelClient, type ZcodeChannelClient } from "./zcodeServeProtocol.ts"; @@ -90,15 +90,6 @@ const resolveProjectId = Effect.fn("ZcodeServe.resolveProject")(function* ( return id; }); -const isZcodeProtocolError = Schema.is(ZcodeProtocolError); - -const errorMessage = (error: unknown): string => - isZcodeProtocolError(error) - ? (error.detail ?? error.operation) - : error instanceof Error - ? error.message - : String(error); - /** The daemon advertises its session model on the snapshot; the session layer only needs it as a default. */ const availableModelDescriptors = ( state: ZcodeConversationState, @@ -254,49 +245,24 @@ export const makeZcodeServeConnection = Effect.fn("ZcodeServeConnection.make")(f }); /** Translates a session-layer answer into the daemon's `resolveInteraction` shape. */ - const interactionAnswer = ( - interaction: ZcodeRecord | undefined, - result: unknown, - ): ZcodeRecord | undefined => { - const wanted = Predicate.isObject(result) ? result : {}; - const optionId = recordString(wanted, "optionId"); - if (optionId !== undefined) return { optionId }; - if (recordField(wanted, "cancelled") === true) return { action: "cancel" }; - const value = recordString(wanted, "value"); - if (value !== undefined) { - const payload = recordField(interaction ?? {}, "payload"); - const questions = recordField(payload, "questions"); - const question = Array.isArray(questions) ? questions[0] : undefined; - // AskUserQuestion interactions resolve through the elicitation path: the daemon expects - // the tool's answers map, not a permission option id. - const questionText = recordString(question, "question"); - if (questionText !== undefined) { - return { action: "accept", content: { answers: { [questionText]: value } } }; - } - const candidates = recordField(payload, "options"); - const match = Array.isArray(candidates) - ? candidates.find((candidate) => recordString(candidate, "label") === value) - : undefined; - const matchedId = recordString(match, "optionId") ?? recordString(match, "value"); - return matchedId === undefined ? { freeText: value } : { optionId: matchedId }; - } - if (recordString(wanted, "decision") === "deny") return { action: "decline" }; - return undefined; - }; - const respond = Effect.fnUntraced(function* (id: string, result: unknown) { const interaction = conversation.pendingInteractions.get(id); const answer = interactionAnswer(interaction, result); if (answer === undefined || sessionId === undefined) return; - conversation.pendingInteractions.delete(id); yield* commitCommand("resolveInteraction", sessionId, { interactionId: id, answer, }).pipe( - Effect.catch((error) => - Effect.logWarning("ZCode interaction answer failed.", { detail: errorMessage(error) }), + Effect.onError(() => + Effect.sync(() => { + // A resubscribe may surface the still-pending interaction again. + conversation.announcedInteractions.delete(id); + }), ), ); + // Only forget the interaction once the daemon accepted the answer; a failed resolve must + // keep it pending so the UI can retry instead of silently dropping the request. + conversation.pendingInteractions.delete(id); }); const request = Effect.fn("ZcodeServe.request")(function* ( diff --git a/packages/provider-zcode/src/server/zcodeServeConversation.test.ts b/packages/provider-zcode/src/server/zcodeServeConversation.test.ts index 778a6d9204968..f48e1677f3966 100644 --- a/packages/provider-zcode/src/server/zcodeServeConversation.test.ts +++ b/packages/provider-zcode/src/server/zcodeServeConversation.test.ts @@ -3,6 +3,7 @@ import { assert, describe, it } from "@effect/vitest"; import { applyConversationPayload, initialConversationState, + interactionAnswer, type ZcodeConversationState, } from "./zcodeServeConversation.ts"; @@ -396,9 +397,14 @@ describe("applyConversationPayload", () => { type: "userInput.requested", payload: { requestId: "int_2", - prompt: "Proceed with the migration?", - inputType: "choice", - choices: ["Yes", "No"], + questions: [ + { + header: "Confirm", + prompt: "Proceed with the migration?", + inputType: "choice", + choices: ["Yes", "No"], + }, + ], }, }, { @@ -432,9 +438,14 @@ describe("applyConversationPayload", () => { type: "userInput.requested", payload: { requestId: "int_3", - prompt: "Explain the change", - inputType: "text", - choices: [], + questions: [ + { + header: "Question 1", + prompt: "Explain the change", + inputType: "text", + choices: [], + }, + ], }, }, { @@ -447,4 +458,74 @@ describe("applyConversationPayload", () => { ], ); }); + + it("keeps every question in a multi-question user input request", () => { + const state = initialConversationState(); + const events = deltas(state, [ + { + op: "state.updated", + patch: { + pendingInteractions: [ + { + interactionId: "int_4", + kind: "userInput", + payload: { + questions: [ + { + header: "Confirm", + question: "Proceed with the migration?", + options: [{ label: "Yes" }, { label: "No" }], + }, + { + header: "Details", + question: "Which database?", + options: [{ label: "SQLite" }, { label: "Postgres" }], + }, + ], + }, + }, + ], + }, + }, + ]); + + assert.deepEqual(events[0], { + type: "userInput.requested", + payload: { + requestId: "int_4", + questions: [ + { + header: "Confirm", + prompt: "Proceed with the migration?", + inputType: "choice", + choices: ["Yes", "No"], + }, + { + header: "Details", + prompt: "Which database?", + inputType: "choice", + choices: ["SQLite", "Postgres"], + }, + ], + }, + }); + assert.deepEqual(interactionAnswer(undefined, { answers: { "Which database?": "SQLite" } }), { + action: "accept", + content: { answers: { "Which database?": "SQLite" } }, + }); + }); + + it("does not reuse an older turn header's token count", () => { + const state = initialConversationState(); + deltas(state, [{ op: "state.updated", patch: { control: { phase: "running" } } }]); + deltas(state, [ + { op: "row.upserted", row: { rowId: 1, kind: "turnHeader", outputTokens: 31 } }, + { op: "row.upserted", row: { rowId: 4, kind: "turnHeader" } }, + ]); + + assert.deepEqual( + deltas(state, [{ op: "state.updated", patch: { control: { phase: "completedSuccess" } } }]), + [{ type: "turn.completed", payload: { resultType: "success" } }], + ); + }); }); diff --git a/packages/provider-zcode/src/server/zcodeServeConversation.ts b/packages/provider-zcode/src/server/zcodeServeConversation.ts index 96f6c77368a98..bf4cfe13ccaa3 100644 --- a/packages/provider-zcode/src/server/zcodeServeConversation.ts +++ b/packages/provider-zcode/src/server/zcodeServeConversation.ts @@ -235,32 +235,37 @@ const permissionOptions = (payload: unknown): ReadonlyArray => }); }; -interface UserInputView { +interface UserInputQuestionView { + readonly header: string; readonly prompt: string; readonly inputType: "text" | "choice"; readonly choices: ReadonlyArray; } -const userInputView = (payload: unknown): UserInputView => { - const questions = field(payload, "questions"); - const question = Array.isArray(questions) ? questions[0] : undefined; - const questionOptions = field(question, "options"); +const questionLabels = (candidates: unknown): ReadonlyArray => + Array.isArray(candidates) + ? candidates.flatMap((option): Array => { + const label = text(option, "label"); + return label === undefined ? [] : [label]; + }) + : []; + +/** Every question in the daemon's AskUserQuestion payload, preserving order. */ +const userInputQuestions = (payload: unknown): ReadonlyArray => { + const raw = field(payload, "questions"); + const entries = Array.isArray(raw) && raw.length > 0 ? raw : [undefined]; const flatOptions = field(payload, "options"); - const labels = (candidates: unknown): ReadonlyArray => - Array.isArray(candidates) - ? candidates.flatMap((option): Array => { - const label = text(option, "label"); - return label === undefined ? [] : [label]; - }) - : []; - const choices = labels(questionOptions ?? flatOptions); - const freeText = - field(payload, "freeText") === true || field(question, "multiSelect") !== undefined; - return { - prompt: text(question, "question") ?? text(payload, "prompt") ?? "The agent asked a question", - inputType: freeText || choices.length === 0 ? "text" : "choice", - choices, - }; + return entries.map((entry, index) => { + const choices = questionLabels(field(entry, "options") ?? flatOptions); + const freeText = + field(payload, "freeText") === true || field(entry, "multiSelect") !== undefined; + return { + header: text(entry, "header") ?? `Question ${index + 1}`, + prompt: text(entry, "question") ?? text(payload, "prompt") ?? "The agent asked a question", + inputType: freeText || choices.length === 0 ? "text" : "choice", + choices, + }; + }); }; /** @@ -299,14 +304,11 @@ const interactionEvents = ( }, }); } else if (kind === "userInput") { - const view = userInputView(payload); events.push({ type: "userInput.requested", payload: { requestId: interactionId, - prompt: view.prompt, - inputType: view.inputType, - choices: view.choices, + questions: userInputQuestions(payload), }, }); events.push({ @@ -321,6 +323,42 @@ const interactionEvents = ( return events; }; +/** + * Translates a session-layer answer into the daemon's `resolveInteraction` shape. + * Questions are answered by their prompt text in the daemon's answers map. + */ +export const interactionAnswer = ( + interaction: ZcodeRecord | undefined, + result: unknown, +): ZcodeRecord | undefined => { + const wanted = Predicate.isObject(result) ? result : {}; + const optionId = text(wanted, "optionId"); + if (optionId !== undefined) return { optionId }; + if (field(wanted, "cancelled") === true) return { action: "cancel" }; + const answers = field(wanted, "answers"); + if (Predicate.isObject(answers)) return { action: "accept", content: { answers } }; + const value = text(wanted, "value"); + if (value !== undefined) { + const payload = field(interaction ?? {}, "payload"); + const questions = field(payload, "questions"); + const question = Array.isArray(questions) ? questions[0] : undefined; + // AskUserQuestion interactions resolve through the elicitation path: the daemon expects + // the tool's answers map, not a permission option id. + const questionText = text(question, "question"); + if (questionText !== undefined) { + return { action: "accept", content: { answers: { [questionText]: value } } }; + } + const candidates = field(payload, "options"); + const match = Array.isArray(candidates) + ? candidates.find((candidate) => text(candidate, "label") === value) + : undefined; + const matchedId = text(match, "optionId") ?? text(match, "value"); + return matchedId === undefined ? { freeText: value } : { optionId: matchedId }; + } + if (text(wanted, "decision") === "deny") return { action: "decline" }; + return undefined; +}; + const interactionResolvedEvents = ( interaction: ZcodeRecord, ): ReadonlyArray => { @@ -336,14 +374,12 @@ const interactionResolvedEvents = ( }; const turnOutputTokens = (state: ZcodeConversationState): number | undefined => { - let best: { rowId: number; tokens: number } | undefined; + let latest: { rowId: number; row: ZcodeRecord } | undefined; for (const [rowId, row] of state.rows) { if (text(row, "kind") !== "turnHeader") continue; - const tokens = number(row, "outputTokens"); - if (tokens === undefined) continue; - if (best === undefined || rowId > best.rowId) best = { rowId, tokens }; + if (latest === undefined || rowId > latest.rowId) latest = { rowId, row }; } - return best?.tokens; + return latest === undefined ? undefined : number(latest.row, "outputTokens"); }; const controlEvents = ( diff --git a/packages/provider-zcode/src/server/zcodeServeDaemon.ts b/packages/provider-zcode/src/server/zcodeServeDaemon.ts index d16202009a69a..b4154cd859c1c 100644 --- a/packages/provider-zcode/src/server/zcodeServeDaemon.ts +++ b/packages/provider-zcode/src/server/zcodeServeDaemon.ts @@ -22,6 +22,7 @@ const TOKEN_FILE = "ingress-token"; const STATUS_READY = "ready"; const STARTUP_TIMEOUT = Duration.seconds(30); const STARTUP_POLL = Duration.millis(250); +const PROBE_TIMEOUT = Duration.seconds(2); const ZcodeServerStatus = Schema.Struct({ state: Schema.String, @@ -71,6 +72,22 @@ const readIngressToken = Effect.fn("ZcodeServe.readIngressToken")(function* ( export const zcodeChannelUrl = (status: ZcodeServerStatus, token: string): string => `ws://${status.host}:${status.port}/ws/host?token=${encodeURIComponent(token)}`; +/** A saved `ready` status outlives a crashed daemon, so probe its HTTP endpoint before trusting it. */ +const zcodeServerResponds = Effect.fn("ZcodeServe.probe")(function* ( + httpClient: HttpClient.HttpClient, + status: ZcodeServerStatus, +) { + const request = HttpClientRequest.get(`http://${status.host}:${status.port}/api/server-info`); + return yield* httpClient.execute(request).pipe( + Effect.timeoutOption(PROBE_TIMEOUT), + Effect.map( + (response) => + Option.isSome(response) && response.value.status >= 200 && response.value.status < 300, + ), + Effect.orElseSucceed(() => false), + ); +}); + const issueZcodeHostCapability = Effect.fn("ZcodeServe.issueHostCapability")(function* ( httpClient: HttpClient.HttpClient, status: ZcodeServerStatus, @@ -140,6 +157,7 @@ const ensureZcodeServer = Effect.fn("ZcodeServe.ensure")(function* (input: { readonly settings: { readonly binaryPath: string; readonly launchArgs: string }; readonly environment: NodeJS.ProcessEnv; readonly fileSystem: FileSystem.FileSystem; + readonly httpClient: HttpClient.HttpClient; }) { const serverRoot = zcodeServerRoot(input.environment); if (serverRoot === undefined) { @@ -149,7 +167,12 @@ const ensureZcodeServer = Effect.fn("ZcodeServe.ensure")(function* (input: { }); } const running = yield* readZcodeServerStatus(input.fileSystem, serverRoot); - if (Option.isSome(running)) return { serverRoot, status: running.value }; + if (Option.isSome(running)) { + if (yield* zcodeServerResponds(input.httpClient, running.value)) { + return { serverRoot, status: running.value }; + } + yield* Effect.logDebug("Ignoring a stale zcode server status.", { serverRoot }); + } const launchCommand = yield* resolveZcodeLaunchCommand( input.settings, @@ -169,7 +192,9 @@ const ensureZcodeServer = Effect.fn("ZcodeServe.ensure")(function* (input: { const started = yield* Effect.gen(function* () { for (;;) { const status = yield* readZcodeServerStatus(input.fileSystem, serverRoot); - if (Option.isSome(status)) return status.value; + if (Option.isSome(status)) { + if (yield* zcodeServerResponds(input.httpClient, status.value)) return status.value; + } yield* Effect.sleep(STARTUP_POLL); } }).pipe(Effect.timeoutOption(STARTUP_TIMEOUT)); From 638d2f7f5d1af9d987d6be40efebe5a3654b78b1 Mon Sep 17 00:00:00 2001 From: panudetjt <3482285+panudetjt@users.noreply.github.com> Date: Thu, 12 Nov 2026 11:00:11 +0700 Subject: [PATCH 23/23] fix(provider-zcode): retire runtime requests only after the answer lands Keep the pending approval or question retryable when resolveInteraction fails, matching the transport's new error propagation. --- packages/provider-zcode/src/server/adapter.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/provider-zcode/src/server/adapter.ts b/packages/provider-zcode/src/server/adapter.ts index 8ad21b28b6ce5..41f590b09ccc0 100644 --- a/packages/provider-zcode/src/server/adapter.ts +++ b/packages/provider-zcode/src/server/adapter.ts @@ -2154,14 +2154,14 @@ export const makeZcodeAdapterV2 = Effect.fn("makeZcodeAdapterV2")(function* ( reason: requestInput.feedback ?? "Denied in T3 Code", }; approval.chosenResponse = response; - pendingApprovals.delete(key); - answeredPermissions.set(approval.nativeRequestId, response); for (const [respondId, awaiting] of awaitingPermissionRequests) { if (awaiting.nativeRequestId === approval.nativeRequestId) { - awaitingPermissionRequests.delete(respondId); yield* connection.respond(respondId, response); + awaitingPermissionRequests.delete(respondId); } } + answeredPermissions.set(approval.nativeRequestId, response); + pendingApprovals.delete(key); yield* resolveRuntimeRequestRecords(approval, yield* DateTime.now); return; } @@ -2182,14 +2182,14 @@ export const makeZcodeAdapterV2 = Effect.fn("makeZcodeAdapterV2")(function* ( requestInput.decision === "cancel" || answered.length === 0 ? { cancelled: true } : { answers: Object.fromEntries(answered) }; - pendingQuestions.delete(key); - answeredUserInputs.set(pendingQuestion.nativeRequestId, pendingQuestion.answer); for (const [respondId, awaiting] of awaitingUserInputRequests) { if (awaiting.nativeRequestId === pendingQuestion.nativeRequestId) { - awaitingUserInputRequests.delete(respondId); yield* connection.respond(respondId, pendingQuestion.answer); + awaitingUserInputRequests.delete(respondId); } } + answeredUserInputs.set(pendingQuestion.nativeRequestId, pendingQuestion.answer); + pendingQuestions.delete(key); yield* resolveRuntimeRequestRecords(pendingQuestion, yield* DateTime.now); }, (effect, requestInput) =>