Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 72 additions & 0 deletions .github/workflows/release-commit.yml
Original file line number Diff line number Diff line change
@@ -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 <session-id>\n```"' .release/preview.json >> "$GITHUB_STEP_SUMMARY"
3 changes: 3 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

33 changes: 33 additions & 0 deletions docs/DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
26 changes: 26 additions & 0 deletions docs/RELEASING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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@<commit-sha> --resume <session-id>
```

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 `<app-version>-<build>`, for example `3.3.5-2`. The prefix
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
72 changes: 48 additions & 24 deletions packages/zcode-tui/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -681,6 +681,8 @@ class ZCodeTui {
private readonly permissionRequests = new PermissionRequestQueue();
private choiceDepth = 0;
private settingSwitchInFlight = false;
private sessionModelIssue?: string;
private sessionModelRecovery?: Promise<void>;
private fullscreenWelcomeVisible = true;
private fullscreenWelcomeTransitionTimer?: ReturnType<typeof setTimeout>;
private sessionHasContent = false;
Expand Down Expand Up @@ -904,6 +906,7 @@ class ZCodeTui {
void this.refreshWorkflowFromEvent();
}) ?? undefined;
}
if (this.sessionModelIssue) void this.recoverSessionModel();
await this.done;
} finally {
process.off("SIGINT", onSigint);
Expand Down Expand Up @@ -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
}),
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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();
}
}

Expand Down Expand Up @@ -3765,17 +3763,29 @@ class ZCodeTui {
/** Switch this session while preserving the shared default model. */
private async showModelPicker(): Promise<boolean> {
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;
Expand All @@ -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"
Expand Down Expand Up @@ -5184,18 +5195,31 @@ class ZCodeTui {
this.addNotice(`Unable to restore session transcript: ${message}`, "warning");
}
}
await this.restoreSessionModel();
}

private async restoreSessionModel(): Promise<void> {
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<void> {
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[] = [
Expand Down
32 changes: 32 additions & 0 deletions scripts/repro-session-model.ts
Original file line number Diff line number Diff line change
@@ -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);
}
Loading
Loading