From 4fe8a0c40d76a5456626d6646457e8a0f765292a Mon Sep 17 00:00:00 2001 From: Kingsword Date: Sat, 19 Sep 2026 11:26:44 +0800 Subject: [PATCH 1/3] fix: recover invalid session models and publish commit previews --- .github/workflows/release-commit.yml | 71 ++++++++++ bun.lock | 3 + docs/DEVELOPMENT.md | 33 +++++ docs/RELEASING.md | 26 ++++ package.json | 1 + packages/zcode-tui/src/index.ts | 72 ++++++---- scripts/repro-session-model.ts | 32 +++++ scripts/sync-runtime.ts | 33 ++++- src/runtime-config-bridge.ts | 2 + src/session-model-recovery.ts | 67 ++++++++++ test/fixtures/session-model-recovery.ts | 90 +++++++++++++ test/release-workflows.test.ts | 28 ++++ test/runtime/session-model-recovery.test.ts | 138 ++++++++++++++++++++ test/session-model-recovery.test.ts | 47 +++++++ test/sync-runtime.test.ts | 32 ++++- 15 files changed, 647 insertions(+), 28 deletions(-) create mode 100644 .github/workflows/release-commit.yml create mode 100644 scripts/repro-session-model.ts create mode 100644 src/session-model-recovery.ts create mode 100644 test/fixtures/session-model-recovery.ts create mode 100644 test/runtime/session-model-recovery.test.ts create mode 100644 test/session-model-recovery.test.ts diff --git a/.github/workflows/release-commit.yml b/.github/workflows/release-commit.yml new file mode 100644 index 0000000..8408096 --- /dev/null +++ b/.github/workflows/release-commit.yml @@ -0,0 +1,71 @@ +name: Publish commit preview + +on: + push: + branches: + - main + pull_request: + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: zcode-cli-preview-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + preview: + runs-on: ubuntu-latest + timeout-minutes: 45 + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} + persist-credentials: false + + - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 + with: + node-version: 24 + package-manager-cache: false + + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 + with: + bun-version: 1.3.12 + + - name: Install extraction tools + run: sudo apt-get update && sudo apt-get install -y p7zip-full + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Build and test the locked runtime + run: bun run release:build + + - name: Pack and install-test + id: pack + run: bun run release:pack + + - name: Verify release metadata is unchanged + run: git diff --exit-code -- package.json zcode-runtime.lock.json + + - name: Upload tested package + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: commit-preview-${{ github.event.pull_request.head.sha || github.sha }} + path: | + .release/*.tgz + .release/release.json + if-no-files-found: error + retention-days: 14 + + # Install https://github.com/apps/pkg-pr-new on this repository first. + # Upload the exact tarball that passed the install smoke test, without repacking it. + - name: Publish commit package + env: + PREVIEW_TARBALL: ${{ steps.pack.outputs.tarball }} + run: bun run pkg-pr-new publish "$PREVIEW_TARBALL" --bin --comment=update --commentWithSha --no-template --json .release/preview.json + + - name: Show preview command + run: | + jq -r '.packages[] | "Preview package: \(.url)\n\n```sh\nnpx --yes \(.url) --resume \n```"' .release/preview.json >> "$GITHUB_STEP_SUMMARY" diff --git a/bun.lock b/bun.lock index 5a427e6..2669a7d 100644 --- a/bun.lock +++ b/bun.lock @@ -17,6 +17,7 @@ "just-bash": "^3.4.2", "mountx": "^0.0.2", "msw": "^2.15.0", + "pkg-pr-new": "0.0.88", "tsdown": "^0.22.7", "typescript": "^7.0.2", "yaml": "^2.8.1", @@ -382,6 +383,8 @@ "picomatch": ["picomatch@4.0.5", "", {}, "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A=="], + "pkg-pr-new": ["pkg-pr-new@0.0.88", "", { "bin": { "pkg-pr-new": "bin/cli.js" } }, "sha512-Xc6PMJ2gher0WZP+rtjefFk26hIb7V1PTLL30bmy1Z2vsRmSvqiss7Ag1XUdyplTGYzIlFNJp4L3vMNmK44N6g=="], + "playwright-core": ["playwright-core@1.59.1", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg=="], "prebuild-install": ["prebuild-install@7.1.3", "", { "dependencies": { "detect-libc": "^2.0.0", "expand-template": "^2.0.3", "github-from-package": "0.0.0", "minimist": "^1.2.3", "mkdirp-classic": "^0.5.3", "napi-build-utils": "^2.0.0", "node-abi": "^3.3.0", "pump": "^3.0.0", "rc": "^1.2.7", "simple-get": "^4.0.0", "tar-fs": "^2.0.0", "tunnel-agent": "^0.6.0" }, "bin": { "prebuild-install": "bin.js" } }, "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug=="], diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 0b66744..ca2f4a3 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -47,6 +47,39 @@ Ctrl+C cancellation remain responsive during rapid Bash progress output. The scenarios advance from observed terminal output instead of fixed timers and do not make model API calls. +## Reproduce invalid models in resumed sessions (#160) + +Build the current CLI, then start an isolated reproduction: + +```bash +bun run sync:local # macOS with /Applications/ZCode.app; otherwise use bun run sync:locked +bun scripts/repro-session-model.ts +``` + +The script creates a real SQLite session, writes the stale model selection from +#160, and resumes it in the real CLI. Model requests go to a local mock server; +no real API key or user configuration is used. Temporary data is removed on exit. + +The expected behavior is an immediate **Select a replacement model** dialog. +Choose `zai/glm-5.3`, then send a prompt to receive `SESSION_MODEL_REPLY`. +Cancelling preserves the saved selection and blocks prompts until `/model` +repairs it. A successful switch saves the selection for future resumes and +leaves the shared default unchanged. + +Other cases and surfaces: + +```bash +bun scripts/repro-session-model.ts --case model-casing --fullscreen +bun scripts/repro-session-model.ts --case missing-model +bun scripts/repro-session-model.ts --case missing-reasoning +bun scripts/repro-session-model.ts --headless +bun test test/runtime/session-model-recovery.test.ts +``` + +Headless recovery exits with the invalid provider/model and instructions to +resume interactively; it sends no model request. The regression tests also +cover `/resume` inside the TUI and restarting after a repair. + ## OAuth login For the OAuth path, run the launcher directly with the login subcommand: diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 0f4fa01..df25fa9 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -64,6 +64,32 @@ are compiled to JavaScript with `tsdown`; its launcher banner adds the Node.js shebang directly, with no post-build rewrite. The compiled TUI is injected into `vendor/` before publication. +## Commit preview packages + +`.github/workflows/release-commit.yml` builds previews for pull requests, pushes +to `main`, and manual workflow runs. It checks out the PR's head commit, builds +the locked runtime, runs the release checks, and install-tests the npm tarball. +The exact tested tarball is uploaded to pkg.pr.new without repacking it. + +Install the [pkg-pr-new GitHub App](https://github.com/apps/pkg-pr-new) on this +repository before the first preview publication. No npm token or npm publish +permission is needed. The publisher is pinned in `devDependencies` and `bun.lock`. + +The app updates a PR comment with a commit-specific preview link. The workflow +summary also gives the command to test an existing session: + +```bash +npx --yes https://pkg.pr.new/zcode-app-cli@ --resume +``` + +Use the exact URL emitted by the successful workflow. This runs the preview +without replacing the globally installed CLI. It uses the user's normal session +store, so the tester can verify their affected sessions. Record the preview URL +with the test result: preview tarballs retain the source package version, while +their URLs identify the commit. They do not update npm's `latest` tag or create +a release tag. The tested tarball is also retained as a workflow artifact for +14 days, including when pkg.pr.new publication fails. + ## Versioning Package versions use `-`, for example `3.3.5-2`. The prefix diff --git a/package.json b/package.json index e75634a..55a42b5 100644 --- a/package.json +++ b/package.json @@ -97,6 +97,7 @@ "just-bash": "^3.4.2", "mountx": "^0.0.2", "msw": "^2.15.0", + "pkg-pr-new": "0.0.88", "tsdown": "^0.22.7", "typescript": "^7.0.2", "yaml": "^2.8.1" diff --git a/packages/zcode-tui/src/index.ts b/packages/zcode-tui/src/index.ts index 5a1a7dd..7c0c5f8 100644 --- a/packages/zcode-tui/src/index.ts +++ b/packages/zcode-tui/src/index.ts @@ -681,6 +681,8 @@ class ZCodeTui { private readonly permissionRequests = new PermissionRequestQueue(); private choiceDepth = 0; private settingSwitchInFlight = false; + private sessionModelIssue?: string; + private sessionModelRecovery?: Promise; private fullscreenWelcomeVisible = true; private fullscreenWelcomeTransitionTimer?: ReturnType; private sessionHasContent = false; @@ -904,6 +906,7 @@ class ZCodeTui { void this.refreshWorkflowFromEvent(); }) ?? undefined; } + if (this.sessionModelIssue) void this.recoverSessionModel(); await this.done; } finally { process.off("SIGINT", onSigint); @@ -1562,7 +1565,9 @@ class ZCodeTui { const submission = queuedSubmission ?? protectSubmission(input); if (!input.startsWith("/") && !this.primaryTurnActive) { const allowed = await preflightSubmission({ - validate: () => missingCodingPlanKey({ + validate: () => this.sessionModelIssue + ? Promise.resolve(`${this.sessionModelIssue} Choose a model with /model before continuing.`) + : missingCodingPlanKey({ model: this.model, workingDirectory: this.options.workspaceDirectory }), @@ -2176,6 +2181,7 @@ class ZCodeTui { if (appliesToSetting(settingTarget, "model") && result.model !== undefined) { this.model = modelLabel(result.model); + this.sessionModelIssue = undefined; } if (typeof result.loginRequired === "boolean") { this.setLoginRequired(result.loginRequired); @@ -2206,18 +2212,10 @@ class ZCodeTui { if (isRecord(result.selection)) await this.showSelection(result.selection); if (result.resetSessionProjection === true) { await this.refreshExecutionState(); - try { - const persistedModel = await this.options.readSessionModel?.(); - if (isRecord(persistedModel) && typeof persistedModel.model === "string") { - this.model = persistedModel.model; - this.thoughtLevel = asString(persistedModel.thoughtLevel); - if (Array.isArray(persistedModel.effortOptions)) this.effortOptions = persistedModel.effortOptions; - } - } catch { - // Model metadata is supplementary; the resume response remains usable. - } + await this.restoreSessionModel(); this.updateMetadata(); this.ui.requestRender(); + if (this.sessionModelIssue) await this.recoverSessionModel(); } } @@ -3765,17 +3763,29 @@ class ZCodeTui { /** Switch this session while preserving the shared default model. */ private async showModelPicker(): Promise { await this.refreshModelOptions(); + if (this.stopped) return true; const picker = modelPicker(this.modelOptions, this.model); - if (picker.items.length === 0) return false; + if (picker.items.length === 0) { + if (!this.sessionModelIssue) return false; + this.addNotice(`${this.sessionModelIssue} No models are available. Run /login or configure a provider in /settings, then use /model.`, "warning"); + return true; + } const selected = await this.showChoice({ - title: "Select model", - prompt: `Current model: ${this.model}. · session only — saved defaults are unchanged`, + title: this.sessionModelIssue ? "Select a replacement model" : "Select model", + prompt: this.sessionModelIssue + ? `${this.sessionModelIssue} Choose a model for this session.` + : `Current model: ${this.model}. · session only — saved defaults are unchanged`, help: "Up/Down choose · Enter switch · Esc cancel", items: picker.items.map((item) => ({ ...item, payload: item.value })), selectedIndex: picker.selectedIndex }); const modelId = selected?.payload; - if (typeof modelId !== "string") return true; + if (typeof modelId !== "string") { + if (this.sessionModelIssue && !this.stopped) { + this.addNotice("Model selection unchanged. Choose a model with /model before continuing.", "warning"); + } + return true; + } await this.switchTransientModel(modelId); return true; @@ -3793,9 +3803,10 @@ class ZCodeTui { this.settingSwitchInFlight = true; try { const previousModel = this.model; + const recovering = this.sessionModelIssue !== undefined; const result = await this.options.setTransientModel(modelId); await this.handleResult(result, false); - const status = this.model === previousModel ? "already active" : "now"; + const status = !recovering && this.model === previousModel ? "already active" : "now"; this.addNotice( `Session model ${status}: ${this.model} · saved defaults unchanged.`, "muted" @@ -5184,18 +5195,31 @@ class ZCodeTui { this.addNotice(`Unable to restore session transcript: ${message}`, "warning"); } } + await this.restoreSessionModel(); + } + + private async restoreSessionModel(): Promise { + this.sessionModelIssue = undefined; try { - const persistedModel = await this.options.readSessionModel?.(); - if (isRecord(persistedModel) && typeof persistedModel.model === "string") { - this.model = persistedModel.model; - this.thoughtLevel = asString(persistedModel.thoughtLevel); - if (Array.isArray(persistedModel.effortOptions)) this.effortOptions = persistedModel.effortOptions; - } - } catch { - // Model metadata is supplementary; transcript restoration remains authoritative. + const saved = await this.options.readSessionModel?.(); + if (!isRecord(saved)) return; + if (typeof saved.model === "string") this.model = saved.model; + this.thoughtLevel = asString(saved.thoughtLevel); + if (Array.isArray(saved.effortOptions)) this.effortOptions = saved.effortOptions; + if (isRecord(saved.issue)) this.sessionModelIssue = asString(saved.issue.message); + } catch (error) { + this.addNotice(`Unable to inspect the saved session model: ${error instanceof Error ? error.message : String(error)}`, "warning"); } } + private recoverSessionModel(): Promise { + if (this.sessionModelRecovery) return this.sessionModelRecovery; + this.sessionModelRecovery = this.showModelPicker().then(() => {}).finally(() => { + this.sessionModelRecovery = undefined; + }); + return this.sessionModelRecovery; + } + private updateMetadata(): void { this.editor.planEnabled = this.planEnabled; const fields: StatusLineField[] = [ diff --git a/scripts/repro-session-model.ts b/scripts/repro-session-model.ts new file mode 100644 index 0000000..4df35cc --- /dev/null +++ b/scripts/repro-session-model.ts @@ -0,0 +1,32 @@ +#!/usr/bin/env bun + +import { parseArgs } from "node:util"; +import { createSessionModelFixture, sessionModelCases, type SessionModelCase } from "../test/fixtures/session-model-recovery.ts"; + +const { values } = parseArgs({ args: process.argv.slice(2), options: { + case: { type: "string", default: "legacy-provider" }, + headless: { type: "boolean", default: false }, + fullscreen: { type: "boolean", default: false } +} }); +if (!Object.hasOwn(sessionModelCases, values.case)) throw new Error(`Choose --case ${Object.keys(sessionModelCases).join(" | ")}`); + +await using fixture = await createSessionModelFixture(values.case as SessionModelCase); +console.log(`Issue #160 reproduction: ${values.case}\nTemporary session: ${fixture.sessionId}\nData: ${fixture.directory}`); +console.log("The runtime and SQLite session are real. Model responses come from a local mock; no API key is needed."); +console.log(values.headless + ? "Resuming and sending a prompt without a terminal." + : "Resume should immediately ask you to replace the unavailable model. Choose zai/glm-5.3, then send a message. Use /exit to finish."); +const child = Bun.spawn([...fixture.command, "--resume", fixture.sessionId, + ...values.headless ? ["--prompt", "Reply after resuming."] : []], { + cwd: fixture.directory, env: { ...fixture.env, ZCODE_TUI_MODE: values.fullscreen ? "fullscreen" : "regular" }, + stdin: "inherit", stdout: "inherit", stderr: "inherit" +}); +const interrupt = () => child.kill("SIGINT"); +const terminate = () => child.kill("SIGTERM"); +process.once("SIGINT", interrupt); +process.once("SIGTERM", terminate); +try { process.exitCode = await child.exited; } +finally { + process.off("SIGINT", interrupt); + process.off("SIGTERM", terminate); +} diff --git a/scripts/sync-runtime.ts b/scripts/sync-runtime.ts index 951e09b..8d90597 100755 --- a/scripts/sync-runtime.ts +++ b/scripts/sync-runtime.ts @@ -543,6 +543,7 @@ export function patchRuntimeTuiBridge(runtime: string): string { const cliModeOverrideBridge = /([A-Za-z_$][\w$]*)\.setMode=async ([A-Za-z_$][\w$]*)=>\(\{mode:await [A-Za-z_$][\w$]*\(\2\)\}\)/u; const transientModelBridgePattern = /\.setTransientModel=async/u; const transientModelOptionPattern = /setTransientModel:[A-Za-z_$][\w$]*\.setTransientModel/u; + const sessionModelStateMarker = ".readSessionModel=async()=>{let $zSessionModelApp="; const alreadyPatched = runtime.includes(".loadSessionTranscript=async()=>await(await") && runtime.includes(".readGoal=async()=>await(await") && runtime.includes(".readTodos=async()=>await(await") @@ -587,6 +588,7 @@ export function patchRuntimeTuiBridge(runtime: string): string { && runtime.includes("...$zExecutionState") && transientModelBridgePattern.test(runtime) && transientModelOptionPattern.test(runtime) + && runtime.includes(sessionModelStateMarker) && sessionEventsBridgePattern.test(runtime) && sessionEventsOptionPattern.test(runtime) && taskMessageBridgePattern.test(runtime) @@ -805,8 +807,15 @@ export function patchRuntimeTuiBridge(runtime: string): string { if (!patched.includes(".setTransientModel=async")) { assignments.push(`${bridge}.setTransientModel=async e=>{if(e==="main"){e=await ${bridge}.readDefaultModel();if(!e)throw new Error("No default model is configured.")}let t=await ${getApp}(),r=await t.setModel(e);return{...r,thoughtLevel:t.getThoughtLevel(),effortOptions:t.listThoughtLevels()}}`); } - if (!patched.includes(".readSessionModel=async")) { - assignments.push(`${bridge}.readSessionModel=async()=>{let t=await ${getApp}(),o=t.sessionStore??t.runtime?.sessionStore,r=await o?.sessionEntries?.({sessionID:t.sessionId,type:"runtime/model_selection"}),n=Array.isArray(r)?r.at(-1)?.data:void 0;if(!n||typeof n.providerId!=="string"||typeof n.modelId!=="string")return;await t.setModel({providerId:n.providerId,modelId:n.modelId,...n.options?{options:n.options}:{}},{transient:!0});return{model:n.providerId+"/"+n.modelId,thoughtLevel:t.getThoughtLevel?.(),effortOptions:t.listThoughtLevels?.()??[]}}`); + if (!patched.includes(sessionModelStateMarker)) { + const assignment = `${bridge}.readSessionModel=async()=>{let $zSessionModelApp=await ${getApp}(),s=await $zSessionModelApp.readSessionModelState();if(s?.selection&&!s.issue)await $zSessionModelApp.setModel(s.selection,{transient:!0});return s}`; + const start = patched.indexOf(`${bridge}.readSessionModel=async`); + if (start < 0) assignments.push(assignment); + else { + const end = patched.indexOf(`,${bridge}.`, start); + if (end < 0) throw new Error("ZCode runtime is incompatible with session model recovery (bridge boundary missing)."); + patched = patched.slice(0, start) + assignment + patched.slice(end); + } } if (!sessionEventsBridgePattern.test(patched)) { assignments.push(`${bridge}.subscribeSessionEvents=e=>{let t=!1,r;${getApp}().then(o=>{t||(r=o.runtime?.subscribeEvents?.({onSessionEvent:e}))});return()=>{t=!0,r?.()}}`); @@ -984,6 +993,22 @@ export function patchRuntimeSharedConfig(runtime: string): string { .replace(main[0], `${main[0]}if(process.env.ZCODE_CLI_MIGRATE_CONFIG==="1"){try{${initRepository}();${initImporter}();await ${bridge}.migrateLegacyProviders({Repository:${repository[1]},importLegacy:${importer[1]},env:process.env})}catch($zError){process.stderr.write(($zError instanceof Error?$zError.message:"CLI configuration migration failed")+"\\n"),process.exitCode=1}return}`); } +/** Keep invalid saved selections inspectable and reject prompts before the generic model-creation wrapper. */ +export function patchRuntimeSessionModelRecovery(runtime: string): string { + const marker = "$zRestoredSessionModel"; + if (runtime.includes(marker) && runtime.includes('"readSessionModelState"')) return runtime; + const facade = /getModelOption:([A-Za-z_$][\w$]*)\(([A-Za-z_$][\w$]*)=>[A-Za-z_$][\w$]*\(([A-Za-z_$][\w$]*)\.providerRegistry,\2\),"getModelOption"\)/u.exec(runtime); + const restore = /let ([A-Za-z_$][\w$]*)=([A-Za-z_$][\w$]*)&&([A-Za-z_$][\w$]*)\.validateSelection\(\2\);return ([A-Za-z_$][\w$]*)\(\)\.setSessionModelSelection\(\1\?\.ok\?\2:void 0\),\2\},"restorePersistedModelSelection"/u.exec(runtime); + const boundary = /([A-Za-z_$][\w$]*)\(async ([A-Za-z_$][\w$]*)=>\{await ([A-Za-z_$][\w$]*)\.prepareUserExecutionBoundary\(\2\)\},"preparePromptBoundary"\)/u.exec(runtime); + if (!facade || !restore || !boundary) throw new Error("ZCode runtime is incompatible with session model recovery (restore/facade anchors missing)."); + const helper = 'require(require("node:path").join(__dirname,"cli-config.cjs"))'; + const context = facade[3], getRuntime = `${restore[4]}()`, inputContext = boundary[3], inputOptions = boundary[2]; + return runtime + .replace(facade[0], `${facade[0]},readSessionModelState:${facade[1]}(async()=>await ${helper}.readSessionModelState({registry:${context}.providerRegistry,sessionStore:${context}.sessionStore,sessionId:${context}.sessionId}),"readSessionModelState")`) + .replace(restore[0], restore[0].replace(";return ", `;${getRuntime}.${marker}={selection:${restore[2]},registry:${restore[3]}};return `)) + .replace(boundary[0], boundary[0].replace('},"preparePromptBoundary")', `;${helper}.assertSessionModelReady({registry:${inputContext}.runtime.${marker}?.registry,sessionId:${inputContext}.sessionId,currentSelection:${inputOptions}?.intent?.modelSelection??${inputContext}.runtime.getSessionModelSelection(),restored:${inputContext}.runtime.${marker}})},"preparePromptBoundary")`)); +} + export function patchRuntimeTuiExecutionState(runtime: string): string { if (runtime.includes('"readExecutionState"') && runtime.includes('"setPlanEnabled"')) return runtime; const modes = /\["plan","build","edit","yolo"\](;[A-Za-z_$][\w$]*\([A-Za-z_$][\w$]*,"formatAvailableCommandCenterModes"\))/u; @@ -1467,6 +1492,10 @@ export const runtimePatchPlan: readonly RuntimePatchDefinition[] = [ apply: patchRuntimeSharedConfig, verify: runtime => runtime.includes('ZCODE_CLI_MIGRATE_CONFIG==="1"') && runtime.includes('="setting.json",') }, + { + id: "session-model-recovery", requirement: "required", apply: patchRuntimeSessionModelRecovery, + verify: runtime => runtime.includes("$zRestoredSessionModel") && runtime.includes('"readSessionModelState"') + }, { id: "official-mcp-availability", requirement: "optional", diff --git a/src/runtime-config-bridge.ts b/src/runtime-config-bridge.ts index 7d0e1fe..7e8962c 100644 --- a/src/runtime-config-bridge.ts +++ b/src/runtime-config-bridge.ts @@ -3,6 +3,8 @@ import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises"; import { dirname, resolve } from "node:path"; import { cliSettingsPath, legacyCliConfigPath, providerConfigPath, providerMigrationMarkerPath, readDesktopSettings } from "./config-paths.ts"; +export { assertSessionModelReady, readSessionModelState } from "./session-model-recovery.ts"; + function record(value: unknown): Record | undefined { return value && typeof value === "object" && !Array.isArray(value) ? value as Record : undefined; } diff --git a/src/session-model-recovery.ts b/src/session-model-recovery.ts new file mode 100644 index 0000000..21b5ee1 --- /dev/null +++ b/src/session-model-recovery.ts @@ -0,0 +1,67 @@ +interface ModelSelection { + providerId: string; + modelId: string; + options?: { reasoningLevel?: string; [key: string]: unknown }; +} + +interface Registry { + validateSelection(selection: ModelSelection): { ok: boolean; code?: string }; + getModel(providerId: string, modelId: string): { + config: { optionSpecs: { reasoningLevel: { values: readonly string[] } } }; + } | undefined; +} + +export interface SessionModelState { + model: string; + selection?: ModelSelection; + thoughtLevel?: string; + effortOptions: readonly string[]; + issue?: { code: string; message: string }; +} + +/** sessionEntries already unwraps the stored modelSelection; legacy sibling fields are not authoritative. */ +function inspectSelection(registry: Registry, value: unknown): SessionModelState { + const selection = value && typeof value === "object" && !Array.isArray(value) + && "providerId" in value && typeof value.providerId === "string" && value.providerId.trim() + && "modelId" in value && typeof value.modelId === "string" && value.modelId.trim() + ? value as ModelSelection : undefined; + const model = selection ? `${selection.providerId}/${selection.modelId}` : "(not selected)"; + const validation = selection ? registry.validateSelection(selection) : { ok: false, code: "selection-missing" }; + const state: SessionModelState = { + model, selection, thoughtLevel: selection?.options?.reasoningLevel, + effortOptions: selection ? registry.getModel(selection.providerId, selection.modelId)?.config.optionSpecs.reasoningLevel.values ?? [] : [] + }; + if (validation.ok) return state; + const code = validation.code ?? "selection-invalid"; + const reason = { + "provider-not-found": "the provider is unavailable", + "model-not-found": "the model is not in the current provider catalog", + "reasoning-level-missing": "the reasoning level is missing", + "reasoning-level-not-supported": "the saved reasoning level is no longer supported", + "selection-missing": "no model selection was saved" + }[code] ?? "the saved selection is invalid"; + return { ...state, issue: { code, message: `Saved model ${JSON.stringify(model)} cannot be used: ${reason}.` } }; +} + +/** Inspect without changing the session, its credentials, or the shared default. */ +export async function readSessionModelState(options: { + registry: Registry; + sessionId: string; + sessionStore: { sessionEntries(options: { sessionID: string; type: string }): Promise> }; +}): Promise { + const entries = await options.sessionStore.sessionEntries({ sessionID: options.sessionId, type: "runtime/model_selection" }); + return entries.length ? inspectSelection(options.registry, entries.at(-1)?.data) : undefined; +} + +/** Fail before model creation so headless callers retain the cause and recovery instructions. */ +export function assertSessionModelReady(options: { + registry: Registry; + sessionId: string; + currentSelection?: ModelSelection; + restored?: { selection?: unknown }; +}): void { + if (!options.restored || options.currentSelection && options.registry.validateSelection(options.currentSelection).ok) return; + const state = inspectSelection(options.registry, options.restored.selection); + if (state.issue) throw new Error(`${state.issue.message} Resume interactively with zcode --resume ${options.sessionId} ` + + "and use /model to choose a replacement. No model request was sent."); +} diff --git a/test/fixtures/session-model-recovery.ts b/test/fixtures/session-model-recovery.ts new file mode 100644 index 0000000..2ed0488 --- /dev/null +++ b/test/fixtures/session-model-recovery.ts @@ -0,0 +1,90 @@ +import { Database } from "bun:sqlite"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; + +import { cliSettingsPath, ensureCliSettings } from "../../src/model-access.ts"; +import { writeProviderFixture } from "./provider-config.ts"; + +export const sessionModelCases = { + "legacy-provider": { + modelId: "GLM-5.3", providerId: "builtin:zai-coding-plan", thoughtLevel: "high", + modelSelection: { providerId: "account:zai-individual-coding-plan", modelId: "GLM-5.3" } + }, + "model-casing": { modelSelection: { providerId: "zai", modelId: "GLM-5.3", options: { reasoningLevel: "high" } } }, + "missing-model": { modelSelection: { providerId: "zai", modelId: "glm-5.1", options: { reasoningLevel: "high" } } }, + "missing-reasoning": { modelSelection: { providerId: "zai", modelId: "glm-5.3" } }, + "valid": { modelSelection: { providerId: "zai", modelId: "glm-5.3", options: { reasoningLevel: "high" } } } +} as const; +export type SessionModelCase = keyof typeof sessionModelCases; + +/** A real persisted session and runtime, with only the model HTTP endpoint mocked. */ +export async function createSessionModelFixture(kind: SessionModelCase = "legacy-provider") { + const directory = await mkdtemp(join(tmpdir(), "zcode-session-model-")); + const root = resolve(import.meta.dir, "../.."); + const node = Bun.which("node"); + if (!node) throw new Error("Node.js is required to reproduce session model recovery."); + const requests: string[] = []; + const server = Bun.serve({ hostname: "127.0.0.1", port: 0, async fetch(request) { + if (new URL(request.url).pathname !== "/v1/chat/completions") return new Response(null, { status: 404 }); + const body = await request.json() as { model: string; stream?: boolean }; + if (!body.stream) return Response.json({ choices: [{ message: { role: "assistant", content: "Model recovery fixture" }, finish_reason: "stop" }] }); + requests.push(body.model); + const chunks = [{ role: "assistant", content: "SESSION_MODEL_REPLY" }, {}].map((delta, index) => + `data: ${JSON.stringify({ id: "fixture", object: "chat.completion.chunk", model: body.model, + choices: [{ index: 0, delta, finish_reason: index ? "stop" : null }] })}\n\n` + ); + return new Response(`${chunks.join("")}data: [DONE]\n\n`, { headers: { "content-type": "text/event-stream" } }); + } }); + const env = { ...process.env, HOME: directory, USERPROFILE: directory, ZCODE_DATA_BASE_DIR: directory, + ZCODE_PERSONAL_PROVIDER_CONFIG_FILE: join(directory, "providers.json"), + ZCODE_BUILTIN_PROVIDER_CONFIG_FILE: join(root, "vendor/provider/zcode-builtin.json"), + ZCODE_BUILTIN_PROVIDER_BUNDLED_CONFIG_FILE: "", ZCODE_DISABLE_UPDATE_CHECK: "1", + ZCODE_TUI_MODE: "regular", ZCODE_NODE: node, TERM: "xterm-256color", CI: "0" }; + const dispose = async () => { server.stop(true); await rm(directory, { recursive: true, force: true }); }; + try { + await ensureCliSettings(env); + const settings = JSON.parse(await readFile(cliSettingsPath(env), "utf8")); + settings.plugins.enabled = false; + settings.memory = { use: false, write: false, autoConsolidate: false }; + settings.features = { compact: false, rewind: false, subagent: false, memory: false, skill: false, mcp: false }; + settings.ui.locale = "en-US"; + await writeFile(cliSettingsPath(env), JSON.stringify(settings)); + const { path: providerPath } = await writeProviderFixture(env, { + providerId: "zai", modelId: "glm-5.3-flash", models: ["glm-5.3-flash", "glm-5.3"], + apiKey: "fixture-key-not-real", baseUrl: `${server.url.origin}/v1` + }); + const command = [node, join(root, "bin/zcode.js")]; + const seed = Bun.spawn([...command, "--prompt", "Reply to seed a session for issue 160.", "--mode", "build"], { + cwd: directory, env, stdin: "ignore", stdout: "pipe", stderr: "pipe" + }); + const timeout = setTimeout(() => seed.kill("SIGKILL"), 15_000); + const [code, stdout, stderr] = await Promise.all([seed.exited, new Response(seed.stdout).text(), new Response(seed.stderr).text()]); + clearTimeout(timeout); + if (code !== 0 || !stdout.includes("SESSION_MODEL_REPLY")) throw new Error(`Unable to seed fixture (${code}): ${stderr}\n${stdout}`); + const dbPath = join(directory, ".zcode/cli/db/db.sqlite"); + const db = new Database(dbPath); + let sessionId: string; + try { + const row = db.query("SELECT session_id FROM session_entry WHERE type = 'runtime/model_selection' ORDER BY time_updated DESC LIMIT 1").get() as { session_id: string } | null; + if (!row) throw new Error("The seeded session has no model selection entry."); + sessionId = row.session_id; + db.query("UPDATE session_entry SET data = ? WHERE session_id = ? AND type = 'runtime/model_selection'") + .run(JSON.stringify(sessionModelCases[kind]), sessionId); + } finally { db.close(); } + requests.length = 0; + return { + directory, env, command, sessionId, dbPath, providerPath, requests, + readSelection() { + // Node removes WAL sidecars on close; allow SQLite to recreate them for this fixture query. + const connection = new Database(dbPath); + try { + return JSON.parse((connection.query("SELECT data FROM session_entry WHERE session_id = ? AND type = 'runtime/model_selection'") + .get(sessionId) as { data: string }).data); + } finally { connection.close(); } + }, + dispose, + [Symbol.asyncDispose]: dispose + }; + } catch (error) { await dispose(); throw error; } +} diff --git a/test/release-workflows.test.ts b/test/release-workflows.test.ts index 0b1ea1a..d9761bd 100644 --- a/test/release-workflows.test.ts +++ b/test/release-workflows.test.ts @@ -72,6 +72,34 @@ async function runInlineVersionComparator(source: string, left: string, right: s } describe("release workflows", () => { + test("publishes the tested commit tarball without npm publishing credentials", async () => { + const { source, workflow } = await readWorkflow("release-commit.yml"); + const steps = workflow.jobs.preview!.steps; + const checkout = findAction(steps, "actions/checkout", actionShas.checkout); + const build = steps.findIndex(step => step.run === "bun run release:build"); + const pack = steps.findIndex(step => step.id === "pack"); + const publish = steps.findIndex(step => step.name === "Publish commit package"); + expect(workflow.on).toHaveProperty("pull_request"); + expect(workflow.on).toHaveProperty("push"); + expect(workflow.on).toHaveProperty("workflow_dispatch"); + expect(workflow.on).not.toHaveProperty("pull_request_target"); + expect(workflow.permissions).toEqual({ contents: "read" }); + expect(checkout?.with?.["persist-credentials"]).toBe(false); + expect(checkout?.with?.ref).toBe("${{ github.event.pull_request.head.sha || github.sha }}"); + expect(build).toBeGreaterThan(-1); + expect(pack).toBeGreaterThan(build); + expect(publish).toBeGreaterThan(pack); + expect(steps[pack]?.run).toBe("bun run release:pack"); + expect(steps[publish]?.env?.PREVIEW_TARBALL).toBe("${{ steps.pack.outputs.tarball }}"); + expect(steps[publish]?.run).toContain('bun run pkg-pr-new publish "$PREVIEW_TARBALL"'); + expect(steps[publish]?.run).toContain("--commentWithSha"); + expect(steps[publish]?.run).toContain("--bin"); + expect(source).not.toContain("NPM_TOKEN"); + expect(source).not.toContain("npm publish"); + expect(source).not.toContain("id-token: write"); + expect(source).not.toContain("bunx"); + }); + test("runs read-only CI with pinned actions and cancels superseded checks", async () => { const { source, workflow } = await readWorkflow("ci.yml"); const job = workflow.jobs.validate!; diff --git a/test/runtime/session-model-recovery.test.ts b/test/runtime/session-model-recovery.test.ts new file mode 100644 index 0000000..d8a29a8 --- /dev/null +++ b/test/runtime/session-model-recovery.test.ts @@ -0,0 +1,138 @@ +import { expect, test } from "bun:test"; +import { readFile, writeFile } from "node:fs/promises"; + +import { createSessionModelFixture, type SessionModelCase } from "../fixtures/session-model-recovery.ts"; +import { TerminalScreen } from "../tui/harness/terminal-screen.ts"; + +const cases = [ + ["legacy-provider", "regular", "startup"], + ["model-casing", "fullscreen", "command"], + ["missing-model", "regular", "command"], + ["missing-reasoning", "fullscreen", "startup"] +] as const; + +test.skipIf(process.platform === "win32").each(cases)( + "resume repairs %s through the %s picker (%s)", async (kind, display, entry) => { + await using fixture = await createSessionModelFixture(kind); + const before = fixture.readSelection(); + const providers = await readFile(fixture.providerPath, "utf8"); + using screen = new TerminalScreen(100, 32); + const terminal = new Bun.Terminal({ cols: 100, rows: 32, name: "xterm-256color", + data(_terminal, data) { void screen.write(data); } }); + const start = (resume = false) => Bun.spawn([...fixture.command, ...resume ? ["--resume", fixture.sessionId] : []], { + cwd: fixture.directory, env: { ...fixture.env, ZCODE_TUI_MODE: display }, terminal + }); + let child = start(entry === "startup"); + const deadline = setTimeout(() => child.kill("SIGKILL"), 35_000); + const wait = async (predicate: (text: string) => boolean) => { + const until = Date.now() + 8_000; + while (Date.now() < until && child.exitCode === null) { + await screen.settled(); + if (predicate(screen.screenText())) return; + await Bun.sleep(25); + } + throw new Error(`Session model recovery did not settle:\n${screen.screenText()}`); + }; + try { + if (entry === "command") { + await wait(text => text.includes("◈ zai/glm-5.3-flash")); + terminal.write(`/resume ${fixture.sessionId}\r`); + } + await wait(text => text.includes("Select a replacement model")); + expect(screen.screenText()).toContain("Saved model"); + expect(fixture.requests).toEqual([]); + expect(fixture.readSelection()).toEqual(before); + if (kind === "legacy-provider") { + terminal.write("\x1b"); + await wait(text => !text.includes("Select a replacement model") && text.includes("/model")); + terminal.write("Keep this unsent draft\r"); + await wait(text => text.includes("Choose a model with /model before continuing.") && text.includes("Keep this unsent draft")); + expect(fixture.requests).toEqual([]); + expect(fixture.readSelection()).toEqual(before); + expect(screen.screenText()).toContain("Keep this unsent draft"); + terminal.write("\x03"); + terminal.write("/model\r"); + await wait(text => text.includes("Select a replacement model")); + } + terminal.write(kind === "missing-reasoning" ? "\r" : "\x1b[B\r"); + await wait(text => !text.includes("Select a replacement model") && /Session model now: zai\/glm-5\.3\s/.test(text)); + const saved = fixture.readSelection().modelSelection; + expect(saved).toMatchObject({ providerId: "zai", modelId: "glm-5.3" }); + expect(saved.options.reasoningLevel).toBeString(); + terminal.write("Continue the preserved conversation.\r"); + await wait(text => fixture.requests.length === 1 && text.includes("SESSION_MODEL_REPLY")); + expect(fixture.requests).toEqual(["glm-5.3"]); + child.kill("SIGTERM"); + await child.exited; + await screen.write("\x1b[2J\x1b[H"); + child = start(true); + await wait(text => text.includes("◈ zai/glm-5.3") && text.includes("SESSION_MODEL_REPLY")); + expect(screen.screenText()).not.toContain("Select a replacement model"); + expect(fixture.readSelection().modelSelection).toEqual(saved); + terminal.write("Continue after restarting again.\r"); + await wait(text => fixture.requests.length === 2 && text.includes("SESSION_MODEL_REPLY")); + expect(fixture.requests).toEqual(["glm-5.3", "glm-5.3"]); + expect(await readFile(fixture.providerPath, "utf8")).toBe(providers); + } finally { + if (child.exitCode === null) child.kill("SIGTERM"); + await child.exited; + clearTimeout(deadline); + terminal.close(); + await screen.settled(); + } + }, 40_000 +); + +test.each(["legacy-provider", "model-casing", "missing-model"] as SessionModelCase[])( + "headless resume explains the invalid saved model (%s)", async kind => { + await using fixture = await createSessionModelFixture(kind); + const before = fixture.readSelection(); + const child = Bun.spawn([...fixture.command, "--resume", fixture.sessionId, "--prompt", "Do not send this with an invalid model."], { + cwd: fixture.directory, env: fixture.env, stdin: "ignore", stdout: "pipe", stderr: "pipe" + }); + const timeout = setTimeout(() => child.kill("SIGKILL"), 10_000); + try { + const [code, stdout, stderr] = await Promise.all([child.exited, new Response(child.stdout).text(), new Response(child.stderr).text()]); + expect(code).toBe(1); + expect(`${stdout}\n${stderr}`).toContain("Saved model"); + expect(`${stdout}\n${stderr}`).toContain("/model"); + expect(`${stdout}\n${stderr}`).not.toContain("Model creation failed"); + expect(fixture.requests).toEqual([]); + expect(fixture.readSelection()).toEqual(before); + } finally { clearTimeout(timeout); } + }, 25_000 +); + +test.skipIf(process.platform === "win32").each([false, true])("resume preserves valid selections and handles an empty catalog (empty: %p)", async empty => { + await using fixture = await createSessionModelFixture("valid"); + const before = fixture.readSelection(); + if (empty) { + const config = JSON.parse(await readFile(fixture.providerPath, "utf8")); + config.config.providerConfigRules.providerRules[0].enabled = false; + await writeFile(fixture.providerPath, JSON.stringify(config)); + } + using screen = new TerminalScreen(100, 32); + const terminal = new Bun.Terminal({ cols: 100, rows: 32, name: "xterm-256color", + data(_terminal, data) { void screen.write(data); } }); + const child = Bun.spawn([...fixture.command, "--resume", fixture.sessionId], { cwd: fixture.directory, env: fixture.env, terminal }); + const timeout = setTimeout(() => child.kill("SIGKILL"), 15_000); + try { + const until = Date.now() + 8_000; + while (Date.now() < until && child.exitCode === null) { + await screen.settled(); + if (empty ? screen.screenText().includes("No models are available") : screen.screenText().includes("⚡ high")) break; + await Bun.sleep(25); + } + expect(screen.screenText()).toContain(empty ? "No models are available" : "⚡ high"); + expect(screen.screenText()).not.toContain("Select a replacement model"); + if (empty) expect(screen.screenText()).toContain("/login"); + expect(fixture.requests).toEqual([]); + expect(fixture.readSelection()).toEqual(before); + } finally { + if (child.exitCode === null) child.kill("SIGTERM"); + await child.exited; + clearTimeout(timeout); + terminal.close(); + await screen.settled(); + } +}, 25_000); diff --git a/test/session-model-recovery.test.ts b/test/session-model-recovery.test.ts new file mode 100644 index 0000000..a0ff463 --- /dev/null +++ b/test/session-model-recovery.test.ts @@ -0,0 +1,47 @@ +import { expect, test } from "bun:test"; +import { assertSessionModelReady, readSessionModelState } from "../src/session-model-recovery.ts"; + +const selection = { providerId: "zai", modelId: "glm-5.3", options: { reasoningLevel: "high" } }; +const registry = { + validateSelection(value: typeof selection | Omit) { + if (value.providerId !== "zai") return { ok: false, code: "provider-not-found" }; + if (value.modelId !== "glm-5.3") return { ok: false, code: "model-not-found" }; + if (!("options" in value) || !value.options?.reasoningLevel) return { ok: false, code: "reasoning-level-missing" }; + return { ok: true }; + }, + getModel(provider: string, model: string) { + return provider === "zai" && model === "glm-5.3" ? { config: { optionSpecs: { reasoningLevel: { values: ["disabled", "high"] } } } } : undefined; + } +}; + +test("inspecting a new session without a saved entry does not request recovery", async () => { + expect(await readSessionModelState({ registry, sessionId: "new", sessionStore: { sessionEntries: async () => [] } })).toBeUndefined(); + expect(() => assertSessionModelReady({ registry, sessionId: "new" })).not.toThrow(); +}); + +test("the latest decoded selection is authoritative and inspection never mutates it", async () => { + const data = structuredClone(selection); + const result = await readSessionModelState({ registry, sessionId: "existing", sessionStore: { + sessionEntries: async () => [{ data: { ...selection, providerId: "old" } }, { data }] + } }); + expect(result).toMatchObject({ model: "zai/glm-5.3", thoughtLevel: "high", effortOptions: ["disabled", "high"] }); + expect(result?.issue).toBeUndefined(); + expect(data).toEqual(selection); +}); + +test.each([null, {}, { modelId: "glm-5.3" }])("a present but invalid entry requires a selection (%j)", async data => { + const state = await readSessionModelState({ registry, sessionId: "existing", sessionStore: { sessionEntries: async () => [{ data }] } }); + expect(state?.issue?.code).toBe("selection-missing"); + expect(() => assertSessionModelReady({ registry, sessionId: "existing", restored: { selection: data } })).toThrow("/model"); +}); + +test("a valid user replacement takes precedence over the originally restored broken selection", () => { + expect(() => assertSessionModelReady({ registry, sessionId: "existing", currentSelection: selection, + restored: { selection: { ...selection, providerId: "old" } } })).not.toThrow(); +}); + +test("store failures remain errors rather than being mistaken for missing models", async () => { + await expect(readSessionModelState({ registry, sessionId: "existing", sessionStore: { + sessionEntries: async () => { throw new Error("Database is unavailable"); } + } })).rejects.toThrow("Database is unavailable"); +}); diff --git a/test/sync-runtime.test.ts b/test/sync-runtime.test.ts index f0ec480..3fd5761 100644 --- a/test/sync-runtime.test.ts +++ b/test/sync-runtime.test.ts @@ -3,6 +3,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { describe, expect, test } from "bun:test"; +import { assertSessionModelReady } from "../src/session-model-recovery.ts"; import { applyRuntimePatchPlan, @@ -26,6 +27,7 @@ import { patchRuntimeLoginModelDefaults, patchRuntimeNetworkRetryClassification, patchRuntimeOAuthHttpErrors, + patchRuntimeSessionModelRecovery, patchRuntimeStreamEofFinishGuard, patchRuntimeTerminalToolProjection, patchRuntimeTuiBridge, @@ -47,6 +49,32 @@ import { } from "../scripts/release-version.ts"; describe("runtime synchronization", () => { + test("adds session recovery at native restore and execution boundaries and can run twice", async () => { + const runtime = 'async function app(options){let read=()=>runtime,restore=label(async()=>{let ok=saved&®istry.validateSelection(saved);return read().setSessionModelSelection(ok?.ok?saved:void 0),saved},"restorePersistedModelSelection"),prepare=label(async args=>{await options.prepareUserExecutionBoundary(args)},"preparePromptBoundary");return{restore,prepare,getModelOption:label(model=>lookup(context.providerRegistry,model),"getModelOption")}}'; + const patched = patchRuntimeSessionModelRecovery(runtime); + expect(patched).toContain('readSessionModelState:label(async()=>await'); + expect(patched).toContain('read().$zRestoredSessionModel={selection:saved,registry:registry}'); + expect(patched).toContain('assertSessionModelReady({registry:options.runtime.$zRestoredSessionModel?.registry'); + expect(patchRuntimeSessionModelRecovery(patched)).toBe(patched); + expect(() => patchRuntimeSessionModelRecovery(runtime.replace('"restorePersistedModelSelection"', '"renamed"'))) + .toThrow("restore/facade anchors missing"); + const saved = { providerId: "old", modelId: "model" }; + const registry = { + validateSelection: (selection: { providerId: string }) => selection.providerId === "valid" ? { ok: true } : { ok: false, code: "provider-not-found" }, + getModel: () => undefined + }; + let current: unknown; + const core = { getSessionModelSelection: () => current, setSessionModelSelection: (value: unknown) => { current = value; } }; + const create = new Function("label", "registry", "saved", "runtime", "require", "__dirname", patched + ";return app;")( + (fn: unknown) => fn, registry, saved, core, + (id: string) => id === "node:path" ? { join } : { assertSessionModelReady }, "/fixture" + ); + // The input facade does not receive providerRegistry; the restore closure owns it. + const facade = await create({ runtime: core, sessionId: "test", prepareUserExecutionBoundary: async () => { await facade.restore(); } }); + await expect(facade.prepare({})).rejects.toThrow("the provider is unavailable"); + await expect(facade.prepare({ intent: { modelSelection: { providerId: "valid", modelId: "model" } } })).resolves.toBeUndefined(); + }); + test("projects native planEnabled separately from permission mode", async () => { const source = 'const a=fn=>fn;const modes=["plan","build","edit","yolo"];a(format,"formatAvailableCommandCenterModes");function format(){return modes.join(", ")}const planning=()=>e.runtime.getPlanEnabled();const app={getMode:a(()=>e.runtime.getMode(),"getMode"),setMode:a(async mode=>{let previous=e.runtime.getMode();await e.runtime.setExecutionState({mode:mode},e.traceContext);return{mode:e.runtime.getMode(),previousMode:previous,traceId:e.traceContext.traceId}},"setMode")};'; let mode = "edit", planEnabled = false; @@ -755,8 +783,8 @@ describe("runtime synchronization", () => { expect(patched).toContain("listSkills:g.listSkills"); expect(patched).toContain("setMode:g.setMode"); expect(patched).toContain("readSessionModel:g.readSessionModel"); - expect(patched).toContain('type:"runtime/model_selection"'); - expect(patched).toContain('...n.options?{options:n.options}:{}'); + expect(patched).toContain("await $zSessionModelApp.readSessionModelState()"); + expect(patched).toContain('setModel(s.selection,{transient:!0})'); expect(patched).not.toContain('modelRef:String(e)'); expect(patched).toContain("subscribeSessionEvents:g.subscribeSessionEvents"); expect(patched).toContain("sendBackgroundTaskMessage:g.sendBackgroundTaskMessage"); From 9951c7ec75e546385f76931dda83b39b857485f2 Mon Sep 17 00:00:00 2001 From: Kingsword Date: Sat, 19 Sep 2026 11:35:11 +0800 Subject: [PATCH 2/3] fix(ci): include hidden release artifacts in commit previews --- .github/workflows/release-commit.yml | 1 + test/release-workflows.test.ts | 1 + 2 files changed, 2 insertions(+) diff --git a/.github/workflows/release-commit.yml b/.github/workflows/release-commit.yml index 8408096..9fb1892 100644 --- a/.github/workflows/release-commit.yml +++ b/.github/workflows/release-commit.yml @@ -56,6 +56,7 @@ jobs: path: | .release/*.tgz .release/release.json + include-hidden-files: true if-no-files-found: error retention-days: 14 diff --git a/test/release-workflows.test.ts b/test/release-workflows.test.ts index d9761bd..86909c3 100644 --- a/test/release-workflows.test.ts +++ b/test/release-workflows.test.ts @@ -90,6 +90,7 @@ describe("release workflows", () => { expect(pack).toBeGreaterThan(build); expect(publish).toBeGreaterThan(pack); expect(steps[pack]?.run).toBe("bun run release:pack"); + expect(steps.find(step => step.name === "Upload tested package")?.with?.["include-hidden-files"]).toBe(true); expect(steps[publish]?.env?.PREVIEW_TARBALL).toBe("${{ steps.pack.outputs.tarball }}"); expect(steps[publish]?.run).toContain('bun run pkg-pr-new publish "$PREVIEW_TARBALL"'); expect(steps[publish]?.run).toContain("--commentWithSha"); From 57067820b3e3672e5eb00bda6c54061b44fd9801 Mon Sep 17 00:00:00 2001 From: Kingsword Date: Sun, 20 Sep 2026 10:12:41 +0800 Subject: [PATCH 3/3] fix(runtime): support ZCode 3.14.0 protocol and TUI bridges --- docs/SQLITE_CONCURRENCY.md | 5 +- package.json | 2 +- scripts/check-runtime.ts | 19 +++----- scripts/sync-runtime.ts | 76 +++++++++++++++++++++++-------- src/app-server-client.ts | 21 +++++++-- test/app-server-client.test.ts | 29 ++++++++++-- test/model-catalog-reload.test.ts | 8 +++- test/plugin-references.test.ts | 5 +- test/runtime/launcher.test.ts | 40 ++++++++++++---- test/sync-runtime.test.ts | 35 +++++++++++--- zcode-runtime.lock.json | 6 +-- 11 files changed, 183 insertions(+), 63 deletions(-) diff --git a/docs/SQLITE_CONCURRENCY.md b/docs/SQLITE_CONCURRENCY.md index d927151..448c5ed 100644 --- a/docs/SQLITE_CONCURRENCY.md +++ b/docs/SQLITE_CONCURRENCY.md @@ -51,5 +51,6 @@ If real workloads still exceed the wait budget, investigate long transactions an add bounded retries only at persistence boundaries that can be safely rolled back and replayed. Never retry a whole agent turn and repeat completed external tools. Per-session databases or a shared writer service are separate architectural changes -requiring discovery, lifecycle, and migration design. Updating to Desktop 3.14.0 -also remains separate from this fix's pinned-runtime validation. +requiring discovery, lifecycle, and migration design. The contention regression +tests also pass against the locked Desktop 3.14.0 runtime. They do not establish +that sustained high-concurrency workloads are free of contention. diff --git a/package.json b/package.json index b2e1dd8..64c6098 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "zcode-app-cli", - "version": "3.12.3-26", + "version": "3.14.0-26", "description": "Unofficial terminal client for the ZCode agent runtime", "keywords": [ "agent", diff --git a/scripts/check-runtime.ts b/scripts/check-runtime.ts index 1f2194d..ea58bae 100755 --- a/scripts/check-runtime.ts +++ b/scripts/check-runtime.ts @@ -8,6 +8,7 @@ import { fileURLToPath, pathToFileURL } from "node:url"; import { formatVersionOutput, readDistributionVersion } from "../src/launcher.ts"; import { capabilitiesFromExtractionMetadata } from "../src/runtime-capabilities.ts"; +import { requestAppServer } from "../src/app-server-client.ts"; import { extractRuntimeCapabilities, hasRuntimeCliHelpContract, @@ -193,18 +194,12 @@ if (version.code !== 0 || !/^\d+\.\d+\.\d+/.test(version.stdout.trim())) { throw new Error(`Version check failed: ${version.stderr || version.stdout}`); } -const request = JSON.stringify({ id: 1, method: "session/list", params: {} }); -const protocol = await execute(node, [runtime, "app-server"], `${request}\n`); -if (protocol.code !== 0) throw new Error(`app-server check failed: ${protocol.stderr}`); -// New runtimes emit storage startup notifications before the RPC response. -const response = protocol.stdout.trim().split("\n").map((line) => JSON.parse(line)).find( - (message) => message.id === 1 -) as { - id?: number; - result?: { sessions?: unknown[] }; -}; -if (!response || !Array.isArray(response.result?.sessions)) { - throw new Error(`Unexpected app-server response: ${protocol.stdout}`); +const response = await requestAppServer({ + method: "session/list", params: {}, + transport: { command: node, args: [runtime, "app-server"], cwd: root, env: process.env } +}) as { sessions?: unknown[] }; +if (!response || !Array.isArray(response.sessions)) { + throw new Error(`Unexpected app-server response: ${JSON.stringify(response)}`); } const tuiImport = await execute(node, [ diff --git a/scripts/sync-runtime.ts b/scripts/sync-runtime.ts index 459de0f..b988efc 100755 --- a/scripts/sync-runtime.ts +++ b/scripts/sync-runtime.ts @@ -506,7 +506,7 @@ export function patchRuntimeTuiBridge(runtime: string): string { const activeTranscriptPattern = /sessionStore\.messages\(\{sessionID:([A-Za-z_$][\w$]*)\.sessionId\}\),[A-Za-z_$][\w$]*=await \1\.sessionStore\.getSession\(\1\.sessionId\);return/u; const activeTurnSteerPattern = /(\.steerTurn\(\{commandKind:([A-Za-z_$][\w$]*)\?\.commandKind,inputId:\2\?\.inputId,queryId:\2\?\.queryId,expectedTurnId:\2\?\.expectedTurnId,)(?:delivery:"guide",)?(?:pendingInputId:\2\?\.pendingInputId,)?input:/u; const activeTurnGuidePattern = /\.steerTurn\(\{commandKind:([A-Za-z_$][\w$]*)\?\.commandKind,inputId:\1\?\.inputId,queryId:\1\?\.queryId,expectedTurnId:\1\?\.expectedTurnId,delivery:"guide",pendingInputId:\1\?\.pendingInputId,input:/u; - const nativeActiveTurnSteerPattern = /([A-Za-z_$][\w$]*)\?\.delivery==="steer_active_turn".{0,700}?\.steerTurn\(\{commandKind:\1\?\.commandKind,delivery:[^,}]+,expectedTurnId:\1\?\.expectedTurnId,input:[^,}]+,inputId:\1\?\.inputId,intent:.{1,160}?,queryId:\1\?\.queryId,/u; + const nativeActiveTurnSteerPattern = /([A-Za-z_$][\w$]*)\?\.delivery==="steer_active_turn".{0,700}?\.steerTurn\(\{commandKind:\1\?\.commandKind,delivery:[^,}]+,expectedTurnId:\1\?\.expectedTurnId,input:[^,}]+,(?:inputPresentation:[^,}]+,)?inputId:\1\?\.inputId,intent:.{1,160}?,queryId:\1\?\.queryId,/u; const nativePromptAdmissionPattern = /\.runtime\.admitPrompt\([^{}]{0,500}\{\.\.\.([A-Za-z_$][\w$]*),delivery:[A-Za-z_$][\w$]*,traceContext:\1\?\.traceContext/u; const legacyStartedTurnResultPattern = /return ([A-Za-z_$][\w$]*)\.kind!=="started_turn"\?\1:([A-Za-z_$][\w$]*)\(\1\.result,([A-Za-z_$][\w$]*),([A-Za-z_$][\w$]*)\(([A-Za-z_$][\w$]*)\)\)/u; const supportsActiveTurnSteer = (value: string): boolean => ( @@ -581,7 +581,7 @@ export function patchRuntimeTuiBridge(runtime: string): string { && listModelOptionsOptionPattern.test(runtime) && modeBridgePattern.test(runtime) && modeOptionPattern.test(runtime) - && !cliModeOverrideBridge.test(runtime) + && (!cliModeOverrideBridge.test(runtime) || runtime.includes("$zTuiBridge.setMode=async")) && runtime.includes(".readExecutionState=async") && runtime.includes(".setPlanEnabled=async") && runtime.includes("...$zExecutionState") @@ -701,7 +701,19 @@ export function patchRuntimeTuiBridge(runtime: string): string { const assignment = assignmentPattern.exec(patched); if (!assignment) throw new Error("ZCode runtime is incompatible with the TUI bridge (adapter assignment anchor missing)."); - const [recallAssignment, bridge, , getApp] = assignment; + const [recallAssignment, originalBridge, , originalGetApp] = assignment; + let bridge = originalBridge!, getApp = originalGetApp!; + const queryHelperPattern = new RegExp(`([A-Za-z_$][\\w$]*)=[A-Za-z_$][\\w$]*\\(\\(${escapeRegExpName(bridge)},${escapeRegExpName(getApp)}\\)=>\\{`, "gu"); + const queryHelper = runtime.includes('"attachTuiAppQueries"') + ? [...patched.slice(0, assignment.index).matchAll(queryHelperPattern)].at(-1) : undefined; + if (queryHelper) { + // The extracted helper uses short parameter names that collide with the + // locals in generated methods. Capture stable aliases in that scope. + const capture = `const $zTuiBridge=${bridge},$zTuiGetApp=${getApp};`; + if (!patched.includes(capture)) patched = patched.replace(queryHelper[0], queryHelper[0] + capture); + bridge = "$zTuiBridge"; + getApp = "$zTuiGetApp"; + } const assignments: string[] = []; // Persisted background-agent restore: after a CLI restart + session resume the // in-memory runtimeTaskRegistry is empty, so /tasks lists nothing and @@ -722,7 +734,18 @@ export function patchRuntimeTuiBridge(runtime: string): string { if (!listSkillsFactory) { throw new Error("ZCode runtime is incompatible with the TUI bridge (skill-list adapter anchor missing)."); } - assignments.push(`${bridge}.listSkills=async()=>await ${listSkillsFactory[1]}(${listSkillsFactory[2]})`); + if (queryHelper) { + // Skill discovery needs the submitter's host (env/cwd/hooks), not the + // extracted query helper's identically named bridge parameter. + const callPattern = new RegExp(`${escapeRegExpName(queryHelper[1]!)}\\(([A-Za-z_$][\\w$]*),([A-Za-z_$][\\w$]*)\\),\\1\\.subscribeSessionEvents=`, "u"); + const call = callPattern.exec(patched); + if (!call || countRegExpMatches(patched, callPattern) !== 1) { + throw new Error("ZCode runtime is incompatible with the TUI bridge (skill-list host anchor missing)."); + } + patched = patched.replace(call[0], `${call[1]}.listSkills=async()=>await ${listSkillsFactory[1]}(${listSkillsFactory[2]}),${call[0]}`); + } else { + assignments.push(`${bridge}.listSkills=async()=>await ${listSkillsFactory[1]}(${listSkillsFactory[2]})`); + } } const interruptAssignment = `${bridge}.interruptTurn=async e=>{let t=await ${getApp}(),r=e?.reservationId??"tui-steer-interrupt",o=(Array.isArray(e?.pendingInputIds)?e.pendingInputIds:[]).filter(Boolean),n=[],i=async()=>{for(let a of n)await t.releaseQueueItemReservation?.(a,r);n=[]};try{if(t.reserveQueueItem&&t.releaseQueueItemReservation)for(let a of o)if(await t.reserveQueueItem(a,r))n.push(a);else{await i();break}let a=t.runtime?.stopActiveForegroundExecution?.({preserveQueueAutoDrainOnCancel:o.length>0&&n.length===o.length,reason:e?.reason??"TUI steer interrupt"})??{kind:"unsupported"};if(a.kind==="stopped"&&e?.waitForIdle===!0&&t.runtime?.getActiveForegroundExecutionId){let u=Date.now()+5e3;for(;t.runtime.getActiveForegroundExecutionId()!==void 0;){if(Date.now()>=u)throw new Error("Timed out waiting for background result processing to stop.");await new Promise(l=>setTimeout(l,25))}}return a.kind!=="stopped"&&await i(),a}catch(a){await i();throw a}}`; const promotionAssignment = `${bridge}.promoteQueuedInput=async(e,t,r)=>{let o=await ${getApp}(),n=r?.pendingInputReservationId??r?.queryId??r?.inputId??"tui-promotion",i=(Array.isArray(t)?t:[t]).filter(Boolean);if(i.length===0||!o.reserveQueueItem||!o.markQueueItemPromoting||!o.releaseQueueItemReservation||!o.removeQueueItem)return ${bridge}.sendInput(e,{...r,delivery:"start_turn"});let a=[],u=!1;try{for(let l of i){if(await o.markQueueItemPromoting(l,n)){a.push(l);continue}if(!await o.reserveQueueItem(l,n))throw new Error("TUI queued input is already reserved: "+l);a.push(l);if(!await o.markQueueItemPromoting(l,n))throw new Error("TUI queued input promotion failed: "+l)}let c=await ${bridge}.sendInput(e,{...r,delivery:"start_turn"});if(c?.kind==="rejected")return c;u=!0;for(let l of a)if(!await o.removeQueueItem(l,{reason:"promoted",reservationId:n}))throw new Error("TUI queued input promotion commit failed: "+l);return c}finally{if(!u)for(let l of a)await o.releaseQueueItemReservation(l,n)}}`; @@ -790,8 +813,8 @@ export function patchRuntimeTuiBridge(runtime: string): string { } // The upstream CLI shim turns every switch into a permanent --mode override, // which prevents later resumes from loading their saved execution state. - patched = patched.replace(cliModeOverrideBridge, `${bridge}.setMode=async e=>{return await(await ${getApp}()).setMode(e)}`); - if (!modeBridgePattern.test(patched)) { + if (!queryHelper) patched = patched.replace(cliModeOverrideBridge, `${bridge}.setMode=async e=>{return await(await ${getApp}()).setMode(e)}`); + if (queryHelper || !modeBridgePattern.test(patched)) { assignments.push(`${bridge}.setMode=async e=>{return await(await ${getApp}()).setMode(e)}`); } if (!patched.includes(".readExecutionState=async")) { @@ -947,7 +970,21 @@ export function patchRuntimeModelCatalogReload(runtime: string): string { if (list && option && registryList) { const [, bridge, getApp] = list; const [, label, , context] = registryList; - const registry = /let ([A-Za-z_$][\w$]*)=await ([A-Za-z_$][\w$]*),[A-Za-z_$][\w$]*=\1\?\.modelSelectionConfigRepository\?await \1\.modelSelectionConfigRepository\.read\(\)/u.exec(runtime.slice(factoryStart, list.index))?.[2]; + let registry = /let ([A-Za-z_$][\w$]*)=await ([A-Za-z_$][\w$]*),[A-Za-z_$][\w$]*=\1\?\.modelSelectionConfigRepository\?await \1\.modelSelectionConfigRepository\.read\(\)/u.exec(runtime.slice(factoryStart, list.index))?.[2]; + if (!registry) { + // 3.14 attaches queries in a separate helper. Its registry promise lives + // in the owning submitter, so pass a lazy accessor instead of capturing + // a minifier variable from a different lexical scope. + const helperPattern = new RegExp(`([A-Za-z_$][\\w$]*)=[A-Za-z_$][\\w$]*\\(\\(${escapeRegExpName(bridge!)},${escapeRegExpName(getApp!)}\\)=>\\{`, "gu"); + const helper = [...runtime.slice(0, list.index).matchAll(helperPattern)].at(-1)?.[1]; + const callPattern = helper && new RegExp(`${escapeRegExpName(helper)}\\(([A-Za-z_$][\\w$]*),([A-Za-z_$][\\w$]*)\\),\\1\\.subscribeSessionEvents=`, "u"); + const call = callPattern && callPattern.exec(runtime); + const owner = call && /\(await ([A-Za-z_$][\w$]*)\.providerRegistryRuntimePromise\)\?\.dispose\(\)/u.exec(runtime.slice(call.index, call.index + 2000))?.[1]; + if (call && owner && runtime.includes('"attachTuiAppQueries"') && countRegExpMatches(runtime, callPattern!) === 1) { + runtime = runtime.replace(call[0], `${call[1]}.$zReadProviderRegistryRuntime=()=>${owner}.providerRegistryRuntimePromise,${call[0]}`); + registry = `${bridge}.$zReadProviderRegistryRuntime()`; + } + } // 3.12+ owns catalog discovery and personal config in the registry service. // Refresh that same service so active sessions keep their model selection. if (!registry || !runtime.includes('"ProviderRegistryService"') || !/refresh\([^)]*="explicit"\)/u.test(runtime)) { @@ -1106,6 +1143,10 @@ function countRegExpMatches(source: string, pattern: RegExp): number { return Array.from(source.matchAll(new RegExp(pattern.source, flags))).length; } +// 3.14 delegates the attempt limit to its bounded/unbounded retry-budget helper. +// Neither form may retry in place once output has crossed the visible boundary. +const runtimeStreamRetryGatePattern = /function [A-Za-z_$][\w$]*\(e\)\{(?:let [A-Za-z_$][\w$]*=[A-Za-z_$][\w$]*\(e\.error\)\.providerErrorCode;)?return e\.emittedRetryBoundaryEvent\|\|(?:e\.attempt>=e\.maxAttempts|![A-Za-z_$][\w$]*\(e\.retryBudget,e\.attempt,e\.maxAttempts\))\|\|e\.failure\.reason===[A-Za-z_$][\w$]*\.Cancelled\?!1:/u; + /** Detect the local transport classifier while preserving the emitted-output retry boundary. */ export function hasRuntimeNetworkRetryGuard(runtime: string): boolean { return runtime.includes("var $zTransportCodes=[") @@ -1114,7 +1155,7 @@ export function hasRuntimeNetworkRetryGuard(runtime: string): boolean { && /if\(\$zTransportChain\(e,new WeakSet\)\)return!0;return [A-Za-z_$][\w$]*\(e\)\}/u.test(runtime) && /\|\|\$zTransportChain\([A-Za-z_$][\w$]*,new WeakSet\)\)return\{code:/u.test(runtime) && /\|\|\$zTransportChain\([A-Za-z_$][\w$]*,new WeakSet\)\)return!0;if\(t!==void 0\)return!1;/u.test(runtime) - && /function [A-Za-z_$][\w$]*\(e\)\{return e\.emittedRetryBoundaryEvent\|\|e\.attempt>=e\.maxAttempts\|\|e\.failure\.reason===[A-Za-z_$][\w$]*\.Cancelled\?!1:/u.test(runtime); + && countRegExpMatches(runtime, runtimeStreamRetryGatePattern) === 1; } /** @@ -1132,7 +1173,6 @@ export function patchRuntimeNetworkRetryClassification(runtime: string): string const whitelistPattern = /function ([A-Za-z_$][\w$]*)\(e\)\{let t=e\?\.toUpperCase\(\);return t==="ECONNRESET"\|\|t==="ECONNREFUSED"\|\|t==="EAI_AGAIN"\|\|t==="ENOTFOUND"\|\|t==="ENETUNREACH"\|\|t==="EHOSTUNREACH"\|\|t==="UND_ERR_SOCKET"\|\|t==="UND_ERR_CONNECT_TIMEOUT"\}/u; const extractorPattern = /function ([A-Za-z_$][\w$]*)\(e\)\{return ([A-Za-z_$][\w$]*)\(e,new WeakSet\)\}function \2\(e,t\)\{if\(([A-Za-z_$][\w$]*)\(e,t\)\)return;let ([A-Za-z_$][\w$]*)=([A-Za-z_$][\w$]*)\(e\),([A-Za-z_$][\w$]*)=([A-Za-z_$][\w$]*)\(\4,"code"\);if\(\6\)return \6;let [A-Za-z_$][\w$]*=\4\.cause;if\([A-Za-z_$][\w$]*&&[A-Za-z_$][\w$]*!==e\)return \2\([A-Za-z_$][\w$]*,t\)\}/u; const classifierPattern = /function ([A-Za-z_$][\w$]*)\(e,t\)\{let ([A-Za-z_$][\w$]*)=[A-Za-z_$][\w$]*\(e\),[A-Za-z_$][\w$]*=[A-Za-z_$][\w$]*\(\2\),([A-Za-z_$][\w$]*)=EXTRACTOR\(\2\),/u; - const streamGatePattern = /function ([A-Za-z_$][\w$]*)\(e\)\{return e\.emittedRetryBoundaryEvent\|\|e\.attempt>=e\.maxAttempts\|\|e\.failure\.reason===([A-Za-z_$][\w$]*)\.Cancelled\?!1:/u; const whitelistMatch = whitelistPattern.exec(runtime); const extractorMatch = extractorPattern.exec(runtime); @@ -1192,7 +1232,7 @@ export function patchRuntimeNetworkRetryClassification(runtime: string): string [networkBranchPattern, "classifier network branch"], [retryDecisionPattern, "final retry decision"], [staleStreamPattern, "stale stream detector"], - [streamGatePattern, "emitted-output stream gate"] + [runtimeStreamRetryGatePattern, "emitted-output stream gate"] ] as const) { if (countRegExpMatches(runtime, pattern) !== 1) { throw new Error(`ZCode runtime is incompatible with the network retry patch (${label} is not unique).`); @@ -1301,23 +1341,21 @@ export function patchRuntimeStreamEofFinishGuard(runtime: string): string { // synthetic `other` finish with no raw reason is treated as EOF. // "async " precedes the generator declaration; anchor on it so the guard // lands before the `async` keyword instead of splitting it. - const runStreamTextPattern = /async function\*([A-Za-z_$][\w$]*)\(e\)\{let ([A-Za-z_$][\w$]*)=([A-Za-z_$][\w$]*)\(/u; + const runStreamTextLabel = /[A-Za-z_$][\w$]*\(([A-Za-z_$][\w$]*),"runStreamText"\)/u; + const runStreamTextName = runStreamTextLabel.exec(runtime)?.[1]; + if (!runStreamTextName || countRegExpMatches(runtime, runStreamTextLabel) !== 1) { + throw new Error("ZCode runtime is incompatible with the stream EOF guard patch (runStreamText label missing or ambiguous)."); + } + const runStreamTextPattern = new RegExp(`async function\\*${escapeRegExpName(runStreamTextName)}\\(`, "u"); const runStreamText = runStreamTextPattern.exec(runtime); if (!runStreamText || countRegExpMatches(runtime, runStreamTextPattern) !== 1) { throw new Error("ZCode runtime is incompatible with the stream EOF guard patch (runStreamText anchor missing)."); } - const runStreamTextAnchor = runStreamText[0]!; // Providers that die mid-stream (or map a null finish_reason) surface the // AI SDK's "other" finish reason with no raw reason. Only content-bearing // or unfinished tool-input streams are treated as partial output here; // the existing empty-completion path owns truly empty responses. - let patched = runtime.replace( - runStreamTextAnchor, - `${runtimeStreamEofFinishGuard}async function*${runStreamText[1]}(e){let ${runStreamText[2]}=${runStreamText[3]}(` - ); - if (patched === runtime) { - throw new Error("ZCode runtime is incompatible with the stream EOF guard patch (runStreamText anchor missing)."); - } + let patched = runtime.slice(0, runStreamText.index) + runtimeStreamEofFinishGuard + runtime.slice(runStreamText.index); // Do not emit `model_request_completed` for an EOF without a provider // finish reason. The idle-timeout-shaped failure is classified by the diff --git a/src/app-server-client.ts b/src/app-server-client.ts index 56f55b5..ec9a745 100644 --- a/src/app-server-client.ts +++ b/src/app-server-client.ts @@ -1,6 +1,7 @@ import { spawn } from "node:child_process"; import { constants as osConstants } from "node:os"; import type { Readable } from "node:stream"; +import { StringDecoder } from "node:string_decoder"; const maximumOutputBytes = 16 * 1024 * 1024; const forceTerminationDelayMilliseconds = 500; @@ -83,7 +84,7 @@ function processError(message: string, code: number): AppServerProcessError { return new AppServerProcessError(message, code); } -async function readBounded(stream: Readable | null, onOverflow: () => void): Promise { +async function readBounded(stream: Readable | null, onOverflow: () => void, onChunk?: (chunk: Buffer) => void): Promise { if (!stream) return ""; const chunks: Buffer[] = []; let bytes = 0; @@ -95,6 +96,7 @@ async function readBounded(stream: Readable | null, onOverflow: () => void): Pro throw new Error(`App-server output exceeded ${maximumOutputBytes} bytes.`); } chunks.push(buffer); + onChunk?.(buffer); } return Buffer.concat(chunks).toString("utf8"); } @@ -156,12 +158,25 @@ export async function requestAppServer(request: AppServerRequest): Promise {}); - child.stdin.end(`${JSON.stringify({ id: 1, method: request.method, params: request.params })}\n`); + // 3.14 treats stdin EOF as client disconnection and cancels outstanding work. + // Send one NDJSON request, but keep the transport alive until its response. + const decoder = new StringDecoder("utf8"); + let pending = ""; + const stdoutPromise = readBounded(child.stdout, terminateForOverflow, chunk => { + pending += decoder.write(chunk); + let newline: number; + while ((newline = pending.indexOf("\n")) >= 0) { + const line = pending.slice(0, newline); + pending = pending.slice(newline + 1); + if (responseEnvelope(line) && !child.stdin.writableEnded) child.stdin.end(); + } + }); + child.stdin.write(`${JSON.stringify({ id: 1, method: request.method, params: request.params })}\n`); try { const [code, stdout, stderr] = await Promise.all([ exited, - readBounded(child.stdout, terminateForOverflow), + stdoutPromise, readBounded(child.stderr, terminateForOverflow) ]); if (request.signal?.aborted) throw cancellationError(request.signal); diff --git a/test/app-server-client.test.ts b/test/app-server-client.test.ts index c349bec..548ec4a 100644 --- a/test/app-server-client.test.ts +++ b/test/app-server-client.test.ts @@ -24,7 +24,8 @@ describe("app-server NDJSON client", () => { let input = ""; process.stdin.setEncoding("utf8"); process.stdin.on("data", chunk => input += chunk); - process.stdin.on("end", () => { + process.stdin.on("data", () => { + if (!input.includes("\\n")) return; const request = JSON.parse(input.trim()); console.log(JSON.stringify({ id: request.id, result: { method: request.method, params: request.params } })); }); @@ -43,7 +44,7 @@ describe("app-server NDJSON client", () => { test("surfaces protocol errors with code and data", async () => { const script = ` process.stdin.resume(); - process.stdin.on("end", () => console.log(JSON.stringify({ + process.stdin.once("data", () => console.log(JSON.stringify({ id: 1, error: { code: -32602, message: "Invalid params", data: { field: "source" } } }))); @@ -64,7 +65,7 @@ describe("app-server NDJSON client", () => { await requestAppServer({ method: "plugins/overview", params: {}, - transport: transport("process.stdin.resume(); process.stdin.on('end', () => process.exit(7));") + transport: transport("process.stdin.resume(); process.stdin.once('data', () => process.exit(7));") }); throw new Error("Expected request to fail."); } catch (error) { @@ -77,7 +78,7 @@ describe("app-server NDJSON client", () => { await expect(requestAppServer({ method: "plugins/list", params: {}, - transport: transport("process.stdin.resume(); process.stdin.on('end', () => console.log('not-json')); ") + transport: transport("process.stdin.resume(); process.stdin.once('data', () => {console.log('not-json');process.exit(0)}); ") })).rejects.toThrow(/did not return a response envelope/u); const controller = new AbortController(); @@ -90,6 +91,26 @@ describe("app-server NDJSON client", () => { })).rejects.toMatchObject({ name: "AbortError" }); }); + test("keeps stdin open through unrelated messages and a chunked UTF-8 response", async () => { + expect(await requestAppServer({ + method: "session/list", params: {}, transport: transport(` + let responded = false; + process.stdin.resume(); + process.stdin.on("end", () => { if (!responded) process.exit(9); }); + process.stdin.once("data", () => { + console.log(JSON.stringify({method:"startup/storageState",params:{phase:"ready"}})); + console.log(JSON.stringify({id:2,result:{ignored:true}})); + const response = Buffer.from(JSON.stringify({id:1,result:{sessions:[{title:"你好,世界"}]}}) + "\\n"); + const split = response.indexOf(Buffer.from("界")) + 1; + setTimeout(() => { + process.stdout.write(response.subarray(0, split)); + setTimeout(() => {responded=true;process.stdout.write(response.subarray(split));}, 30); + }, 150); + }); + `) + })).toEqual({ sessions: [{ title: "你好,世界" }] }); + }); + test("finishes cancellation when the app-server ignores SIGTERM", async () => { const controller = new AbortController(); const pending = requestAppServer({ diff --git a/test/model-catalog-reload.test.ts b/test/model-catalog-reload.test.ts index fff467a..9acba49 100644 --- a/test/model-catalog-reload.test.ts +++ b/test/model-catalog-reload.test.ts @@ -3,12 +3,16 @@ import { describe, expect, test } from "bun:test"; import { patchRuntimeModelCatalogReload } from "../scripts/sync-runtime.ts"; describe("runtime model catalog reload bridge", () => { - test("refreshes a registry in place and propagates refresh failures", async () => { + test.each(["inline", "attached"])("refreshes a registry in place and propagates failures (%s)", async layout => { const source = [ 'const kind="ProviderRegistryService";class Registry{refresh(reason="explicit"){}}', 'function makeApp(ctx){return{listModels:label(()=>listRegistry(ctx.providerRegistry),"listModels")}}', - 'function makeBridge(host){const bridge={},pending=Promise.resolve(host);const create=async()=>{let state=await pending,selection=state?.modelSelectionConfigRepository?await state.modelSelectionConfigRepository.read():undefined};bridge.listModelOptions=async()=>(await getApp()).listModels?.()??[];', + layout === "inline" + ? 'function makeBridge(host){const bridge={},pending=Promise.resolve(host);const create=async()=>{let state=await pending,selection=state?.modelSelectionConfigRepository?await state.modelSelectionConfigRepository.read():undefined};' + : 'const attach=label((bridge,getApp)=>{', + 'bridge.listModelOptions=async()=>(await getApp()).listModels?.()??[];', 'bridge.setTransientModel=async model=>(await getApp()).setModel(model,{transient:true});', + layout === "inline" ? "" : '},"attachTuiAppQueries");function makeBridge(host){const bridge={},state={providerRegistryRuntimePromise:Promise.resolve(host)};attach(bridge,getApp),bridge.subscribeSessionEvents=()=>{},bridge.close=async()=>{(await state.providerRegistryRuntimePromise)?.dispose()};', 'return{listModelOptions:bridge.listModelOptions}}' ].join(""); const patched = patchRuntimeModelCatalogReload(source); diff --git a/test/plugin-references.test.ts b/test/plugin-references.test.ts index 6b6b478..00831ba 100644 --- a/test/plugin-references.test.ts +++ b/test/plugin-references.test.ts @@ -90,8 +90,9 @@ describe("runtime Plugin references", () => { await writeFile(runtime, ` let input = ""; process.stdin.setEncoding("utf8"); - process.stdin.on("data", chunk => input += chunk); - process.stdin.on("end", () => { + process.stdin.on("data", chunk => { + input += chunk; + if (!input.includes("\\n")) return; const request = JSON.parse(input.trim()); console.log(JSON.stringify({ id: request.id, result: { authority: "workspace", diff --git a/test/runtime/launcher.test.ts b/test/runtime/launcher.test.ts index dedde31..b2568ee 100644 --- a/test/runtime/launcher.test.ts +++ b/test/runtime/launcher.test.ts @@ -18,7 +18,7 @@ afterAll(async () => { if (home) await rm(home, { recursive: true, force: true }); }); -async function run(args: string[], input = "", environment: Record = {}) { +async function run(args: string[], input = "", environment: Record = {}, responseId?: number) { if (!node) throw new Error("Node.js is required for launcher/runtime integration tests."); const child = Bun.spawn([process.execPath, "bin/zcode.ts", ...args], { cwd: root, @@ -34,10 +34,29 @@ async function run(args: string[], input = "", environment: Record { + const decoder = new TextDecoder(); + let output = "", pending = ""; + for await (const chunk of child.stdout) { + const text = decoder.decode(chunk, { stream: true }); + output += text; + pending += text; + let newline: number; + while ((newline = pending.indexOf("\n")) >= 0) { + const line = pending.slice(0, newline); + pending = pending.slice(newline + 1); + if (responseId === undefined) continue; + try { + if (JSON.parse(line).id === responseId) child.stdin.end(); + } catch { /* Non-protocol output is retained for assertions. */ } + } + } + return output + decoder.decode(); + })(); const [code, stdout, stderr] = await Promise.all([ child.exited, - new Response(child.stdout).text(), + stdoutPromise, new Response(child.stderr).text() ]); return { code, stdout, stderr }; @@ -145,7 +164,7 @@ describe("launcher/runtime integration", () => { const plugins = await run(["plugins", "list", "--json"]); expect(plugins.code).toBe(0); - expect(JSON.parse(plugins.stdout).plugins).toEqual(expect.arrayContaining([ + expect(JSON.parse(plugins.stdout)).toEqual(expect.arrayContaining([ expect.objectContaining({ name: "browser-use", enabled: true }) ])); @@ -174,7 +193,10 @@ describe("launcher/runtime integration", () => { const listed = await run(["--cwd", directory, "commands", "list", "--json"]); expect(listed.code).toBe(0); - expect(JSON.parse(listed.stdout)).toMatchObject({ + const catalog = JSON.parse(listed.stdout); + expect(catalog.totalDiscovered).toBe(catalog.commands.length); + expect(catalog.commands.filter((command: { scope: string }) => command.scope === "project")).toHaveLength(1); + expect(catalog).toMatchObject({ commands: expect.arrayContaining([ expect.objectContaining({ argumentHint: "", @@ -186,8 +208,7 @@ describe("launcher/runtime integration", () => { }) ]), cwd: directory, - diagnostics: [], - totalDiscovered: 1 + diagnostics: [] }); const inspected = await run(["--cwd", directory, "commands", "inspect", "smoke", "--json"]); @@ -222,7 +243,8 @@ describe("launcher/runtime integration", () => { workspace: { workspacePath, workspaceKey: workspacePath } } }; - const result = await run(["app-server"], `${JSON.stringify(request)}\n`); + // EOF means client disconnection in 3.14; close only after the response. + const result = await run(["app-server"], `${JSON.stringify(request)}\n`, {}, request.id); expect(result.code).toBe(0); const response = result.stdout.trim().split("\n").map((line) => JSON.parse(line)) .find((message) => message.id === request.id); @@ -323,7 +345,7 @@ describe("launcher/runtime integration", () => { }); const plugins = await run(["plugins", "list", "--json"]); - expect(JSON.parse(plugins.stdout).plugins).toEqual(expect.arrayContaining([ + expect(JSON.parse(plugins.stdout)).toEqual(expect.arrayContaining([ expect.objectContaining({ enabled: true, id: "cli-smoke-plugin@cli-smoke-marketplace", diff --git a/test/sync-runtime.test.ts b/test/sync-runtime.test.ts index eb28636..7db17a4 100644 --- a/test/sync-runtime.test.ts +++ b/test/sync-runtime.test.ts @@ -362,8 +362,8 @@ describe("runtime synchronization", () => { expect(() => patchRuntimeSqliteBusyTimeout(partial)).toThrow(/migration anchors/); }); - test("classifies wrapped transport failures without retrying in place after output", () => { - const runtime = [ + test.each(["legacy", "budgeted"])("classifies transport failures without retrying after output (%s)", budget => { + let runtime = [ "var yt2={ModelRequestFailed:'model_request_failed',ProviderNotConfigured:'provider_not_configured'},", "it2={Cancelled:'cancelled',NetworkError:'network_error',AuthFailed:'auth_failed',ServerError:'server_error',Unknown:'unknown'},", "nn2={NetworkError:'network_error',AuthRefresh:'auth_refresh',ServerError:'server_error'},", @@ -396,12 +396,18 @@ describe("runtime synchronization", () => { "function* walk(e){let t=e,r=new WeakSet;for(let n=0;n<=6;n+=1){if(!t||typeof t!=='object'||r.has(t))return;r.add(t);yield t;t=t.cause}}", "function recoverable(e){for(let t of walk(e)){if(t.retryable===!0)return!0;let r=pP(t.context);if(r.retryable===!0)return!0}return!1}" ].join(""); + if (budget === "budgeted") runtime = runtime.replace( + "function z9o(e){return e.emittedRetryBoundaryEvent||e.attempt>=e.maxAttempts", + 'function budgetAllows(budget,attempt,max){return budget==="unbounded"||attempt=e.maxAttempts"); + expect(patched).toContain(budget === "legacy" + ? "return e.emittedRetryBoundaryEvent||e.attempt>=e.maxAttempts" + : "return e.emittedRetryBoundaryEvent||!budgetAllows(e.retryBudget,e.attempt,e.maxAttempts)"); expect(patched).not.toContain("e.emittedRetryBoundaryEvent&&!$zTransportChain"); expect(() => new Function(patched)).not.toThrow(); expect(patchRuntimeNetworkRetryClassification(patched)).toBe(patched); @@ -460,6 +466,10 @@ describe("runtime synchronization", () => { expect(gate(beforeOutput)).toBe(true); expect(gate({ ...beforeOutput, emittedRetryBoundaryEvent: true })).toBe(false); expect(gate({ ...beforeOutput, attempt: 6 })).toBe(false); + if (budget === "budgeted") { + expect(gate({ ...beforeOutput, attempt: 6, retryBudget: "unbounded" })).toBe(true); + expect(gate({ ...beforeOutput, attempt: 6, retryBudget: "unbounded", emittedRetryBoundaryEvent: true })).toBe(false); + } expect(gate({ ...beforeOutput, failure: { ...failure, reason: "cancelled" } })).toBe(false); expect(recoverable({ cause: wrapped, context: { retryable: failure.retryable } })).toBe(true); @@ -1335,6 +1345,14 @@ describe("runtime synchronization", () => { expect(nativeSteerPatched).toContain('l1t(await(X.result??X.completion),Q,R5(t))'); expect(nativeSteerPatched).not.toContain('l1t(X.result,Q,R5(t))'); expect(patchRuntimeTuiBridge(nativeSteerPatched)).toBe(nativeSteerPatched); + const presentationSteerRuntime = nativeSteerRuntime.replace( + "input:A,inputId:$?.inputId", + 'input:A,inputPresentation:$?.inputPresentation??($?.inputSource?void 0:"user_steer"),inputId:$?.inputId' + ); + const presentationSteerPatched = patchRuntimeTuiBridge(presentationSteerRuntime); + expect(presentationSteerPatched).toContain('inputPresentation:$?.inputPresentation??($?.inputSource?void 0:"user_steer")'); + expect(presentationSteerPatched).not.toContain('pendingInputId:$?.pendingInputId'); + expect(patchRuntimeTuiBridge(presentationSteerPatched)).toBe(presentationSteerPatched); expect(() => patchRuntimeTuiBridge( nativeSteerRuntime.replace( "return t.runtime.admitPrompt(A,[],{...$,delivery:d,traceContext:$?.traceContext})", @@ -1629,7 +1647,7 @@ describe("runtime synchronization", () => { expect(() => patchRuntimeGoalFailurePause("incompatible runtime")).toThrow(/incompatible/); }); - test("reclassifies registry stream EOF as retryable", () => { + test.each(["legacy", "budgeted"])("reclassifies registry stream EOF as retryable (%s)", budget => { const runtime = [ "function detectFallback(e){return}", "function findProviderError(e){return}", @@ -1650,10 +1668,14 @@ describe("runtime synchronization", () => { "await Ac({...k,attempt:u,durationMs:de-c,requestHeaderCount:U,requestHeaders:F,responseHeaderCount:Object.keys(le).length,responseHeaders:le,providerRequestId:m2e(le),finishReason:x.finishReason,usage:x.usage,timeToFirstProviderEventMs:Z,timeToFirstContentMs:J,timeToFirstTextMs:Q,streamMaxIdleMs:X||void 0,streamStallCount:V,streamOutputCommitted:z,timestamp:new Date(de).toISOString(),type:\"model_request_completed\"},_A(e)),O=!0}", "r&&P&&await Yrt({modelIoFullRetentionEnabled:e.modelIoFullRetentionEnabled,attempt:u,debugDir:e.debugDir,isDev:n,normalizedToolCalls:S.snapshotNormalizedToolCalls(),options:P,recordModelIO:r,request:b,requestId:k.requestId,resolved:H,result:W,startedAt:c});return}" ].join(""); - const source = `${runtime}${runner.replace( + let source = `${runtime}${runner.replace( 'providerId:String(k.model.providerId),providerKind:k.providerKind,source:x.lastErrorChunk??x.lastFinishChunk})', 'providerId:String(k.providerId),providerKind:H.providerKind,source:x.lastErrorChunk??x.lastFinishChunk})??fallback({captcha:H.accountAccess?.mode==="start-plan",providerId:String(k.providerId),providerKind:H.providerKind})' - )}`; + )}label(streamRunner,"runStreamText");`; + if (budget === "budgeted") source = source.replace( + "let retryState=createRetryState({maxAttempts:e.retry.maxAttempts});", + "let retryBudget=e.request.modelRetryBudget,retryState=createRetryState({maxAttempts:e.retry.maxAttempts});" + ); expect(hasRuntimeStreamEofFinishGuard(source)).toBe(false); const patched = patchRuntimeStreamEofFinishGuard(source); @@ -1672,6 +1694,7 @@ describe("runtime synchronization", () => { expect(() => patchRuntimeStreamEofFinishGuard("incompatible runtime")).toThrow( /stream EOF guard patch/ ); + expect(() => patchRuntimeStreamEofFinishGuard(source.replace('label(streamRunner,"runStreamText");', ""))).toThrow(/label missing/); // The guard must classify the SDK's synthetic "other" finish (null // finish_reason) and a missing reason as EOF, while trusting a provider diff --git a/zcode-runtime.lock.json b/zcode-runtime.lock.json index 903c564..3e1610a 100644 --- a/zcode-runtime.lock.json +++ b/zcode-runtime.lock.json @@ -1,8 +1,8 @@ { "schemaVersion": 1, - "appVersion": "3.12.3", + "appVersion": "3.14.0", "platform": "linux", "arch": "x64", - "url": "https://cdn-zcode.z.ai/zcode/electron/releases/3.12.3/linux-x64/ZCode-3.12.3-linux-x64.deb", - "sha512": "0qTB5098ZqyacbPjUCUPqqgKAt+a9jP3mij8bln0GfF232W8DnKoGmohytMTwdHN4L26AtYB4AS7mq9m9jB6FA==" + "url": "https://cdn-zcode.z.ai/zcode/electron/releases/3.14.0/linux-x64/ZCode-3.14.0-linux-x64.deb", + "sha512": "Dl4QnSclUTbGx2J6RzkWVXiKzdrmtoT+XW1uBPLud/96lGCQ+nf50ia/u/unTY5xHKgTI1UMC3N+gBCOPbAy0g==" }