diff --git a/.github/workflows/release-commit.yml b/.github/workflows/release-commit.yml new file mode 100644 index 0000000..1386084 --- /dev/null +++ b/.github/workflows/release-commit.yml @@ -0,0 +1,72 @@ +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.4.1 + + - 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 + include-hidden-files: true + 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 5195ee4..643cd0e 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 64c6098..7bb90a6 100644 --- a/package.json +++ b/package.json @@ -98,6 +98,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 b988efc..2164caa 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) @@ -828,8 +830,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?.()}}`); @@ -1021,6 +1030,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; @@ -1543,6 +1568,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..86909c3 100644 --- a/test/release-workflows.test.ts +++ b/test/release-workflows.test.ts @@ -72,6 +72,35 @@ 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.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"); + 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 7db17a4..1a4d934 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, @@ -27,6 +28,7 @@ import { patchRuntimeLoginModelDefaults, patchRuntimeNetworkRetryClassification, patchRuntimeOAuthHttpErrors, + patchRuntimeSessionModelRecovery, patchRuntimeSqliteBusyTimeout, patchRuntimeStreamEofFinishGuard, patchRuntimeTerminalToolProjection, @@ -50,6 +52,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; @@ -828,8 +856,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");