From 7a4acf5e19f556e9bf0b30a19d29cf30deee5b94 Mon Sep 17 00:00:00 2001 From: Kingsword Date: Thu, 22 Oct 2026 08:15:08 +0800 Subject: [PATCH] fix(session): persist named sessions and validate resume targets --- README.md | 6 + package.json | 2 +- packages/zcode-tui/src/index.ts | 24 ++- packages/zcode-tui/src/types.ts | 3 +- scripts/runtime-session-lifecycle-patches.ts | 57 ++++++ scripts/sync-runtime.ts | 14 ++ src/launcher.ts | 11 +- src/runtime-config-bridge.ts | 1 + src/runtime-session-lifecycle.ts | 40 +++++ test/fixtures/session-lifecycle-runtime.cjs | 29 +++ test/fixtures/tui-features.ts | 1 + test/node/session-lifecycle.test.cjs | 93 ++++++++++ .../runtime-session-lifecycle-patches.test.ts | 105 +++++++++++ test/runtime-session-lifecycle.test.ts | 61 +++++++ test/runtime/launcher.test.ts | 22 +++ test/runtime/session-lifecycle.test.ts | 170 ++++++++++++++++++ test/sync-runtime.test.ts | 2 + test/tui/fixtures/session-lifecycle.ts | 20 +++ test/tui/session-lifecycle.test.ts | 36 ++++ test/turn-work-tracker.test.ts | 7 + 20 files changed, 698 insertions(+), 6 deletions(-) create mode 100644 scripts/runtime-session-lifecycle-patches.ts create mode 100644 src/runtime-session-lifecycle.ts create mode 100644 test/fixtures/session-lifecycle-runtime.cjs create mode 100644 test/node/session-lifecycle.test.cjs create mode 100644 test/runtime-session-lifecycle-patches.test.ts create mode 100644 test/runtime-session-lifecycle.test.ts create mode 100644 test/runtime/session-lifecycle.test.ts create mode 100644 test/tui/fixtures/session-lifecycle.ts create mode 100644 test/tui/session-lifecycle.test.ts diff --git a/README.md b/README.md index 897af29c..a3a826be 100644 --- a/README.md +++ b/README.md @@ -322,6 +322,12 @@ picker to return to input selection, then `Esc` again to close rewind. /cls clear the visible transcript only ``` +New sessions are saved when you submit their first input or explicitly rename them. +Exiting an untouched session does not save it or print a resume command. Renaming +an empty session saves it immediately, including its custom title, so it can be +resumed before its first turn. Resume commands are shown only for saved sessions; +an invalid resume target is rejected without replacing the current session. + `/cls` clears what the TUI displays without touching the session. The runtime's own `/clear` is an alias of `/new` and starts a fresh session, so it is forwarded to the runtime unchanged. diff --git a/package.json b/package.json index eaea263a..83694c8c 100644 --- a/package.json +++ b/package.json @@ -72,7 +72,7 @@ "test:node": "node --test test/node/*.test.cjs", "test:tui": "bun run test:tui:component && bun run test:tui:e2e", "test:tui:component": "bun test test/tui/scenario-http.test.ts test/tui/scenario-runtime.test.ts test/tui/scenario-shell.test.ts test/tui/scenario-workspace.test.ts test/tui/terminal-screen.test.ts", - "test:tui:e2e": "bun test test/tui/allowlisted-shell.test.ts test/tui/dynamic-workflows.test.ts test/tui/http-mock.test.ts test/tui/input-actions.test.ts test/tui/model-resume.test.ts test/tui/model-status.test.ts test/tui/permission-request-queue.test.ts test/tui/run-scenario.test.ts test/tui/runtime-refresh.test.ts test/tui/session-rename.test.ts test/tui/startup.test.ts test/tui/terminal-session.test.ts test/tui/write-and-diff.test.ts", + "test:tui:e2e": "bun test test/tui/allowlisted-shell.test.ts test/tui/dynamic-workflows.test.ts test/tui/http-mock.test.ts test/tui/input-actions.test.ts test/tui/model-resume.test.ts test/tui/model-status.test.ts test/tui/permission-request-queue.test.ts test/tui/run-scenario.test.ts test/tui/runtime-refresh.test.ts test/tui/session-lifecycle.test.ts test/tui/session-rename.test.ts test/tui/startup.test.ts test/tui/terminal-session.test.ts test/tui/write-and-diff.test.ts", "test:tui:host": "bun test test/tui/scenario-mountx.test.ts", "test:tui:manual": "bun scripts/tui-scenario.ts --manual", "test:tui-scenario": "bun scripts/tui-scenario.ts", diff --git a/packages/zcode-tui/src/index.ts b/packages/zcode-tui/src/index.ts index a5fc5b96..71c6db8a 100644 --- a/packages/zcode-tui/src/index.ts +++ b/packages/zcode-tui/src/index.ts @@ -5993,6 +5993,22 @@ class ZCodeTui { if (usage !== undefined) this.applySessionUsage(usage); } + private async readExitResumeTarget(): Promise { + const readSessionResumeTarget = this.options.readSessionResumeTarget; + if (!readSessionResumeTarget) return undefined; + let timeout: ReturnType | undefined; + try { + return await Promise.race([ + Promise.resolve().then(() => readSessionResumeTarget()).catch(() => undefined), + new Promise((resolve) => { + timeout = setTimeout(() => resolve(undefined), exitUsageQueryTimeoutMs); + }) + ]); + } finally { + if (timeout) clearTimeout(timeout); + } + } + private finishTurn(unfinishedToolState = "interrupted"): void { const notification = this.pendingTurnNotification; const notificationDetail = notification === "completed" @@ -6008,7 +6024,9 @@ class ZCodeTui { if (unfinishedToolState === "cancelled") { this.turnWork.cancel(); this.settleTurnTiming(); - } else if (!this.turnWork.finishForeground(Boolean(this.options.readRuntimeProjection))) { + } else if (!this.turnWork.finishForeground( + Boolean(this.options.readRuntimeProjection) && unfinishedToolState !== "failed" + )) { this.settleTurnTiming(); } this.activity = undefined; @@ -6093,11 +6111,11 @@ class ZCodeTui { private async finishStop(elapsedMilliseconds: number): Promise { try { - await this.refreshExitUsage(); + const [, resumeTarget] = await Promise.all([this.refreshExitUsage(), this.readExitResumeTarget()]); const summary = buildExitSummary({ elapsedMilliseconds, metrics: this.sessionMetrics, - sessionId: this.sessionId ?? this.runtimeProjection?.sessionId, + sessionId: resumeTarget, width: this.ui.terminal.columns }); const lines = [ diff --git a/packages/zcode-tui/src/types.ts b/packages/zcode-tui/src/types.ts index 3be3f9f0..4c826519 100644 --- a/packages/zcode-tui/src/types.ts +++ b/packages/zcode-tui/src/types.ts @@ -95,6 +95,7 @@ export interface RuntimeAdapter { readTodos?: () => Promise; readRuntimeProjection?: () => Promise; readSessionUsage?: () => Promise; + readSessionResumeTarget?: () => Promise; cancelBackgroundTask?: (taskId: string) => Promise; sendBackgroundTaskMessage?: (options: { taskId: string; @@ -113,7 +114,7 @@ export interface RuntimeAdapter { ) => Promise; submitPrompt: (input: unknown, options: PromptCallOptions) => Promise; setMode?: (mode: string) => Promise; - /** Persists a user-owned session title (`title_source='custom'`). */ + /** Saves the session if needed, then persists a user-owned title (`title_source='custom'`). */ setCustomSessionTitle?: (options: { title: string; traceContext?: unknown }) => Promise; /** Returns the current session's custom title, or undefined for other title sources. */ readCustomSessionTitle?: () => Promise; diff --git a/scripts/runtime-session-lifecycle-patches.ts b/scripts/runtime-session-lifecycle-patches.ts new file mode 100644 index 00000000..ebff3c1b --- /dev/null +++ b/scripts/runtime-session-lifecycle-patches.ts @@ -0,0 +1,57 @@ +const identifier = "[A-Za-z_$][\\w$]*"; +const helper = 'require(require("node:path").join(__dirname,"cli-config.cjs"))'; + +function escape(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&"); +} + +function unique(source: string, pattern: RegExp, name: string): RegExpMatchArray { + const matches = [...source.matchAll(new RegExp(pattern.source, "gu"))]; + if (matches.length !== 1) throw new Error(`ZCode runtime is incompatible with session lifecycle (${name} missing or ambiguous).`); + return matches[0]!; +} + +export function hasRuntimeSessionLifecycle(runtime: string): boolean { + return runtime.includes(`${helper}.ensureSessionForTitle(this,`) + && runtime.includes("$zValidatedResumeSession=") + && runtime.includes("$zResumeCandidateError") + && runtime.includes("$zTuiResumeReady=") + && runtime.includes("$zTuiResumeOptions=") + && runtime.includes('"readSessionResumeTarget"'); +} + +export function patchRuntimeSessionLifecycle(runtime: string): string { + if (hasRuntimeSessionLifecycle(runtime)) return runtime; + let patched = runtime; + if (!patched.includes(`${helper}.ensureSessionForTitle(this,`)) { + const title = unique(patched, new RegExp(`${identifier}\\((${identifier}),"setCustomSessionTitle"\\)`, "u"), "custom title method"); + const prefix = unique(patched, new RegExp(`async function ${escape(title[1]!)}\\((${identifier})\\)\\{`, "u"), "custom title function"); + patched = patched.replace(prefix[0], `${prefix[0]}await ${helper}.ensureSessionForTitle(this,${prefix[1]});`); + } + if (!patched.includes("$zValidatedResumeSession=")) { + const store = unique(patched, new RegExp(`(${identifier})=(${identifier})\\.sessionStore===void 0,(${identifier})=\\2\\.sessionStore\\?\\?await ${identifier}\\(${identifier},${identifier}\\),`, "u"), "app session store"); + patched = patched.replace(store[0], `${store[0]}$zValidatedResumeSession=${store[2]}.resume?await ${helper}.assertResumableSession(${store[3]},${store[2]}.sessionId).catch(async $zResumeStoreError=>{if(${store[1]})await ${store[3]}.close();throw $zResumeStoreError}):void 0,`); + } + if (!patched.includes('"readSessionResumeTarget"')) { + const facade = unique(patched, new RegExp(`setCustomSessionTitle:(${identifier})\\(async (${identifier})=>(${identifier})\\.runtime\\.setCustomSessionTitle\\(\\{title:\\2\\.title,traceContext:\\2\\.traceContext\\?\\?\\3\\.traceContext\\}\\),"setCustomSessionTitle"\\)`, "u"), "app title facade"); + patched = patched.replace(facade[0], `${facade[0]},readSessionResumeTarget:${facade[1]}(async()=>await ${helper}.readSessionResumeTarget(${facade[3]}),"readSessionResumeTarget")`); + } + if (!patched.includes("$zResumeCandidateError")) { + const switchApp = unique(patched, new RegExp(`(${identifier})\\(async (${identifier})=>\\{let (${identifier})=(${identifier}),(${identifier})=await \\2\\(\\);return \\4=\\5,\\3&&\\3!==\\5&&await (${identifier})\\(\\3\\),`, "u"), "app replacement"); + const createApp = unique(patched, new RegExp(`return (${identifier})\\.current=(${identifier})\\((${identifier}),${identifier}\\(\\1\\)\\),\\3\\},"createApp"\\)`, "u"), "candidate app publication"); + patched = patched.replace(switchApp[0], switchApp[0].replace(";return ", `;try{await ${switchApp[5]}.readExecutionState()}catch($zResumeCandidateError){await ${switchApp[6]}(${switchApp[5]});throw $zResumeCandidateError}return `)); + patched = patched.replace(createApp[0], `return ${createApp[3]}},"createApp")`); + } + if (!patched.includes("$zTuiResumeReady=")) { + const cleanup = unique(patched, new RegExp(`cleanup:${identifier}\\(async\\(\\)=>\\{await (${identifier})\\.close\\?\\.\\(\\)\\},"cleanup"\\)`, "u"), "TUI app cleanup"); + const options = unique(patched, new RegExp(`${escape(cleanup[1]!)}=${identifier}\\(${identifier},${identifier},${identifier},(${identifier}),${identifier}\\.locale,`, "u"), "TUI resume options"); + const startTui = unique(patched, new RegExp(`try\\{return await (${identifier})\\(\\{loadStartupOptions:`, "u"), "TUI startup"); + patched = patched.replace(startTui[0], `try{let $zTuiResumeOptions=${options[1]},$zTuiResumeReady=($zTuiResumeOptions.resumeSessionId||$zTuiResumeOptions.continueSession)?await ${cleanup[1]}.readExecutionState?.():void 0;return await ${startTui[1]}({loadStartupOptions:`); + } else if (!patched.includes("$zTuiResumeOptions=")) { + const cleanup = unique(patched, new RegExp(`cleanup:${identifier}\\(async\\(\\)=>\\{await (${identifier})\\.close\\?\\.\\(\\)\\},"cleanup"\\)`, "u"), "TUI app cleanup"); + const options = unique(patched, new RegExp(`${escape(cleanup[1]!)}=${identifier}\\(${identifier},${identifier},${identifier},(${identifier}),${identifier}\\.locale,`, "u"), "TUI resume options"); + patched = patched.replace(`let $zTuiResumeReady=await ${cleanup[1]}.readExecutionState?.();`, `let $zTuiResumeOptions=${options[1]},$zTuiResumeReady=($zTuiResumeOptions.resumeSessionId||$zTuiResumeOptions.continueSession)?await ${cleanup[1]}.readExecutionState?.():void 0;`); + } + if (!hasRuntimeSessionLifecycle(patched)) throw new Error("Session lifecycle patch failed postcondition verification."); + return patched; +} diff --git a/scripts/sync-runtime.ts b/scripts/sync-runtime.ts index 7491565e..f85a305b 100755 --- a/scripts/sync-runtime.ts +++ b/scripts/sync-runtime.ts @@ -14,6 +14,8 @@ import { } from "../src/runtime-capabilities.ts"; import { parseReleaseVersion, syncedReleaseVersion } from "./release-version.ts"; import { markRuntimeModified } from "./runtime-attribution.ts"; +import { hasRuntimeSessionLifecycle, patchRuntimeSessionLifecycle } from "./runtime-session-lifecycle-patches.ts"; +export { hasRuntimeSessionLifecycle, patchRuntimeSessionLifecycle } from "./runtime-session-lifecycle-patches.ts"; import { hasRuntimeBigmodelOAuth, patchRuntimeBigmodelOAuth } from "./runtime-bigmodel-oauth-patches.ts"; import { hasRuntimeSharedOAuth, patchRuntimeSharedOAuth } from "./runtime-shared-oauth-patches.ts"; export { hasRuntimeSharedOAuth, patchRuntimeSharedOAuth } from "./runtime-shared-oauth-patches.ts"; @@ -585,6 +587,7 @@ export function patchRuntimeTuiBridge(runtime: string): string { && runtime.includes(".applyFileRewind=async e=>") && runtime.includes(".setCustomSessionTitle=async e=>") && runtime.includes(".readCustomSessionTitle=async()=>") + && runtime.includes(".readSessionResumeTarget=async()=>") && runtime.includes(interruptTurnMarker) && runtime.includes(interruptWaitForIdleMarker) && runtime.includes(".promoteQueuedInput=async(") @@ -604,6 +607,7 @@ export function patchRuntimeTuiBridge(runtime: string): string { && /applyFileRewind:[A-Za-z_$][\w$]*\.applyFileRewind/u.test(runtime) && /setCustomSessionTitle:[A-Za-z_$][\w$]*\.setCustomSessionTitle/u.test(runtime) && /readCustomSessionTitle:[A-Za-z_$][\w$]*\.readCustomSessionTitle/u.test(runtime) + && /readSessionResumeTarget:[A-Za-z_$][\w$]*\.readSessionResumeTarget/u.test(runtime) && /interruptTurn:[A-Za-z_$][\w$]*\.interruptTurn/u.test(runtime) && /promoteQueuedInput:[A-Za-z_$][\w$]*\.promoteQueuedInput/u.test(runtime) && /readSessionUsage:[A-Za-z_$][\w$]*\.readSessionUsage/u.test(runtime) @@ -825,6 +829,9 @@ export function patchRuntimeTuiBridge(runtime: string): string { if (!patched.includes(".readCustomSessionTitle=async()=>")) { assignments.push(`${bridge}.readCustomSessionTitle=async()=>{let t=await ${getApp}(),o=t.sessionStore??t.runtime?.sessionStore,r=await o?.getSession?.(t.sessionId);return r?.titleSource==="custom"&&typeof r.title==="string"?r.title:void 0}`); } + if (!patched.includes(".readSessionResumeTarget=async()=>")) { + assignments.push(`${bridge}.readSessionResumeTarget=async()=>await(await ${getApp}()).readSessionResumeTarget?.()`); + } // The upstream CLI shim turns every switch into a permanent --mode override, // which prevents later resumes from loading their saved execution state. if (!queryHelper) patched = patched.replace(cliModeOverrideBridge, `${bridge}.setMode=async e=>{return await(await ${getApp}()).setMode(e)}`); @@ -938,6 +945,9 @@ export function patchRuntimeTuiBridge(runtime: string): string { if (!/readCustomSessionTitle:[A-Za-z_$][\w$]*\.readCustomSessionTitle/u.test(patched)) { optionFields.push(`readCustomSessionTitle:${submitBridge}.readCustomSessionTitle`); } + if (!/readSessionResumeTarget:[A-Za-z_$][\w$]*\.readSessionResumeTarget/u.test(patched)) { + optionFields.push(`readSessionResumeTarget:${submitBridge}.readSessionResumeTarget`); + } if (!/interruptTurn:[A-Za-z_$][\w$]*\.interruptTurn/u.test(patched)) { optionFields.push(`interruptTurn:${submitBridge}.interruptTurn`); } @@ -1757,6 +1767,10 @@ export const runtimePatchPlan: readonly RuntimePatchDefinition[] = [ id: "session-model-recovery", requirement: "required", apply: patchRuntimeSessionModelRecovery, verify: runtime => runtime.includes("$zRestoredSessionModel") && runtime.includes('"readSessionModelState"') }, + { + id: "session-lifecycle", requirement: "required", apply: patchRuntimeSessionLifecycle, + verify: hasRuntimeSessionLifecycle + }, { id: "official-mcp-availability", requirement: "optional", diff --git a/src/launcher.ts b/src/launcher.ts index 660977e2..2601315a 100644 --- a/src/launcher.ts +++ b/src/launcher.ts @@ -350,9 +350,17 @@ interface TuiRuntimeDiagnosticState { initialized: boolean; path?: string; writeFailed: boolean; + pendingLine?: string; + sessionError?: string; } function appendTuiRuntimeDiagnostic(chunk: Buffer | string, state: TuiRuntimeDiagnosticState): void { + const lines = `${state.pendingLine ?? ""}${chunk.toString()}`.split(/\r?\n/u); + state.pendingLine = lines.pop()?.slice(-1024); + for (const line of lines) { + const missing = /^Error: Session not found: ([A-Za-z0-9_-]{1,512})$/u.exec(line); + if (missing) state.sessionError = `Session not found: ${missing[1]}`; + } if (state.bytes >= tuiRuntimeLogLimitBytes) return; const text = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); try { @@ -386,7 +394,8 @@ function tuiRuntimeFailureMessage(code: number, state: TuiRuntimeDiagnosticState const diagnostic = state.path && !state.writeFailed ? ` Diagnostics: ${state.path}` : " Runtime diagnostics could not be written."; - return `Error: ZCode runtime exited with status ${code}.${diagnostic}\n`; + const message = state.sessionError ?? `ZCode runtime exited with status ${code}`; + return `Error: ${message}.${diagnostic}\n`; } async function runRuntime( diff --git a/src/runtime-config-bridge.ts b/src/runtime-config-bridge.ts index 994d43aa..6c7bd4fd 100644 --- a/src/runtime-config-bridge.ts +++ b/src/runtime-config-bridge.ts @@ -4,6 +4,7 @@ import { dirname, resolve } from "node:path"; import { cliSettingsPath, legacyCliConfigPath, providerConfigPath, providerMigrationMarkerPath, readDesktopSettings } from "./config-paths.ts"; export { assertSessionModelReady, readSessionModelState } from "./session-model-recovery.ts"; +export { assertResumableSession, ensureSessionForTitle, readSessionResumeTarget } from "./runtime-session-lifecycle.ts"; export { readTuiRuntimeProjection, sendTuiBackgroundTaskMessage } from "./runtime-tui-bridge.ts"; export { createTuiWorkflowManagement, startTuiSavedWorkflow } from "./runtime-workflow-bridge.ts"; export { restoreTuiBackgroundTasks } from "./runtime-background-restore.ts"; diff --git a/src/runtime-session-lifecycle.ts b/src/runtime-session-lifecycle.ts new file mode 100644 index 00000000..fe896f26 --- /dev/null +++ b/src/runtime-session-lifecycle.ts @@ -0,0 +1,40 @@ +export interface SessionLifecycleStore { + getSession(sessionId: string): Promise<{ time?: { created?: number; updated?: number; deleted?: number } } | null | undefined>; +} + +export interface SessionLifecycleRuntime { + sessionId?: string; + sessionStore?: SessionLifecycleStore; + rootTraceContext?: unknown; + ensureSessionPersistedForExternalActivity?: (title: string, options: { traceContext?: unknown }) => Promise; +} + +export async function assertResumableSession(store: SessionLifecycleStore, sessionId: string): Promise { + const session = await store.getSession(sessionId); + if (!session || session.time?.deleted !== undefined) throw new Error(`Session not found: ${sessionId}`); +} + +export async function ensureSessionForTitle( + runtime: SessionLifecycleRuntime, + options: { title: string; traceContext?: unknown } +): Promise { + if (!runtime.sessionId || !runtime.sessionStore || !runtime.ensureSessionPersistedForExternalActivity) { + throw new Error("Session persistence is unavailable in this runtime."); + } + await runtime.ensureSessionPersistedForExternalActivity(options.title, { + traceContext: options.traceContext ?? runtime.rootTraceContext + }); + await assertResumableSession(runtime.sessionStore, runtime.sessionId); +} + +export async function readSessionResumeTarget(app: { + sessionId?: string; + sessionStore?: SessionLifecycleStore; + runtime?: SessionLifecycleRuntime; +}): Promise { + const sessionId = app.sessionId ?? app.runtime?.sessionId; + const store = app.sessionStore ?? app.runtime?.sessionStore; + if (!sessionId || !store) return undefined; + const session = await store.getSession(sessionId); + return session && session.time?.deleted === undefined ? sessionId : undefined; +} diff --git a/test/fixtures/session-lifecycle-runtime.cjs b/test/fixtures/session-lifecycle-runtime.cjs new file mode 100644 index 00000000..7e1b8ad0 --- /dev/null +++ b/test/fixtures/session-lifecycle-runtime.cjs @@ -0,0 +1,29 @@ +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const Module = require("node:module"); +const path = require("node:path"); + +function loadSessionLifecycle() { + const file = path.resolve(__dirname, "../../vendor/zcode.cjs"); + let source = fs.readFileSync(file, "utf8"); + const names = ["setCustomSessionTitle", "ensureSessionPersisted", "ensureSessionPersistedForExternalActivity", "persistSession"]; + const functions = names.map(name => { + const symbol = new RegExp(`[A-Za-z_$][\\w$]*\\(([A-Za-z_$][\\w$]*),"${name}"\\)`, "u").exec(source); + assert.ok(symbol, `Missing native ${name}`); + const init = [...source.slice(0, symbol.index).matchAll(/([A-Za-z_$][\w$]*)=[A-Za-z_$][\w$]*\(\(\)=>\{/gu)].at(-1)?.[1]; + assert.ok(init, `Missing initializer for ${name}`); + return { name, symbol: symbol[1], init }; + }); + const main = /async function [A-Za-z_$][\w$]*\(\)\{let [A-Za-z_$][\w$]*=process\.argv\.slice\(2\);/u.exec(source); + assert.ok(main, "Missing native lifecycle test entry"); + source = source.replace(main[0], () => main[0] + + [...new Set(functions.map(entry => entry.init))].map(init => `${init}();`).join("") + + `module.exports={${functions.map(entry => `${entry.name}:${entry.symbol}`).join(",")}};return;`); + const runtime = new Module(file, module); + runtime.filename = file; + runtime.paths = Module._nodeModulePaths(path.dirname(file)); + runtime._compile(source, file); + return runtime.exports; +} + +module.exports = { loadSessionLifecycle }; diff --git a/test/fixtures/tui-features.ts b/test/fixtures/tui-features.ts index 2258d269..4d516d35 100644 --- a/test/fixtures/tui-features.ts +++ b/test/fixtures/tui-features.ts @@ -531,6 +531,7 @@ await runTui({ } return { applied: true, response: "Rewound fixture workspace files." }; }, + readSessionResumeTarget: async () => "feature-session", readSessionUsage: async () => ({ totalTokens: 18_500, inputTokens: 14_000, diff --git a/test/node/session-lifecycle.test.cjs b/test/node/session-lifecycle.test.cjs new file mode 100644 index 00000000..e9bacb25 --- /dev/null +++ b/test/node/session-lifecycle.test.cjs @@ -0,0 +1,93 @@ +const assert = require("node:assert/strict"); +const { test } = require("node:test"); +const { mkdtemp, rm } = require("node:fs/promises"); +const { tmpdir } = require("node:os"); +const path = require("node:path"); +const { loadSessionStore } = require("../fixtures/sqlite-session-store.cjs"); +const { loadSessionLifecycle } = require("../fixtures/session-lifecycle-runtime.cjs"); + +const Store = loadSessionStore(); +const lifecycle = loadSessionLifecycle(); +const helper = require("../../vendor/cli-config.cjs"); + +function runtime(store, directory) { + return { + sessionId: "sess_native_lifecycle", sessionPersisted: false, sessionStore: store, + workingDirectory: directory, appVersion: "test", config: { taskType: "interactive", mode: "build" }, + rootTraceContext: { traceId: "trace_native_lifecycle" }, + getSessionModelSelection: () => undefined, + createEvent: (type, payload) => ({ type, payload }), + appendEvent: async () => {}, + persistSession: lifecycle.persistSession, + ensureSessionPersisted: lifecycle.ensureSessionPersisted, + ensureSessionPersistedForExternalActivity: lifecycle.ensureSessionPersistedForExternalActivity, + setCustomSessionTitle: lifecycle.setCustomSessionTitle + }; +} + +test("native persistence merges concurrent requests and saves a custom title without messages", async () => { + const directory = await mkdtemp(path.join(tmpdir(), "zcode-native-lifecycle-")); + const store = await Store.openStartup({ dbPath: path.join(directory, "db.sqlite") }); + try { + const app = runtime(store, directory); + let creates = 0; + const create = store.createSession.bind(store); + store.createSession = async input => { creates++; return create(input); }; + assert.equal(await helper.readSessionResumeTarget({ runtime: app }), undefined); + await Promise.all([ + app.setCustomSessionTitle({ title: "Chosen title", traceContext: app.rootTraceContext }), + app.ensureSessionPersistedForExternalActivity("First prompt", { traceContext: app.rootTraceContext }) + ]); + assert.equal(creates, 1); + assert.equal(app.sessionPersisted, true); + assert.equal(await helper.readSessionResumeTarget({ runtime: app }), app.sessionId); + assert.equal((await store.getSession(app.sessionId)).title, "Chosen title"); + assert.equal((await store.getSession(app.sessionId)).titleSource, "custom"); + assert.equal(store.db.prepare("SELECT count(*) AS count FROM message WHERE session_id = ?").get(app.sessionId).count, 0); + await app.ensureSessionPersistedForExternalActivity("Later prompt", { traceContext: app.rootTraceContext }); + assert.equal(creates, 1); + assert.equal((await store.getSession(app.sessionId)).title, "Chosen title"); + } finally { + store.close(); + await rm(directory, { recursive: true, force: true }); + } +}); + +test("native persistence failure is retryable and does not claim a custom title was saved", async () => { + const directory = await mkdtemp(path.join(tmpdir(), "zcode-native-lifecycle-retry-")); + const store = await Store.openStartup({ dbPath: path.join(directory, "db.sqlite") }); + try { + const app = runtime(store, directory); + const create = store.createSession.bind(store); + store.createSession = async () => { throw new Error("Injected write failure"); }; + await assert.rejects(app.setCustomSessionTitle({ title: "Failed title", traceContext: app.rootTraceContext }), /Injected write failure/); + assert.equal(app.sessionPersisted, false); + assert.equal(await store.getSession(app.sessionId), null); + store.createSession = create; + await app.setCustomSessionTitle({ title: "Retry title", traceContext: app.rootTraceContext }); + assert.equal(app.sessionPersisted, true); + assert.equal((await store.getSession(app.sessionId)).title, "Retry title"); + } finally { + store.close(); + await rm(directory, { recursive: true, force: true }); + } +}); + +test("a failed custom title write preserves the stored title and can be retried", async () => { + const directory = await mkdtemp(path.join(tmpdir(), "zcode-native-title-retry-")); + const store = await Store.openStartup({ dbPath: path.join(directory, "db.sqlite") }); + try { + const app = runtime(store, directory); + await app.setCustomSessionTitle({ title: "Original title", traceContext: app.rootTraceContext }); + const update = store.updateSession.bind(store); + store.updateSession = async () => { throw new Error("Injected title failure"); }; + await assert.rejects(app.setCustomSessionTitle({ title: "Failed title", traceContext: app.rootTraceContext }), /Injected title failure/); + assert.equal((await store.getSession(app.sessionId)).title, "Original title"); + store.updateSession = update; + await app.setCustomSessionTitle({ title: "Retried title", traceContext: app.rootTraceContext }); + assert.equal((await store.getSession(app.sessionId)).title, "Retried title"); + } finally { + store.close(); + await rm(directory, { recursive: true, force: true }); + } +}); diff --git a/test/runtime-session-lifecycle-patches.test.ts b/test/runtime-session-lifecycle-patches.test.ts new file mode 100644 index 00000000..a5256fd4 --- /dev/null +++ b/test/runtime-session-lifecycle-patches.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, test } from "bun:test"; +import { + hasRuntimeSessionLifecycle, patchRuntimeSessionLifecycle +} from "../scripts/runtime-session-lifecycle-patches.ts"; +import { runtimePatchPlan } from "../scripts/sync-runtime.ts"; +import { assertResumableSession, ensureSessionForTitle, readSessionResumeTarget } from "../src/runtime-session-lifecycle.ts"; + +function fixture(): string { + return `const label=fn=>fn; +async function nativeTitle(input){await this.sessionStore.updateSession({id:this.sessionId,title:input.title,titleSource:"custom"})} +label(nativeTitle,"setCustomSessionTitle"); +async function create(options){let owns=options.sessionStore===void 0,store=options.sessionStore??await open(config,timer),ready=true;return ready} +const facade={setCustomSessionTitle:label(async input=>context.runtime.setCustomSessionTitle({title:input.title,traceContext:input.traceContext??context.traceContext}),"setCustomSessionTitle")}; +const state={},view=value=>value,snapshot=value=>value;let current; +const replace=label(async factory=>{let previous=current,candidate=await factory();return current=candidate,previous&&previous!==candidate&&await close(previous),state.current=view(current,snapshot(state)),current},"replaceApp"); +const make=label(async factory=>{let candidate=await factory();return state.current=view(candidate,snapshot(state)),candidate},"createApp"); +const locale={locale:"en-US"},resumeOptions=context.resumeOptions??{},unused={},prepare=()=>context; +const handler=prepare(unused,unused,unused,resumeOptions,locale.locale,unused); +const shutdown={cleanup:label(async()=>{await handler.close?.()},"cleanup")}; +const run=()=>({started:true}),config={},timer={},open=async()=>context.ownedStore; +async function start(){try{return await run({loadStartupOptions:()=>({})})}finally{await shutdown.cleanup()}} +return {nativeTitle,facade,create,replace,make,start,read:()=>({current,visible:state.current})};`; +} + +function load(source: string, context: object, close: (app: unknown) => Promise = async () => {}) { + return new Function("require", "__dirname", "context", "close", source)( + (name: string) => name === "node:path" ? { join: (...parts: string[]) => parts.join("/") } + : { ensureSessionForTitle, assertResumableSession, readSessionResumeTarget }, + "/fixture", context, close + ); +} + +describe("session lifecycle runtime patch", () => { + test("is required, executable, idempotent, and fails closed on incompatible anchors", () => { + const patched = patchRuntimeSessionLifecycle(fixture()); + expect(hasRuntimeSessionLifecycle(patched)).toBeTrue(); + expect(patchRuntimeSessionLifecycle(patched)).toBe(patched); + expect(() => new Function(patched)).not.toThrow(); + expect(runtimePatchPlan.find(patch => patch.id === "session-lifecycle")?.requirement).toBe("required"); + expect(() => patchRuntimeSessionLifecycle("incompatible")).toThrow("missing or ambiguous"); + expect(() => patchRuntimeSessionLifecycle(fixture() + fixture())).toThrow("ambiguous"); + }); + + test("persists before a title write and skips the write after persistence failure", async () => { + const calls: string[] = []; + let session: object | null = null; + const runtime = { + sessionId: "session", rootTraceContext: {}, + sessionStore: { + getSession: async () => session, + updateSession: async () => { calls.push("title"); } + }, + ensureSessionPersistedForExternalActivity: async () => { calls.push("persist"); session = {}; } + }; + const api = load(patchRuntimeSessionLifecycle(fixture()), { runtime }); + await api.nativeTitle.call(runtime, { title: "chosen" }); + expect(calls).toEqual(["persist", "title"]); + expect(await api.facade.readSessionResumeTarget()).toBe("session"); + runtime.ensureSessionPersistedForExternalActivity = async () => { throw new Error("Write failed"); }; + await expect(api.nativeTitle.call(runtime, { title: "failed" })).rejects.toThrow("Write failed"); + expect(calls).toEqual(["persist", "title"]); + }); + + test("validates a replacement before publishing it or closing the previous app", async () => { + const closed: unknown[] = []; + const api = load(patchRuntimeSessionLifecycle(fixture()), {}, async app => { closed.push(app); }); + const previous = { readExecutionState: async () => ({ mode: "build" }) }; + await api.replace(() => api.make(async () => previous)); + const invalid = { readExecutionState: async () => { throw new Error("Session not found: missing"); } }; + await expect(api.replace(() => api.make(async () => invalid))).rejects.toThrow("Session not found: missing"); + expect(api.read()).toEqual({ current: previous, visible: previous }); + expect(closed).toEqual([invalid]); + }); + + test("checks a resumed target without creating a replacement session", async () => { + const api = load(patchRuntimeSessionLifecycle(fixture()), {}); + const store = { getSession: async () => null }; + await expect(api.create({ resume: true, sessionId: "missing", sessionStore: store })).rejects.toThrow("Session not found: missing"); + expect(await api.create({ sessionStore: store })).toBeTrue(); + }); + + test("closes an owned store when startup resume validation fails", async () => { + let closed = false; + const api = load(patchRuntimeSessionLifecycle(fixture()), { ownedStore: { + getSession: async () => null, close: async () => { closed = true; } + } }); + await expect(api.create({ resume: true, sessionId: "missing" })).rejects.toThrow("Session not found: missing"); + expect(closed).toBeTrue(); + }); + + test("gates only resume startup and cleans up without constructing a broken TUI", async () => { + const calls: string[] = []; + const context = { + resumeOptions: { resumeSessionId: "missing" }, + readExecutionState: async () => { calls.push("validate"); throw new Error("Session not found: missing"); }, + close: async () => { calls.push("close"); } + }; + const api = load(patchRuntimeSessionLifecycle(fixture()), context); + await expect(api.start()).rejects.toThrow("Session not found: missing"); + expect(calls).toEqual(["validate", "close"]); + const fresh = load(patchRuntimeSessionLifecycle(fixture()), { ...context, resumeOptions: {} }); + expect(await fresh.start()).toEqual({ started: true }); + expect(calls).toEqual(["validate", "close", "close"]); + }); +}); diff --git a/test/runtime-session-lifecycle.test.ts b/test/runtime-session-lifecycle.test.ts new file mode 100644 index 00000000..fccfa5e2 --- /dev/null +++ b/test/runtime-session-lifecycle.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, test } from "bun:test"; +import { + assertResumableSession, ensureSessionForTitle, readSessionResumeTarget, + type SessionLifecycleRuntime +} from "../src/runtime-session-lifecycle.ts"; + +describe("runtime session lifecycle", () => { + test("checks real storage without persisting an empty session", async () => { + let persisted = false; + const store = { getSession: async () => persisted ? { time: { created: 1 } } : null }; + const runtime = { + sessionId: "empty", sessionStore: store, + ensureSessionPersistedForExternalActivity: async () => { persisted = true; } + }; + expect(await readSessionResumeTarget({ runtime })).toBeUndefined(); + expect(persisted).toBeFalse(); + await expect(assertResumableSession(store, "empty")).rejects.toThrow("Session not found: empty"); + await ensureSessionForTitle(runtime, { title: "Chosen title" }); + expect(await readSessionResumeTarget({ runtime })).toBe("empty"); + }); + + test("uses the native persistence entry and preserves explicit trace context", async () => { + const calls: unknown[] = []; + const rootTraceContext = { traceId: "root" }; + const runtime = { + sessionId: "session", rootTraceContext, + sessionStore: { getSession: async () => ({}) }, + async ensureSessionPersistedForExternalActivity(title: string, options: unknown) { + expect(this).toBe(runtime); + calls.push({ title, options }); + } + }; + await ensureSessionForTitle(runtime, { title: "First title" }); + await ensureSessionForTitle(runtime, { title: "Second title", traceContext: { traceId: "explicit" } }); + expect(calls).toEqual([ + { title: "First title", options: { traceContext: rootTraceContext } }, + { title: "Second title", options: { traceContext: { traceId: "explicit" } } } + ]); + }); + + test("does not treat deleted, missing, or unreadable sessions as resumable", async () => { + const store = { getSession: async () => ({ time: { deleted: 1 } }) }; + expect(await readSessionResumeTarget({ sessionId: "deleted", sessionStore: store })).toBeUndefined(); + await expect(assertResumableSession(store, "deleted")).rejects.toThrow("Session not found: deleted"); + expect(await readSessionResumeTarget({ sessionId: "id-only" })).toBeUndefined(); + await expect(readSessionResumeTarget({ sessionId: "broken", sessionStore: { + getSession: async () => { throw new Error("Store unreadable"); } + } })).rejects.toThrow("Store unreadable"); + }); + + test("propagates persistence failures and rejects a persistence no-op", async () => { + const runtime: SessionLifecycleRuntime = { + sessionId: "failed", sessionStore: { getSession: async () => null }, + ensureSessionPersistedForExternalActivity: async () => { throw new Error("Write failed"); } + }; + await expect(ensureSessionForTitle(runtime, { title: "title" })).rejects.toThrow("Write failed"); + runtime.ensureSessionPersistedForExternalActivity = async () => {}; + await expect(ensureSessionForTitle(runtime, { title: "title" })).rejects.toThrow("Session not found: failed"); + await expect(ensureSessionForTitle({}, { title: "title" })).rejects.toThrow("persistence is unavailable"); + }); +}); diff --git a/test/runtime/launcher.test.ts b/test/runtime/launcher.test.ts index 15682af7..723fac54 100644 --- a/test/runtime/launcher.test.ts +++ b/test/runtime/launcher.test.ts @@ -159,6 +159,28 @@ describe("launcher/runtime integration", () => { } }); + test("shows a missing resume target without exposing unrelated runtime diagnostics", async () => { + const directory = await mkdtemp(join(tmpdir(), "zcode-launcher-missing-session-")); + const fakeNode = join(directory, "fake-node"); + await writeFile(fakeNode, [ + "#!/bin/sh", + "printf '%s\\n' 'ProviderBusinessError: internal diagnostics' >&2", + "printf '%s' 'Error: Session not ' >&2", + "printf '%s\\n' 'found: sess_missing_target' >&2", + "exit 1", + "" + ].join("\n")); + await chmod(fakeNode, 0o755); + try { + const result = await run(["--cwd", directory, "--resume", "sess_missing_target"], "", { + ZCODE_NODE: fakeNode, ZCODE_TUI_RUNTIME_LOG: join(directory, "runtime.log") + }); + expect(result.code).toBe(1); + expect(result.stderr).toContain("Session not found: sess_missing_target"); + expect(result.stderr).not.toContain("ProviderBusinessError"); + } finally { await rm(directory, { recursive: true, force: true }); } + }); + test("keeps non-agent runtime subcommands usable", async () => { const doctor = await run(["doctor", "--json"]); expect(doctor.code).toBe(0); diff --git a/test/runtime/session-lifecycle.test.ts b/test/runtime/session-lifecycle.test.ts new file mode 100644 index 00000000..b6789d32 --- /dev/null +++ b/test/runtime/session-lifecycle.test.ts @@ -0,0 +1,170 @@ +import { expect, test } from "bun:test"; +import { Database } from "bun:sqlite"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { cliSettingsPath, ensureCliSettings } from "../../src/model-access.ts"; +import { writeProviderFixture } from "../fixtures/provider-config.ts"; +import { runtimeTestEnv } from "../fixtures/runtime-env.ts"; +import { TerminalScreen } from "../tui/harness/terminal-screen.ts"; + +async function createFixture(display = "regular") { + const directory = await mkdtemp(join(tmpdir(), "zcode-session-lifecycle-")); + const root = join(import.meta.dir, "../.."); + const node = Bun.which("node")!; + const requests: string[] = []; + let rejectRequests = false; + const server = Bun.serve({ hostname: "127.0.0.1", port: 0, async fetch(request) { + const body = await request.json() as { stream?: boolean; model: string }; + if (body.stream) requests.push(body.model); + if (rejectRequests) return Response.json({ error: { message: "Lifecycle request rejected" } }, { status: 400 }); + if (!body.stream) return Response.json({ choices: [{ message: { role: "assistant", content: "Generated lifecycle title" }, finish_reason: "stop" }] }); + return new Response('data: {"choices":[{"index":0,"delta":{"role":"assistant","content":"LIFECYCLE_REPLY"},"finish_reason":null}]}\n\ndata: {"choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}\n\ndata: [DONE]\n\n', + { headers: { "content-type": "text/event-stream" } }); + } }); + const env = { ...runtimeTestEnv(directory), ZCODE_NODE: node, ZCODE_TUI_MODE: display, TERM: "xterm-256color", CI: "0" }; + const children: Bun.Subprocess[] = []; + const terminals: Bun.Terminal[] = []; + const screens: TerminalScreen[] = []; + const dispose = async () => { + for (const child of children) { + if (child.exitCode === null) child.kill("SIGTERM"); + await child.exited; + } + for (const terminal of terminals) terminal.close(); + for (const screen of screens) { await screen.settled(); screen[Symbol.dispose](); } + 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.ui.locale = "en-US"; + await writeFile(cliSettingsPath(env), JSON.stringify(settings)); + await writeProviderFixture(env, { apiKey: "fixture-key", modelId: "fixture", baseUrl: `${server.url.origin}/v1` }); + const command = [node, join(root, "bin/zcode.js")]; + const query = (sql: string, ...params: string[]): Result => { + const connection = new Database(join(directory, ".zcode/cli/db/db.sqlite")); + try { return connection.query(sql).get(...params) as Result; } + finally { connection.close(); } + }; + const start = (args: string[] = []) => { + const screen = new TerminalScreen(100, 32); + screens.push(screen); + const terminal = new Bun.Terminal({ cols: 100, rows: 32, name: "xterm-256color", data(_terminal, data) { void screen.write(data); } }); + terminals.push(terminal); + const child = Bun.spawn([...command, ...args], { cwd: directory, env, terminal }); + children.push(child); + const deadline = setTimeout(() => child.kill("SIGKILL"), 25_000); + void child.exited.then(() => clearTimeout(deadline)); + 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.bufferText())) return; + await Bun.sleep(25); + } + throw new Error(`Session lifecycle did not settle:\n${screen.bufferText()}`); + }; + const exit = async () => { + terminal.write("/exit\r"); + expect(await child.exited).toBe(0); + await screen.settled(); + return screen.bufferText(); + }; + return { child, screen, terminal, wait, exit }; + }; + const print = async (args: string[]) => { + const child = Bun.spawn([...command, ...args], { cwd: directory, env, stdin: "ignore", stdout: "pipe", stderr: "pipe" }); + children.push(child); + const deadline = 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()]); + return { code, output: stdout + stderr }; + } finally { clearTimeout(deadline); } + }; + return { start, print, query, requests, rejectRequests: () => { rejectRequests = true; }, [Symbol.asyncDispose]: dispose }; + } catch (error) { await dispose(); throw error; } +} + +test.skipIf(process.platform === "win32").each(["regular", "fullscreen"])( + "untouched %s sessions stay unsaved and omit resume guidance", async display => { + await using fixture = await createFixture(display); + const tui = fixture.start(); + await tui.wait(text => text.includes("◈ custom/fixture")); + const output = await tui.exit(); + expect(output).not.toContain("To continue this session"); + expect(fixture.query<{ count: number }>("SELECT count(*) AS count FROM session").count).toBe(0); + expect(fixture.requests).toEqual([]); + }, 30_000 +); + +test.skipIf(process.platform === "win32").each(["regular", "fullscreen"])( + "renaming an empty %s session saves its title and supports restart and headless resume", async display => { + await using fixture = await createFixture(display); + const tui = fixture.start(); + await tui.wait(text => text.includes("◈ custom/fixture")); + tui.terminal.write("/rename Chosen empty title\r"); + await tui.wait(text => text.includes("Session renamed to: Chosen empty title")); + const row = fixture.query<{ id: string; title: string; title_source: string }>("SELECT id,title,title_source FROM session"); + expect(row.title).toBe("Chosen empty title"); + expect(row.title_source).toBe("custom"); + expect(fixture.query<{ count: number }>("SELECT count(*) AS count FROM message").count).toBe(0); + expect(fixture.requests).toEqual([]); + expect(await tui.exit()).toContain(`zcode --resume ${row.id}`); + const resumed = fixture.start(["--resume", row.id]); + await resumed.wait(text => text.includes("◈ custom/fixture")); + await resumed.screen.settled(); + expect(resumed.screen.bufferText()).not.toContain("Could not read execution state"); + expect(resumed.screen.title()).toContain("Chosen empty title"); + resumed.terminal.write("Reply after restarting.\r"); + await resumed.wait(text => text.includes("LIFECYCLE_REPLY")); + expect(await resumed.exit()).toContain(`zcode --resume ${row.id}`); + const printed = await fixture.print(["--resume", row.id, "-p", "Reply in print mode."]); + expect(printed.code).toBe(0); + expect(printed.output).toContain("LIFECYCLE_REPLY"); + expect(fixture.query<{ title: string }>("SELECT title FROM session WHERE id=?", row.id).title).toBe("Chosen empty title"); + }, 30_000 +); + +test.skipIf(process.platform === "win32")("invalid resume fails before startup and preserves the current interactive session", async () => { + await using fixture = await createFixture(); + const missing = "sess_missing_lifecycle"; + const startup = fixture.start(["--resume", missing]); + expect(await startup.child.exited).toBe(1); + await startup.screen.settled(); + expect(startup.screen.bufferText()).toContain(`Session not found: ${missing}`); + expect(startup.screen.bufferText()).not.toContain("Could not read execution state"); + const printed = await fixture.print(["--resume", missing, "-p", "Do not send this."]); + expect(printed.code).toBe(1); + expect(printed.output).toContain(`Session not found: ${missing}`); + const tui = fixture.start(); + await tui.wait(text => text.includes("◈ custom/fixture")); + tui.terminal.write("/rename Original session\r"); + await tui.wait(text => text.includes("Session renamed to: Original session")); + const original = fixture.query<{ id: string }>("SELECT id FROM session").id; + tui.terminal.write(`/resume ${missing}\r`); + await tui.wait(text => text.includes(`Session not found: ${missing}`)); + tui.terminal.write("/rename Still original\r"); + await tui.wait(text => text.includes("Session renamed to: Still original")); + expect(fixture.query<{ title: string }>("SELECT title FROM session WHERE id=?", original).title).toBe("Still original"); + tui.terminal.write("Reply from the preserved session.\r"); + await tui.wait(text => text.includes("LIFECYCLE_REPLY")); + expect(await tui.exit()).toContain(`zcode --resume ${original}`); + expect(fixture.query<{ count: number }>("SELECT count(*) AS count FROM session").count).toBe(1); +}, 30_000); + +test.skipIf(process.platform === "win32")("a failed first request still saves a resumable session without a custom title", async () => { + await using fixture = await createFixture(); + fixture.rejectRequests(); + const tui = fixture.start(); + await tui.wait(text => text.includes("◈ custom/fixture")); + tui.terminal.write("First prompt should name this session\r"); + await tui.wait(text => text.includes("Turn execution failed")); + const row = fixture.query<{ id: string; title: string; title_source: string }>("SELECT id,title,title_source FROM session"); + expect(row.title).toContain("First prompt should name this session"); + expect(row.title_source).not.toBe("custom"); + expect(await tui.exit()).toContain(`zcode --resume ${row.id}`); +}, 30_000); diff --git a/test/sync-runtime.test.ts b/test/sync-runtime.test.ts index ff8b97e5..67d49471 100644 --- a/test/sync-runtime.test.ts +++ b/test/sync-runtime.test.ts @@ -1575,6 +1575,8 @@ describe("runtime synchronization", () => { expect(patched).toContain("setCustomSessionTitle:g.setCustomSessionTitle"); expect(patched).toContain("readCustomSessionTitle:g.readCustomSessionTitle"); + expect(patched).toContain("readSessionResumeTarget:g.readSessionResumeTarget"); + expect(patched).toContain("E.readSessionResumeTarget=async()=>await(await S()).readSessionResumeTarget?.()"); expect(patchRuntimeTuiBridge(patched)).toBe(patched); const missingReader = patched .replace(`${patched.slice(readerStart, end)},`, "") diff --git a/test/tui/fixtures/session-lifecycle.ts b/test/tui/fixtures/session-lifecycle.ts new file mode 100644 index 00000000..5c1d9030 --- /dev/null +++ b/test/tui/fixtures/session-lifecycle.ts @@ -0,0 +1,20 @@ +import { runTui } from "../../../packages/zcode-tui/src/index.ts"; + +const mode = process.env.ZCODE_TEST_RESUME_TARGET; +await runTui({ + initialModel: "fixture/model", + readSessionUsage: async () => ({ sessionId: "id-only", totalTokens: 10 }), + readRuntimeProjection: async () => { throw new Error("Projection unavailable"); }, + ...(mode === "unsupported" ? {} : { + readSessionResumeTarget: async () => { + if (mode === "saved") return "saved-session"; + if (mode === "unreadable") throw new Error("Store unreadable"); + if (mode === "timeout") return new Promise(() => {}); + return undefined; + } + }), + submitPrompt: async () => { throw new Error("Session not found: missing"); }, + stdin: process.stdin, + stdout: process.stdout, + stderr: process.stderr +}); diff --git a/test/tui/session-lifecycle.test.ts b/test/tui/session-lifecycle.test.ts new file mode 100644 index 00000000..1260b060 --- /dev/null +++ b/test/tui/session-lifecycle.test.ts @@ -0,0 +1,36 @@ +import { expect, test } from "bun:test"; +import { join } from "node:path"; +import { ScenarioWorkspace } from "./harness/scenario-workspace.ts"; +import { TerminalSession } from "./harness/terminal-session.ts"; + +test.each(["unsupported", "missing", "unreadable", "timeout", "saved"])( + "TUI exit uses only a verified resume target (%s)", async mode => { + await using workspace = await ScenarioWorkspace.create({ prefix: "zcode-tui-lifecycle-" }); + await using session = TerminalSession.start({ + command: [process.execPath, join(import.meta.dir, "fixtures/session-lifecycle.ts")], workspace, + env: { ZCODE_TEST_RESUME_TARGET: mode } + }); + await session.waitForScreen("ready", /◈ fixture\/model/u); + await session.exit(); + const output = session.historyText(); + expect(output).not.toContain("zcode --resume id-only"); + if (mode === "saved") expect(output).toContain("zcode --resume saved-session"); + else expect(output).not.toContain("To continue this session"); + }, 10_000 +); + +test("TUI settles a failed submission even when runtime projection reads fail", async () => { + await using workspace = await ScenarioWorkspace.create({ prefix: "zcode-tui-lifecycle-failure-" }); + await using session = TerminalSession.start({ + command: [process.execPath, join(import.meta.dir, "fixtures/session-lifecycle.ts")], workspace, + env: { ZCODE_TEST_RESUME_TARGET: "missing" } + }); + await session.waitForScreen("ready", /◈ fixture\/model/u); + await session.sendAndWait("first prompt\r", "first failure", /Session not found: missing/u); + await session.waitForScreen("failed turn settled", /\[ ✓ /u); + const start = session.historyCheckpoint(); + session.send("second prompt\r"); + await session.waitForHistory("second failure", /Session not found: missing/u, start); + await session.waitForScreen("next failed turn settled", /\[ ✓ /u); + await session.exit(); +}, 10_000); diff --git a/test/turn-work-tracker.test.ts b/test/turn-work-tracker.test.ts index cb476293..8c9d5037 100644 --- a/test/turn-work-tracker.test.ts +++ b/test/turn-work-tracker.test.ts @@ -75,6 +75,13 @@ describe("turn work tracker", () => { expect(tracker.reconcile(projection())).toBeFalse(); }); + test("settles a failed admission without waiting for an unreadable projection", () => { + const tracker = new TurnWorkTracker(); + tracker.begin(); + expect(tracker.finishForeground(false)).toBeFalse(); + expect(tracker.isActive()).toBeFalse(); + }); + test("resets task ownership when a newer foreground turn begins", () => { const tracker = new TurnWorkTracker(); tracker.begin();