diff --git a/README.md b/README.md index c5523454e..a68642abb 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@

MiniMax Code

-

A terminal coding agent with MiniMax, your own models, and tools beyond code.

+

Turn a prompt into something that works. Build, test, and keep iterating from your terminal—with MiniMax or your own model.

Get started · Documentation · @@ -21,11 +21,13 @@ First-party default license: MIT

-Understand a project, make changes, and run tests from your terminal. Use your MiniMax account or bring your own model, with search, plugins, and multimodal tools in the same workflow. +Give a blinking pocket pet a focus timer. Then ask: “Make pause a long press, and celebrate when the timer ends.” Watch a request become something you can actually use. -[![Real MiniMax Code TUI output: fixing clamp, inspecting the diff, and running tests](docs/assets/tui-demo.png)](docs/demo.md) +[![Pocket Pet: from a blinking face to a working focus companion](docs/assets/pocket-pet-demo.png)](docs/demo.md) -

Watch the 20-second demo → · Real terminal output, with pauses shortened

+

Watch the build story and browser demo → · Build it yourself →

+ +**No hardware required.** The example runs locally in your browser, with no frontend dependencies. Asking the CLI to edit code requires a MiniMax account with available credits or your own compatible model API; model calls may incur charges. The finished example runs without a model account. ## Quick start @@ -102,7 +104,26 @@ Providers added this way are stored under `custom_provider` in the active profil -### 3. Run your first task +### 3. Build the pocket pet + +Clone this repository and copy the starter into a separate directory: + +```bash +git clone https://github.com/MiniMax-AI/minimax-code.git +cd minimax-code +node examples/pocket-pet/setup.mjs ../my-pocket-pet +cd ../my-pocket-pet +node serve.mjs +``` + +Open `http://127.0.0.1:4173`. In a second terminal, open `mcode` in `my-pocket-pet` and paste [the first prompt](examples/pocket-pet/README.md#first-request), then [the follow-up](examples/pocket-pet/README.md#change-the-requirement). Refresh the browser after each change. + +Prefer to try the result first? From the repository root, run `node examples/pocket-pet/serve.mjs finished` and open the same URL. Add `?demo=1` for the visibly labeled 10-second mode. + +[Full walkthrough and requirements](examples/pocket-pet) · [Small code-repair example](examples/clamp) · [Models, search, and tools](docs/examples.md) + + +### Work in your own project Open the project you want to work on: @@ -211,13 +232,6 @@ A profile uses `~/.minimax-`; `MINIMAX_DATA_DIR` or `MAVIS_DATA_DIR` ca Account features, updates, feedback, and diagnostics are also included. Managed tools require network access and the relevant authorization. See [capabilities and service boundaries](docs/tui-capabilities.md) for details. -## Try it - -Start the TUI in a copy of the example project and enter: - -> Read clamp.mjs and clamp.test.mjs. Run node --test to reproduce the failure, fix clamp without changing the tests, then run the tests again. - -The [small, reproducible project](examples/clamp) is the same task used in the demo above. [More examples](docs/examples.md) cover switching models, calling real search, and using your own image inputs. ## Build from source diff --git a/README_ZH.md b/README_ZH.md index b59bf85ae..64472308b 100644 --- a/README_ZH.md +++ b/README_ZH.md @@ -7,7 +7,7 @@

MiniMax Code

-

A terminal coding agent with MiniMax, your own models, and tools beyond code.

+

把一句话,做成能运行的东西。用 MiniMax 或自己的模型,在终端里构建、验证、继续改进。

Get started · Documentation · @@ -21,11 +21,13 @@ First-party default license: MIT

-在终端里读懂项目、修改代码并运行测试。使用 MiniMax 账号或自己的模型,把搜索、插件和多模态工具接入同一个工作流。 +给一个会眨眼的小宠物加上番茄钟,再追问一句:“长按才能暂停,结束时跳舞。”看代码变成可以亲手操作的结果。 -[![MiniMax Code 真实 TUI:修复 clamp、查看代码 diff 并运行测试](docs/assets/tui-demo.png)](docs/demo.md) +[![Pocket Pet:从会眨眼到会陪你专注](docs/assets/pocket-pet-demo.png)](docs/demo.md) -

观看 20 秒真实演示 → · 真实终端输出回放,已压缩等待时间

+

看真实修改与浏览器演示 → · 自己做一次 →

+ +**无需硬件。** 示例在本地浏览器运行,不需要前端依赖。让 CLI 修改代码需要 MiniMax 账号及可用额度,或你自己的兼容模型 API;模型调用可能产生费用。完成版可直接运行,无需模型账号。 ## 快速开始 @@ -102,7 +104,26 @@ mcode -### 3. 完成第一个任务 +### 3. 做一个自己的桌面宠物 + +克隆仓库,把起始工程复制到独立目录: + +```bash +git clone https://github.com/MiniMax-AI/minimax-code.git +cd minimax-code +node examples/pocket-pet/setup.mjs ../my-pocket-pet +cd ../my-pocket-pet +node serve.mjs +``` + +打开 `http://127.0.0.1:4173`。另开一个终端,在 `my-pocket-pet` 目录运行 `mcode`,依次粘贴[第一条提示词](examples/pocket-pet/README.md#first-request)和[追加需求](examples/pocket-pet/README.md#change-the-requirement),每次修改后刷新浏览器。 + +想先体验成品?在仓库根目录运行 `node examples/pocket-pet/serve.mjs finished`,打开同一地址。加上 `?demo=1` 可体验有明确标识的 10 秒演示模式。 + +[完整教程与使用条件](examples/pocket-pet) · [小型代码修复示例](examples/clamp) · [模型、搜索与工具](docs/examples.md) + + +### 在自己的项目中使用 进入要处理的项目目录: @@ -211,13 +232,6 @@ profile 使用 `~/.minimax-`;`MINIMAX_DATA_DIR` 或 `MAVIS_DATA_DIR` 账号、更新、反馈与诊断能力也在。托管工具需要网络和相应授权,详细边界见 [能力与服务边界](docs/tui-capabilities.md)。 -## 试一试 - -在示例目录启动 TUI,输入: - -> Read clamp.mjs and clamp.test.mjs. Run node --test to reproduce the failure, fix clamp without changing the tests, then run the tests again. - -这个 [可复现的小项目](examples/clamp) 就是上方演示使用的任务。[更多示例](docs/examples.md) 包括切换模型、执行真实搜索,以及使用自己的图片输入。 ## 从源码构建 diff --git a/docs/assets/hook-system-message-tui.png b/docs/assets/hook-system-message-tui.png new file mode 100644 index 000000000..e26fc2022 Binary files /dev/null and b/docs/assets/hook-system-message-tui.png differ diff --git a/docs/assets/pocket-pet-demo.mp4 b/docs/assets/pocket-pet-demo.mp4 new file mode 100644 index 000000000..8550c9ccf Binary files /dev/null and b/docs/assets/pocket-pet-demo.mp4 differ diff --git a/docs/assets/pocket-pet-demo.png b/docs/assets/pocket-pet-demo.png new file mode 100644 index 000000000..74f5a7cb9 Binary files /dev/null and b/docs/assets/pocket-pet-demo.png differ diff --git a/docs/demo.md b/docs/demo.md index 4bb000aad..8b616aa98 100644 --- a/docs/demo.md +++ b/docs/demo.md @@ -1,4 +1,31 @@ -# A real coding task +# Turn a prompt into something that works + +[![Pocket Pet browser demo](assets/pocket-pet-demo.png)](assets/pocket-pet-demo.mp4) + +[Watch or download the 36-second video](assets/pocket-pet-demo.mp4) · [Build it yourself](../examples/pocket-pet) · [View the finished source](../examples/pocket-pet/finished) + +## A new feature, then a new requirement + +The starter is a small, predesigned browser pet that blinks and says hello. The first request asks MiniMax Code to add a Pomodoro timer. The follow-up changes pause to an 800 ms hold and adds a completion celebration. The finished example runs locally without hardware or frontend dependencies. + +The video pairs actual browser recordings with clearly labeled prompt excerpts, generated source, and excerpts of real headless output. The interface shown in the final demonstration includes subsequent maintainer design work. It is an edited demonstration, not a recording of the interactive TUI. See the [full prompts](../examples/pocket-pet) and try the same changes in your own copy. + +## Recording and verification + +- Recorded on September 28–29, 2026 (Asia/Shanghai), using the installed MiniMax Code **0.5.8**, with the existing **BYOK** configuration. This is a CLI workflow demonstration, not a MiniMax-model evaluation. +- The two real agent turns took approximately **102 seconds** and **154 seconds**. Model waiting time is omitted from the 36-second edit; it is not a speed benchmark. Browser footage plays at its captured speed. +- The first run added the timer and passed 6 tests. The follow-up added hold-to-pause and the celebration and passed all 13 tests. Both runs explicitly said browser behavior was unverified; the maintainer then checked it in Chrome. +- Browser checks cover start, short-click behavior, pointer hold/pause/resume, keyboard hold/repeat/release/resume, the default 25-minute setting, canceled holds, completion, reduced-motion behavior, and a 390 px viewport without horizontal overflow. +- `?demo=1` visibly labels a **10-second demonstration**. No 25-minute wait or physical hardware operation is implied. State is in memory and a page reload resets it. +- The visual starter was authored before the agent runs. The reference implementation retains the generated timer and hold logic. Subsequent maintainer work redesigned the HTML/CSS, added session-progress and short-tap feedback, made the mode links visible, avoided repeated live-region text writes, and canceled pending holds when the button loses focus. This visual polish was not produced by the two recorded CLI turns. The video does not claim generation from an empty project. +- Only synthetic project content appears. Private run logs, account state, provider aliases, local paths and session identifiers are excluded. Captions and layout are editorial; the cursor highlight follows actual pointer events. Browser footage is cropped for readability and its interaction segment plays continuously at 1×. No tool results were invented. +- Video assembly uses HyperFrames with locally staged browser footage. The video is silent so it works in muted README and social contexts. The revised cut uses a warm page and a cobalt device, with larger interaction shots and visible action labels. + +To edit the starter, install and configure MCode with a MiniMax account with available credits or your own compatible model API. Calls may incur charges. Running the finished example requires only Node.js and a browser. + +--- + +## Earlier demo: a small code repair ![Real MiniMax Code terminal replay: request, failing tests, a code fix, and passing tests](assets/tui-demo.gif) diff --git a/docs/examples.md b/docs/examples.md index 44fe04b96..b732cfc18 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -1,5 +1,8 @@ # Examples +Try the [Pocket Pet walkthrough](../examples/pocket-pet) for a visual example with two real requests: add a focus timer, then change pause to a long press. It includes a starter, finished implementation, and tests. No hardware is required. + + Build the project using the [installation guide](installation.md). Run the `pnpm mcode` commands below from the source root. For interactive tasks, open the target project directory and launch the built CLI by absolute path. ## 1. Edit code and run tests @@ -170,6 +173,8 @@ See [capability coverage](tui-capabilities.md) for custom MCP, managed connector ## 4. Manage plugins +Synchronous Hooks can show [TUI-only messages](hooks.md) with `systemMessage`, including notices after a normal Stop. + Open `/plugins` inside the TUI, or run `mcode plugin` from a shell to open that panel. For a source build, use `pnpm mcode plugin` from the source root instead; the commands below use the installed `mcode` executable. The panel combines the **official** catalog and **local** plugin directories. Use `Tab` / `Shift+Tab` to switch between All Plugins, Installed, Official, and Local; type to search and use the arrow keys to select a row. diff --git a/docs/hooks.md b/docs/hooks.md new file mode 100644 index 000000000..8dc6691bc --- /dev/null +++ b/docs/hooks.md @@ -0,0 +1,90 @@ +# Hook messages in the TUI + +A synchronous command Hook can return a top-level `systemMessage` to show a +notice to the user without adding that text to model context: + +```json +{"systemMessage":"Checks complete. The report is ready."} +``` + +Write one JSON object to stdout and exit with code 0. For example, a Node.js +script can use `console.log(JSON.stringify({ systemMessage: "Checks complete." }))`. +This is the script's output, not the Hook registration document. + +For a MiniMax-format Plugin, reference the Hook file in the manifest's `hooks` +array, for example `"hooks": ["hooks/notify.json"]`. A registration document for +an existing Plugin with a `scripts/notice.cjs` file looks like this: + +```json +{ + "hooks": { + "Stop": [{ + "hooks": [{ + "type": "command", + "command": "node \"${PLUGIN_ROOT}/scripts/notice.cjs\"", + "timeout": 5 + }] + }] + } +} +``` + +The command requires Node.js on the Hook process's PATH. See [local Plugin +management](examples.md#4-manage-plugins) for the active installation directory. + +## Display and model context + +On normal completion, a Stop notice appears after the response as `Hook · Stop`. +The warning color is presentation, not a failed-turn status. Multiline text is +supported; terminal control sequences in the message or title are stripped. + +![A Stop Hook notice shown after the assistant reply in the TUI](assets/hook-system-message-tui.png) + +The notice is persisted in Session display history and restored when reopening +that Session. Replaying the same message does not duplicate it. Separate Hook +invocations can repeat the same text and each gets its own notice. Notices stay +in their owning Session; child-session notices are not broadcast to the parent. + +`systemMessage` alone does not request another model call. Its text is excluded +from canonical model history, compaction input, final-answer copying and default +Markdown export. This does not mean it is excluded from local display storage. +If you also return `additionalContext`, `decision` or other control fields, those +fields keep their existing event-specific behavior. `suppressOutput` does not +control this notice. + +Only categorized Plugin user messages are shown. Diagnostics, terminal-control +notices and older events without a category remain hidden. This change concerns +interactive TUI presentation; headless and ACP output policies are unchanged. + +## Event limits + +The same display behavior applies where Runtime already emits notices: +SessionStart/UserPromptSubmit, PreToolUse, PermissionRequest, PostToolUse, +SubagentStart/SubagentStop and automatic PostCompact. A cancelled or failed turn +does not fire the normal Stop Hook. + +PreCompact and manual PostCompact do not currently emit these notices. +SessionEnd delivery is best-effort and is not guaranteed to appear before exit. +The Claude-compatible adapter discards `systemMessage` for PreCompact, +PostCompact and SessionEnd; the Codex adapter discards it for SessionEnd. These +format-specific limits are unchanged. Only synchronous command Hooks are covered. + +## Manual check from a source build + +Build with `pnpm build` in the repository. To select a test workspace, change the +shell's working directory before launching the built CLI by its absolute path. +The interactive TUI does not accept the headless `exec --cwd` option. + +With a dedicated `MINIMAX_DATA_DIR` and an enabled test Plugin: + +1. Ask for a short answer without tools. Confirm one Stop notice after completion. +2. Repeat the prompt. Confirm a second notice even when its text is identical. +3. Switch away and reopen the Session, then restart the CLI. Confirm the notices + are retained without duplication. +4. Use `/copy` and `/export`. Confirm the notice text is absent. +5. Narrow the terminal and check multiline text remains readable. + +Do not paste the notice marker into the prompt or ask the model to read the Hook +script when checking context isolation. For live request acceptance, inspect the +next model request and an actually executed compaction request. Model answers or +`/context` usage statistics alone cannot prove the marker was absent. diff --git a/docs/installation.md b/docs/installation.md index 0b8c52676..cfbc37819 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -41,7 +41,7 @@ This guide builds the 0.4.12 source preview. Workspace/local build manifests rem For a source build, you need Git, Node.js 22.19+ (22.x), 24.2+ (24.x), 25, or 26, and pnpm 9.12.0. Regular CI uses Node.js 24 across Linux and macOS. The weekly and manual compatibility matrix covers Node.js 22.19.0, 24.2.0, 25, and 26 on both platforms. Windows CI and source-candidate validation are temporarily paused while their checks are made reliable. Initial installation and build require access to public npm. -On Windows, check out this repository on a local NTFS volume before running `pnpm install`. The repository uses pnpm workspace links for vendored packages, and those links require NTFS junctions. FAT32/exFAT volumes, network shares, and other non-local Windows volumes cannot create the required junctions. The preflight command below verifies the volume and stops with a clear message before pnpm creates workspace links; run it immediately before `pnpm install`. A local NTFS volume can still contain a cloud-synced folder, which the preflight cannot identify reliably; keep the checkout outside OneDrive, Google Drive, Dropbox, and similar synced folders. +On Windows, check out this repository on a local NTFS volume before running `pnpm install`. The repository uses pnpm workspace links for vendored packages, and those links require NTFS junctions. FAT32/exFAT volumes, network shares, and other non-local Windows volumes cannot create the required junctions. The preflight command below verifies the volume and stops with a clear message before pnpm creates workspace links; run it immediately before `pnpm install`. It queries structured volume properties through Windows PowerShell and CIM, without requiring an elevated shell or parsing localized `fsutil` output. If PowerShell or CIM is unavailable or blocked, verification fails with the query error. A local NTFS volume can still contain a cloud-synced folder, which the preflight cannot identify reliably; keep the checkout outside OneDrive, Google Drive, Dropbox, and similar synced folders. Node 24.0 and 24.1 are unsupported: their bundled libuv can return inconsistent Windows file identity metadata, causing safe configuration reads to fail. [Node 24.2.0](https://nodejs.org/en/blog/release/v24.2.0) includes libuv 1.51.0 with the [upstream fix](https://github.com/libuv/libuv/commit/82cdfb75f). Use a current patch release of a supported Node line. diff --git a/docs/tui-capabilities.md b/docs/tui-capabilities.md index be2a3b6ae..cb52e4758 100644 --- a/docs/tui-capabilities.md +++ b/docs/tui-capabilities.md @@ -2,6 +2,28 @@ The current capability target is **TUI 0.4.12**; see [version and evidence baseline](open-source-status.md#version-and-evidence-baseline) for the separate workspace and embedded-tool versions. “Restored” below describes implementation and assembly, not acceptance of every account or online service. +## Regular-mode terminal history + +Regular mode preserves native terminal history, including shell output from before +MCode started. Rows already emitted into scrollback are snapshots of what was shown +at that time; folding, completed tools and later edits do not rewrite those rows. +The live application view remains the source for the current transcript. + +Long turns keep a bounded live projection. Dropping its oldest cells does not replay +the welcome banner or erase earlier output. When activity or Todo panels shrink, +released rows temporarily remain blank between the transcript and footer; new +output consumes that space. Tables and code blocks remain contiguous. + +When an unrelated transcript replaces the current projection, a labelled refresh +boundary separates the retained output from the new document. Resize and forced +redraw preserve pending output and native history. Explicit resume-start history +clearing remains a separate startup policy. Native image history is retained until +the terminal itself evicts it. + +Transient overlays use an alternate buffer so opening, nesting or resizing panels +cannot place menu rows in native history. Closing the last overlay restores the +main buffer and reconciles background output; terminal shutdown also restores it. + ## Output speed The activity line and completed-turn summary show provider output tokens divided by @@ -103,6 +125,33 @@ report a failed command. Stop failures and incomplete logs are reported separate An optional `description` supplies the TUI summary while execution and permission checks continue to use the original command. +## Stopping a conversation and its background work + +An explicit user stop (Esc in the TUI) also cancels background Bash commands and +subagents owned by that conversation in the current process, including activated +`task_append` continuations and work spawned by their child turns. Completion +notices for those tasks no longer automatically wake the conversation. Their +terminal results remain unread so the next turn can inspect them through the +background-task reminder and `task_output`. + +Leaving a conversation with `/clear` or a session switch still stops its current +turn and pauses its Goal and queued instructions, but leaves background work +running. This pause also applies when unconsumed steering must fall back to the +queue after a history-write failure. A stop rejected because it names an older +turn does not cancel the current turn's background work. + +Only locally owned running tasks are canceled; tasks held by another runtime +process are left alone. Child cleanup follows the task's owning child turn, so a +later continuation in the same child session is preserved. Delivery suppression +is held in process memory. If a turn does not release within the abort timeout, +work created later during its shutdown is outside the stop boundary. A delivery +already being admitted can also race with a stop. + +Offline regression tests cover cascade ownership, delivery suppression, late +task creation, repeated stops, append completion/shutdown, and queue fallback. +The host integration tests use a scripted runner and real background processes; +they do not establish live-model or cross-platform acceptance. + ## Skill directory links Workspace `.agents/skills`, `.claude/skills`, and `.minimax/skills` support diff --git a/examples/pocket-pet/README.md b/examples/pocket-pet/README.md new file mode 100644 index 000000000..ba30d8c9c --- /dev/null +++ b/examples/pocket-pet/README.md @@ -0,0 +1,59 @@ +# Pocket Pet: teach a blinking face to help you focus + +A small browser project for a real coding-agent workflow: add a timer, then change the interaction. No hardware, framework, package installation, or external assets are needed to run the example. + +## Requirements + +- Node.js 22.19+ and a modern browser. The local server binds only to `127.0.0.1:4173`. +- To have the agent edit the starter: install [MCode](../../README.md#quick-start) and configure a MiniMax account with available credits or your own compatible model API. Model calls may incur charges. Source availability does not make hosted inference free. +- Running the finished example and its tests needs no account or model call. + +## Try the finished pet + +From the repository root: + +```bash +node examples/pocket-pet/serve.mjs finished +``` + +Open `http://127.0.0.1:4173`. The mode links switch between a normal session and the 10-second demo; switching modes resets the timer. Click **Start** for a 25-minute focus session. While running, hold the button for 0.8 seconds to pause; **Resume** is an ordinary click. Space and Enter work with the focused button. Completion triggers a short celebration; **Reset** prepares another session. + +For a quick demonstration, open `http://127.0.0.1:4173/?demo=1`. The screen explicitly labels its **10-second demo mode**. It is not a sped-up 25-minute session. Stop the server with Ctrl+C before starting another copy. + +## Build it yourself + +Create an independent copy so the agent does not edit the repository: + +```bash +node examples/pocket-pet/setup.mjs ../my-pocket-pet +cd ../my-pocket-pet +node serve.mjs +``` + +The setup command refuses an existing destination. Open the URL above. In a second terminal, change to the same `my-pocket-pet` directory and run `mcode`. Use the normal permission prompts to review file edits and test execution. + +### First request + +Paste [prompt-1.txt](prompt-1.txt): + +> Give this browser pocket pet a working Pomodoro timer. Keep its existing visual design. Start with 25 minutes, show MM:SS, let the button start, pause, and resume, and give it a focused expression while running. Add a clearly labeled 10-second demo mode through ?demo=1 so the video does not imply 25 minutes passed in real time. Use an elapsed-time deadline so background tabs do not slow the clock. Extract pure timer logic into timer.mjs and write node:test tests for start, pause, resume, completion, and reset. Use no external dependencies, network requests, or assets. Keep all work in this directory. Update the intro to say "A little focus. A little company." and explain the working controls. Run the tests. Do not commit. Reply briefly in English with the actual result. + +Refresh the browser. Check Start → Pause → Resume, and let demo mode finish. Read the actual test output. + +### Change the requirement + +In the same session, paste [prompt-2.txt](prompt-2.txt): + +> Change the requirement: while the timer is running, pause only after holding the button for 800 ms. A short click must not pause it. Support pointer and keyboard (Space or Enter), cancel a pending hold on pointer cancellation, lost capture, or window blur, and keep start/resume as normal clicks. Show a visible hold progress indicator and explain the control. When the timer completes, make the pet celebrate with a short finite animation, respecting prefers-reduced-motion. Keep the labeled ?demo=1 mode. Add regression tests for the hold threshold and cancellation; run all tests. Keep the existing design, use no dependencies, and do not commit. Reply briefly in English with actual results. + +Refresh again. Confirm a short tap leaves the timer running; a hold pauses it; a normal click resumes it; completion celebrates. Model output varies: inspect its changes and verify the behavior rather than expecting byte-identical files. + +## Verify the reference implementation + +```bash +node --test examples/pocket-pet/finished/timer.test.mjs examples/pocket-pet/finished/hold.test.mjs +``` + +The shipped `finished/` reference is based on a real MiniMax Code 0.5.8 BYOK session, followed by maintainer visual polish and browser verification. The shipped reference has a revised design, a session progress bar, short-tap feedback, and visible mode links; these are documented maintainer additions beyond the two prompts. The starter was designed beforehand. This demonstrates the agent modifying an existing project; it is not a claim that the CLI generated the entire design from an empty directory. See [recording details](../../docs/demo.md). + +The example keeps state in memory. Reloading resets the timer. It is an educational example, not an alarm service guaranteed to run while a computer sleeps. diff --git a/examples/pocket-pet/finished/app.js b/examples/pocket-pet/finished/app.js new file mode 100644 index 000000000..8c93a2d61 --- /dev/null +++ b/examples/pocket-pet/finished/app.js @@ -0,0 +1,177 @@ +import { + createTimer, + toggle, + tick, + remaining, + formatMMSS, + POMODORO_MS, + DEMO_MS, + HOLD_MS, + idleHold, + beginHold, + cancelHold, + holdProgress, + stepHold, +} from "./timer.mjs"; + +const demo = new URLSearchParams(location.search).get("demo") === "1"; +const $ = (s) => document.querySelector(s); +const action = $("#action"), + status = $("#status"), + timerEl = $("#timer"), + hint = $("#hint"), + pet = $(".pet"); + +let state = createTimer(demo ? DEMO_MS : POMODORO_MS); +let hold = idleHold(); +let raf = 0; +let swallowClick = false; // the click that follows a completed hold must not resume + +if (demo) { + $("#demo-badge").hidden = false; + $("#focus-mode").removeAttribute("aria-current"); + $("#quick-mode").setAttribute("aria-current", "page"); +} +const progress = $(".session-track"); +const power = $(".power"); +let feedbackUntil = 0; +function setText(element, value) { + if (element.textContent !== value) element.textContent = value; +} + +const copy = { + idle: ["Ready when you are.", "Start", "Tap Start. I’ll keep you company."], + running: [ + "You’ve got this.", + "Hold to pause", + `Need a breather? Hold for ${HOLD_MS / 1000} seconds.`, + ], + paused: ["Take your time.", "Resume", "Tap Resume whenever you’re ready."], + done: ["Look at you go!", "Reset", "One small win. Ready for another?"], +}; + +function render(now = Date.now()) { + const [s, label, h] = copy[state.status]; + setText(status, s); + setText(action, label); + setText( + hint, + now < feedbackUntil && state.status === "running" + ? "Still focused! Hold a little longer to pause." + : h, + ); + const left = remaining(state, now); + setText(timerEl, formatMMSS(left)); + const fraction = 1 - left / state.durationMs; + progress.style.setProperty("--progress", fraction); + const percent = String(Math.round(fraction * 100)); + if (progress.getAttribute("aria-valuenow") !== percent) + progress.setAttribute("aria-valuenow", percent); + const powerLabel = { + idle: "READY", + running: "FOCUS", + paused: "REST", + done: "NICE!", + }[state.status]; + if (power.lastChild.textContent !== " " + powerLabel) + power.lastChild.textContent = " " + powerLabel; + const p = holdProgress(hold, now); + action.style.setProperty("--hold", p); + action.classList.toggle("holding", hold.startedAt !== null); + pet.dataset.mood = state.status; + pet.setAttribute( + "aria-label", + state.status === "running" + ? "A focused pixel pet" + : state.status === "done" + ? "A celebrating pixel pet" + : "A friendly blinking pixel pet", + ); + document.title = + state.status === "running" + ? `${timerEl.textContent} · Pocket Pet` + : "Pocket Pet · MiniMax Code"; +} + +function loop() { + const now = Date.now(); + const r = stepHold(state, hold, now); + if (r.fired) swallowClick = true; + if (hold.startedAt !== null || r.fired) ({ timer: state, hold } = r); + state = tick(state, now); + if (state.status !== "running") hold = idleHold(); + render(now); + cancelAnimationFrame(raf); + raf = state.status === "running" ? requestAnimationFrame(loop) : 0; +} + +function startHold() { + if (state.status !== "running" || hold.startedAt !== null) return; + swallowClick = false; + hold = beginHold(Date.now()); + loop(); +} +function abortHold() { + if (hold.startedAt === null) return; + hold = cancelHold(hold); + render(); +} + +// Start / resume / reset stay ordinary clicks. A click while running never pauses. +action.addEventListener("click", () => { + if (swallowClick) { + swallowClick = false; + return; + } + if (state.status === "running") { + feedbackUntil = Date.now() + 1800; + render(); + return; + } + feedbackUntil = 0; + state = toggle(state, Date.now()); + loop(); +}); + +action.addEventListener("pointerdown", (e) => { + swallowClick = false; // fresh gesture; a stale swallow must not eat the next Resume + if (e.button !== 0 || state.status !== "running") return; + action.setPointerCapture(e.pointerId); + startHold(); +}); +action.addEventListener("pointerup", abortHold); +action.addEventListener("pointercancel", abortHold); +action.addEventListener("lostpointercapture", abortHold); + +const isActivate = (e) => e.key === " " || e.key === "Enter"; +action.addEventListener("keydown", (e) => { + if (!isActivate(e)) return; + if (e.repeat) { + e.preventDefault(); + return; + } // auto-repeat must never resume after a hold-pause + if (state.status !== "running") return; + e.preventDefault(); // no synthetic click while running + if (!e.repeat) startHold(); +}); +action.addEventListener("keyup", (e) => { + if (!isActivate(e)) return; + if (hold.startedAt !== null || swallowClick || state.status === "running") { + e.preventDefault(); // stop Space's keyup click + swallowClick = false; + abortHold(); + } +}); + +action.addEventListener("blur", abortHold); +window.addEventListener("blur", abortHold); +document.addEventListener("visibilitychange", () => { + if (document.hidden) abortHold(); + else loop(); +}); +// Low-frequency fallback so the title/completion updates in background tabs. +setInterval(() => { + if (state.status === "running") loop(); +}, 1000); + +render(); diff --git a/examples/pocket-pet/finished/hold.test.mjs b/examples/pocket-pet/finished/hold.test.mjs new file mode 100644 index 000000000..aa29fdd4b --- /dev/null +++ b/examples/pocket-pet/finished/hold.test.mjs @@ -0,0 +1,61 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { + createTimer, + start, + toggle, + HOLD_MS, + beginHold, + cancelHold, + holdProgress, + stepHold, + idleHold, +} from "./timer.mjs"; + +const running = () => start(createTimer(), 0); + +test("hold threshold is 800 ms", () => assert.equal(HOLD_MS, 800)); + +test("holding under 800 ms does not pause", () => { + const r = stepHold(running(), beginHold(1000), 1000 + 799); + assert.equal(r.fired, false); + assert.equal(r.timer.status, "running"); + assert.ok(holdProgress(r.hold, 1799) < 1); +}); + +test("holding for 800 ms pauses at the threshold", () => { + const r = stepHold(running(), beginHold(1000), 1800); + assert.equal(r.fired, true); + assert.equal(r.timer.status, "paused"); + assert.equal(r.hold.startedAt, null); +}); + +test("a short click (released early) leaves the timer running", () => { + let t = running(), + h = beginHold(1000); + ({ timer: t, hold: h } = stepHold(t, h, 1150)); + h = cancelHold(); // pointerup before threshold + const later = stepHold(t, h, 99_999); + assert.equal(later.fired, false); + assert.equal(later.timer.status, "running"); +}); + +test("cancellation (pointercancel / lost capture / blur) discards the pending hold", () => { + const h = cancelHold(beginHold(0)); + assert.deepEqual(h, idleHold()); + assert.equal(holdProgress(h, 10_000), 0); + assert.equal(stepHold(running(), h, 10_000).timer.status, "running"); +}); + +test("a new hold after cancellation restarts from zero", () => { + const h = beginHold(5000); // canceled one at 0 is gone + assert.equal(stepHold(running(), h, 5500).fired, false); + assert.equal(stepHold(running(), h, 5800).fired, true); +}); + +test("holds do nothing when the timer is not running", () => { + const r = stepHold(createTimer(), beginHold(0), 5000); + assert.equal(r.fired, false); + assert.equal(r.timer.status, "idle"); + assert.equal(toggle(createTimer(), 0).status, "running"); // start stays a normal click +}); diff --git a/examples/pocket-pet/finished/index.html b/examples/pocket-pet/finished/index.html new file mode 100644 index 000000000..baf3f39d6 --- /dev/null +++ b/examples/pocket-pet/finished/index.html @@ -0,0 +1,110 @@ + + + + + + Pocket Pet · MiniMax Code + + + +
+ MiniMax CodeA SMALL IDEA, MADE REAL. +
+
+
+

+ MEET YOUR POCKET PET +

+

Small friend.
Big focus.

+

+ A little company for the thing
+ you’ve been meaning to do. +

+ +

Switching modes starts a fresh session.

+
+ +

+ A browser pet. A real timer.
Built to be changed with MiniMax + Code. +

+
+
+
+
+
+ POCKET / PET READY +
+
+
+ YOUR FOCUS BUDDY +
+ +

Ready when you are.

+
+ 25:00 +
+
+ +
+ +
+
+ + +
+
+ ONE THING AT A TIME. +
+
+ +

Tap Start. I’ll keep you company.

+

+ Keyboard friendly: Space / Enter +

+
+
+ + + + diff --git a/examples/pocket-pet/finished/style.css b/examples/pocket-pet/finished/style.css new file mode 100644 index 000000000..2517ba66f --- /dev/null +++ b/examples/pocket-pet/finished/style.css @@ -0,0 +1,639 @@ +* { + box-sizing: border-box; +} +:root { + color-scheme: light; + --paper: #f6f3eb; + --ink: #172b39; + --muted: #536470; + --blue: #254fe8; + --mint: #a7f4cf; +} +body { + margin: 0; + background: var(--paper); + color: var(--ink); + font-family: Arial, Helvetica, sans-serif; + min-height: 100vh; + display: flex; + flex-direction: column; +} +a { + color: inherit; +} +header, +footer { + display: flex; + align-items: center; + justify-content: space-between; + gap: 24px; + padding: 27px 5vw; +} +header { + border-bottom: 1px solid #d5d9d5; +} +.brand { + display: flex; + align-items: center; + gap: 11px; + text-decoration: none; + font-size: 21px; + font-weight: 750; + letter-spacing: -0.8px; +} +.brand-mark { + background: var(--blue); + color: white; + width: 29px; + height: 29px; + border-radius: 7px; + display: grid; + place-items: center; + font-size: 25px; + line-height: 1; +} +.header-note, +.eyebrow, +.device-top, +.screen-label, +.device-bottom, +.mode-note, +.keyboard-note, +footer { + font-family: Menlo, Consolas, monospace; +} +.header-note { + font-size: 10px; + letter-spacing: 1.5px; + color: var(--muted); +} +main { + flex: 1; + display: grid; + grid-template-columns: 1fr 1fr; + align-items: center; + gap: 45px; + width: min(1160px, 90vw); + margin: 48px auto 32px; +} +.eyebrow { + font-size: 11px; + letter-spacing: 1.5px; + font-weight: 700; + display: flex; + align-items: center; + gap: 12px; +} +.eyebrow span { + font-size: 23px; + color: var(--blue); +} +h1 { + font-size: clamp(54px, 6.4vw, 91px); + letter-spacing: -5px; + line-height: 1.02; + margin: 23px 0 27px; + font-weight: 800; +} +h1 em { + font-style: normal; + color: var(--blue); +} +.description { + font-size: 22px; + line-height: 1.5; + color: var(--muted); + margin: 0 0 33px; +} +.modes { + display: flex; + gap: 5px; + flex-wrap: wrap; + align-items: center; + font-size: 13px; + font-weight: 700; +} +.modes a { + padding: 12px 16px; + border-radius: 30px; + text-decoration: none; + border: 1px solid #c4c9c8; +} +.modes a[aria-current] { + background: var(--ink); + color: var(--paper); + border-color: var(--ink); +} +.modes a:hover { + box-shadow: 0 0 0 2px #254fe838; +} +.mode-note { + font-size: 10px; + color: var(--muted); + margin: 12px 0 0; +} +.project-note { + display: flex; + gap: 12px; + margin-top: 42px; + align-items: center; + font-size: 13px; + line-height: 1.6; +} +.note-star { + font-size: 37px; + color: var(--blue); +} +.project-note p { + margin: 0; +} +.device-area { + position: relative; + display: flex; + flex-direction: column; + align-items: center; +} +.device { + width: 408px; + padding: 25px 27px 19px; + background: var(--blue); + color: #fff; + border: 2px solid #6485ff; + border-radius: 43px 43px 57px 43px; + box-shadow: + inset 5px 5px 0 #ffffff24, + inset -9px -11px 0 #1536ab, + 0 12px 0 #142e91, + 0 25px 35px #172b3926; + position: relative; + z-index: 1; +} +.device-top { + display: flex; + align-items: center; + justify-content: space-between; + font-size: 10px; + font-weight: 700; + letter-spacing: 1px; + margin: 0 2px 19px; +} +.power { + font-size: 9px; + display: flex; + align-items: center; + gap: 5px; +} +.power i { + display: block; + width: 5px; + height: 5px; + background: #a7f4cf; + border-radius: 50%; +} +.screen { + background: #173e36; + border: 7px solid #102c2e; + border-radius: 19px; + padding: 15px 20px 17px; + display: flex; + flex-direction: column; + align-items: center; + min-height: 321px; + color: var(--mint); + box-shadow: inset 0 4px 10px #0005; + position: relative; +} +.screen-label { + font-size: 7px; + letter-spacing: 1px; + display: flex; + justify-content: space-between; + width: 100%; + color: #96c6ac; +} +.pet { + width: 148px; + height: 93px; + position: relative; + margin: 22px 0 7px; + animation: bob 3.6s ease-in-out infinite; +} +.eyes { + display: flex; + justify-content: space-between; + padding: 0 12px; + margin-top: 7px; +} +.eyes i { + display: block; + width: 30px; + height: 37px; + background: var(--mint); + border-radius: 3px; + animation: blink 5.2s infinite; + box-shadow: 0 0 18px #a7f4cf20; +} +.mouth { + position: absolute; + width: 32px; + height: 13px; + border-bottom: 6px solid var(--mint); + border-left: 6px solid var(--mint); + border-right: 6px solid var(--mint); + left: 58px; + top: 56px; + border-radius: 0 0 5px 5px; +} +.cheeks { + position: absolute; + top: 49px; + width: 100%; + display: flex; + justify-content: space-between; +} +.cheeks i { + width: 19px; + height: 8px; + background: #ec9baa; + border-radius: 2px; +} +.sparks i { + position: absolute; + font-style: normal; + font-size: 22px; + opacity: 0; +} +.sparks i:nth-child(1) { + left: -31px; + top: 3px; +} +.sparks i:nth-child(2) { + right: -30px; + top: -8px; +} +.sparks i:nth-child(3) { + left: -20px; + top: 63px; +} +.sparks i:nth-child(4) { + right: -24px; + top: 62px; +} +#status { + font-size: 13px; + margin: 3px 0 12px; + height: 18px; +} +.timer { + font: + 700 47px/1.15 Menlo, + Consolas, + monospace; + letter-spacing: 1px; + font-variant-numeric: tabular-nums; +} +.session-track { + height: 4px; + width: 82%; + margin: 17px 0 0; + background: #376456; + border-radius: 3px; + overflow: hidden; +} +.session-track i { + display: block; + width: 100%; + height: 100%; + background: var(--mint); + transform: scaleX(var(--progress, 0)); + transform-origin: left; +} +.demo-badge { + font: + 700 8px Menlo, + Consolas, + monospace; + letter-spacing: 1px; + margin: 12px 0 0; + color: #bbdcca; +} +.demo-badge[hidden] { + display: none; +} +.controls { + display: flex; + justify-content: space-between; + align-items: center; + gap: 20px; + margin: 26px 8px 0; +} +button { + font: + 700 15px Arial, + Helvetica, + sans-serif; + color: var(--ink); + background: #f6f3eb; + border: 0; + border-radius: 30px; + min-width: 196px; + min-height: 53px; + padding: 15px 22px; + box-shadow: + 0 6px #b5bcce, + 0 9px #142e9166; + cursor: pointer; + touch-action: manipulation; +} +button:active { + transform: translateY(4px); + box-shadow: 0 2px #b5bcce; +} +button:focus-visible, +a:focus-visible { + outline: 3px solid #e39017; + outline-offset: 5px; +} +#action { + position: relative; + overflow: hidden; + user-select: none; + -webkit-user-select: none; + -webkit-touch-callout: none; +} +#action::after { + content: ""; + position: absolute; + left: 0; + bottom: 0; + width: 100%; + height: 6px; + background: var(--blue); + transform: scaleX(var(--hold, 0)); + transform-origin: left; + pointer-events: none; +} +#action.holding { + background: #cfe8da; +} +.speaker { + display: flex; + gap: 5px; + transform: rotate(-16deg); +} +.speaker i { + width: 5px; + height: 27px; + background: #102e9e; + border-radius: 3px; + box-shadow: 1px 0 #6485ff; +} +.device-bottom { + display: flex; + align-items: center; + justify-content: space-between; + font-size: 7px; + letter-spacing: 1px; + margin: 23px 8px 0; + color: #dce4ff; +} +.device-bottom span:last-child { + font-size: 16px; +} +.shadow { + height: 19px; + width: 315px; + border-radius: 50%; + background: #172b3915; + filter: blur(8px); + margin-top: 20px; +} +.hint { + text-align: center; + font-size: 12px; + color: var(--ink); + min-height: 18px; + margin: 12px 0 0; + max-width: 408px; + line-height: 1.5; +} +.keyboard-note { + font-size: 9px; + color: var(--muted); + margin: 8px 0 0; +} +kbd { + font: inherit; + border: 1px solid #b9c0be; + border-bottom-width: 2px; + border-radius: 4px; + padding: 2px 4px; +} +footer { + font-size: 10px; + color: var(--muted); + border-top: 1px solid #d5d9d5; + padding-top: 21px; + padding-bottom: 21px; +} +footer a { + text-decoration: none; + color: var(--ink); + font-weight: 700; +} +.pet[data-mood="running"] { + animation: none; +} +.pet[data-mood="running"] .eyes i { + height: 17px; + margin-top: 10px; + animation: none; + border-radius: 2px; +} +.pet[data-mood="running"] .mouth { + border-left: 0; + border-right: 0; + height: 0; + width: 26px; + left: 61px; + top: 58px; +} +.pet[data-mood="running"] .cheeks { + opacity: 0.5; +} +.pet[data-mood="paused"] .eyes i { + height: 8px; + margin-top: 15px; + animation: none; + border-radius: 0 0 8px 8px; +} +.pet[data-mood="done"] { + animation: celebrate 0.5s ease-in-out 4; +} +.pet[data-mood="done"] .eyes i { + height: 20px; + background: transparent; + border: 7px solid var(--mint); + border-bottom: 0; + border-radius: 20px 20px 0 0; + animation: none; +} +.pet[data-mood="done"] .mouth { + height: 19px; + top: 51px; +} +.pet[data-mood="done"] .sparks i { + animation: spark 0.8s ease-in-out 2; +} +.pet[data-mood="done"] .sparks i:nth-child(even) { + animation-delay: 0.15s; +} +@keyframes blink { + 0%, + 42%, + 46%, + 100% { + transform: scaleY(1); + } + 44% { + transform: scaleY(0.12); + } +} +@keyframes bob { + 0%, + 100% { + transform: translateY(0); + } + 50% { + transform: translateY(-5px); + } +} +@keyframes celebrate { + 0%, + 100% { + transform: translateY(0) rotate(0); + } + 35% { + transform: translateY(-12px) rotate(-7deg); + } + 70% { + transform: translateY(-4px) rotate(7deg); + } +} +@keyframes spark { + 0%, + 100% { + opacity: 0; + transform: scale(0.5); + } + 35%, + 70% { + opacity: 1; + transform: scale(1); + } +} +@media (max-width: 760px) { + header { + padding: 18px 6vw; + } + .header-note { + display: none; + } + main { + grid-template-columns: 1fr; + gap: 32px; + margin: 27px auto 24px; + width: 90vw; + } + .intro { + text-align: center; + } + .intro .eyebrow, + .intro .description { + display: none; + } + .eyebrow { + justify-content: center; + font-size: 9px; + margin: 0; + } + h1 { + font-size: 46px; + letter-spacing: -2px; + margin: 0 0 20px; + } + .description { + font-size: 17px; + line-height: 1.4; + margin-bottom: 19px; + } + .desktop-break { + display: none; + } + .modes { + justify-content: center; + font-size: 11px; + } + .modes a { + padding: 10px 13px; + } + .mode-note { + font-size: 8px; + } + .project-note { + display: none; + } + .device { + width: min(408px, 88vw); + padding: 21px 22px 16px; + border-radius: 35px 35px 45px 35px; + } + .screen { + padding: 13px 13px 15px; + min-height: 296px; + } + .pet { + margin-top: 17px; + } + .timer { + font-size: 41px; + } + .controls { + gap: 10px; + margin: 22px 1px 0; + } + button { + min-width: 177px; + font-size: 13px; + } + .speaker { + gap: 4px; + } + .speaker i { + width: 4px; + height: 23px; + } + .hint { + font-size: 11px; + max-width: 88vw; + } + .keyboard-note { + font-size: 8px; + } + footer { + font-size: 8px; + padding: 18px 6vw; + gap: 12px; + } + .shadow { + width: 260px; + } +} +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation: none !important; + transition: none !important; + } +} diff --git a/examples/pocket-pet/finished/timer.mjs b/examples/pocket-pet/finished/timer.mjs new file mode 100644 index 000000000..4e99e8fff --- /dev/null +++ b/examples/pocket-pet/finished/timer.mjs @@ -0,0 +1,74 @@ +// Pure Pomodoro timer logic. No DOM, no clocks: callers pass `now` (ms). +export const POMODORO_MS = 25 * 60 * 1000; +export const DEMO_MS = 10 * 1000; + +export function createTimer(durationMs = POMODORO_MS) { + return { + status: "idle", + durationMs, + remainingMs: durationMs, + deadline: null, + }; +} + +export function remaining(state, now) { + if (state.status !== "running") return state.remainingMs; + return Math.max(0, state.deadline - now); +} + +export function start(state, now) { + if (state.status === "running" || state.status === "done") return state; + return { ...state, status: "running", deadline: now + state.remainingMs }; +} + +export function pause(state, now) { + if (state.status !== "running") return state; + const left = remaining(state, now); + if (left === 0) + return { ...state, status: "done", remainingMs: 0, deadline: null }; + return { ...state, status: "paused", remainingMs: left, deadline: null }; +} + +export const resume = (state, now) => + state.status === "paused" ? start(state, now) : state; + +export function tick(state, now) { + if (state.status === "running" && remaining(state, now) === 0) { + return { ...state, status: "done", remainingMs: 0, deadline: null }; + } + return state; +} + +export const reset = (state) => createTimer(state.durationMs); + +export function toggle(state, now) { + if (state.status === "running") return pause(state, now); + if (state.status === "paused") return resume(state, now); + if (state.status === "done") return reset(state); + return start(state, now); +} + +export function formatMMSS(ms) { + const total = Math.ceil(Math.max(0, ms) / 1000); + const m = Math.floor(total / 60), + s = total % 60; + return `${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`; +} + +// Hold-to-pause: a pending hold only fires after HOLD_MS of continuous holding. +export const HOLD_MS = 800; +export const idleHold = () => ({ startedAt: null }); +export const beginHold = (now) => ({ startedAt: now }); +export const cancelHold = () => idleHold(); +export function holdProgress(hold, now, thresholdMs = HOLD_MS) { + if (hold.startedAt === null) return 0; + return Math.min(1, Math.max(0, now - hold.startedAt) / thresholdMs); +} +// Advance a hold against the timer. Pauses only once the threshold is reached. +export function stepHold(timer, hold, now, thresholdMs = HOLD_MS) { + if (timer.status !== "running" || hold.startedAt === null) + return { timer, hold: idleHold(), fired: false }; + if (holdProgress(hold, now, thresholdMs) < 1) + return { timer, hold, fired: false }; + return { timer: pause(timer, now), hold: idleHold(), fired: true }; +} diff --git a/examples/pocket-pet/finished/timer.test.mjs b/examples/pocket-pet/finished/timer.test.mjs new file mode 100644 index 000000000..a29c51b14 --- /dev/null +++ b/examples/pocket-pet/finished/timer.test.mjs @@ -0,0 +1,61 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { + createTimer, + start, + pause, + resume, + tick, + reset, + toggle, + remaining, + formatMMSS, + POMODORO_MS, + DEMO_MS, +} from "./timer.mjs"; + +test("starts at 25:00 and counts down from a deadline", () => { + const t0 = createTimer(); + assert.equal(formatMMSS(remaining(t0, 0)), "25:00"); + const t = start(t0, 1000); + assert.equal(t.status, "running"); + assert.equal(t.deadline, 1000 + POMODORO_MS); + assert.equal(formatMMSS(remaining(t, 1000 + 61_000)), "23:59"); +}); + +test("pause freezes remaining time", () => { + const p = pause(start(createTimer(), 0), 5 * 60_000); + assert.equal(p.status, "paused"); + assert.equal(remaining(p, 999_999_999), 20 * 60_000); +}); + +test("resume continues from paused remaining time", () => { + const p = pause(start(createTimer(), 0), 60_000); + const r = resume(p, 500_000); + assert.equal(r.status, "running"); + assert.equal(remaining(r, 500_000 + 30_000), POMODORO_MS - 60_000 - 30_000); +}); + +test("completes at the deadline even after a long gap (background tab)", () => { + const t = start(createTimer(DEMO_MS), 0); + assert.equal(tick(t, 9_999).status, "running"); + const done = tick(t, 3_600_000); + assert.equal(done.status, "done"); + assert.equal(formatMMSS(remaining(done, 3_600_000)), "00:00"); +}); + +test("reset returns to idle with full duration", () => { + const done = tick(start(createTimer(DEMO_MS), 0), DEMO_MS); + const r = reset(done); + assert.deepEqual(r, createTimer(DEMO_MS)); + assert.deepEqual(toggle(done, 0), createTimer(DEMO_MS)); +}); + +test("toggle cycles start -> pause -> resume", () => { + let t = toggle(createTimer(), 0); + assert.equal(t.status, "running"); + t = toggle(t, 1000); + assert.equal(t.status, "paused"); + t = toggle(t, 2000); + assert.equal(t.status, "running"); +}); diff --git a/examples/pocket-pet/prompt-1.txt b/examples/pocket-pet/prompt-1.txt new file mode 100644 index 000000000..0dd3a0edf --- /dev/null +++ b/examples/pocket-pet/prompt-1.txt @@ -0,0 +1 @@ +Give this browser pocket pet a working Pomodoro timer. Keep its existing visual design. Start with 25 minutes, show MM:SS, let the button start, pause, and resume, and give it a focused expression while running. Add a clearly labeled 10-second demo mode through ?demo=1 so the video does not imply 25 minutes passed in real time. Use an elapsed-time deadline so background tabs do not slow the clock. Extract pure timer logic into timer.mjs and write node:test tests for start, pause, resume, completion, and reset. Use no external dependencies, network requests, or assets. Keep all work in this directory. Update the intro to say "A little focus. A little company." and explain the working controls. Run the tests. Do not commit. Reply briefly in English with the actual result. diff --git a/examples/pocket-pet/prompt-2.txt b/examples/pocket-pet/prompt-2.txt new file mode 100644 index 000000000..c273b16dd --- /dev/null +++ b/examples/pocket-pet/prompt-2.txt @@ -0,0 +1 @@ +Change the requirement: while the timer is running, pause only after holding the button for 800 ms. A short click must not pause it. Support pointer and keyboard (Space or Enter), cancel a pending hold on pointer cancellation, lost capture, or window blur, and keep start/resume as normal clicks. Show a visible hold progress indicator and explain the control. When the timer completes, make the pet celebrate with a short finite animation, respecting prefers-reduced-motion. Keep the labeled ?demo=1 mode. Add regression tests for the hold threshold and cancellation; run all tests. Keep the existing design, use no dependencies, and do not commit. Reply briefly in English with actual results. diff --git a/examples/pocket-pet/serve.mjs b/examples/pocket-pet/serve.mjs new file mode 100644 index 000000000..b6b2e99ef --- /dev/null +++ b/examples/pocket-pet/serve.mjs @@ -0,0 +1,59 @@ +import { createServer } from "node:http"; +import { readFile, realpath, stat } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +// Bind only to loopback. Serve this example, never the repository or a parent. +const here = path.dirname(fileURLToPath(import.meta.url)); +const variant = process.argv[2] ?? "."; +if (![".", "starter", "finished"].includes(variant)) { + throw new Error( + "Choose starter or finished, or omit the argument in a copied project.", + ); +} +const root = await realpath(path.resolve(here, variant)); +const types = { + ".html": "text/html", + ".css": "text/css", + ".js": "text/javascript", + ".mjs": "text/javascript", +}; +const server = createServer(async (req, res) => { + if (!["GET", "HEAD"].includes(req.method)) { + res.writeHead(405, { Allow: "GET, HEAD" }).end(); + return; + } + try { + const url = new URL(req.url, "http://127.0.0.1"); + const name = decodeURIComponent( + url.pathname === "/" ? "/index.html" : url.pathname, + ); + const target = await realpath(path.resolve(root, `.${name}`)); + if ( + !target.startsWith(`${root}${path.sep}`) || + !(await stat(target)).isFile() + ) { + res.writeHead(404).end("Not found"); + return; + } + const body = await readFile(target); + res.writeHead(200, { + "Content-Type": `${types[path.extname(target)] ?? "application/octet-stream"}; charset=utf-8`, + "Cache-Control": "no-store", + }); + res.end(req.method === "HEAD" ? undefined : body); + } catch { + res.writeHead(404).end("Not found"); + } +}); +server.on("error", (error) => { + console.error( + error.code === "EADDRINUSE" + ? "Port 4173 is in use. Stop the other example server, then retry." + : error.message, + ); + process.exitCode = 1; +}); +server.listen(4173, "127.0.0.1", () => + console.log("Pocket Pet: http://127.0.0.1:4173 (Ctrl+C to stop)"), +); diff --git a/examples/pocket-pet/setup.mjs b/examples/pocket-pet/setup.mjs new file mode 100644 index 000000000..c2c596d5b --- /dev/null +++ b/examples/pocket-pet/setup.mjs @@ -0,0 +1,15 @@ +import { cp, mkdir, copyFile } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const here = path.dirname(fileURLToPath(import.meta.url)); +if (!process.argv[2]) + throw new Error("Usage: node examples/pocket-pet/setup.mjs "); +const target = path.resolve(process.argv[2]); +// Refuse existing destinations so a reproduction cannot overwrite user work. +await mkdir(target, { recursive: false }); +await cp(path.join(here, "starter"), target, { recursive: true }); +await copyFile(path.join(here, "serve.mjs"), path.join(target, "serve.mjs")); +console.log( + `Created ${target}. Run node serve.mjs there, then open http://127.0.0.1:4173.`, +); diff --git a/examples/pocket-pet/starter/app.js b/examples/pocket-pet/starter/app.js new file mode 100644 index 000000000..7433b624d --- /dev/null +++ b/examples/pocket-pet/starter/app.js @@ -0,0 +1,5 @@ +const action = document.querySelector("#action"); +action.addEventListener("click", () => { + document.querySelector("#status").textContent = "Nice to meet you!"; + document.querySelector("#timer").textContent = "HI, YOU"; +}); diff --git a/examples/pocket-pet/starter/index.html b/examples/pocket-pet/starter/index.html new file mode 100644 index 000000000..f5a37baa4 --- /dev/null +++ b/examples/pocket-pet/starter/index.html @@ -0,0 +1,56 @@ + + + + + + Pocket Pet · MiniMax Code + + + +
+ MiniMax CodeONE PROMPT. SOMETHING REAL. +
+
+
+

A LITTLE COMPANY FOR YOUR DESK

+

Small pet.
Big potential.

+

It can blink. What should it learn next?

+

+ An editable, local browser project.
No hardware needed. +

+
+
+
+
+ POCKET / PET● ONLINE +
+
+ +

Ready when you are.

+
HELLO!
+
+
+ + +
+

Press to meet your new desk buddy.

+
+
+
+
+
+ Built to be changed.github.com/MiniMax-AI/minimax-code +
+ + + diff --git a/examples/pocket-pet/starter/style.css b/examples/pocket-pet/starter/style.css new file mode 100644 index 000000000..7575439f1 --- /dev/null +++ b/examples/pocket-pet/starter/style.css @@ -0,0 +1,275 @@ +* { + box-sizing: border-box; +} +:root { + color-scheme: dark; + --ink: #eef7ff; + --muted: #a2b9cf; + --blue: #3daeff; + --cyan: #75edf1; + --bg: #091725; +} +body { + margin: 0; + background: var(--bg); + color: var(--ink); + font-family: "Avenir Next", Avenir, system-ui, sans-serif; + min-height: 100vh; + display: flex; + flex-direction: column; +} +header, +footer { + display: flex; + justify-content: space-between; + gap: 24px; + padding: 32px 5vw; + align-items: center; +} +header a { + font-size: 23px; + letter-spacing: -0.6px; + font-weight: 750; + color: var(--ink); + text-decoration: none; +} +header span, +.eyebrow, +footer, +.device-top, +.hint { + font-family: Menlo, Consolas, monospace; + font-size: 12px; + letter-spacing: 1px; +} +header span, +footer, +.note { + color: var(--muted); +} +main { + flex: 1; + display: grid; + grid-template-columns: 1fr 1fr; + align-items: center; + gap: 55px; + width: min(1160px, 90vw); + margin: 38px auto 52px; +} +.eyebrow { + color: var(--cyan); + font-size: 11px; +} +h1 { + font-size: clamp(50px, 5.8vw, 83px); + line-height: 1.02; + letter-spacing: -4px; + margin: 30px 0; +} +.description { + font-size: 21px; + line-height: 1.5; + max-width: 410px; +} +.note { + font-size: 14px; + line-height: 1.7; + margin-top: 38px; +} +.device-area { + position: relative; + display: grid; + justify-items: center; +} +.device { + width: 390px; + background: #a9c7dd; + border: 2px solid #cde9fc; + border-radius: 38px; + padding: 24px 25px 18px; + box-shadow: + inset -10px -12px #7498b2, + inset 5px 5px #d8eafb, + 0 24px 50px #0005; + position: relative; + z-index: 1; + color: #173348; +} +.device-top { + display: flex; + justify-content: space-between; + font-size: 10px; + font-weight: bold; + margin-bottom: 20px; +} +.power { + color: #234f41; +} +.screen { + background: #102a36; + border: 8px solid #345364; + border-radius: 19px; + min-height: 283px; + display: flex; + align-items: center; + flex-direction: column; + padding: 32px 12px 18px; + color: #97faf1; + box-shadow: inset 0 4px 18px #0007; +} +.pet { + width: 145px; + height: 95px; + position: relative; + margin: 2px 0 12px; +} +.eyes { + display: flex; + justify-content: space-between; + padding: 0 14px; + margin-top: 12px; +} +.eyes i { + display: block; + width: 26px; + height: 33px; + background: #97faf1; + box-shadow: 0 0 20px #75edf133; + animation: blink 5s infinite; + image-rendering: pixelated; +} +.mouth { + position: absolute; + width: 32px; + height: 13px; + border-bottom: 7px solid #97faf1; + border-left: 7px solid #97faf1; + border-right: 7px solid #97faf1; + left: 56px; + top: 55px; +} +.cheeks { + display: flex; + justify-content: space-between; + position: absolute; + width: 100%; + top: 52px; +} +.cheeks i { + background: #e999af; + width: 19px; + height: 8px; +} +.screen p { + font-size: 13px; + margin: 8px 0 13px; +} +.timer { + font-family: Menlo, monospace; + font-size: 32px; + letter-spacing: 3px; + font-weight: 700; +} +.controls { + display: flex; + align-items: center; + justify-content: space-between; + margin-top: 25px; + gap: 16px; +} +button { + border: 0; + border-radius: 13px; + color: #07304c; + background: #e9f7ff; + padding: 15px 22px; + font: 700 14px inherit; + min-height: 50px; + box-shadow: 0 5px #597e98; + cursor: pointer; + touch-action: manipulation; +} +button:active { + transform: translateY(3px); + box-shadow: 0 2px #597e98; +} +button:focus-visible { + outline: 3px solid #126ca3; + outline-offset: 5px; +} +.speaker { + letter-spacing: 3px; + color: #557e97; + transform: rotate(-12deg); + white-space: nowrap; +} +.hint { + font-size: 9px; + letter-spacing: 0; + min-height: 22px; + margin: 17px 0 0; +} +.shadow { + width: 320px; + height: 24px; + background: #0005; + filter: blur(13px); + margin-top: 34px; +} +footer { + font-size: 11px; + padding-top: 18px; + border-top: 1px solid #a2b9cf25; +} +@keyframes blink { + 0%, + 43%, + 47%, + 100% { + transform: scaleY(1); + } + 45% { + transform: scaleY(0.1); + } +} +@media (max-width: 760px) { + header span { + display: none; + } + main { + grid-template-columns: 1fr; + gap: 34px; + margin: 15px auto 30px; + } + .intro { + text-align: center; + } + h1 { + font-size: 54px; + letter-spacing: -2.5px; + } + .description { + margin: 0 auto; + } + .note { + margin-top: 20px; + } + .device { + width: min(390px, 88vw); + } + footer { + font-size: 9px; + flex-wrap: wrap; + } + .eyebrow { + font-size: 9px; + } +} +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation: none !important; + transition: none !important; + } +} diff --git a/package.json b/package.json index c10d7b7e4..59b306551 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "minimax-code", - "version": "0.5.7", + "version": "0.5.8", "private": true, "type": "module", "description": "Standalone MiniMax Code TUI with managed accounts, BYOK models, cloud tools, plugins and ACP.", diff --git a/packages/agent-core/src/event-bridge/runtime-warning.ts b/packages/agent-core/src/event-bridge/runtime-warning.ts index 3f6475164..87f70f337 100644 --- a/packages/agent-core/src/event-bridge/runtime-warning.ts +++ b/packages/agent-core/src/event-bridge/runtime-warning.ts @@ -16,6 +16,8 @@ export interface RuntimeWarningEventInput { readonly runtimeSeq: number; readonly message: string; readonly source: string; + /** Producer-owned category; consumers must not infer it from message text. */ + readonly category?: string; readonly code?: string; readonly pluginName?: string; readonly hookEvent?: string; @@ -39,6 +41,7 @@ export function buildRuntimeWarningEvent(input: RuntimeWarningEventInput): Strea eventType: RUNTIME_WARNING_EVENT_TYPE, message: input.message, source: input.source, + ...(input.category ? { category: input.category } : {}), ...(input.code ? { code: input.code } : {}), ...(input.pluginName ? { pluginName: input.pluginName } : {}), ...(input.hookEvent ? { hookEvent: input.hookEvent } : {}), diff --git a/packages/agent-core/src/pi-turn-runner/types.ts b/packages/agent-core/src/pi-turn-runner/types.ts index b8965049c..39864b929 100644 --- a/packages/agent-core/src/pi-turn-runner/types.ts +++ b/packages/agent-core/src/pi-turn-runner/types.ts @@ -109,6 +109,12 @@ export type RunTurnCaller = /** Bounded origin of a cancelled Pi turn. Unknown values collapse to `unknown`. */ export type AbortSource = | 'user_stop' + /** + * Leaving a conversation (TUI `/clear`, switching Sessions). Keeps the Goal and + * Queue pause effects of `user_stop`, but is NOT a stop: it must not cascade + * into the Session's background work, which is expected to keep running. + */ + | 'session_leave' | 'immediate_send' | 'input_safety' | 'output_safety' @@ -118,6 +124,7 @@ export type AbortSource = export function normalizeAbortSource(value: unknown): AbortSource { switch (value) { case 'user_stop': + case 'session_leave': case 'immediate_send': case 'input_safety': case 'output_safety': diff --git a/packages/agent-core/test/unit/event-bridge/converters.test.ts b/packages/agent-core/test/unit/event-bridge/converters.test.ts index cd81c1f7a..f8ea5993a 100644 --- a/packages/agent-core/test/unit/event-bridge/converters.test.ts +++ b/packages/agent-core/test/unit/event-bridge/converters.test.ts @@ -342,12 +342,14 @@ describe('event-bridge/converters: toolCallFromRuntime', () => { }); describe('event-bridge/converters: stream-event builders', () => { - it('buildRuntimeWarningEvent uses a SystemEvent envelope rather than model history content', () => { + it.each([undefined, 'system-message'])( + 'buildRuntimeWarningEvent preserves the optional category %s in a SystemEvent', (category) => { const event = buildRuntimeWarningEvent({ sessionId: fixture.sessionId, turnId: fixture.turnId, eventId: 'warning-1', runtimeSeq: 1, + category, message: 'A Plugin Hook failed open.', source: 'plugin-hook', hookEvent: 'PreToolUse', @@ -362,6 +364,7 @@ describe('event-bridge/converters: stream-event builders', () => { expect(envelope.agent_message.msg_type).toBe(MsgType.SystemEvent); expect(JSON.parse(envelope.agent_message.msg_content)).toEqual({ eventType: 'runtime.warning', + ...(category ? { category } : {}), message: 'A Plugin Hook failed open.', source: 'plugin-hook', hookEvent: 'PreToolUse', diff --git a/packages/local-runtime-v2/src/application/conversation/conversation-application.ts b/packages/local-runtime-v2/src/application/conversation/conversation-application.ts index 1f7c92f88..cce59a0c1 100644 --- a/packages/local-runtime-v2/src/application/conversation/conversation-application.ts +++ b/packages/local-runtime-v2/src/application/conversation/conversation-application.ts @@ -353,7 +353,7 @@ export class ConversationApplication { sessionId: req.id, ...(req.turnId ? { turnId: req.turnId } : {}), reason: req.reason ?? "user", - ...(abortSource === "user_stop" + ...(abortSource === "user_stop" || abortSource === "session_leave" ? { onAccepted: () => this.pauseActiveGoalForAbort(req.id), } diff --git a/packages/local-runtime-v2/src/compat/v1/runtime.ts b/packages/local-runtime-v2/src/compat/v1/runtime.ts index ba6c3f7f8..2315e60f7 100644 --- a/packages/local-runtime-v2/src/compat/v1/runtime.ts +++ b/packages/local-runtime-v2/src/compat/v1/runtime.ts @@ -5,6 +5,8 @@ import type { import { buildCompressedModelImageFromBuffer, closeAgentDb, + createUserStopCascade, + type UserStopCascade, createLocalWorkspaceGitFacade, emitSubagentTelemetry, listFileTree, @@ -337,6 +339,7 @@ export interface V1ServiceCompatibility extends V1ProcessLocalSupport { }): void; recover(recoveredTurnIds: readonly string[]): Promise; pollRecovery(): Promise; + readonly userStop: UserStopCascade; }; readonly conversation: { bind(conversation: RuntimeConversation): Promise; @@ -534,6 +537,10 @@ export function createV1RuntimeCompatibility( return api.backgroundTaskService.hasPendingStartupRecovery(); }, pollRecovery: () => api.backgroundTaskService.pollStartupLostTasks(), + userStop: createUserStopCascade({ + host: api, + pauseActiveGoal: (sessionId) => api.threadGoal.pauseActiveGoalForAbort(sessionId), + }), }, conversation: { bind: async (service) => { diff --git a/packages/local-runtime-v2/src/service/turn-system/agent-host/assembly/local-turn-plugin-hooks.test.ts b/packages/local-runtime-v2/src/service/turn-system/agent-host/assembly/local-turn-plugin-hooks.test.ts new file mode 100644 index 000000000..95b513952 --- /dev/null +++ b/packages/local-runtime-v2/src/service/turn-system/agent-host/assembly/local-turn-plugin-hooks.test.ts @@ -0,0 +1,115 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { PiEventWriter } from '@mavis/agent-core/pi-turn-runner'; +import { + createLocalPluginHookEventReporter, + emitLocalPluginHookWarnings, +} from './local-turn-plugin-hooks.js'; + +describe('Plugin Hook display messages', () => { + it('deduplicates diagnostics but retains system messages across tool calls', async () => { + const appendEvents = vi.fn( + async (_events: Parameters[0]) => undefined, + ); + const reporter = createLocalPluginHookEventReporter({ + writer: { appendEvents, pushRuntime: vi.fn() }, + sessionId: 'session-dedupe', + turnId: 'turn-dedupe', + }); + const result = { + decision: { + decision: 'allow' as const, + systemMessage: 'initial system message', + terminalSequence: '\u0007', + }, + diagnostics: [ + { + code: 'HOOK_INVALID_INPUT' as const, + pluginName: 'compatible-tools', + event: 'PostToolUse' as const, + sourcePath: '/plugin/hook.mjs', + declarationOrder: 0, + }, + ], + }; + const repeatedResult = { + ...result, + decision: { + ...result.decision, + systemMessage: 'a later system message from the same event category', + terminalSequence: '\u001b[0m', + }, + }; + + await emitLocalPluginHookWarnings({ + reporter, + sessionId: 'session-dedupe', + turnId: 'turn-dedupe', + event: 'PostToolUse', + result, + }); + await emitLocalPluginHookWarnings({ + reporter, + sessionId: 'session-dedupe', + turnId: 'turn-dedupe', + event: 'PostToolUse', + result: repeatedResult, + }); + + await emitLocalPluginHookWarnings({ + reporter, + sessionId: 'session-dedupe', + turnId: 'turn-dedupe', + event: 'PostToolUse', + result: repeatedResult, + }); + + expect(appendEvents).toHaveBeenCalledTimes(3); + const emitted = appendEvents.mock.calls.flatMap(([events]) => + events.map((event) => { + const envelope = JSON.parse(event.payload.stream_resp as string); + return { + id: envelope.agent_message.msg_id, + ...JSON.parse(envelope.agent_message.msg_content), + }; + }), + ); + const messages = emitted.filter((event) => event.category === 'system-message'); + expect(messages.map((event) => event.message)).toEqual([ + 'initial system message', + 'a later system message from the same event category', + 'a later system message from the same event category', + ]); + expect(new Set(messages.map((event) => event.id)).size).toBe(3); + expect(emitted.filter((event) => event.category === 'diagnostic')).toHaveLength(1); + expect(emitted.filter((event) => event.category === 'terminal-control')).toHaveLength(1); + const serialized = JSON.stringify(appendEvents.mock.calls[0]?.[0]); + expect(serialized).toContain('was skipped'); + expect(serialized).toContain('tool execution and result were not changed'); + expect(serialized).toContain('HOOK_INVALID_INPUT'); + }); + + it('preserves identical text in different categories and keeps delivery failure best-effort', async () => { + const appendEvents = vi.fn().mockResolvedValue(undefined); + const reporter = createLocalPluginHookEventReporter({ + writer: { appendEvents, pushRuntime: vi.fn() }, + sessionId: 'category-session', + turnId: 'category-turn', + }); + const input = { + reporter, + sessionId: 'category-session', + turnId: 'category-turn', + event: 'Stop' as const, + message: 'same text', + decision: { decision: 'allow' as const, systemMessage: 'same text' }, + }; + await emitLocalPluginHookWarnings(input); + expect(appendEvents.mock.calls[0]?.[0]).toHaveLength(2); + appendEvents.mockRejectedValueOnce(new Error('writer unavailable')); + await expect(emitLocalPluginHookWarnings(input)).resolves.toBeUndefined(); + expect(input.decision).toEqual({ + decision: 'allow', + systemMessage: 'same text', + }); + }); +}); diff --git a/packages/local-runtime-v2/src/service/turn-system/agent-host/assembly/local-turn-plugin-hooks.ts b/packages/local-runtime-v2/src/service/turn-system/agent-host/assembly/local-turn-plugin-hooks.ts index 4c2ebc321..558116d99 100644 --- a/packages/local-runtime-v2/src/service/turn-system/agent-host/assembly/local-turn-plugin-hooks.ts +++ b/packages/local-runtime-v2/src/service/turn-system/agent-host/assembly/local-turn-plugin-hooks.ts @@ -122,12 +122,17 @@ export async function emitLocalPluginHookWarnings(input: { const uniqueWarnings = [ ...new Map( warnings.map((warning) => [ - `${warning.message}\u0000${warning.terminalSequence ?? ''}`, + `${warning.category}\u0000${warning.message}\u0000${warning.terminalSequence ?? ''}`, warning, ]), ).values(), ] - .filter((warning) => !seen.has(warningDedupeKey(input, warning))) + // Each Hook invocation may intentionally repeat a user-facing message. + // Only diagnostics/control notices are deduplicated across invocations. + .filter( + (warning) => + warning.category === 'system-message' || !seen.has(warningDedupeKey(input, warning)), + ) .slice(0, MAX_PLUGIN_HOOK_WARNINGS_PER_EVENT); if (uniqueWarnings.length === 0) return; try { @@ -141,6 +146,7 @@ export async function emitLocalPluginHookWarnings(input: { runtimeSeq: reporter.nextRuntimeSeq(), message: warning.message, source: 'plugin-hook', + category: warning.category, hookEvent: input.event, ...(warning.code ? { code: warning.code } : {}), ...(warning.pluginName ? { pluginName: warning.pluginName } : {}), @@ -148,7 +154,9 @@ export async function emitLocalPluginHookWarnings(input: { }); }), ); - for (const warning of uniqueWarnings) seen.add(warningDedupeKey(input, warning)); + for (const warning of uniqueWarnings.filter((item) => item.category !== 'system-message')) { + seen.add(warningDedupeKey(input, warning)); + } } catch { // Warning delivery is best-effort and must not alter Hook control flow. } diff --git a/packages/local-runtime-v2/src/service/turn-system/contracts.ts b/packages/local-runtime-v2/src/service/turn-system/contracts.ts index eb5d65ef5..14012774f 100644 --- a/packages/local-runtime-v2/src/service/turn-system/contracts.ts +++ b/packages/local-runtime-v2/src/service/turn-system/contracts.ts @@ -590,8 +590,46 @@ export interface TurnSystemSessionCapabilities { }; } +/** + * Narrow seam for the user-stop cascade, implemented by the local Runtime + * composition. TurnSystem deliberately holds no background-task knowledge. + */ +export interface UserStopCascadePort { + /** + * Runs before the abort. Resolving `undefined` means "do not cascade" (child + * worker Session, unknown Session, or the hook itself failed). + */ + begin(sessionId: string): Promise; +} + +/** + * How a stop came to be accepted: the controller's identity check passed (it ran + * `onAccepted`), or nothing was running at all. A rejected stop is neither. + */ +export type UserStopAcceptance = 'controller_accepted' | 'not_running'; + +export interface UserStopCascadeRun { + /** + * Side effects a stop may only have once it is accepted (pausing the Goal). + * Idempotent; must never throw; resolves after the pause attempt. + */ + accept(how: UserStopAcceptance): Promise; + /** Detached follow-up; must never throw and never block the stop response. */ + complete(result: AbortTurnResult): void; + /** + * The abort itself failed, so there is nothing to cascade from. Releases the + * delivery window `begin` opened; must never throw. + */ + cancel(reason: string): void; +} + export interface InitializeTurnSystemOptions { readonly db: AppDb; + /** + * Host-injected user-stop cascade. Absent in embeddings/tests, which then keep + * exactly today's behaviour: a stop aborts the Turn and nothing else. + */ + readonly userStop?: UserStopCascadePort; readonly logger?: { info(fields: Record, message: string): void; warn(fields: Record, message: string): void; diff --git a/packages/local-runtime-v2/src/service/turn-system/execution/execution.coordinator.test.ts b/packages/local-runtime-v2/src/service/turn-system/execution/execution.coordinator.test.ts new file mode 100644 index 000000000..c5caa2766 --- /dev/null +++ b/packages/local-runtime-v2/src/service/turn-system/execution/execution.coordinator.test.ts @@ -0,0 +1,225 @@ +import { describe, expect, it, vi } from 'vitest'; + +import type { AgentHost } from '../agent-host/contracts.js'; +import type { TurnFailureProjection, TurnPreparationProjection } from '../contracts.js'; +import type { AcceptedAgentTurn, AcceptedCompactionTurn, TurnController } from './contracts.js'; +import type { TurnRepository } from '../persistence/contracts.js'; +import { + createExecutionCoordinator, + type ExecutionCoordinatorOptions, +} from './execution.coordinator.js'; + +describe('ExecutionCoordinator durable Queue pause terminal policy', () => { + it('commits a final Foreground Turn failure and QueuePaused through one repository settlement', async () => { + const harness = fixture(); + harness.host.run.mockResolvedValueOnce({ status: 'failed', error: new Error('model failed') }); + + const started = await harness.coordinator.startTurn({ + turn: agentTurn(), + request: { + input: { text: 'hello' }, + genuineUserQueryText: 'hello', + provenance: { source: 'api', routingFingerprint: 'api:failure' }, + }, + }); + await expect(started.completion).resolves.toMatchObject({ status: 'failed' }); + + expect(harness.repository.settle).toHaveBeenCalledWith({ + sessionId: 'session-1', + turnId: 'turn-1', + leaseId: 'lease-1', + outcome: 'failed', + queuePauseCause: 'turn-final-failure', + }); + }); + + it('pauses both pre-Host and accepted-admission final failures', async () => { + const harness = fixture(); + + const preHost = await harness.coordinator.failTurn({ + turn: agentTurn(), + error: new Error('pre-host failed'), + }); + await preHost.completion; + const failedAdmission = await harness.coordinator.failAdmission({ + admission: admission(), + error: new Error('controller registration failed'), + }); + await failedAdmission.completion; + + expect(harness.repository.settle).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ queuePauseCause: 'turn-final-failure' }), + ); + expect(harness.repository.settle).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ queuePauseCause: 'turn-final-failure' }), + ); + }); + + // Leaving the conversation still stops the live Turn and must pause like Stop. + it.each(['user_stop', 'session_leave'])( + 'pauses an aborted product Turn for a normalized %s controller signal', + async (reason) => { + const harness = fixture(); + harness.host.run.mockResolvedValueOnce({ status: 'aborted', reason: 'runner stopped' }); + + const started = await harness.coordinator.startTurn({ + turn: agentTurn(reason), + request: { + input: { text: 'hello' }, + genuineUserQueryText: 'hello', + provenance: { source: 'api', routingFingerprint: `api:${reason}` }, + }, + }); + await started.completion; + + expect(harness.repository.settle).toHaveBeenCalledWith( + expect.objectContaining({ queuePauseCause: 'user-stop' }), + ); + }, + ); + + it('does not pause natural completion, non-user abort, or failed compaction', async () => { + const harness = fixture(); + + const completed = await harness.coordinator.startTurn({ + turn: agentTurn(), + request: { + input: { text: 'hello' }, + genuineUserQueryText: 'hello', + provenance: { source: 'api', routingFingerprint: 'api:completed' }, + }, + }); + await completed.completion; + + harness.host.run.mockResolvedValueOnce({ status: 'aborted', reason: 'runner stopped' }); + const aborted = await harness.coordinator.startTurn({ + turn: agentTurn('immediate_send'), + request: { + input: { text: 'hello again' }, + genuineUserQueryText: 'hello again', + provenance: { source: 'api', routingFingerprint: 'api:immediate-send' }, + }, + }); + await aborted.completion; + + harness.host.compact.mockResolvedValueOnce({ status: 'failed', error: new Error('compact') }); + await harness.coordinator.compact({ lease: compactionTurn(), reason: 'manual' }); + + expect(harness.repository.settle.mock.calls.map(([input]) => input.queuePauseCause)).toEqual([ + undefined, + undefined, + undefined, + ]); + }); + + it('fails closed without releasing Turn ownership when atomic terminal settlement fails', async () => { + const harness = fixture(); + const pauseError = new Error('queue pause unavailable'); + harness.host.run.mockResolvedValueOnce({ status: 'failed', error: new Error('model failed') }); + harness.repository.settle.mockRejectedValueOnce(pauseError).mockRejectedValueOnce(pauseError); + + const started = await harness.coordinator.startTurn({ + turn: agentTurn(), + request: { + input: { text: 'hello' }, + genuineUserQueryText: 'hello', + provenance: { source: 'api', routingFingerprint: 'api:pause-failure' }, + }, + }); + + await expect(started.completion).resolves.toEqual({ status: 'failed', error: pauseError }); + expect(harness.repository.settle).toHaveBeenCalledTimes(2); + expect(harness.controller.complete).not.toHaveBeenCalled(); + expect(harness.released.publish).not.toHaveBeenCalled(); + }); +}); + +function fixture(overrides: Partial = {}) { + const host = { + run: vi.fn(async () => ({ status: 'completed' })), + compact: vi.fn(async () => ({ + status: 'unchanged', + reason: 'nothing-to-compact', + })), + }; + const repository = { + settle: vi.fn(async ({ outcome }) => ({ + status: 'settled' as const, + completedAtMs: 100, + outcome, + })), + }; + const controller = { + complete: vi.fn(), + }; + const failures = { + project: vi.fn(async () => undefined), + }; + const preparation = { + projectStarted: vi.fn(async () => undefined), + }; + const released = { publish: vi.fn(async () => undefined) }; + const turnSettlement = { + settle: vi.fn(async () => undefined), + abandon: vi.fn(async () => undefined), + }; + const onBeginSettlement = vi.fn(); + const options: ExecutionCoordinatorOptions = { + onBeginSettlement, + host, + repository, + controller, + preparation, + failures, + released, + turnSettlement, + ...overrides, + }; + return { + onBeginSettlement, + host, + repository, + controller, + failures, + released, + turnSettlement, + coordinator: createExecutionCoordinator(options), + preparation, + }; +} + +function admission() { + return { + sessionId: 'session-1', + turnId: 'turn-1', + leaseId: 'lease-1', + acceptedSequence: 1, + acceptedAtMs: 50, + busyReason: 'turn' as const, + foreground: true as const, + }; +} + +function agentTurn(abortReason?: string): AcceptedAgentTurn { + const controller = new AbortController(); + if (abortReason) controller.abort(abortReason); + return { + ...admission(), + signal: controller.signal, + }; +} + +function compactionTurn(): AcceptedCompactionTurn { + const turn = agentTurn(); + return { + sessionId: turn.sessionId, + turnId: turn.turnId, + leaseId: turn.leaseId, + acceptedSequence: turn.acceptedSequence, + acceptedAtMs: turn.acceptedAtMs, + busyReason: 'compaction', + signal: turn.signal, + }; +} diff --git a/packages/local-runtime-v2/src/service/turn-system/execution/execution.coordinator.ts b/packages/local-runtime-v2/src/service/turn-system/execution/execution.coordinator.ts index 6c08f927c..98f874187 100644 --- a/packages/local-runtime-v2/src/service/turn-system/execution/execution.coordinator.ts +++ b/packages/local-runtime-v2/src/service/turn-system/execution/execution.coordinator.ts @@ -1,5 +1,3 @@ -import { normalizeAbortSource } from '@mavis/agent-core/pi-turn-runner'; - import type { AgentHost, AgentHostTurnOutcome, @@ -14,6 +12,7 @@ import type { RegisterAcceptedTurnInput, TurnController, } from './contracts.js'; +import { pausesQueueOnAbort } from './queue-pause-on-abort.js'; import type { TurnReleasedSignal } from '../lifecycle/turn-released-signal.js'; import type { TurnRepository, TurnTerminalOutcome } from '../persistence/contracts.js'; @@ -304,7 +303,7 @@ function terminalQueuePauseCause(input: { if ( input.outcome === 'aborted' && input.controllerTurn?.busyReason === 'turn' && - normalizeAbortSource(input.controllerTurn.signal.reason) === 'user_stop' + pausesQueueOnAbort(input.controllerTurn.signal.reason) ) { return 'user-stop'; } diff --git a/packages/local-runtime-v2/src/service/turn-system/execution/queue-pause-on-abort.test.ts b/packages/local-runtime-v2/src/service/turn-system/execution/queue-pause-on-abort.test.ts new file mode 100644 index 000000000..a5e59873a --- /dev/null +++ b/packages/local-runtime-v2/src/service/turn-system/execution/queue-pause-on-abort.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest'; + +import { pausesQueueOnAbort } from './queue-pause-on-abort.js'; + +describe('pausesQueueOnAbort', () => { + it.each(['user_stop', 'session_leave'])('pauses the queue after a %s abort', (reason) => { + expect(pausesQueueOnAbort(reason)).toBe(true); + }); + + // Runtime-driven aborts and raw, un-normalized reasons never pause: only the user + // stopping or leaving the conversation means pending work must wait for them. + it.each([ + 'immediate_send', + 'input_safety', + 'output_safety', + 'lifecycle', + 'runtime-shutdown', + 'user-stop', + undefined, + ])('leaves the queue running after %s', (reason) => { + expect(pausesQueueOnAbort(reason)).toBe(false); + }); +}); diff --git a/packages/local-runtime-v2/src/service/turn-system/execution/queue-pause-on-abort.ts b/packages/local-runtime-v2/src/service/turn-system/execution/queue-pause-on-abort.ts new file mode 100644 index 000000000..4491fc350 --- /dev/null +++ b/packages/local-runtime-v2/src/service/turn-system/execution/queue-pause-on-abort.ts @@ -0,0 +1,18 @@ +import { normalizeAbortSource } from '@mavis/agent-core/pi-turn-runner'; + +/** + * Abort sources after which the Session queue must not drain on its own. An + * explicit stop and leaving the conversation (TUI `/clear` or switching Sessions, + * both of which still stop the live Turn) each mean the user stopped attending to + * this Session, so its pending instructions wait for them instead of + * auto-dispatching. The two differ only in whether background work is cascaded. + * Both pause with the `user-stop` cause, the only user-driven cause the queue + * schema accepts. + * + * Turn settlement and the steering fallback requeue both decide through this one + * predicate, so the two pause decisions cannot drift apart again. + */ +export function pausesQueueOnAbort(reason: unknown): boolean { + const source = normalizeAbortSource(reason); + return source === 'user_stop' || source === 'session_leave'; +} diff --git a/packages/local-runtime-v2/src/service/turn-system/execution/steering/steering-requeue.test.ts b/packages/local-runtime-v2/src/service/turn-system/execution/steering/steering-requeue.test.ts new file mode 100644 index 000000000..aeaf8a6b5 --- /dev/null +++ b/packages/local-runtime-v2/src/service/turn-system/execution/steering/steering-requeue.test.ts @@ -0,0 +1,344 @@ +import { describe, expect, it, vi } from 'vitest'; + +import type { + QueueEnqueueInput, + QueueEnqueueResult, + QueueItem, +} from '../../../session-system/index.js'; +import { + createSteeringRequeue, + type DiscardedSteeringBatch, + type SteeringRequeueOptions, +} from './steering-requeue.js'; +import { createSteeringTeardownCommit } from './steering-teardown-commit.js'; + +describe('createSteeringRequeue teardown fallback (Decision v5)', () => { + it('returns the batch to the queue after bounded teardown retries fail (TS-70)', async () => { + const logger = { warn: vi.fn() }; + const enqueue = enqueueOk(); + const publish = vi.fn(async (_sessionId: string) => undefined); + const teardown = { + commit: vi.fn(async () => { + throw new Error('history write failed'); + }), + }; + const requeue = createSteeringRequeue({ + queue: queuePorts({ enqueue }), + released: { publish }, + teardown, + logger, + }); + + requeue.discard({ + ...batch([steering('composer-steer', 'req-1', 'stopped instruction')]), + cause: 'abort', + abortReason: 'user_stop', + }); + + // Losing an admitted user message outweighs the fall-to-session shape: + // after bounded retries the batch goes back to the queue and warns. + await vi.waitFor(() => expect(enqueue).toHaveBeenCalledTimes(1)); + expect(teardown.commit).toHaveBeenCalledTimes(2); + expect(logger.warn).toHaveBeenCalledWith( + expect.objectContaining({ sessionId: 'session-1', turnId: 'turn-1' }), + expect.any(String), + ); + await requeue.drain(); + }); + + it('parks the fallback-requeued abort batch as queued without publishing a wake (TS-70)', async () => { + const enqueue = enqueueOk(); + const publish = vi.fn(async (_sessionId: string) => undefined); + const teardown = { + commit: vi.fn(async () => { + throw new Error('history write failed'); + }), + }; + const requeue = createSteeringRequeue({ + queue: queuePorts({ enqueue }), + released: { publish }, + teardown, + logger: { warn: vi.fn() }, + }); + + requeue.discard({ + ...batch([steering('composer-steer', 'req-1', 'stopped instruction')]), + cause: 'abort', + abortReason: 'user_stop', + }); + + await vi.waitFor(() => expect(enqueue).toHaveBeenCalledTimes(1)); + await requeue.drain(); + await new Promise((resolve) => setTimeout(resolve, 0)); + // A wake here would auto-dispatch the instruction the user just stopped. + // The item stays queued and visible for a manual send instead. + expect(publish).not.toHaveBeenCalled(); + }); + + // Leaving the conversation (TUI `/clear`, switching Sessions) still stops the + // live Turn and pauses at settlement exactly like Stop, so its fallback must too. + it.each(['user_stop', 'session_leave'])( + 'restores the durable user-stop pause after a %s fallback requeue commits (TS-70)', + async (abortReason) => { + const enqueue = enqueueOk(); + const pauseIfPending = vi.fn(async () => ({ pendingCount: 1 })); + const publish = vi.fn(async (_sessionId: string) => undefined); + const teardown = { + commit: vi.fn(async () => { + throw new Error('history write failed'); + }), + }; + const requeue = createSteeringRequeue({ + queue: queuePorts({ enqueue, pauseIfPending }), + released: { publish }, + teardown, + logger: { warn: vi.fn() }, + }); + + requeue.discard({ + ...batch([steering('composer-steer', 'req-1', 'stopped instruction')]), + cause: 'abort', + abortReason, + }); + + // The fallback lane re-parks the queue exactly like Stop over pending + // items: settlement may have judged the pause with an empty queue before + // this detached requeue landed. It still never wakes dispatch. + await vi.waitFor(() => expect(enqueue).toHaveBeenCalledTimes(1)); + await vi.waitFor(() => expect(pauseIfPending).toHaveBeenCalledTimes(1)); + expect(pauseIfPending).toHaveBeenCalledWith({ + sessionId: 'session-1', + cause: 'user-stop', + triggerTurnId: 'turn-1', + }); + expect(publish).not.toHaveBeenCalled(); + }, + ); + + it('requeues a lifecycle abort fallback without pausing the queue (TS-70)', async () => { + const enqueue = enqueueOk(); + const pauseIfPending = vi.fn(async () => ({ pendingCount: 1 })); + const requeue = createSteeringRequeue({ + queue: queuePorts({ enqueue, pauseIfPending }), + released: { publish: vi.fn(async () => undefined) }, + teardown: { + commit: vi.fn(async () => { + throw new Error('history write failed'); + }), + }, + logger: { warn: vi.fn() }, + }); + + requeue.discard({ + ...batch([steering('composer-steer', 'req-1', 'interrupted instruction')]), + cause: 'abort', + abortReason: 'lifecycle', + }); + + // A runtime-driven abort is not the user walking away: settlement does not + // pause for it, so neither may its steering fallback. + await vi.waitFor(() => expect(enqueue).toHaveBeenCalledTimes(1)); + await requeue.drain(); + expect(pauseIfPending).not.toHaveBeenCalled(); + }); +}); + +describe('createSteeringRequeue teardown fallback retries (Decision v5)', () => { + it('keeps the fallback batch in the retry loop until the pause restore lands (TS-70)', async () => { + const logger = { warn: vi.fn() }; + // Mirrors the repo's identity absorption (clientRequestId replay / + // itemId restore): a retried enqueue of the same batch identity replaces + // the row instead of adding a second queue entry. + const queued = new Map(); + const enqueue = vi.fn(async (input: QueueEnqueueInput) => { + queued.set(`${String(input.itemId)}:${String(input.clientRequestId)}`, input); + return enqueueResult(input); + }); + const pauseIfPending = vi + .fn(async () => ({ pendingCount: 1 })) + .mockRejectedValueOnce(new Error('pause lane unavailable')); + const teardown = { + commit: vi.fn(async () => { + throw new Error('history write failed'); + }), + }; + const requeue = createSteeringRequeue({ + queue: queuePorts({ enqueue, pauseIfPending }), + released: { publish: vi.fn(async () => undefined) }, + teardown, + logger, + }); + + requeue.discard({ + ...batch([steering('composer-steer', 'req-1', 'stopped instruction')]), + cause: 'abort', + abortReason: 'user_stop', + }); + + // The enqueue landed but the user-stop pause did not: the batch must + // stay un-settled so the existing pending/drain retry lanes restore the + // pause — otherwise a later wake auto-dispatches the instruction the + // user just stopped, with no retry left to prevent it. + await vi.waitFor(() => expect(pauseIfPending).toHaveBeenCalledTimes(1)); + expect(logger.warn).toHaveBeenCalledWith( + expect.objectContaining({ sessionId: 'session-1', turnId: 'turn-1' }), + expect.any(String), + ); + await requeue.drain(); + expect(pauseIfPending).toHaveBeenCalledTimes(2); + // The retry replays the identical batch identity; the queue absorbs it + // into the same entry, so the panel still shows one parked item. + expect(enqueue).toHaveBeenCalledTimes(2); + expect(enqueue.mock.calls[0]).toEqual(enqueue.mock.calls[1]); + expect(queued.size).toBe(1); + // The pause landed: the batch settles and later drains find nothing. + await requeue.drain(); + expect(enqueue).toHaveBeenCalledTimes(2); + expect(pauseIfPending).toHaveBeenCalledTimes(2); + }); + + it('never settles or throws while the pause restore keeps failing (TS-70)', async () => { + const enqueue = enqueueOk(); + const publish = vi.fn(async (_sessionId: string) => undefined); + const pauseIfPending = vi.fn(async () => { + throw new Error('pause lane unavailable'); + }); + const teardown = { + commit: vi.fn(async () => { + throw new Error('history write failed'); + }), + }; + const requeue = createSteeringRequeue({ + queue: queuePorts({ enqueue, pauseIfPending }), + released: { publish }, + teardown, + logger: { warn: vi.fn() }, + }); + + requeue.discard({ + ...batch([steering('composer-steer', 'req-1', 'stopped instruction')]), + cause: 'abort', + abortReason: 'user_stop', + }); + + // Status quo: each drain retries the identical hand-off once and leaves + // the batch pending — never a throw, never a premature settle, never a + // wake. A shutdown drain that ends here leaves the durable queued row + // without its pause; the in-memory retry dies with the process. + await vi.waitFor(() => expect(pauseIfPending).toHaveBeenCalledTimes(1)); + await requeue.drain(); + await requeue.drain(); + expect(pauseIfPending).toHaveBeenCalledTimes(3); + expect(enqueue).toHaveBeenCalledTimes(3); + expect(enqueue.mock.calls[0]).toEqual(enqueue.mock.calls[1]); + expect(enqueue.mock.calls[0]).toEqual(enqueue.mock.calls[2]); + expect(publish).not.toHaveBeenCalled(); + }); + + it('degrades to the queue lane with a warn when no teardown sink is wired', async () => { + const logger = { warn: vi.fn() }; + const enqueue = enqueueOk(); + const requeue = createSteeringRequeue({ + queue: queuePorts({ enqueue }), + released: { publish: vi.fn(async () => undefined) }, + logger, + }); + + requeue.discard({ + ...batch([steering('composer-steer', 'req-1', 'stopped instruction')]), + cause: 'abort', + abortReason: 'user_stop', + }); + + await vi.waitFor(() => expect(enqueue).toHaveBeenCalledTimes(1)); + expect(logger.warn).toHaveBeenCalledWith( + expect.objectContaining({ sessionId: 'session-1', turnId: 'turn-1' }), + expect.any(String), + ); + }); + + it('leaves the wake lane intact for an exit-boundary close discard (TS-64)', async () => { + const publish = vi.fn(async (_sessionId: string) => undefined); + const pauseIfPending = vi.fn(async () => ({ pendingCount: 0 })); + const requeue = createSteeringRequeue({ + queue: queuePorts({ pauseIfPending }), + released: { publish }, + }); + + requeue.discard(batch([steering('composer-steer', 'req-1', 'follow-up query')])); + + await vi.waitFor(() => expect(publish).toHaveBeenCalledTimes(1)); + // Exit-close keeps the v2 lane: requeue + wake, never a stop pause. + expect(pauseIfPending).not.toHaveBeenCalled(); + }); +}); + +type AgentHostSteeringMessage = DiscardedSteeringBatch['messages'][number]; + +type TeardownCommitInput = Parameters['commit']>[0]; + +function queuePorts( + overrides: Partial = {}, +): SteeringRequeueOptions['queue'] { + return { + requireMutableSession: vi.fn(async () => ({ + sessionId: 'session-1', + agentName: 'general', + runtime: 'pi-agent', + })), + enqueue: enqueueOk(), + pauseIfPending: vi.fn(async () => ({ pendingCount: 1 })), + ...overrides, + } as SteeringRequeueOptions['queue']; +} + +function enqueueOk() { + return vi.fn(async (input: QueueEnqueueInput) => enqueueResult(input)); +} + +function enqueueResult(input: QueueEnqueueInput): QueueEnqueueResult { + return { + item: { + itemId: input.itemId ?? 'queue-generated', + sessionId: input.session.sessionId, + agentName: input.session.agentName, + source: input.source ?? 'api', + status: 'queued', + message: input.message, + createdAt: 10, + } as QueueItem, + position: 0, + }; +} + +function batch(messages: readonly AgentHostSteeringMessage[]): DiscardedSteeringBatch { + return { + sessionId: 'session-1', + turnId: 'turn-1', + messages, + turnReleased: Promise.resolve(), + }; +} + +function steering( + producerId: string, + idempotencyKey: string, + text: string, +): AgentHostSteeringMessage { + return { + producerId, + idempotencyKey, + userMessageId: `user-${idempotencyKey}` as AgentHostSteeringMessage['userMessageId'], + message: { text }, + genuineUserQueryText: text, + provenance: { source: 'api', routingFingerprint: `api:${idempotencyKey}` }, + }; +} + +function deferredVoid(): { readonly promise: Promise; readonly resolve: () => void } { + let resolvePromise: (() => void) | undefined; + const promise = new Promise((resolve) => { + resolvePromise = resolve; + }); + return { promise, resolve: () => resolvePromise?.() }; +} diff --git a/packages/local-runtime-v2/src/service/turn-system/execution/steering/steering-requeue.ts b/packages/local-runtime-v2/src/service/turn-system/execution/steering/steering-requeue.ts index fe6ef5de8..197b2574b 100644 --- a/packages/local-runtime-v2/src/service/turn-system/execution/steering/steering-requeue.ts +++ b/packages/local-runtime-v2/src/service/turn-system/execution/steering/steering-requeue.ts @@ -1,7 +1,7 @@ import { createHash } from 'node:crypto'; import { basename } from 'node:path'; -import { normalizeAbortSource } from '@mavis/agent-core/pi-turn-runner'; +import { pausesQueueOnAbort } from '../queue-pause-on-abort.js'; import { createUserMessageId, isQueueMessageSource } from '../../../session-system/index.js'; import type { @@ -439,7 +439,7 @@ async function persistRequeue( toBatchRequeueInput(batch, session.agentName, messages), ); if (result === undefined) throw new Error('Immediate-send batch enqueue rejected'); - // A user-stop abort that fell back to the queue must leave the queue + // A user stop or session leave that fell back to the queue must leave the queue // paused exactly like Stop over pending items: settlement may have judged // the pause with an empty queue before this detached requeue landed, and // without the pause a later wake would auto-dispatch the instruction the @@ -450,7 +450,7 @@ async function persistRequeue( // absorbed by the queue's clientRequestId replay / itemId restore, so the // replay costs one write and buys back the lost pause. Pausing real // queued items at settlement stays with the execution coordinator. - if (normalizeAbortSource(batch.abortReason) === 'user_stop') { + if (pausesQueueOnAbort(batch.abortReason)) { await options.queue.pauseIfPending({ sessionId: batch.sessionId, cause: 'user-stop', diff --git a/packages/local-runtime-v2/src/service/turn-system/execution/turn-execution.service.test.ts b/packages/local-runtime-v2/src/service/turn-system/execution/turn-execution.service.test.ts new file mode 100644 index 000000000..b669fdacd --- /dev/null +++ b/packages/local-runtime-v2/src/service/turn-system/execution/turn-execution.service.test.ts @@ -0,0 +1,406 @@ +import { describe, expect, it, vi } from 'vitest'; + +import type { AgentHostTurnOutcome } from '../agent-host/contracts.js'; +import type { TurnSubmissionPreparation } from '../contracts.js'; +import { createSessionOperationGate } from '../lifecycle/session-operation-gate.js'; +import { digestTurnInput } from './turn-input-identity.js'; +import type { TurnRepository } from '../persistence/contracts.js'; +import type { ExecutionCoordinator, TurnController } from './contracts.js'; +import { + createTurnExecutionService, + type TurnExecutionServiceOptions, +} from './turn-execution.service.js'; + +describe('user stop cascade trigger', () => { + it('cascades an explicit user stop and hands the abort result to the follow-up', async () => { + const fixture = harness(); + fixture.controller.abort.mockResolvedValue({ status: 'aborted', turnId: 'turn-1' }); + + await fixture.service.abort({ sessionId: 'session-1', reason: 'user_stop' }); + + expect(fixture.cascadeBegin).toHaveBeenCalledWith('session-1'); + expect(fixture.cascadeComplete).toHaveBeenCalledWith({ + status: 'aborted', + turnId: 'turn-1', + }); + }); + + it('opens the cascade before aborting, so a task driven terminal cannot slip a delivery through', async () => { + const fixture = harness(); + const order: string[] = []; + fixture.cascadeBegin.mockImplementation(async () => { + order.push('cascade-begin'); + return { + accept: vi.fn(async () => undefined), + complete: vi.fn(() => { + order.push('cascade-complete'); + }), + cancel: vi.fn(), + }; + }); + fixture.controller.abort.mockImplementation(async () => { + order.push('abort'); + return { status: 'aborted', turnId: 'turn-1' }; + }); + + await fixture.service.abort({ sessionId: 'session-1', reason: 'user_stop' }); + + expect(order).toEqual(['cascade-begin', 'abort', 'cascade-complete']); + }); + + it.each(['session_leave', 'immediate_send', 'lifecycle', 'unknown', 'user'])( + 'does not cascade a %s abort', + async (reason) => { + const fixture = harness(); + fixture.controller.abort.mockResolvedValue({ status: 'aborted', turnId: 'turn-1' }); + + await fixture.service.abort({ sessionId: 'session-1', reason }); + + expect(fixture.cascadeBegin).not.toHaveBeenCalled(); + }, + ); + + it('still cascades when the Turn is already gone (not-running)', async () => { + // The Turn finished but background work can still be running. + const fixture = harness(); + fixture.controller.abort.mockResolvedValue({ status: 'not-running' }); + + await fixture.service.abort({ sessionId: 'session-1', reason: 'user_stop' }); + + expect(fixture.cascadeComplete).toHaveBeenCalledWith({ status: 'not-running' }); + }); + + it('does not open a window or cascade for a stop naming an older Turn', async () => { + // A phone still showing the previous Turn stops it while a newer Turn runs. + // The controller rejects that as turn-mismatch (409) before any side effect; + // cascading would kill the background work of the Turn the user now sees. + const fixture = harness(); + fixture.controller.activeTurnId.mockReturnValue('turn-new'); + fixture.controller.abort.mockResolvedValue({ status: 'turn-mismatch' }); + + await expect( + fixture.service.abort({ sessionId: 'session-1', turnId: 'turn-old', reason: 'user_stop' }), + ).resolves.toEqual({ status: 'turn-mismatch' }); + + expect(fixture.cascadeBegin).not.toHaveBeenCalled(); + expect(fixture.cascadeAccept).not.toHaveBeenCalled(); + expect(fixture.cascadeComplete).not.toHaveBeenCalled(); + expect(fixture.controller.abort).toHaveBeenCalledWith( + expect.objectContaining({ turnId: 'turn-old', reason: 'user_stop' }), + ); + expect(fixture.loggerInfo).toHaveBeenCalledWith( + { + event: 'user_stop_cascade_skipped', + session_id: 'session-1', + requested_turn_id: 'turn-old', + active_turn_id: 'turn-new', + reason: 'stale_turn_id', + }, + expect.any(String), + ); + }); + + it.each([ + ['no turn id', undefined, 'turn-live'], + ['the running turn id', 'turn-live', 'turn-live'], + ['a turn id while nothing runs', 'turn-old', undefined], + ] as const)('opens the cascade for a stop naming %s', async (_label, turnId, activeTurnId) => { + const fixture = harness(); + fixture.controller.activeTurnId.mockReturnValue(activeTurnId); + fixture.controller.abort.mockResolvedValue({ status: 'not-running' }); + + await fixture.service.abort({ + sessionId: 'session-1', + ...(turnId ? { turnId } : {}), + reason: 'user_stop', + }); + + expect(fixture.cascadeBegin).toHaveBeenCalledWith('session-1'); + expect(fixture.loggerInfo).not.toHaveBeenCalled(); + }); + + it('accepts once, after the caller onAccepted, when the controller accepts the stop', async () => { + const fixture = harness(); + const order: string[] = []; + const callerOnAccepted = vi.fn(async () => { + order.push('caller-onAccepted'); + }); + fixture.cascadeAccept.mockImplementation(async () => { + order.push('cascade-accept'); + }); + fixture.controller.abort.mockImplementation(async (input) => { + // The controller runs `onAccepted` only after its identity check passed. + await input.onAccepted?.(); + return { status: 'aborted', turnId: 'turn-1' }; + }); + + await fixture.service.abort({ + sessionId: 'session-1', + reason: 'user_stop', + onAccepted: callerOnAccepted, + }); + + expect(callerOnAccepted).toHaveBeenCalledTimes(1); + expect(fixture.cascadeAccept).toHaveBeenCalledTimes(1); + expect(fixture.cascadeAccept).toHaveBeenCalledWith('controller_accepted'); + expect(order).toEqual(['caller-onAccepted', 'cascade-accept']); + expect(fixture.cascadeComplete).toHaveBeenCalled(); + }); + + it('accepts before cascading when nothing was running', async () => { + // The controller never runs `onAccepted` for not-running, yet the Goal must be + // paused before the cascade stops tasks, or it restarts on their wake-ups. + const fixture = harness(); + const order: string[] = []; + fixture.cascadeAccept.mockImplementation(async () => { + order.push('accept'); + }); + fixture.cascadeComplete.mockImplementation(() => { + order.push('complete'); + }); + fixture.controller.abort.mockResolvedValue({ status: 'not-running' }); + + await fixture.service.abort({ sessionId: 'session-1', reason: 'user_stop' }); + + expect(fixture.cascadeAccept).toHaveBeenCalledWith('not_running'); + expect(order).toEqual(['accept', 'complete']); + }); + + it('releases the window without cascading when a newer Turn wins the race', async () => { + // Nothing was running at the identity check, so `begin` ran; a new Turn then + // registered before the abort landed and the controller rejected the stop. + const fixture = harness(); + fixture.controller.activeTurnId.mockReturnValue(undefined); + fixture.controller.abort.mockResolvedValue({ status: 'turn-mismatch' }); + + await expect( + fixture.service.abort({ sessionId: 'session-1', turnId: 'turn-old', reason: 'user_stop' }), + ).resolves.toEqual({ status: 'turn-mismatch' }); + + expect(fixture.cascadeBegin).toHaveBeenCalled(); + // A rejected stop must not touch the Goal: nothing may run `accept`. + expect(fixture.cascadeAccept).not.toHaveBeenCalled(); + expect(fixture.cascadeCancel).toHaveBeenCalledWith('turn_mismatch'); + expect(fixture.cascadeComplete).not.toHaveBeenCalled(); + }); + + it('releases the cascade window and rethrows unchanged when the abort itself fails', async () => { + // `begin` opened the delivery window before the abort. If the abort rejects, + // `complete` never runs, so without an explicit release the window would pin + // every later completion notice of this Session as `busy` forever. + const fixture = harness(); + const failure = new Error('onAccepted blew up'); + fixture.controller.abort.mockRejectedValue(failure); + + await expect( + fixture.service.abort({ sessionId: 'session-1', reason: 'user_stop' }), + ).rejects.toBe(failure); + + expect(fixture.cascadeCancel).toHaveBeenCalledWith('abort_failed'); + expect(fixture.cascadeComplete).not.toHaveBeenCalled(); + }); + + it('still rethrows the original abort error when releasing the cascade throws', async () => { + const fixture = harness(); + const failure = new Error('abort failed'); + fixture.controller.abort.mockRejectedValue(failure); + fixture.cascadeCancel.mockImplementation(() => { + throw new Error('cancel contract violated'); + }); + + await expect( + fixture.service.abort({ sessionId: 'session-1', reason: 'user_stop' }), + ).rejects.toBe(failure); + }); + + it('still aborts when the cascade hook itself fails', async () => { + const fixture = harness(); + fixture.cascadeBegin.mockRejectedValue(new Error('cascade unavailable')); + fixture.controller.abort.mockResolvedValue({ status: 'aborted', turnId: 'turn-1' }); + + await expect( + fixture.service.abort({ sessionId: 'session-1', reason: 'user_stop' }), + ).resolves.toEqual({ status: 'aborted', turnId: 'turn-1' }); + expect(fixture.controller.abort).toHaveBeenCalled(); + }); +}); + +function harness() { + const commit = vi.fn(async () => undefined); + const rollback = vi.fn(async () => undefined); + const compensate = vi.fn(async () => undefined); + const preparation = { + commit, + rollback, + compensate, + prepare: vi.fn(async () => ({ + status: 'ready', + commit, + rollback, + compensate, + })), + }; + const repository = { + tryAcquireSessionMaintenance: vi.fn( + async () => undefined, + ), + renewSessionMaintenance: vi.fn(async () => true), + releaseSessionMaintenance: vi.fn( + async () => undefined, + ), + admit: vi.fn(async () => ({ + status: 'accepted', + leaseId: 'lease-1', + acceptedSequence: 1, + acceptedAtMs: 100, + })), + renew: vi.fn(async () => true), + settle: vi.fn(async ({ outcome }) => ({ + status: 'settled', + completedAtMs: 100, + outcome, + })), + recoverExpired: vi.fn(async () => ({ + released: false, + terminalFacts: [], + })), + recoverProcessRestart: vi.fn(async () => ({ + recovered: [], + terminalFacts: [], + })), + findActiveTurn: vi.fn(async () => undefined), + findLatestTurnActivity: vi.fn(async () => undefined), + preparePluginHookSessionOwnership: vi.fn( + async () => undefined, + ), + activatePluginHookSessionOwnership: vi.fn( + async () => undefined, + ), + findLatestPluginHookSessionOwnership: vi.fn< + TurnRepository['findLatestPluginHookSessionOwnership'] + >(async () => undefined), + tryClaimPluginHookSessionEnd: vi.fn( + async () => ({ status: 'claimed' }), + ), + completePluginHookSessionEnd: vi.fn( + async () => true, + ), + findReceipt: vi.fn(async () => undefined), + findSteeringReceipt: vi.fn(async () => undefined), + reserveSteeringReceipt: vi.fn(async () => ({ + status: 'not-accepted', + })), + releaseSteeringReceipt: vi.fn(async () => undefined), + revokeAdmission: vi.fn(async () => true), + beginSessionDeletion: vi.fn(async () => ({ + status: 'quiescent', + })), + readSessionDeletion: vi.fn(async () => ({ + status: 'not-started', + })), + isSessionDeleting: vi.fn(async () => false), + deleteSessionData: vi.fn(async () => undefined), + completeSessionDeletion: vi.fn( + async () => undefined, + ), + isAcceptedInTransaction: vi.fn(() => true), + markAcknowledgedInTransaction: vi.fn(), + }; + const lease = { + sessionId: 'session-1', + turnId: 'turn-1', + leaseId: 'lease-1', + acceptedSequence: 1, + acceptedAtMs: 100, + busyReason: 'turn' as const, + signal: new AbortController().signal, + }; + const controller = { + register: vi.fn(() => lease), + abort: vi.fn(async () => ({ status: 'not-running' })), + steerActiveTurn: vi.fn(async () => ({ + status: 'not-running', + })), + steerToolResultTail: vi.fn(async () => ({ + status: 'not-running', + })), + beginClose: vi.fn(() => ({ closed: true })), + complete: vi.fn(), + activeTurnId: vi.fn(() => undefined), + close: vi.fn(async () => undefined), + }; + const completion: Promise = Promise.resolve({ status: 'completed' }); + const coordinator = { + startTurn: vi.fn(async () => ({ completion })), + failTurn: vi.fn(async ({ error }) => ({ + completion: Promise.resolve({ status: 'failed', error }), + })), + failAdmission: vi.fn(async ({ error }) => ({ + completion: Promise.resolve({ status: 'failed', error }), + })), + compact: vi.fn(async () => ({ + status: 'unchanged', + reason: 'nothing-to-compact', + })), + }; + const sessionPreparation = { projectStarted: vi.fn(async () => undefined) }; + const failures = { project: vi.fn(async () => undefined) }; + const operations = createSessionOperationGate(); + const onInterruptSendReleased = vi.fn(async () => undefined); + const cascadeComplete = vi.fn(); + const cascadeCancel = vi.fn(); + const cascadeAccept = vi.fn(async () => undefined); + const loggerInfo = vi.fn(); + const cascadeBegin = vi.fn(async () => ({ + accept: cascadeAccept, + complete: cascadeComplete, + cancel: cascadeCancel, + })); + const options = { + repository, + controller: controller as unknown as TurnController, + coordinator, + operations, + onInterruptSendReleased, + sessions: { has: vi.fn(async () => true) }, + submissionPreparation: preparation, + preparation: sessionPreparation, + failures, + nowMs: () => 1, + makeTurnId: () => 'turn-1', + userStop: { begin: cascadeBegin }, + logger: { info: loggerInfo }, + } satisfies TurnExecutionServiceOptions; + return { + repository, + controller, + coordinator, + operations, + preparation, + sessionPreparation, + failures, + onInterruptSendReleased, + cascadeBegin, + cascadeComplete, + cascadeCancel, + cascadeAccept, + loggerInfo, + service: createTurnExecutionService(options), + }; +} + +function deferred() { + let resolvePromise: (() => void) | undefined; + const promise = new Promise((resolve) => { + resolvePromise = resolve; + }); + return { promise, resolve: () => resolvePromise?.() }; +} + +function deferredValue(value: T) { + let resolvePromise: ((value: T) => void) | undefined; + const promise = new Promise((resolve) => { + resolvePromise = resolve; + }); + return { promise, resolve: () => resolvePromise?.(value) }; +} diff --git a/packages/local-runtime-v2/src/service/turn-system/execution/turn-execution.service.ts b/packages/local-runtime-v2/src/service/turn-system/execution/turn-execution.service.ts index 2fec54a41..4753de061 100644 --- a/packages/local-runtime-v2/src/service/turn-system/execution/turn-execution.service.ts +++ b/packages/local-runtime-v2/src/service/turn-system/execution/turn-execution.service.ts @@ -6,8 +6,14 @@ import type { AgentHostUserInput, CompactionOutcome, } from '../agent-host/contracts.js'; +import { normalizeAbortSource } from '@mavis/agent-core/pi-turn-runner'; import type { AbortTurnInput, + AbortTurnResult, + InitializeTurnSystemOptions, + UserStopAcceptance, + UserStopCascadePort, + UserStopCascadeRun, ActivateTurnResult, DirectTurnSubmission, QueueTurnSubmission, @@ -38,6 +44,17 @@ export interface TurnExecutionServiceOptions { readonly submissionPreparation?: TurnSubmissionPreparation; readonly nowMs?: () => number; readonly makeTurnId?: () => string; + /** + * Host-injected user-stop cascade. TurnSystem deliberately knows nothing about + * background tasks; the local Runtime composition supplies this seam so an + * explicit user stop can also tear down the Session's background work. + */ + readonly userStop?: UserStopCascadePort; + /** + * Same logger TurnSystem already hands to its sibling components. Optional so + * embeddings and tests stay silent; only the user-stop skip path uses it. + */ + readonly logger?: Pick, 'info'>; } /** Durable admission, in-process ownership, and AgentHost execution only. */ @@ -82,10 +99,55 @@ export function createTurnExecutionService( ); return result.entered ? result.value : { status: 'closing' }; }, - abort: (input) => { + abort: async (input) => { const activeTurnId = options.controller.activeTurnId(input.sessionId); - if (!input.turnId || !activeTurnId || input.turnId === activeTurnId) pending.stop(input); - return options.controller.abort(input); + // One identity rule for everything a stop may touch: it targets the current + // Turn when it names no Turn, nothing is running, or it names the running + // one. A stop that names an older Turn is rejected by the controller as + // `turn-mismatch` (HTTP 409) before any side effect, so it must not open a + // window, pause the Goal or cascade either — otherwise it would kill the + // background work of the newer Turn the user is actually looking at. + const targetsCurrentTurn = !input.turnId || !activeTurnId || input.turnId === activeTurnId; + if (targetsCurrentTurn) pending.stop(input); + // Open the suppression window BEFORE aborting, so a task driven terminal by + // the abort cannot slip a delivery through. The Goal is NOT paused here: only + // an accepted stop may do that (controller `onAccepted`, or `not-running`). + const cascade = targetsCurrentTurn + ? await beginUserStopCascade(options, input) + : skipStaleUserStopCascade(options, input, activeTurnId); + let result: AbortTurnResult; + try { + result = await options.controller.abort(withUserStopAcceptance(input, cascade)); + } catch (error) { + // `begin` already opened the Session's delivery window. Without a result + // there is no cascade to run, but the window must still be released — + // otherwise every later completion notice of this Session would be held + // as `busy` for the life of the process, worse than having no cascade. + // The stop request itself keeps failing exactly as it did before. + releaseUserStopCascade(cascade, 'abort_failed'); + throw error; + } + if (result.status === 'turn-mismatch') { + // Race: the check above saw no conflict, but a newer Turn took over before + // the abort landed, so the controller rejected it. Same outcome as a + // stale stop — release the window, cascade nothing, and never `accept`: + // a rejected stop must leave the Goal exactly as it was. + releaseUserStopCascade(cascade, 'turn_mismatch'); + return result; + } + if (result.status === 'not-running') { + // Nothing was running, so the controller had no identity to check and + // never ran `onAccepted` — the stop is accepted as is. The Goal must be + // paused here, BEFORE the cascade stops tasks: a terminal task wakes the + // queue, and a live Goal would otherwise start a new Turn on its own. + await acceptUserStopCascade(cascade, 'not_running'); + } + // Detached on purpose: the cascade is a follow-up action and the stop + // response must not wait for background teardown. `not-running` still + // cascades: that is exactly the "Turn already finished but background work + // is still running" case this feature exists for. + cascade?.complete(result); + return result; }, activeTurnId: (sessionId) => options.controller.activeTurnId(sessionId), }; @@ -98,6 +160,103 @@ interface TurnExecutionContext { readonly pending: PendingSubmissions; } +/** + * Starts the user-stop cascade for an explicit stop only. + * + * `session_leave` (TUI `/clear` and Session switching) deliberately does NOT + * cascade: it pauses the old Session's Goal and Queue exactly like today, but + * leaving a conversation must not kill the background build still running in it. + */ +async function beginUserStopCascade( + options: TurnExecutionServiceOptions, + input: AbortTurnInput, +): Promise { + if (!options.userStop) return undefined; + if (normalizeAbortSource(input.reason) !== 'user_stop') return undefined; + try { + return await options.userStop.begin(input.sessionId); + } catch { + // The stop itself is authoritative and must succeed even if the cascade + // cannot start; this degrades to today's behaviour (no cascade). + return undefined; + } +} + +/** + * A stop naming an older Turn never cascades. Logged only when it WOULD have + * cascaded (an explicit `user_stop` with the seam wired): that is the case worth + * explaining later — e.g. a phone that still shows the previous Turn — while + * every other stale abort stays as quiet as before. + */ +function skipStaleUserStopCascade( + options: TurnExecutionServiceOptions, + input: AbortTurnInput, + activeTurnId: string | undefined, +): undefined { + if (!options.userStop || normalizeAbortSource(input.reason) !== 'user_stop') return undefined; + try { + options.logger?.info( + { + event: 'user_stop_cascade_skipped', + session_id: input.sessionId, + requested_turn_id: input.turnId, + active_turn_id: activeTurnId, + reason: 'stale_turn_id', + }, + 'User stop names an older Turn; not cascading', + ); + } catch { + // Diagnostics must never change the outcome of a stop. + } + return undefined; +} + +/** + * Runs the cascade's accepted-stop side effects from inside the controller's own + * `onAccepted`, i.e. only after its identity check passed. The caller's original + * `onAccepted` (ConversationApplication's Goal pause) runs first and unchanged; + * the cascade's pause is idempotent, so the overlap is harmless, and paths that + * pass no `onAccepted` at all (IM `/stop`) now pause the Goal too. + */ +function withUserStopAcceptance( + input: AbortTurnInput, + cascade: UserStopCascadeRun | undefined, +): AbortTurnInput { + if (!cascade) return input; + return { + ...input, + onAccepted: async () => { + await input.onAccepted?.(); + await acceptUserStopCascade(cascade, 'controller_accepted'); + }, + }; +} + +/** The port contract says `accept` never throws; guard anyway so a stop cannot fail on it. */ +async function acceptUserStopCascade( + cascade: UserStopCascadeRun | undefined, + how: UserStopAcceptance, +): Promise { + try { + await cascade?.accept(how); + } catch { + // Swallowed on purpose: accepting is a best-effort side effect of the stop. + } +} + +/** + * Releases a cascade whose abort failed. The port contract says `cancel` never + * throws; guarding anyway keeps a contract violation from replacing the abort's + * own error, which the caller rethrows unchanged. The implementation logs. + */ +function releaseUserStopCascade(cascade: UserStopCascadeRun | undefined, reason: string): void { + try { + cascade?.cancel(reason); + } catch { + // Swallowed on purpose: the original abort failure is what must surface. + } +} + interface SubmitTurnPolicy { readonly bypassPriorityFence?: boolean; readonly bypassSessionMutation?: boolean; diff --git a/packages/local-runtime-v2/src/service/turn-system/initialize.ts b/packages/local-runtime-v2/src/service/turn-system/initialize.ts index d8dbf2cb9..b0e5f9dce 100644 --- a/packages/local-runtime-v2/src/service/turn-system/initialize.ts +++ b/packages/local-runtime-v2/src/service/turn-system/initialize.ts @@ -269,6 +269,8 @@ function turnExecutionOverrides(options: InitializeTurnSystemOptions) { : {}), ...(options.nowMs ? { nowMs: options.nowMs } : {}), ...(options.makeTurnId ? { makeTurnId: options.makeTurnId } : {}), + ...(options.userStop ? { userStop: options.userStop } : {}), + ...(options.logger ? { logger: options.logger } : {}), }; } diff --git a/packages/local-runtime-v2/src/services.test.ts b/packages/local-runtime-v2/src/services.test.ts index 9bfd5f1fb..1e8ab491d 100644 --- a/packages/local-runtime-v2/src/services.test.ts +++ b/packages/local-runtime-v2/src/services.test.ts @@ -3892,6 +3892,8 @@ function defaultCompatibility( bindRuntimeOwner: vi.fn(), recover: vi.fn(async () => undefined), pollRecovery: vi.fn(async () => false), + // No cascade in this fixture: `begin` declining keeps today's stop behaviour. + userStop: { begin: vi.fn(async () => undefined) }, }, conversation: { bind: async (service) => { diff --git a/packages/local-runtime-v2/src/services.ts b/packages/local-runtime-v2/src/services.ts index 9c63e89e9..9369a8266 100644 --- a/packages/local-runtime-v2/src/services.ts +++ b/packages/local-runtime-v2/src/services.ts @@ -917,6 +917,7 @@ async function initializeRuntimeTurnSystem( }); return initializeTurnSystem({ db: input.options.db, + userStop: input.options.compatibility.backgroundTasks.userStop, ...(input.options.logger ? { logger: input.options.logger } : {}), sessions: input.sessionSystem, processStartedAtMs: input.processStartedAtMs, diff --git a/packages/local-runtime-v2/test/integration/session-stop-cascade.integration.test.ts b/packages/local-runtime-v2/test/integration/session-stop-cascade.integration.test.ts new file mode 100644 index 000000000..f99c2bb9c --- /dev/null +++ b/packages/local-runtime-v2/test/integration/session-stop-cascade.integration.test.ts @@ -0,0 +1,783 @@ +/** + * Session stop cascade, end to end over the assembled V2 Runtime host. + * + * Covers the user-visible contract: a user stop tears + * down the Session's background work, the resulting terminals never wake the + * Session, and the model still learns what happened on the next turn through + * the existing background cadence reminder. + * + * The Pi runner is mocked so each Turn is a deterministic script, but every + * other layer is production: real TurnSystem admission/abort, real background + * task rows, real bash execution (a real OS process), real delivery scheduler. + */ +import { mkdir, mkdtemp, readFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { + type CreatedLocalRuntimeHost, + type LocalRuntimeConfig, +} from '@mavis/local-runtime'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { closeLocalRuntimeDb } from '../../../local-runtime/src/persistence/db.js'; +import { createLocalRuntimeHostV2ForTest } from '../../src/runtime.js'; + +interface ScriptedTurnInput { + readonly sessionId: string; + readonly turnId: string; + readonly signal?: AbortSignal; + readonly eventIdGenerator?: (kind: string) => string; + readonly runtimeSeqGenerator?: () => number; + readonly canonicalMessages?: readonly unknown[]; + readonly hooks?: { readonly beforeLlmCallHook?: readonly ((input: unknown) => unknown)[] }; + readonly model?: unknown; + readonly eventWriter: { + pushRuntime(event: Record): void | Promise; + }; + readonly toolConfig: { + readonly context: unknown; + readonly tools?: readonly { + readonly def: { readonly name: string }; + readonly impl: { + execute( + context: unknown, + input: Record, + signal?: AbortSignal, + ): Promise<{ readonly details?: Record }>; + }; + }[]; + }; + readonly beforeLlmCall?: (input: unknown) => unknown; +} + +/** + * Per-Turn scripts keyed by the order in which Turns start. The harness records + * everything a "model" would have seen so assertions can inspect the assembled + * context of a later Turn. + */ +const harness = vi.hoisted(() => ({ + /** Turn scripts run in order; a missing script just completes the Turn. */ + scripts: [] as ((input: ScriptedTurnInput) => Promise)[], + invocations: 0, + /** Session ids of every Turn the Runtime actually started. */ + startedTurns: [] as { sessionId: string; turnId: string }[], + /** Canonical message arrays captured per Turn. */ + observedContexts: [] as { turnId: string; messages: readonly unknown[] }[], + /** Messages the production before-LLM hooks inject into the provider request. */ + injectedMessages: [] as { turnId: string; content: string }[], +})); + +vi.mock('@mavis/agent-core/pi-turn-runner', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + PiTurnRunner: class { + async runTurn(input: ScriptedTurnInput): Promise { + const index = harness.invocations; + harness.invocations += 1; + harness.startedTurns.push({ sessionId: input.sessionId, turnId: input.turnId }); + harness.observedContexts.push({ + turnId: input.turnId, + messages: [...(input.canonicalMessages ?? [])], + }); + // The production reminder is injected by a before-LLM hook, not by the + // durable history. Run the real hooks exactly as PiTurnRunner would and + // record every message they would have added to the provider request. + for (const hook of input.hooks?.beforeLlmCallHook ?? []) { + const decision = await hook({ + sessionId: input.sessionId, + turnId: input.turnId, + phase: 'initial', + messages: [], + canonicalMessages: [], + // Shape required by the reminder's context-budget admission check + // (provider/api/id build the usage-anchor key; the limits gate size). + model: { + id: 'model', + provider: 'test', + api: 'openai-completions', + contextWindow: 200_000, + maxTokens: 8_192, + }, + systemPrompt: '', + tools: [], + thinkingLevel: 'off', + }); + const message = + decision && typeof decision === 'object' ? Reflect.get(decision, 'message') : undefined; + const content = + message && typeof message === 'object' ? Reflect.get(message, 'content') : undefined; + if (typeof content === 'string') { + harness.injectedMessages.push({ turnId: input.turnId, content }); + } + } + const script = harness.scripts[index]; + if (script) await script(input); + await input.eventWriter.pushRuntime(terminalEvent(input, 6, 1, 'completed')); + } + }, + }; +}); + +function terminalEvent( + input: ScriptedTurnInput, + status: number, + stopReason: number, + message: string, +): Record { + return { + schema: 'archon.runtime.event.v1', + event_id: input.eventIdGenerator?.('session-status') ?? `evt-${input.turnId}-${status}`, + session_id: input.sessionId, + turn_id: input.turnId, + runtime_seq: input.runtimeSeqGenerator?.() ?? status, + type: 3, + payload: { status, stop_reason: { type: stopReason, message } }, + }; +} + +function tool(input: ScriptedTurnInput, name: string) { + const found = input.toolConfig.tools?.find((candidate) => candidate.def.name === name); + if (!found) throw new Error(`Production Turn omitted the ${name} tool`); + return found; +} + +/** Starts a detached bash whose OS process outlives the Turn. */ +async function startBackgroundBash(input: ScriptedTurnInput, command: string): Promise { + const result = await tool(input, 'bash').impl.execute( + input.toolConfig.context, + { command, run_in_background: true }, + input.signal, + ); + const taskId = result.details?.task_id; + if (typeof taskId !== 'string') throw new Error('Background bash returned no task id'); + return taskId; +} + +/** + * Starts a background bash the way a tool call that is ALREADY past its entry + * signal check behaves: `startBackgroundLocalBash` only tests `signal.aborted` + * once, on entry (bash-runner.ts:92), and inserts the durable row several awaits + * later. A stop that lands inside that window still produces a live task row. + */ +async function startBackgroundBashPastSignalCheck( + input: ScriptedTurnInput, + command: string, +): Promise { + const result = await tool(input, 'bash').impl.execute(input.toolConfig.context, { + command, + run_in_background: true, + }); + const taskId = result.details?.task_id; + if (typeof taskId !== 'string') throw new Error('Late background bash returned no task id'); + return taskId; +} + +async function startBackgroundSubagent(input: ScriptedTurnInput, prompt: string): Promise { + const result = await tool(input, 'task').impl.execute( + input.toolConfig.context, + { + agent_name: 'worker', + description: 'Long running child', + prompt, + run_in_background: true, + }, + input.signal, + ); + const taskId = result.details?.task_id; + if (typeof taskId !== 'string') throw new Error('Background subagent returned no task id'); + return taskId; +} + +/** Blocks until aborted; models a child Turn that keeps running on its own. */ +function awaitAbort(input: ScriptedTurnInput): Promise { + const signal = input.signal; + if (!signal) throw new Error('Production child Turn omitted the AbortSignal'); + if (signal.aborted) return Promise.resolve(); + return new Promise((resolve) => + signal.addEventListener('abort', () => resolve(), { once: true }), + ); +} + +let host: CreatedLocalRuntimeHost | undefined; +let dataDir: string | undefined; +const HOST_TEST_TIMEOUT_MS = 150_000; + +afterEach(async () => { + await host?.apiHost.close().catch(() => undefined); + await host?.metricsClient.close().catch(() => undefined); + if (dataDir) { + closeLocalRuntimeDb(dataDir); + await rm(dataDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 }); + } + host = undefined; + dataDir = undefined; + harness.scripts = []; + harness.invocations = 0; + harness.startedTurns = []; + harness.observedContexts = []; + harness.injectedMessages = []; +}); + +describe('session stop cascade over the production V2 host', () => { + it( + 'A: stops background bash and subagent, never wakes the Session, and reports them on the next turn', + async () => { + dataDir = await mkdtemp(join(tmpdir(), 'stop-cascade-a-')); + await mkdir(join(dataDir, 'workspace'), { recursive: true }); + host = await createHost(dataDir); + const conversation = host.apiHost.runtimeConversation!; + + const pidFile = join(dataDir, 'bash.pid'); + const bashTaskId = deferred(); + const subagentTaskId = deferred(); + + harness.scripts = [ + // Turn 1 (owner): start a long bash plus a background subagent. + async (input) => { + const bashId = await startBackgroundBash( + input, + `echo $$ > ${JSON.stringify(pidFile)}; sleep 600`, + ); + bashTaskId.resolve(bashId); + subagentTaskId.resolve(await startBackgroundSubagent(input, 'Run until stopped')); + }, + // Turn 2 (child Session): stays alive until the cascade aborts it. + async (input) => { + await awaitAbort(input); + }, + ]; + + const owner = await conversation.lifecycle.createSession({ + agentName: 'mavis', + workspaceDir: join(dataDir, 'workspace'), + sessionType: 'root', + }); + const firstTurn = await conversation.ingress.submit({ + sessionId: owner.sessionId, + source: 'api', + allowQueue: false, + message: { content: 'Start long background work', attachments: [] }, + }); + await firstTurn.completion; + const bashId = await bashTaskId.promise; + const subagentId = await subagentTaskId.promise; + + // The bash task must own a real, live OS process before we stop it. + const pid = await vi.waitFor(() => readPidFile(pidFile), { + timeout: 20_000, + interval: 100, + }); + expect(isProcessAlive(pid)).toBe(true); + + const turnsBeforeStop = harness.startedTurns.length; + + // Desktop Stop button entry: the same abort sink every stop funnels into. + const stopStartedAt = Date.now(); + await conversation.ingress.abort(owner.sessionId, 'user_stop'); + const stopDurationMs = Date.now() - stopStartedAt; + // The stop response must not wait for background teardown. + expect(stopDurationMs).toBeLessThan(5_000); + + await expectTasksCanceled(host, [bashId, subagentId]); + await vi.waitFor(() => expect(isProcessAlive(pid)).toBe(false), { + timeout: 15_000, + interval: 100, + }); + + // Wait out the delivery retry window (1s/2s/5s). Nothing may wake the Session. + await new Promise((resolve) => setTimeout(resolve, 9_000)); + expect(harness.startedTurns.length).toBe(turnsBeforeStop); + expect( + harness.startedTurns.filter((turn) => turn.turnId.startsWith('turn_task_delivery')), + ).toEqual([]); + + // The user's next message: the model is told both tasks are canceled. + const secondTurn = await conversation.ingress.submit({ + sessionId: owner.sessionId, + source: 'api', + allowQueue: false, + message: { content: 'What happened to the background work?', attachments: [] }, + }); + // Assert on what the model is actually handed, not on Turn completion: the + // reminder is produced by the before-LLM hooks when the request is built. + const reminder = await vi.waitFor( + () => { + const found = harness.injectedMessages + .map((entry) => entry.content) + .find((content) => content.includes('')); + expect(found, 'next turn must carry the background completion reminder').toBeDefined(); + return found!; + }, + { timeout: 30_000, interval: 100 }, + ); + void secondTurn.completion.catch(() => undefined); + // Evidence for the report: this is verbatim what the model receives. + console.log(`REMINDER_EVIDENCE_BEGIN\n${reminder}\nREMINDER_EVIDENCE_END`); + expect(reminder).toContain(''); + for (const taskId of [bashId, subagentId]) { + expect(reminder).toContain(taskId); + } + // Both are reported with their real terminal status. + expect(reminder).toContain('"status":"canceled"'); + }, + HOST_TEST_TIMEOUT_MS, + ); +}); + +describe('session stop cascade boundary', () => { + it( + 'A2: stops a task the dying Turn created while the abort was still settling', + async () => { + dataDir = await mkdtemp(join(tmpdir(), 'stop-cascade-a2-')); + await mkdir(join(dataDir, 'workspace'), { recursive: true }); + host = await createHost(dataDir); + const conversation = host.apiHost.runtimeConversation!; + + const lateTaskId = deferred(); + const turnRunning = deferred(); + + harness.scripts = [ + async (input) => { + // Stay alive until the stop signals, then insert one more background + // row before releasing — the Turn is aborting but has not released yet. + turnRunning.resolve(); + await awaitAbort(input); + lateTaskId.resolve(await startBackgroundBashPastSignalCheck(input, 'sleep 600')); + }, + ]; + + const owner = await conversation.lifecycle.createSession({ + agentName: 'mavis', + workspaceDir: join(dataDir, 'workspace'), + sessionType: 'root', + }); + const running = await conversation.ingress.submit({ + sessionId: owner.sessionId, + source: 'api', + allowQueue: false, + message: { content: 'Work until I stop you', attachments: [] }, + }); + + // The Turn must really be executing before the stop, otherwise it is + // cancelled pre-admission and never reaches the tool call we are testing. + await turnRunning.promise; + await conversation.ingress.abort(owner.sessionId, 'user_stop'); + await running.completion.catch(() => undefined); + + const lateId = await lateTaskId.promise; + + // The row was created after the stop was accepted, so an implementation + // that froze the boundary at `begin` would leave it running and let its + // terminal wake the Session later. Process teardown itself is covered by A. + await expectTasksCanceled(host, [lateId]); + + const turnsBefore = harness.startedTurns.length; + await new Promise((resolve) => setTimeout(resolve, 8_500)); + expect(harness.startedTurns.length).toBe(turnsBefore); + expect( + harness.startedTurns.filter((turn) => turn.turnId.startsWith('turn_task_delivery')), + ).toEqual([]); + + await conversation.ingress.submit({ + sessionId: owner.sessionId, + source: 'api', + allowQueue: false, + message: { content: 'And the background work?', attachments: [] }, + }); + const reminder = await vi.waitFor( + () => { + const found = harness.injectedMessages + .map((entry) => entry.content) + .find((content) => content.includes('')); + expect(found, 'the late task must be reported to the model').toBeDefined(); + return found!; + }, + { timeout: 30_000, interval: 100 }, + ); + expect(reminder).toContain(lateId); + expect(reminder).toContain('"status":"canceled"'); + }, + HOST_TEST_TIMEOUT_MS, + ); +}); + +describe('session stop cascade identity', () => { + it( + 'F1: a stop naming an older Turn is rejected and leaves the newer Turn background work alone', + async () => { + dataDir = await mkdtemp(join(tmpdir(), 'stop-cascade-f1-')); + await mkdir(join(dataDir, 'workspace'), { recursive: true }); + host = await createHost(dataDir); + const conversation = host.apiHost.runtimeConversation!; + + const pidFile = join(dataDir, 'turn-b.pid'); + const bashTaskId = deferred(); + harness.scripts = [ + // Turn A: finishes immediately. Its id is what a stale client still shows. + async () => undefined, + // Turn B: starts real background work and keeps running. + async (input) => { + bashTaskId.resolve( + await startBackgroundBash(input, `echo $$ > ${JSON.stringify(pidFile)}; sleep 600`), + ); + await awaitAbort(input); + }, + ]; + + const owner = await conversation.lifecycle.createSession({ + agentName: 'mavis', + workspaceDir: join(dataDir, 'workspace'), + sessionType: 'root', + }); + await ( + await conversation.ingress.submit({ + sessionId: owner.sessionId, + source: 'api', + allowQueue: false, + message: { content: 'First turn', attachments: [] }, + }) + ).completion; + const turnA = harness.startedTurns[0]!.turnId; + const running = await conversation.ingress.submit({ + sessionId: owner.sessionId, + source: 'api', + allowQueue: false, + message: { content: 'Second turn with background work', attachments: [] }, + }); + const bashId = await bashTaskId.promise; + const turnB = harness.startedTurns[1]!.turnId; + const pid = await vi.waitFor(() => readPidFile(pidFile), { timeout: 20_000, interval: 100 }); + + // Stale stop: rejected by identity (false == turn-mismatch on this seam). + await expect(conversation.ingress.abort(owner.sessionId, 'user_stop', turnA)).resolves.toBe( + false, + ); + // Give a (wrong) cascade ample time to act before asserting it did not. + await new Promise((resolve) => setTimeout(resolve, 2_000)); + expect(isProcessAlive(pid)).toBe(true); + await expect(host.apiHost.backgroundTaskService.get(bashId)).resolves.toMatchObject({ + status: 'running', + }); + + // Stopping the Turn that is actually running cascades as usual. + await conversation.ingress.abort(owner.sessionId, 'user_stop', turnB); + await running.completion.catch(() => undefined); + await expectTasksCanceled(host, [bashId]); + await vi.waitFor(() => expect(isProcessAlive(pid)).toBe(false), { + timeout: 15_000, + interval: 100, + }); + }, + HOST_TEST_TIMEOUT_MS, + ); +}); + +describe('session stop cascade must not change unrelated behaviour', () => { + it( + 'B: without a stop, a finished background bash still wakes the Session', + async () => { + dataDir = await mkdtemp(join(tmpdir(), 'stop-cascade-b-')); + await mkdir(join(dataDir, 'workspace'), { recursive: true }); + host = await createHost(dataDir); + const conversation = host.apiHost.runtimeConversation!; + const bashTaskId = deferred(); + + harness.scripts = [ + async (input) => { + bashTaskId.resolve(await startBackgroundBash(input, 'echo finished-normally')); + }, + ]; + + const owner = await conversation.lifecycle.createSession({ + agentName: 'mavis', + workspaceDir: join(dataDir, 'workspace'), + sessionType: 'root', + }); + const firstTurn = await conversation.ingress.submit({ + sessionId: owner.sessionId, + source: 'api', + allowQueue: false, + message: { content: 'Run a quick background command', attachments: [] }, + }); + await firstTurn.completion; + const bashId = await bashTaskId.promise; + + // Delivery starts its own Turn and tells the model the task finished. + await vi.waitFor( + () => { + const deliveryTurn = harness.startedTurns.find((turn) => + turn.turnId.startsWith('turn_task_delivery'), + ); + expect(deliveryTurn).toBeDefined(); + }, + { timeout: 20_000, interval: 100 }, + ); + // The task finished on its own and stayed unread, which is what makes the + // delivery Turn above a genuine wake-up rather than a cascade side effect. + await expect(host.apiHost.backgroundTaskService.get(bashId)).resolves.toMatchObject({ + status: 'succeeded', + }); + }, + HOST_TEST_TIMEOUT_MS, + ); + + it( + 'C: work started after a stop is delivered normally again', + async () => { + dataDir = await mkdtemp(join(tmpdir(), 'stop-cascade-c-')); + await mkdir(join(dataDir, 'workspace'), { recursive: true }); + host = await createHost(dataDir); + const conversation = host.apiHost.runtimeConversation!; + const stoppedId = deferred(); + const freshId = deferred(); + + harness.scripts = [ + async (input) => { + stoppedId.resolve(await startBackgroundBash(input, 'sleep 600')); + }, + async (input) => { + freshId.resolve(await startBackgroundBash(input, 'echo after-stop')); + }, + ]; + + const owner = await conversation.lifecycle.createSession({ + agentName: 'mavis', + workspaceDir: join(dataDir, 'workspace'), + sessionType: 'root', + }); + await ( + await conversation.ingress.submit({ + sessionId: owner.sessionId, + source: 'api', + allowQueue: false, + message: { content: 'Start slow work', attachments: [] }, + }) + ).completion; + const slowId = await stoppedId.promise; + + await conversation.ingress.abort(owner.sessionId, 'user_stop'); + await vi.waitFor( + async () => { + await expect(host!.apiHost.backgroundTaskService.get(slowId)).resolves.toMatchObject({ + status: 'canceled', + }); + }, + { timeout: 20_000, interval: 100 }, + ); + + await ( + await conversation.ingress.submit({ + sessionId: owner.sessionId, + source: 'api', + allowQueue: false, + message: { content: 'Now run something new', attachments: [] }, + }) + ).completion; + const newId = await freshId.promise; + + // The suppression is per task, not per Session: new work notifies again. + await vi.waitFor( + () => { + const deliveryTurn = harness.startedTurns.find((turn) => + turn.turnId.startsWith('turn_task_delivery'), + ); + expect(deliveryTurn).toBeDefined(); + }, + { timeout: 20_000, interval: 100 }, + ); + // The fresh task completed and woke the Session; the stopped one stayed + // suppressed and unread, so only new work can have caused that wake-up. + await expect(host.apiHost.backgroundTaskService.get(newId)).resolves.toMatchObject({ + status: 'succeeded', + }); + const stopped = await host.apiHost.backgroundTaskService.get(slowId); + expect(stopped?.status).toBe('canceled'); + expect(stopped?.deliveredAt).toBeUndefined(); + }, + HOST_TEST_TIMEOUT_MS, + ); +}); + +describe('session stop cascade scope', () => { + it( + 'D: leaving the conversation (session_leave) does not cascade', + async () => { + dataDir = await mkdtemp(join(tmpdir(), 'stop-cascade-d-')); + await mkdir(join(dataDir, 'workspace'), { recursive: true }); + host = await createHost(dataDir); + const conversation = host.apiHost.runtimeConversation!; + const bashTaskId = deferred(); + + harness.scripts = [ + async (input) => { + bashTaskId.resolve(await startBackgroundBash(input, 'echo leave-keeps-running')); + }, + ]; + + const owner = await conversation.lifecycle.createSession({ + agentName: 'mavis', + workspaceDir: join(dataDir, 'workspace'), + sessionType: 'root', + }); + await ( + await conversation.ingress.submit({ + sessionId: owner.sessionId, + source: 'api', + allowQueue: false, + message: { content: 'Start background work', attachments: [] }, + }) + ).completion; + const bashId = await bashTaskId.promise; + + await conversation.ingress.abort(owner.sessionId, 'session_leave'); + + // Not a stop: the task runs to its own completion and still notifies. + await vi.waitFor( + async () => { + await expect(host!.apiHost.backgroundTaskService.get(bashId)).resolves.toMatchObject({ + status: 'succeeded', + }); + }, + { timeout: 20_000, interval: 100 }, + ); + await vi.waitFor( + () => { + const deliveryTurn = harness.startedTurns.find((turn) => + turn.turnId.startsWith('turn_task_delivery'), + ); + expect(deliveryTurn).toBeDefined(); + }, + { timeout: 20_000, interval: 100 }, + ); + }, + HOST_TEST_TIMEOUT_MS, + ); + + it( + 'E: stopping only the child Session leaves the owner Session background work alone', + async () => { + dataDir = await mkdtemp(join(tmpdir(), 'stop-cascade-e-')); + await mkdir(join(dataDir, 'workspace'), { recursive: true }); + host = await createHost(dataDir); + const conversation = host.apiHost.runtimeConversation!; + const bashTaskId = deferred(); + const subagentTaskId = deferred(); + + harness.scripts = [ + async (input) => { + bashTaskId.resolve(await startBackgroundBash(input, 'echo owner-task-survives')); + subagentTaskId.resolve(await startBackgroundSubagent(input, 'Run until stopped')); + }, + async (input) => { + await awaitAbort(input); + }, + ]; + + const owner = await conversation.lifecycle.createSession({ + agentName: 'mavis', + workspaceDir: join(dataDir, 'workspace'), + sessionType: 'root', + }); + await ( + await conversation.ingress.submit({ + sessionId: owner.sessionId, + source: 'api', + allowQueue: false, + message: { content: 'Start a child and a bash', attachments: [] }, + }) + ).completion; + const bashId = await bashTaskId.promise; + const subagentId = await subagentTaskId.promise; + + const subagentTask = await host.apiHost.backgroundTaskService.get(subagentId); + const childSessionId = subagentTask?.metadata?.['childSessionId']; + expect(typeof childSessionId).toBe('string'); + + // A stop aimed at the child Session must not cascade into the owner. + await conversation.ingress.abort(String(childSessionId), 'user_stop'); + + await vi.waitFor( + async () => { + await expect(host!.apiHost.backgroundTaskService.get(bashId)).resolves.toMatchObject({ + status: 'succeeded', + }); + }, + { timeout: 20_000, interval: 100 }, + ); + }, + HOST_TEST_TIMEOUT_MS, + ); +}); + +/** + * The runtime records no child pid, so the command publishes its own shell pid. + * That is the process group the cascade must actually kill. + */ +async function readPidFile(path: string): Promise { + const raw = await readFile(path, 'utf8'); + const pid = Number.parseInt(raw.trim(), 10); + if (!Number.isSafeInteger(pid) || pid <= 0) throw new Error(`Unusable pid file: ${raw}`); + return pid; +} + +/** Both cascade targets must reach a real terminal state, not just be requested. */ +async function expectTasksCanceled( + liveHost: CreatedLocalRuntimeHost, + taskIds: readonly string[], +): Promise { + await vi.waitFor( + async () => { + for (const taskId of taskIds) { + await expect(liveHost.apiHost.backgroundTaskService.get(taskId)).resolves.toMatchObject({ + status: 'canceled', + }); + } + }, + { timeout: 20_000, interval: 100 }, + ); +} + +function createHost(hostDataDir: string): Promise { + const config = { + dataDir: hostDataDir, + defaultModel: 'test/model', + provider: { + test: { + options: { apiKey: 'test-key', baseURL: 'https://provider.test/v1' }, + models: { + model: { modalities: { input: ['text'], output: ['text'] } }, + }, + }, + }, + } as LocalRuntimeConfig; + return createLocalRuntimeHostV2ForTest({ + dataDir: hostDataDir, + runtimeOwnerKind: 'cli', + capabilities: { cliEmbedded: true }, + configGetter: () => config, + defaultWorkspaceDir: join(hostDataDir, 'workspace'), + fetchImpl: async () => Response.json({ errorCode: 0, action: 1 }), + }); +} + +function isProcessAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} + +function deferred(): { + readonly promise: Promise; + readonly resolve: (value: T | PromiseLike) => void; +} { + let resolve!: (value: T | PromiseLike) => void; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} diff --git a/packages/local-runtime/src/api/local-task-append-lifecycle.ts b/packages/local-runtime/src/api/local-task-append-lifecycle.ts index d0b6e316c..0c93335ec 100644 --- a/packages/local-runtime/src/api/local-task-append-lifecycle.ts +++ b/packages/local-runtime/src/api/local-task-append-lifecycle.ts @@ -1,10 +1,126 @@ import type { LocalRuntimeToolContext, LocalTaskRunResult } from '@mavis/agent-tools/desktop'; -import type { ConversationTurnResult } from '@mavis/conversation-contract'; +import { + RuntimeConversationShutdownError, + type ConversationTurnResult, +} from '@mavis/conversation-contract'; import type { BackgroundTask } from '../background-task/domain.js'; +import { admitBackgroundTask, type BackgroundTaskAdmission } from '../background-task/lifecycle.js'; import { persistLocalSubagentTaskTerminal } from '../background-task/terminal.js'; +import { taskTurnId } from '../background-task/turn-id.js'; +import { logger } from '../common/logger.js'; import type { LocalTaskRunnerHostWithSessionLookup } from './local-task-host.js'; +/** + * This process's claim on an activated append: the same claim `runner.ts` and + * `bash-runner.ts` take for their tasks. Without it a Session stop cascade reads + * the append row as another process's work, so it neither stops the append nor + * sweeps what its child Turn spawned — while the row is already registered for + * delivery suppression, leaving the work running with its notice hidden. + */ +export interface AppendAdmission { + /** Hands the claim to the completion handler, which writes the terminal row first. */ + bind(settled: Promise): void; + /** Drops the claim of an activation that did not go through; a no-op once settled. */ + release(reason: string): void; +} + +interface AppendAdmissionFields { + readonly taskId: string; + readonly childSessionId: string; + readonly turnId: string; +} + +/** + * Taken inside the activation guard, before the row is persisted, so a cascade that + * can read the row always finds the claim. Steered and duplicate appends never get + * here: they create no row and start no Turn, so they have nothing to claim. + */ +export function admitAppendActivation( + host: LocalTaskRunnerHostWithSessionLookup, + taskId: string, + childSessionId: string, +): AppendAdmission { + const fields = { taskId, childSessionId, turnId: taskTurnId(taskId) }; + let admission: BackgroundTaskAdmission; + try { + // Shutdown aborts every claimed task through this callback and the host drain + // then waits for it, so the abort has to reach the child Turn itself. + admission = admitBackgroundTask(host, (reason) => abortAppendTurn(host, fields, reason)); + } catch (error) { + // `admitBackgroundTask` only throws once the Local Runtime is shutting down. + // As a shutting-down Runtime Conversation it maps to the append contract's + // 503 TASK_APPEND_UNAVAILABLE, rather than escaping as an uncontracted error. + logAppendAdmission('warn', 'closing_rejected', { ...fields, error: errorText(error) }); + throw new RuntimeConversationShutdownError(errorText(error), { cause: error }); + } + admission.identify(taskId); + logAppendAdmission('info', 'identified', fields); + let settled = false; + return { + bind: (completion) => { + if (settled) { + logAppendAdmission('warn', 'bind_after_release', fields); + return; + } + settled = true; + admission.bind(completion); + logAppendAdmission('info', 'bound', fields); + }, + release: (reason) => { + if (settled) return; + settled = true; + admission.release(); + logAppendAdmission('info', 'released', { ...fields, reason }); + }, + }; +} + +/** + * The exact abort the task's `stopRuntime` issues (`api/host.ts`). It runs inside + * the shutdown fan-out over every claimed task, so it must never throw. + */ +function abortAppendTurn( + host: LocalTaskRunnerHostWithSessionLookup, + fields: AppendAdmissionFields, + reason: string, +): void { + try { + const conversation = host.runtimeConversation; + if (!conversation) { + logAppendAdmission('warn', 'abort_unavailable', { ...fields, reason }); + return; + } + logAppendAdmission('info', 'abort_requested', { ...fields, reason }); + void conversation.ingress + .abort(fields.childSessionId, 'lifecycle', fields.turnId) + .catch((error: unknown) => { + logAppendAdmission('warn', 'abort_failed', { ...fields, error: errorText(error) }); + }); + } catch (error) { + logAppendAdmission('warn', 'abort_failed', { ...fields, error: errorText(error) }); + } +} + +function logAppendAdmission( + level: 'info' | 'warn', + event: string, + fields: Record, +): void { + try { + logger[level]( + { event: `task_append_admission_${event}`, ...fields }, + `task_append admission: ${event}`, + ); + } catch { + // Diagnostics must never change admission or shutdown behavior. + } +} + +function errorText(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + export function createAppendTaskRow(args: { host: LocalTaskRunnerHostWithSessionLookup; taskId: string; @@ -68,8 +184,9 @@ export function registerAppendCompletion(args: { childSessionId: string; completion: Promise; sourceTask: BackgroundTask; + admission?: AppendAdmission; }): void { - void (async () => { + const settled = (async () => { let result: LocalTaskRunResult | { failure: unknown }; try { result = toAppendRunResult(await args.completion, args.sourceTask, args.childSessionId); @@ -82,7 +199,21 @@ export function registerAppendCompletion(args: { result, delivery: 'schedule', }); - })().catch((error: unknown) => { + })(); + // The claim ends only after the terminal row is written: a stop cascade that no + // longer finds the claim re-reads the row and must then see it terminal. + if (args.admission) { + args.admission.bind(settled); + } else { + // Every activation passes the accept guard that takes the claim; a missing one + // means a stop cascade would skip this row as another process's work. + logAppendAdmission('warn', 'unclaimed_activation', { + taskId: args.taskId, + childSessionId: args.childSessionId, + turnId: args.turnId, + }); + } + void settled.catch((error: unknown) => { args.host.matrixLogger?.warn( { sessionId: args.childSessionId, turnId: args.turnId }, `task_append could not persist the terminal state of task ${args.taskId}: ${ diff --git a/packages/local-runtime/src/api/local-task-append.ts b/packages/local-runtime/src/api/local-task-append.ts index aaa4cf8cc..f73ebb068 100644 --- a/packages/local-runtime/src/api/local-task-append.ts +++ b/packages/local-runtime/src/api/local-task-append.ts @@ -24,6 +24,8 @@ import { import { taskIdFromTurnId, taskTurnId } from '../background-task/turn-id.js'; import { isLocalDirectTaskPurpose } from '../sessions/session-policy.js'; import { + admitAppendActivation, + type AppendAdmission, createAppendTaskRow, persistActivatedAppendAdmissionFailure, registerAppendCompletion, @@ -137,6 +139,7 @@ async function appendLocalTask(args: { const requestedTurnId = taskTurnId(candidateTaskId); let admittedTaskId: string | undefined; let createdCandidateTaskRow = false; + let admission: AppendAdmission | undefined; let steered: ConversationSteerResult; try { steered = await steerChild({ @@ -164,6 +167,7 @@ async function appendLocalTask(args: { { status: 409, code: 'TASK_APPEND_ACTIVE_TURN_UNMAPPED', taskId: sourceTaskId }, ); } + admission = admitAppendActivation(host, candidateTaskId, childSessionId); try { await createAppendTaskRow({ host, @@ -176,6 +180,7 @@ async function appendLocalTask(args: { createdCandidateTaskRow = true; admittedTaskId = candidateTaskId; } catch (error) { + admission.release('row_persist_failed'); host.matrixLogger?.warn( { sessionId: childSessionId, turnId: requestedTurnId }, `task_append could not persist task ${candidateTaskId} before Turn delivery: ${ @@ -202,6 +207,7 @@ async function appendLocalTask(args: { error, }); } + admission?.release('activation_failed'); throw error; } @@ -232,6 +238,7 @@ async function appendLocalTask(args: { childSessionId, completion: steered.completion, sourceTask, + admission, }); } return { taskId: admittedTaskId, mode: steered.mode }; diff --git a/packages/local-runtime/src/background-task/bash-runner.ts b/packages/local-runtime/src/background-task/bash-runner.ts index ffe08b0f8..c43a8a6bb 100644 --- a/packages/local-runtime/src/background-task/bash-runner.ts +++ b/packages/local-runtime/src/background-task/bash-runner.ts @@ -229,6 +229,9 @@ async function startAdmittedBackgroundLocalBash( validateBashCommand(input.bashInput.command); const timing = resolveLocalBashTiming(input.bashInput.timeout, executionMode); const taskId = createBackgroundTaskId(); + // Claim the row for this process before it exists durably, so a Session stop + // cascade can tell "our task" from a row owned by another client process. + admission.identify(taskId); const now = input.host.nowMs(); const description = resolveBashDescription(input.bashInput.description, input.bashInput.command); const maxRunMs = capBackgroundBashMaxRunMs( diff --git a/packages/local-runtime/src/background-task/delivery.ts b/packages/local-runtime/src/background-task/delivery.ts index 7a70ff0fc..addae6960 100644 --- a/packages/local-runtime/src/background-task/delivery.ts +++ b/packages/local-runtime/src/background-task/delivery.ts @@ -1,9 +1,11 @@ import type { LocalTaskRunnerHostWithSessionLookup } from '../api/local-task-host.js'; import { requireTaskConversation } from '../api/local-task-host.js'; +import { logger } from '../common/logger.js'; import { isLocalChildWorkerSession } from '../sessions/session-policy.js'; import { isTerminalTaskStatus, type BackgroundTask } from './domain.js'; import { startConversationBackgroundTaskDeliveryTurn } from './conversation-delivery.js'; import { createDeliveryTaskPreview, selectDeliveryBatch } from './delivery-batch.js'; +import { isTaskDeliverySuppressed, isUserStopWindowOpen } from './user-stop-suppression.js'; const DELIVERY_RETRY_DELAYS_MS = [1_000, 2_000, 5_000] as const; const DELIVERY_BURST_WINDOW_MS = 60_000; @@ -66,6 +68,16 @@ async function deliverBatch( ): Promise { const session = await host.getSessionById(ownerSessionId); if (closedHosts.has(host) || !session || isLocalChildWorkerSession(session)) return 'skipped'; + // Gate 1 (Session): a user stop for this Session is still collecting its target + // tasks, so we cannot yet tell targets from unrelated new work. Hold the whole + // batch as `busy` — which the scheduler retries (1s/2s/5s, then cooldown) — and + // NOT `skipped`, which is dequeued without a reschedule and would permanently + // lose the completion notice of a new task that happened to finish in the window. + if (isUserStopWindowOpen(host, ownerSessionId)) { + recordDeliverySuppressed(host, 'user_stop_window'); + logDeliverySuppressed(host, ownerSessionId, taskIds, 'user_stop_window'); + return 'busy'; + } const reservation = reserveDeliveryTurn(host, ownerSessionId); if (!reservation) { try { @@ -84,21 +96,50 @@ async function deliverBatch( const candidates = await Promise.all( taskIds.map((taskId) => host.backgroundTaskService.get(taskId)), ); + // Gate 2 (task): a task the user stopped never wakes this Session again, for + // the life of the process. This is the primary "no wake-up" guarantee and does + // not depend on the cascade finishing: it also covers a stop that timed out and + // finished much later, and retries/cooldown batches scheduled before the stop. + // Suppressed terminals stay undelivered, so the next turn's background reminder + // still reports them — no information is lost. + const suppressedIds = taskIds.filter((taskId) => isTaskDeliverySuppressed(host, taskId)); + if (suppressedIds.length > 0) { + recordDeliverySuppressed(host, 'user_stop_target', suppressedIds.length); + logDeliverySuppressed(host, ownerSessionId, suppressedIds, 'user_stop_target'); + } const unreadIds = new Set( candidates .filter( (task): task is BackgroundTask => - shouldDeliverTask(task) && task.ownerSessionId === ownerSessionId, + shouldDeliverTask(task) && + task.ownerSessionId === ownerSessionId && + !isTaskDeliverySuppressed(host, task.taskId), ) .map((task) => task.taskId), ); const tasks = previews.filter((task) => unreadIds.has(task.taskId)); + // Every candidate was suppressed or already consumed: nothing to say, and a + // retry would say the same thing, so `skipped` (no reschedule) is correct. if (closedHosts.has(host) || tasks.length === 0) return 'skipped'; + // Gate 1 again, after every await above. A stop that begins while this batch + // was reading the snapshot or re-reading its tasks opened the window after the + // first check, and its targets may not be registered yet — e.g. the Turn had + // ended and only background work remained, which is exactly what a user stop + // is for. Known bound: the few synchronous steps between this check and the + // ingress accepting the steer are not covered; that gap is sub-millisecond. + if (isUserStopWindowOpen(host, ownerSessionId)) { + recordDeliverySuppressed(host, 'user_stop_window_recheck'); + logDeliverySuppressed(host, ownerSessionId, taskIds, 'user_stop_window_recheck'); + return 'busy'; + } const result = await startConversationBackgroundTaskDeliveryTurn({ host, conversation: requireTaskConversation(host), ownerSessionId, tasks, + // `batchTaskIds` only forms the retry-stable idempotency key; the message's + // `origin.taskIds` is built from `tasks`, so suppressed siblings are never + // named to the model even though they still key this batch. batchTaskIds: taskIds, observedTerminalCount: snapshot.terminalTotal, }); @@ -110,6 +151,45 @@ async function deliverBatch( } } +/** Why a batch was held back after a user stop; also the metric/log `reason` label. */ +type DeliverySuppressionReason = + | 'user_stop_window' + | 'user_stop_window_recheck' + | 'user_stop_target'; + +function recordDeliverySuppressed( + host: LocalTaskRunnerHostWithSessionLookup, + reason: DeliverySuppressionReason, + count = 1, +): void { + try { + host.metricsClient?.counter('background_task_delivery_suppressed_total', count, { reason }); + } catch { + // Telemetry must not turn a deliberate suppression into a retry storm. + } +} + +function logDeliverySuppressed( + host: LocalTaskRunnerHostWithSessionLookup, + ownerSessionId: string, + taskIds: readonly string[], + reason: DeliverySuppressionReason, +): void { + try { + logger.info( + { + event: 'background_task_delivery_suppressed', + sessionId: ownerSessionId, + taskIds: [...taskIds], + reason, + }, + 'Suppressed local background task delivery after a user stop', + ); + } catch { + // Diagnostics must not change the delivery result or abandon retries. + } +} + interface DeliveryBatch { readonly taskIds: readonly string[]; readonly previews: readonly BackgroundTask[]; diff --git a/packages/local-runtime/src/background-task/index.ts b/packages/local-runtime/src/background-task/index.ts index 67beae4be..0a13eafe5 100644 --- a/packages/local-runtime/src/background-task/index.ts +++ b/packages/local-runtime/src/background-task/index.ts @@ -7,6 +7,14 @@ export { LocalBackgroundTaskService } from './service.js'; export type { LocalBackgroundTaskServiceOptions } from './service.js'; export { FsLocalTaskOutputStore, SqliteLocalBackgroundTaskStore } from './store.js'; export { abortAllBackgroundLocalBashTasks } from './bash-runner.js'; +export { createUserStopCascade } from './user-stop-cascade.js'; +export type { + UserStopCascade, + UserStopCascadeDependencies, + UserStopCascadeHandle, + UserStopAbortResult, +} from './user-stop-cascade.js'; +export type { StoppedTaskRecord, StoppedTaskStatus } from './session-cascade.js'; export function createLocalBackgroundTaskService( dataDir: string | (() => string), diff --git a/packages/local-runtime/src/background-task/lifecycle.ts b/packages/local-runtime/src/background-task/lifecycle.ts index e819b31d6..ca7fb2b0b 100644 --- a/packages/local-runtime/src/background-task/lifecycle.ts +++ b/packages/local-runtime/src/background-task/lifecycle.ts @@ -3,6 +3,12 @@ import type { LocalTaskRunnerHostWithSessionLookup } from '../api/local-task-hos interface ActiveBackgroundTask { readonly settled: Promise; readonly abort?: (reason: string) => void; + /** + * Durable row identity, published by the runner once the row exists. Admission + * happens before `createBackgroundTaskId()`, so this stays undefined during the + * short setup window and the task is simply not claimed yet. + */ + taskId?: string; } interface HostBackgroundTaskState { @@ -13,6 +19,14 @@ interface HostBackgroundTaskState { export interface BackgroundTaskAdmission { bind(settled: Promise): void; release(): void; + /** + * Claim the durable task row for this process. Only claimed tasks may be + * stopped by a Session cascade: `performStop` writes `canceled` whether or not + * `stopRuntime` actually reached a live runner, so stopping a row owned by + * another client process (shared data directory) would mark it canceled while + * it keeps running there. Unclaimed rows are skipped instead. + */ + identify(taskId: string): void; } const hostStates = new WeakMap(); @@ -49,9 +63,31 @@ export function admitBackgroundTask( bound = true; resolve(); }, + identify: (taskId) => { + active.taskId = taskId; + }, }; } +/** + * Whether this process admitted (and therefore can actually stop) the task. + * + * The entry is removed as soon as the task settles, so a terminal task always + * answers `false`; callers only need this for non-terminal rows, which are the + * ones a cascade would otherwise mis-cancel. + */ +export function isBackgroundTaskAdmittedHere( + host: LocalTaskRunnerHostWithSessionLookup, + taskId: string, +): boolean { + const state = hostStates.get(host); + if (!state) return false; + for (const task of state.active) { + if (task.taskId === taskId) return true; + } + return false; +} + export function beginBackgroundTaskShutdown( host: LocalTaskRunnerHostWithSessionLookup, reason = 'runtime-shutdown', diff --git a/packages/local-runtime/src/background-task/runner.ts b/packages/local-runtime/src/background-task/runner.ts index 89284bce8..4fb6c4f1b 100644 --- a/packages/local-runtime/src/background-task/runner.ts +++ b/packages/local-runtime/src/background-task/runner.ts @@ -57,6 +57,9 @@ async function startAdmittedBackgroundLocalTask( const taskModelSelection = taskModelSelectionFor(taskInput); const taskId = createBackgroundTaskId(); + // Claim the row for this process before it exists durably, so a Session stop + // cascade can tell "our task" from a row owned by another client process. + admission.identify(taskId); const turnId = taskTurnId(taskId); await createLocalSubagentTaskRow({ host: input.host, diff --git a/packages/local-runtime/src/background-task/session-cascade-targets.ts b/packages/local-runtime/src/background-task/session-cascade-targets.ts new file mode 100644 index 000000000..b42d4abac --- /dev/null +++ b/packages/local-runtime/src/background-task/session-cascade-targets.ts @@ -0,0 +1,147 @@ +import type { LocalTaskRunnerHostWithSessionLookup } from '../api/local-task-host.js'; +import { + isTerminalTaskStatus, + type BackgroundTask, + type BackgroundTaskKind, + type BackgroundTaskStatus, +} from './domain.js'; + +/** + * Target collection and child-Session ownership for the Session stop cascade in + * `session-cascade.ts`: which rows a cascade looks at, and which child-Session rows + * belong to a sweep. Kept apart so the cascade stays within the local-runtime layout + * line budget. This module is a leaf: it must never import the cascade back. + */ + +/** Which child Turn owns a child-Session sweep; see `StopSessionBackgroundWorkInput`. */ +export type CascadeChildScope = { readonly ownerTurnId: string; readonly parentTaskId: string }; + +/** Rows scanned per Session. Far above any real Session's live task count. */ +const MAX_CASCADE_TASKS = 512; + +const ACTIVE_STATUSES: readonly BackgroundTaskStatus[] = ['queued', 'running', 'stopping']; +const TERMINAL_STATUSES: readonly BackgroundTaskStatus[] = [ + 'succeeded', + 'failed', + 'canceled', + 'lost', +]; + +/** + * Subagents whose result the model already read (`deliveredAt` set) are not + * targets — there is nothing to stop or suppress — but their child Session may, + * as a fallback, still hold live work. Only their child Session is swept: the row + * itself is not stopped, not registered for suppression, not reported and not + * counted in metrics. The boundary still applies, protecting subagents launched + * by the user's next message. + */ +export async function collectReadSubagents( + host: LocalTaskRunnerHostWithSessionLookup, + sessionId: string, + boundaryMs: number, +): Promise { + // No delivered-only filter exists on the store, so list terminal subagents and + // pick the read ones here. Foreground rows count too: any executionMode. + const terminalSubagents = await listTasks(host, sessionId, TERMINAL_STATUSES, { + kinds: ['subagent'], + }); + return terminalSubagents.filter( + (task) => + task.ownerSessionId === sessionId && + task.kind === 'subagent' && + isTerminalTaskStatus(task.status) && + task.deliveredAt !== undefined && + task.createdAt <= boundaryMs && + readChildSessionId(task) !== undefined, + ); +} + +/** + * Targets = this Session's non-terminal tasks, plus terminal ones whose result was + * never delivered (they finished just before the stop and their notice is still in + * retry backoff — delivering it later is exactly the wake-up we must prevent). + */ +export async function collectCascadeTargets( + host: LocalTaskRunnerHostWithSessionLookup, + sessionId: string, + boundaryMs: number, +): Promise { + const [active, undelivered] = await Promise.all([ + listTasks(host, sessionId, ACTIVE_STATUSES), + listTasks(host, sessionId, TERMINAL_STATUSES, { undeliveredOnly: true }), + ]); + const byId = new Map(); + for (const task of [...active, ...undelivered]) { + if (task.ownerSessionId !== sessionId) continue; + if (task.createdAt > boundaryMs) continue; + if (isTerminalTaskStatus(task.status) && task.deliveredAt !== undefined) continue; + // Foreground subagent rows (plain `task` calls and Goal verification) belong to + // the aborted Turn and are settled by it: terminal ones are already latched as + // delivered, and running ones were never admitted here. Excluding them keeps + // `cascade_skipped_not_owned` meaningful as a cross-process signal. + if (isForegroundSubagentRow(task)) continue; + byId.set(task.taskId, task); + } + return [...byId.values()]; +} + +async function listTasks( + host: LocalTaskRunnerHostWithSessionLookup, + sessionId: string, + statuses: readonly BackgroundTaskStatus[], + options: { readonly undeliveredOnly?: boolean; readonly kinds?: BackgroundTaskKind[] } = {}, +): Promise { + const result = await host.backgroundTaskService.list({ + ownerSessionId: sessionId, + statuses: [...statuses], + limit: MAX_CASCADE_TASKS, + ...(options.undeliveredOnly ? { undeliveredOnly: true } : {}), + ...(options.kinds ? { kinds: options.kinds } : {}), + }); + return result.items; +} + +function isForegroundSubagentRow(task: BackgroundTask): boolean { + return task.kind === 'subagent' && readExecutionMode(task) === 'foreground'; +} + +function readExecutionMode(task: BackgroundTask): string | undefined { + const mode = task.metadata?.['executionMode']; + return typeof mode === 'string' ? mode : undefined; +} + +/** + * Splits child-Session rows by the Turn that spawned them. Without a scope (the + * root Session) every row passes; rows lacking `parentTurnId` are never swept, + * only counted, because their owner cannot be established. + */ +export function scopeToOwnerTurn( + tasks: readonly BackgroundTask[], + childScope: CascadeChildScope | undefined, +): { owned: BackgroundTask[]; skippedOtherTurn: number; skippedMissingOwner: number } { + if (!childScope) return { owned: [...tasks], skippedOtherTurn: 0, skippedMissingOwner: 0 }; + const owned: BackgroundTask[] = []; + let skippedOtherTurn = 0; + let skippedMissingOwner = 0; + for (const task of tasks) { + const parentTurnId = readMetadataString(task, 'parentTurnId'); + if (parentTurnId === undefined) skippedMissingOwner += 1; + else if (parentTurnId !== childScope.ownerTurnId) skippedOtherTurn += 1; + else owned.push(task); + } + return { owned, skippedOtherTurn, skippedMissingOwner }; +} + +export function readSubTurnId(task: BackgroundTask): string | undefined { + return readMetadataString(task, 'subTurnId'); +} + +function readMetadataString(task: BackgroundTask, key: string): string | undefined { + const value = task.metadata?.[key]; + return typeof value === 'string' && value.trim() ? value : undefined; +} + +export function readChildSessionId(task: BackgroundTask): string | undefined { + const childSessionId = task.metadata?.['childSessionId']; + return typeof childSessionId === 'string' && childSessionId.trim() ? childSessionId : undefined; +} diff --git a/packages/local-runtime/src/background-task/session-cascade.ts b/packages/local-runtime/src/background-task/session-cascade.ts new file mode 100644 index 000000000..81ce5a296 --- /dev/null +++ b/packages/local-runtime/src/background-task/session-cascade.ts @@ -0,0 +1,446 @@ +import type { LocalTaskRunnerHostWithSessionLookup } from '../api/local-task-host.js'; +import { logger } from '../common/logger.js'; +import { + isTerminalTaskStatus, + type BackgroundTask, + type BackgroundTaskKind, + type BackgroundTaskStatus, +} from './domain.js'; +import { isBackgroundTaskAdmittedHere } from './lifecycle.js'; +import { + collectCascadeTargets, + collectReadSubagents, + readChildSessionId, + readSubTurnId, + scopeToOwnerTurn, + type CascadeChildScope, +} from './session-cascade-targets.js'; + +/** + * Session-level cascade: stop every piece of background work a Session owns. + * + * Invariants. The cascade is a best-effort action that + * runs AFTER the stop response was already sent, so every failure mode must + * degrade to "as if there were no cascade at all": + * - only tasks this process admitted are stopped, never another client process's; + * - grandchildren are stopped before their parent, so a parent's teardown cannot + * re-parent live work; + * - a task that cannot be stopped is reported as `termination_requested` rather + * than being written terminal behind the runner's back. + */ + +export type CascadeReason = 'owner_abort' | 'owner_close'; + +/** Reported per target; `termination_requested` means "stop asked, not settled". */ +export type StoppedTaskStatus = BackgroundTaskStatus | 'termination_requested'; + +export interface StoppedTaskRecord { + readonly taskId: string; + readonly kind: BackgroundTaskKind; + readonly status: StoppedTaskStatus; + readonly description?: string; +} + +export interface SessionCascadeContext { + /** Recursion guard: subagent → child Session → its own subagents. */ + readonly depth: number; + /** + * Sweeps already done in this cascade, keyed by Session for the root and by + * (child Session, owning child Turn) below it. Several subagent rows can share + * one child Session — an `append` continues its source's — and each owns a + * disjoint slice of it, so a per-Session key would wrongly skip the second one. + */ + readonly visited: Set; + /** + * Sessions on the current recursion path (root first). Reaching a Session that + * is already on the path is a genuine cycle in parent/child metadata, whatever + * Turn scope it is reached with. + */ + readonly path?: readonly string[]; + /** Targets collected so far, used to register delivery suppression. */ + readonly onTargets?: (taskIds: readonly string[]) => void; +} + +/** Deeper nesting than this is pathological; stop descending and say so. */ +const MAX_CASCADE_DEPTH = 5; +/** Per-task stop budget; a hung runner must not consume the whole cascade. */ +const TASK_STOP_TIMEOUT_MS = 5_000; + +export interface StopSessionBackgroundWorkInput { + readonly host: LocalTaskRunnerHostWithSessionLookup; + readonly sessionId: string; + readonly reason: CascadeReason; + /** + * Only tasks created at or before the stop boundary are targets. Work created + * after it can only come from the user's next message and must not be touched. + */ + readonly boundaryMs: number; + readonly ctx: SessionCascadeContext; + /** + * Set only when sweeping a subagent's child Session. A child Session is shared by + * every row that ran in it (an `append` continues its source's), so ownership is + * by execution, not by time: only tasks spawned by this row's own child Turn + * (`metadata.parentTurnId === ownerTurnId`) belong to the sweep. Anything else — + * notably work of a newer Turn that continued the same child after the stop — + * is left untouched. + */ + readonly childScope?: CascadeChildScope; +} + +export async function stopSessionBackgroundWork( + input: StopSessionBackgroundWorkInput, +): Promise<{ targets: StoppedTaskRecord[] }> { + const { host, sessionId, reason, boundaryMs, ctx, childScope } = input; + if (ctx.depth > MAX_CASCADE_DEPTH) { + logCascade('cascade_depth_exceeded', { sessionId, depth: ctx.depth }); + return { targets: [] }; + } + if ((ctx.path ?? []).includes(sessionId)) { + logCascade('cascade_cycle_skipped', { sessionId, depth: ctx.depth }); + return { targets: [] }; + } + const visitKey = childScope ? `${sessionId}\0${childScope.ownerTurnId}` : sessionId; + if (ctx.visited.has(visitKey)) { + logCascade('cascade_session_already_swept', { + sessionId, + depth: ctx.depth, + ...(childScope ? { ownerTurnId: childScope.ownerTurnId } : {}), + }); + return { targets: [] }; + } + ctx.visited.add(visitKey); + + const [collectedTargets, collectedReadSubagents] = await Promise.all([ + collectCascadeTargets(host, sessionId, boundaryMs), + collectReadSubagents(host, sessionId, boundaryMs), + ]); + const scopedTargets = scopeToOwnerTurn(collectedTargets, childScope); + const scopedReadSubagents = scopeToOwnerTurn(collectedReadSubagents, childScope); + if (childScope) { + // One summary per child sweep; per-task lines would drown the stop in noise. + logCascade('cascade_child_sweep_scoped', { + childSessionId: sessionId, + ownerTurnId: childScope.ownerTurnId, + parentTaskId: childScope.parentTaskId, + matched: scopedTargets.owned.length + scopedReadSubagents.owned.length, + skippedOtherTurn: scopedTargets.skippedOtherTurn + scopedReadSubagents.skippedOtherTurn, + skippedMissingOwner: + scopedTargets.skippedMissingOwner + scopedReadSubagents.skippedMissingOwner, + }); + } + const candidates = scopedTargets.owned; + const readSubagents = scopedReadSubagents.owned; + // Register targets for delivery suppression BEFORE stopping anything: stopping + // a task drives it terminal, which schedules a delivery immediately. + ctx.onTargets?.(candidates.map((task) => task.taskId)); + if (candidates.length === 0) logCascade('cascade_no_targets', { sessionId, depth: ctx.depth }); + + const records: StoppedTaskRecord[] = []; + const settled = await Promise.allSettled( + candidates.map((task) => stopOneTarget({ host, task, reason, ctx })), + ); + settled.forEach((result, index) => { + const task = candidates[index]; + if (!task) return; + if (result.status === 'fulfilled') { + if (result.value) records.push(result.value); + return; + } + // A thrown stop is still a requested stop: the task stays suppressed and is + // reported as unsettled rather than being silently dropped from the note. + logCascade('cascade_stop_failed', { + sessionId, + taskId: task.taskId, + kind: task.kind, + error: errorMessage(result.reason), + }); + records.push(describe(task, 'termination_requested')); + }); + // Kept strictly AFTER every target has settled, as defense in depth. An `append` + // target and the read row it continues share one child Session; with sweeps now + // keyed and scoped per owning child Turn they no longer collide, but finishing + // every target's stop-then-sweep first keeps this cascade's order deterministic + // and independent of that keying. + await traverseReadSubagents({ host, sessionId, readSubagents, reason, ctx }); + return { targets: records }; +} + +async function traverseReadSubagents(input: { + readonly host: LocalTaskRunnerHostWithSessionLookup; + readonly sessionId: string; + readonly readSubagents: readonly BackgroundTask[]; + readonly reason: CascadeReason; + readonly ctx: SessionCascadeContext; +}): Promise { + const { host, sessionId, readSubagents, reason, ctx } = input; + if (readSubagents.length === 0) return; + logCascade('cascade_traverse_read_subagents', { sessionId, count: readSubagents.length }); + // `sweepChildSession` logs and swallows its own failure, so one bad child + // Session cannot stop the others from being swept. + await Promise.allSettled( + readSubagents.map((task) => + sweepChildSession({ + host, + parent: task, + childSessionId: readChildSessionId(task), + reason, + ctx, + }), + ), + ); +} + +async function stopOneTarget(input: { + readonly host: LocalTaskRunnerHostWithSessionLookup; + readonly task: BackgroundTask; + readonly reason: CascadeReason; + readonly ctx: SessionCascadeContext; +}): Promise { + const { host, task, reason, ctx } = input; + const childSessionId = readChildSessionId(task); + + // Already terminal: nothing to stop. It is a target purely so its undelivered + // result cannot wake the Session later. + if (isTerminalTaskStatus(task.status)) { + return handleTerminalTarget({ + host, + task, + childSessionId, + reason, + ctx, + event: 'cascade_target_already_terminal', + }); + } + + // Only this process can actually stop its own runners. `performStop` would write + // `canceled` regardless, so stopping a row owned by another client process (shared + // data directory) would lie about a task that keeps running there. + if (!isBackgroundTaskAdmittedHere(host, task.taskId)) { + // The snapshot may be stale: a runner of ours can finish after collection, and + // its admission is dropped as soon as it settles. Both runners settle only after + // writing the terminal row (subagent: `persistLocalSubagentTaskTerminal`; bash: + // `settleBackgroundBashTask`), so re-reading the row separates "just finished" + // (terminal) from "held by another process" (still active). A finished task needs + // no stop wherever it ran, so it is handled like any terminal target; skipping it + // would leave whatever it spawned in its child Session running after the stop. + // Its delivery is already suppressed: `onTargets` ran before any stop. + const current = await rereadUnadmittedTarget(host, task); + if (current && isTerminalTaskStatus(current.status)) { + return handleTerminalTarget({ + host, + task: current, + childSessionId: readChildSessionId(current) ?? childSessionId, + reason, + ctx, + event: 'cascade_target_settled_before_stop', + snapshotStatus: task.status, + }); + } + // Still active, or the row is gone: nothing this process can stop. A row held by + // another process stays active on re-read, so it is skipped exactly as before. + logCascade('cascade_skipped_not_owned', { + sessionId: task.ownerSessionId, + taskId: task.taskId, + kind: task.kind, + status: current?.status ?? 'missing', + }); + recordCascadeMetric(host, 'cascade_skipped_not_owned'); + return undefined; + } + + // The task itself FIRST, then its child Session. For a subagent, `performStop` + // awaits `stopRuntime`, which aborts the child Turn; once that returns the child + // can spawn nothing more, so the sweep that follows sees its complete output. + // Sweeping first would race a still-running child that keeps spawning work. + const settledStatus = await stopWithTimeout(host, task, reason); + recordCascadeMetric(host, 'cascade_stopped'); + await sweepChildSession({ host, parent: task, childSessionId, reason, ctx }); + return describe(task, settledStatus); +} + +/** + * A target with nothing left to stop, reported with its real terminal status. A + * finished subagent may still have left live background work in its child Session, + * so that is swept all the same. Shared by both terminal paths so a target that + * settled just before the ownership check is treated exactly like one the snapshot + * already saw terminal. + */ +async function handleTerminalTarget(input: { + readonly host: LocalTaskRunnerHostWithSessionLookup; + /** The latest read of the row; its status is terminal. */ + readonly task: BackgroundTask; + readonly childSessionId: string | undefined; + readonly reason: CascadeReason; + readonly ctx: SessionCascadeContext; + readonly event: 'cascade_target_already_terminal' | 'cascade_target_settled_before_stop'; + /** Set when the snapshot still showed the task active, to make the race visible. */ + readonly snapshotStatus?: BackgroundTaskStatus; +}): Promise { + const { host, task, childSessionId, reason, ctx } = input; + logCascade(input.event, { + sessionId: task.ownerSessionId, + taskId: task.taskId, + kind: task.kind, + ...(input.snapshotStatus ? { snapshotStatus: input.snapshotStatus } : {}), + status: task.status, + }); + await sweepChildSession({ host, parent: task, childSessionId, reason, ctx }); + return describe(task, task.status); +} + +/** + * Fresh read of a target this process did not admit. A failed read falls back to + * the snapshot, so the target is skipped as not owned exactly as it was before the + * re-read existed: letting the error escape would report a task nobody asked to + * stop as `termination_requested`. + */ +async function rereadUnadmittedTarget( + host: LocalTaskRunnerHostWithSessionLookup, + task: BackgroundTask, +): Promise { + try { + return await host.backgroundTaskService.get(task.taskId); + } catch (error) { + logCascade('cascade_target_reread_failed', { + sessionId: task.ownerSessionId, + taskId: task.taskId, + kind: task.kind, + error: errorMessage(error), + }); + return task; + } +} + +/** + * Stops everything in a subagent's child Session. Child-Session work belongs to the + * owner's task tree but `task_output` cannot reach it from the owner, so it is + * stopped here yet never listed in the owner's report. + */ +async function sweepChildSession(input: { + readonly host: LocalTaskRunnerHostWithSessionLookup; + readonly parent: BackgroundTask; + readonly childSessionId: string | undefined; + readonly reason: CascadeReason; + readonly ctx: SessionCascadeContext; +}): Promise { + const { host, parent, childSessionId, reason, ctx } = input; + if (!childSessionId) return; + const ownerTurnId = readSubTurnId(parent); + if (!ownerTurnId) { + // Without the child Turn's id this row's work cannot be told apart from the + // work of another row sharing the child Session (an `append` continuing it), + // so nothing is swept for it — exactly today's behaviour for this row. + logCascade('cascade_child_sweep_skipped', { + sessionId: parent.ownerSessionId, + taskId: parent.taskId, + childSessionId, + reason: 'missing_sub_turn_id', + }); + return; + } + try { + await stopSessionBackgroundWork({ + host, + sessionId: childSessionId, + // Recursion stops tasks directly; it never re-enters the user-stop hook, + // which only fires from TurnService.abort. So a child Session cannot + // start a second Session-level cascade of its own. + reason, + // Ownership, not time, bounds this sweep: only what this row's own child + // Turn spawned. That still covers work the subagent started while winding + // down after the root boundary (it carries the same child Turn id), and it + // never touches a newer Turn that continued the same child Session. + boundaryMs: Number.POSITIVE_INFINITY, + childScope: { ownerTurnId, parentTaskId: parent.taskId }, + ctx: { ...ctx, depth: ctx.depth + 1, path: [...(ctx.path ?? []), parent.ownerSessionId] }, + }); + } catch (error) { + logCascade('cascade_child_session_failed', { + sessionId: parent.ownerSessionId, + taskId: parent.taskId, + childSessionId, + error: errorMessage(error), + }); + } +} + +async function stopWithTimeout( + host: LocalTaskRunnerHostWithSessionLookup, + task: BackgroundTask, + reason: CascadeReason, +): Promise { + let timer: ReturnType | undefined; + const timedOut = Symbol('cascade-stop-timeout'); + try { + const outcome = await Promise.race([ + host.backgroundTaskService.stop(task.taskId, `session-${reason}`), + new Promise((resolve) => { + timer = setTimeout(() => resolve(timedOut), TASK_STOP_TIMEOUT_MS); + timer.unref?.(); + }), + ]); + if (outcome === timedOut) { + logCascade('cascade_stop_timeout', { + sessionId: task.ownerSessionId, + taskId: task.taskId, + kind: task.kind, + timeoutMs: TASK_STOP_TIMEOUT_MS, + }); + return 'termination_requested'; + } + // Re-read the row: a task that completed normally while we were stopping it is + // `succeeded`, and reporting it as canceled would be wrong. + const settled = (await host.backgroundTaskService.get(task.taskId)) ?? outcome; + const status = settled?.status; + if (status && isTerminalTaskStatus(status)) { + logCascade('cascade_stopped', { + sessionId: task.ownerSessionId, + taskId: task.taskId, + kind: task.kind, + status, + }); + return status; + } + logCascade('cascade_stop_unsettled', { + sessionId: task.ownerSessionId, + taskId: task.taskId, + kind: task.kind, + status: status ?? 'unknown', + }); + return 'termination_requested'; + } finally { + clearTimeout(timer); + } +} + +function describe(task: BackgroundTask, status: StoppedTaskStatus): StoppedTaskRecord { + return { + taskId: task.taskId, + kind: task.kind, + status, + ...(task.description ? { description: task.description } : {}), + }; +} + +function logCascade(event: string, fields: Record): void { + try { + logger.info({ event: `background_task_${event}`, ...fields }, `Session stop cascade: ${event}`); + } catch { + // Diagnostics must never abort a cascade that is already tearing work down. + } +} + +function recordCascadeMetric( + host: LocalTaskRunnerHostWithSessionLookup, + reason: 'cascade_stopped' | 'cascade_skipped_not_owned', +): void { + try { + host.metricsClient?.counter('background_task_session_cascade_total', 1, { reason }); + } catch { + // Telemetry cannot be allowed to fail a stop. + } +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/packages/local-runtime/src/background-task/user-stop-cascade.ts b/packages/local-runtime/src/background-task/user-stop-cascade.ts new file mode 100644 index 000000000..aa51b3d1d --- /dev/null +++ b/packages/local-runtime/src/background-task/user-stop-cascade.ts @@ -0,0 +1,304 @@ +import type { LocalTaskRunnerHostWithSessionLookup } from '../api/local-task-host.js'; +import { logger } from '../common/logger.js'; +import { isLocalChildWorkerSession } from '../sessions/session-policy.js'; +import { stopSessionBackgroundWork, type StoppedTaskRecord } from './session-cascade.js'; +import { + isUserStopWindowOpen, + openUserStopWindow, + runSerializedUserStop, + type UserStopWindowHandle, +} from './user-stop-suppression.js'; + +/** + * User-stop cascade orchestration. + * + * `begin` runs synchronously inside the stop request, before the Turn abort: + * it decides whether this stop cascades at all and opens the delivery suppression + * window. `accept` pauses the Thread Goal once the stop is accepted (never for a + * stop the controller rejects). `complete` runs detached AFTER the abort + * answered, so the stop request never waits for background teardown, and it is + * also where the target boundary is taken (see `complete`). + * + * Everything here is best effort. The hard rule is that any failure degrades to + * "no cascade happened", never to something worse than today: the window always + * closes in a `finally`, no Session lifecycle lock is taken, and no failure path + * ever re-delivers a suppressed task. + * + * How the model learns a task was stopped: stopped tasks stay *undelivered*, so + * the next turn's existing `background_cadence_reminder` lists them with their + * real terminal status (`canceled`). Writing a dedicated history note instead + * is deliberately NOT part of this change — `committedHistory` + * requires a live Turn's `AgentEventContext` (`turnSequence` is mandatory) and + * always fires the Turn's live projection, so appending after a Turn released + * needs a new non-projecting conditional-append channel. Tracked separately. + */ + +/** Whole-cascade budget. Past this the window closes and targets stay suppressed. */ +const CASCADE_TIMEOUT_MS = 15_000; + +export interface UserStopAbortResult { + readonly status: 'aborted' | 'released' | 'abort-timeout' | 'not-running' | 'turn-mismatch'; + readonly turnId?: string; +} + +export interface UserStopCascadeDependencies { + readonly host: LocalTaskRunnerHostWithSessionLookup; + /** + * Pause the Session's active Thread Goal. Runs from `accept`, i.e. only once the + * stop is known to be accepted, and always BEFORE tasks are stopped: a task + * reaching terminal wakes the Session queue, and an unpaused Goal would sail + * through its "background work" gate and start a fresh Turn immediately. + */ + readonly pauseActiveGoal?: (sessionId: string) => Promise; +} + +/** + * How a stop came to be accepted. Only these two count: the controller's own + * identity check passed (it runs `onAccepted`), or nothing was running at all. + */ +export type UserStopAcceptance = 'controller_accepted' | 'not_running'; + +export interface UserStopCascadeHandle { + /** + * The side effects a stop may only have once it is ACCEPTED: pausing the Goal. + * A stop the controller rejects (`turn-mismatch`) must leave the Goal exactly as + * it was, so this is not done in `begin`. Idempotent; never throws; resolves + * after the pause attempt so the caller can order it before the cascade. + */ + accept(how: UserStopAcceptance): Promise; + /** Detached; never throws and never blocks the caller. */ + complete(result: UserStopAbortResult): void; + /** + * The stop itself failed, so there is no abort result to cascade from. Releases + * this handle's window without stopping anything — exactly today's behaviour. + */ + cancel(reason: string): void; +} + +export interface UserStopCascade { + begin(sessionId: string): Promise; +} + +export function createUserStopCascade(deps: UserStopCascadeDependencies): UserStopCascade { + const { host } = deps; + return { + begin: async (sessionId) => { + let window: UserStopWindowHandle | undefined; + try { + const session = await host.getSessionById(sessionId); + if (!session) { + log('user_stop_cascade_session_missing', { sessionId }); + return undefined; + } + // Only a main conversation cascades. A child worker Session is never woken + // by delivery in the first place (delivery skips it), so stopping one alone + // keeps today's behaviour: its own terminal still notifies its parent once. + if (isLocalChildWorkerSession(session)) { + log('cascade_skipped_child_session', { sessionId }); + return undefined; + } + // Only the window here. The Goal is paused in `accept`, once the stop is + // known to be accepted: `begin` runs before the controller's identity + // check, and a stop it then rejects must not have paused anything. + window = openUserStopWindow(host, sessionId, host.nowMs()); + return createCascadeHandle(deps, sessionId, window); + } catch (error) { + // A stop must never fail because its cascade could not start. A window we + // already opened has no owner once we return undefined, so release it here + // rather than leaving it to the watchdog. + window?.close('cancelled'); + log('user_stop_cascade_begin_failed', { sessionId, error: errorMessage(error) }); + return undefined; + } + }, + }; +} + +/** + * Exactly one of `complete` / `cancel` takes effect per handle, and every path + * releases this handle's own window hold: that is what guarantees a failed or + * duplicated stop can never pin the Session's deliveries as `busy`. + */ +function createCascadeHandle( + deps: UserStopCascadeDependencies, + sessionId: string, + window: UserStopWindowHandle, +): UserStopCascadeHandle { + const { host } = deps; + let settled = false; + let cancelled = false; + let acceptance: Promise | undefined; + return { + accept: (how) => { + if (cancelled) { + // The stop was rejected or failed; it must not change the Goal. + log('user_stop_cascade_accept_after_cancel', { sessionId, how }); + return Promise.resolve(); + } + if (acceptance) { + log('user_stop_cascade_duplicate_accept', { sessionId, how }); + return acceptance; + } + log('user_stop_cascade_accepted', { sessionId, how }); + // `pauseGoal` swallows and logs its own failure, so this never rejects. + acceptance = pauseGoal(deps, sessionId); + return acceptance; + }, + complete: (result) => { + if (settled) { + log('user_stop_cascade_duplicate_settle', { sessionId, call: 'complete' }); + return; + } + settled = true; + // The boundary is read HERE, synchronously, before the first await: + // `complete` runs after `controller.abort` returned, so the stopped + // Turn has already released the Session (except on `abort-timeout`, + // see below). Reading it in `begin` + // instead would miss a task the dying Turn created while it was + // still winding down — background bash and background subagents do + // not observe the parent signal, so a tool call in flight can still + // insert a row after the stop request was accepted. Those rows must + // be targets. Nothing newer can exist yet: the Session lock keeps + // the next Turn from starting until this point. + const boundaryMs = host.nowMs(); + // Known limit (documented in docs/tui-capabilities.md): on + // `abort-timeout` the stopped Turn has NOT released yet, so the boundary + // above can precede the end of its wind-down. Anything it creates after + // this point is neither stopped nor suppressed — it runs and is delivered + // exactly as it would be without a cascade. Warn, so such a stop can be + // told apart from a clean one when a late task wakes the Session. + if (result.status === 'abort-timeout') { + log( + 'user_stop_cascade_boundary_before_release', + { sessionId, boundaryMs, ...(result.turnId ? { turnId: result.turnId } : {}) }, + 'warn', + ); + } + // Queued behind any cascade already running for this Session, then runs + // with its OWN boundary. The window is released after this stop's own run + // (and again, idempotently, inside `completeCascade`), whatever happened. + const { settled: cascadeSettled, queued } = runSerializedUserStop(host, sessionId, () => + completeCascade(deps, sessionId, boundaryMs, window, result), + ); + if (queued) log('user_stop_cascade_queued', { sessionId, boundaryMs }); + void cascadeSettled + .catch((error: unknown) => { + log('user_stop_cascade_flight_failed', { sessionId, error: errorMessage(error) }); + }) + .finally(() => window.close()); + }, + cancel: (reason) => { + if (settled) { + log('user_stop_cascade_duplicate_settle', { sessionId, call: 'cancel', reason }); + return; + } + settled = true; + cancelled = true; + log('user_stop_cascade_cancelled', { sessionId, reason }); + window.close('cancelled'); + }, + }; +} + +async function pauseGoal(deps: UserStopCascadeDependencies, sessionId: string): Promise { + if (!deps.pauseActiveGoal) return; + try { + await deps.pauseActiveGoal(sessionId); + log('user_stop_cascade_goal_paused', { sessionId }); + } catch (error) { + // The Turn abort is authoritative; Goal pause stays a best-effort side effect. + log('user_stop_cascade_goal_pause_failed', { sessionId, error: errorMessage(error) }); + } +} + +async function completeCascade( + deps: UserStopCascadeDependencies, + sessionId: string, + boundaryMs: number, + window: UserStopWindowHandle, + result: UserStopAbortResult, +): Promise { + let targets: StoppedTaskRecord[] = []; + log('user_stop_cascade_started', { + sessionId, + boundaryMs, + abortStatus: result.status, + ...(result.turnId ? { turnId: result.turnId } : {}), + }); + try { + targets = await withTimeout( + stopSessionBackgroundWork({ + host: deps.host, + sessionId, + reason: 'owner_abort', + boundaryMs, + ctx: { + depth: 0, + visited: new Set(), + // Suppress as soon as targets are known, before any stop lands. + onTargets: (taskIds) => window.registerTargets(taskIds), + }, + }).then(({ targets: stopped }) => stopped), + CASCADE_TIMEOUT_MS, + [] as StoppedTaskRecord[], + () => log('user_stop_cascade_timeout', { sessionId, timeoutMs: CASCADE_TIMEOUT_MS }), + ); + } catch (error) { + log('user_stop_cascade_failed', { sessionId, error: errorMessage(error) }); + } finally { + // Always reopen ordinary delivery. Targets remain individually suppressed. + window.close(); + } + + // Targets stay undelivered on purpose: that is exactly what makes the next + // turn's background reminder report them as `canceled` to the model. + log('user_stop_cascade_completed', { + sessionId, + targetCount: targets.length, + abortStatus: result.status, + stoppedTaskIds: targets.map((target) => target.taskId), + ...(result.turnId ? { turnId: result.turnId } : {}), + }); +} + +async function withTimeout( + work: Promise, + timeoutMs: number, + fallback: T, + onTimeout: () => void, +): Promise { + let timer: ReturnType | undefined; + const timedOut = Symbol('user-stop-cascade-timeout'); + try { + const outcome = await Promise.race([ + work, + new Promise((resolve) => { + timer = setTimeout(() => resolve(timedOut), timeoutMs); + timer.unref?.(); + }), + ]); + if (outcome !== timedOut) return outcome; + onTimeout(); + return fallback; + } finally { + clearTimeout(timer); + } +} + +function log( + event: string, + fields: Record, + level: 'info' | 'warn' = 'info', +): void { + try { + logger[level]({ event: `background_task_${event}`, ...fields }, `User stop cascade: ${event}`); + } catch { + // Diagnostics must not interfere with an in-progress stop. + } +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +export { isUserStopWindowOpen }; diff --git a/packages/local-runtime/src/background-task/user-stop-suppression.ts b/packages/local-runtime/src/background-task/user-stop-suppression.ts new file mode 100644 index 000000000..f215a36a6 --- /dev/null +++ b/packages/local-runtime/src/background-task/user-stop-suppression.ts @@ -0,0 +1,204 @@ +import type { LocalTaskRunnerHostWithSessionLookup } from '../api/local-task-host.js'; +import { logger } from '../common/logger.js'; + +/** + * Process-local delivery suppression for the user-stop cascade. + * + * Deliberately in-memory only (zero schema change): a stop is a live user + * action, and everything it suppresses is either already terminal-but-undelivered + * (the next turn's background reminder reports it) or still running in THIS + * process. After a crash both are gone, and delivery is only ever scheduled at + * the moment a task turns terminal, so nothing is re-delivered on startup — + * restart behaviour is exactly today's. + * + * Two independent gates, because they answer different questions: + * - session window: "a stop for this Session is mid-flight, we do not yet know + * which tasks it owns" → hold the whole batch, retryable; + * - target set: "this exact task was stopped by the user" → never deliver. + */ + +/** + * Upper bound on remembered targets. Sized well above any plausible number of + * stopped tasks in one process lifetime; eviction is logged because an evicted + * target could regain the ability to wake its Session. + */ +const MAX_SUPPRESSED_TASK_IDS = 4_096; + +/** + * Last-resort bound on how long any single window may stay open. A window that + * never closes would hold every later completion notice of the Session as `busy` + * for the life of the process — strictly worse than having no cascade at all. + * Normal paths always close well before this: the abort waits at most 5s + * (DEFAULT_ABORT_TIMEOUT_MS) and the cascade at most 15s (CASCADE_TIMEOUT_MS), + * so 60s leaves ample margin and only ever fires for a path we did not foresee. + */ +const USER_STOP_WINDOW_WATCHDOG_MS = 60_000; + +/** Why a window handle was closed; recorded in the close log. */ +export type UserStopWindowCloseReason = 'completed' | 'cancelled' | 'watchdog'; + +export interface UserStopWindowHandle { + registerTargets(taskIds: Iterable): void; + /** Idempotent; only releases this handle's own hold on the Session window. */ + close(reason?: UserStopWindowCloseReason): void; +} + +interface HostSuppressionState { + /** + * Open window holds per Session. Keyed per handle rather than per Session: + * two stops of the same Session can overlap (double click, desktop + TUI), and + * with a single key the first `close` would drop the second stop's window — + * or, in the opposite order, a handle nobody closes would pin it open forever. + * The window is open exactly while at least one handle is still held. + */ + readonly openSessions: Map>; + /** Targets of a completed-or-failed cascade; never delivered in this process. */ + readonly suppressedTaskIds: Set; + /** Tail of each Session's serial cascade queue (see `runSerializedUserStop`). */ + readonly inFlight: Map>; +} + +const hostStates = new WeakMap(); + +function getState(host: LocalTaskRunnerHostWithSessionLookup): HostSuppressionState { + let state = hostStates.get(host); + if (!state) { + state = { openSessions: new Map(), suppressedTaskIds: new Set(), inFlight: new Map() }; + hostStates.set(host, state); + } + return state; +} + +/** + * Opens (adds a hold on) the Session suppression window. Every handle MUST be + * closed: callers close it in a `finally`, and a watchdog closes it anyway if an + * unforeseen path forgets to, so the worst case degrades to today's behaviour. + */ +export function openUserStopWindow( + host: LocalTaskRunnerHostWithSessionLookup, + sessionId: string, + nowMs: number, +): UserStopWindowHandle { + const state = getState(host); + const token = Symbol(sessionId); + let holds = state.openSessions.get(sessionId); + if (!holds) { + holds = new Set(); + state.openSessions.set(sessionId, holds); + } + holds.add(token); + const openedAtWallMs = Date.now(); + let closed = false; + + const close = (reason: UserStopWindowCloseReason = 'completed'): void => { + if (closed) return; + closed = true; + clearTimeout(watchdog); + const current = state.openSessions.get(sessionId); + current?.delete(token); + const remainingHolds = current?.size ?? 0; + if (current && remainingHolds === 0) state.openSessions.delete(sessionId); + logWindow(reason === 'watchdog' ? 'warn' : 'info', { + event: + reason === 'watchdog' + ? 'background_task_user_stop_window_watchdog_closed' + : 'background_task_user_stop_window_closed', + sessionId, + reason, + openedAtMs: nowMs, + openForMs: Date.now() - openedAtWallMs, + remainingHolds, + }); + }; + + // Guards against an unforeseen path that never closes this handle. + const watchdog = setTimeout(() => close('watchdog'), USER_STOP_WINDOW_WATCHDOG_MS); + watchdog.unref?.(); + + return { + registerTargets: (taskIds) => { + for (const taskId of taskIds) rememberSuppressedTask(state, taskId, sessionId); + }, + close, + }; +} + +function logWindow(level: 'info' | 'warn', fields: Record): void { + try { + logger[level](fields, 'User stop delivery window closed'); + } catch { + // Diagnostics must never keep a window open. + } +} + +function rememberSuppressedTask( + state: HostSuppressionState, + taskId: string, + sessionId: string, +): void { + if (state.suppressedTaskIds.has(taskId)) return; + state.suppressedTaskIds.add(taskId); + if (state.suppressedTaskIds.size <= MAX_SUPPRESSED_TASK_IDS) return; + const oldest = state.suppressedTaskIds.values().next().value; + if (oldest === undefined) return; + state.suppressedTaskIds.delete(oldest); + try { + logger.warn( + { + event: 'background_task_stop_suppression_evicted', + taskId: oldest, + sessionId, + retained: state.suppressedTaskIds.size, + }, + 'Evicted the oldest stopped-task delivery suppression entry', + ); + } catch { + // Diagnostics must not break an in-progress stop. + } +} + +/** True while any stop for this Session still holds the window open. */ +export function isUserStopWindowOpen( + host: LocalTaskRunnerHostWithSessionLookup, + sessionId: string, +): boolean { + return (getState(host).openSessions.get(sessionId)?.size ?? 0) > 0; +} + +/** True when the task was stopped by a user stop and must never be delivered. */ +export function isTaskDeliverySuppressed( + host: LocalTaskRunnerHostWithSessionLookup, + taskId: string, +): boolean { + return getState(host).suppressedTaskIds.has(taskId); +} + +/** + * Serializes cascades per Session. A second stop never joins the first one's + * run: it may carry a later boundary (the user sent a new message, the new Turn + * started background work, then stopped again), and joining would silently drop + * that boundary and leave the new work running. Instead it waits for the + * previous cascade to settle — success OR failure — and then runs its own. + * Re-running is idempotent: tasks the earlier cascade already stopped are + * terminal and are only re-reported, never stopped twice. + */ +export function runSerializedUserStop( + host: LocalTaskRunnerHostWithSessionLookup, + sessionId: string, + run: () => Promise, +): { readonly settled: Promise; readonly queued: boolean } { + const state = getState(host); + const previous = state.inFlight.get(sessionId); + // A failed predecessor must not cancel this stop; its caller already logged it. + const next = (previous ?? Promise.resolve()).catch(() => undefined).then(run); + const tail: Promise = next.finally(() => { + if (state.inFlight.get(sessionId) === tail) state.inFlight.delete(sessionId); + }); + state.inFlight.set(sessionId, tail); + return { settled: tail, queued: previous !== undefined }; +} + +/** Test seam: drop all suppression state for a host. */ +export function resetUserStopSuppressionForTests(host: LocalTaskRunnerHostWithSessionLookup): void { + hostStates.delete(host); +} diff --git a/packages/local-runtime/src/index.ts b/packages/local-runtime/src/index.ts index 5e04a1884..ca86bfeb0 100644 --- a/packages/local-runtime/src/index.ts +++ b/packages/local-runtime/src/index.ts @@ -1,3 +1,4 @@ +export { createUserStopCascade, type UserStopCascade } from "./background-task/index.js"; export { resolveAgentBashEnvPolicy } from "./infra/ensure-rm-shim.js"; export { ManagedWorktreeService, diff --git a/packages/local-runtime/test/unit/local-background-task-user-stop-cascade.test.ts b/packages/local-runtime/test/unit/local-background-task-user-stop-cascade.test.ts new file mode 100644 index 000000000..5e9faf351 --- /dev/null +++ b/packages/local-runtime/test/unit/local-background-task-user-stop-cascade.test.ts @@ -0,0 +1,1325 @@ +import { describe, expect, it, vi } from 'vitest'; + +import type { LocalTaskRunnerHostWithSessionLookup } from '../../src/api/local-task-host.js'; +import { logger } from '../../src/common/logger.js'; +import { startLocalBackgroundTaskDeliveryTurn } from '../../src/background-task/delivery.js'; +import type { BackgroundTask, BackgroundTaskStatus } from '../../src/background-task/domain.js'; +import { admitBackgroundTask } from '../../src/background-task/lifecycle.js'; +import { stopSessionBackgroundWork } from '../../src/background-task/session-cascade.js'; +import { createUserStopCascade } from '../../src/background-task/user-stop-cascade.js'; +import { + isUserStopWindowOpen, + openUserStopWindow, +} from '../../src/background-task/user-stop-suppression.js'; +import type { LocalSessionRecord } from '../../src/sessions/controller.js'; + +const OWNER = 'session-owner'; + +describe('user stop cascade: delivery suppression', () => { + it('holds a batch as retryable `busy` while the stop window is open', async () => { + // `skipped` would be dequeued without a reschedule, permanently losing the + // notice of a task that finished during the window but is not a target. + const fixture = harness([terminalTask('task-a')]); + const window = openUserStopWindow(fixture.host, OWNER, 1_000); + + await expect(startLocalBackgroundTaskDeliveryTurn(fixture.host, 'task-a')).resolves.toBe( + 'busy', + ); + expect(fixture.steer).not.toHaveBeenCalled(); + + window.close(); + await expect(startLocalBackgroundTaskDeliveryTurn(fixture.host, 'task-a')).resolves.toBe( + 'delivered', + ); + }); + + it('delivers a non-target that finished during the window once the window closes', async () => { + const fixture = harness([terminalTask('target'), terminalTask('newcomer')]); + const window = openUserStopWindow(fixture.host, OWNER, 1_000); + window.registerTargets(['target']); + + await expect(startLocalBackgroundTaskDeliveryTurn(fixture.host, 'newcomer')).resolves.toBe( + 'busy', + ); + window.close(); + + await expect(startLocalBackgroundTaskDeliveryTurn(fixture.host, 'newcomer')).resolves.toBe( + 'delivered', + ); + expect(fixture.steer).toHaveBeenCalledTimes(1); + expect(fixture.steer.mock.calls[0]![0].message.origin.taskIds).toEqual(['newcomer']); + }); + + it('never delivers a stopped target, even after the window closed', async () => { + const fixture = harness([terminalTask('target')]); + const window = openUserStopWindow(fixture.host, OWNER, 1_000); + window.registerTargets(['target']); + window.close(); + + // `skipped`: the batch is empty after filtering, so a retry is pointless. + await expect(startLocalBackgroundTaskDeliveryTurn(fixture.host, 'target')).resolves.toBe( + 'skipped', + ); + expect(fixture.steer).not.toHaveBeenCalled(); + }); + + it('keeps a suppressed target out of a mixed batch and out of origin.taskIds', async () => { + const fixture = harness([terminalTask('target'), terminalTask('other')]); + const window = openUserStopWindow(fixture.host, OWNER, 1_000); + window.registerTargets(['target']); + window.close(); + + await expect(fixture.deliverBoth()).resolves.toBe('delivered'); + const origin = fixture.steer.mock.calls[0]![0].message.origin; + expect(origin.taskIds).toEqual(['other']); + expect(fixture.steer.mock.calls[0]![0].message.content).not.toContain('target'); + }); + + it('leaves a suppressed terminal undelivered so the next reminder reports it', async () => { + const fixture = harness([terminalTask('target')]); + const window = openUserStopWindow(fixture.host, OWNER, 1_000); + window.registerTargets(['target']); + window.close(); + + await startLocalBackgroundTaskDeliveryTurn(fixture.host, 'target'); + expect(fixture.tasks.get('target')!.deliveredAt).toBeUndefined(); + }); +}); + +describe('user stop cascade: delivery recheck', () => { + it('re-checks the stop window after its reads, right before handing the batch to ingress', async () => { + // Gate 1 passed, then a user stop began while this batch awaited its reads. + // Without the recheck the batch would reach ingress and wake the Session. + const fixture = harness([terminalTask('late')]); + const service = fixture.host.backgroundTaskService as unknown as { + reminderSnapshot: () => Promise; + }; + let window: ReturnType | undefined; + service.reminderSnapshot = async () => { + // Only the first read races the stop; the retry must see a closed window. + window ??= openUserStopWindow(fixture.host, OWNER, 1_000); + return { tasks: [], undeliveredTotal: 0, terminalTotal: 1 }; + }; + + await expect(startLocalBackgroundTaskDeliveryTurn(fixture.host, 'late')).resolves.toBe('busy'); + + expect(fixture.steer).not.toHaveBeenCalled(); + expect(fixture.metrics).toHaveBeenCalledWith('background_task_delivery_suppressed_total', 1, { + reason: 'user_stop_window_recheck', + }); + window!.close(); + // Retryable: once the window closes the same batch is delivered. + await expect(startLocalBackgroundTaskDeliveryTurn(fixture.host, 'late')).resolves.toBe( + 'delivered', + ); + }); +}); + +describe('user stop cascade: target collection', () => { + it('targets running tasks and terminal tasks whose result was never delivered', async () => { + const fixture = harness([ + runningTask('running'), + terminalTask('undelivered'), + { ...terminalTask('already-told'), deliveredAt: 5 }, + ]); + fixture.admit('running'); + + const { targets } = await cascade(fixture.host); + + expect(ids(targets)).toEqual(['running', 'undelivered']); + }); + + it('ignores work created after the stop boundary (collection rule)', async () => { + const fixture = harness([ + { ...runningTask('before'), createdAt: 900 }, + { ...runningTask('after'), createdAt: 1_100 }, + ]); + fixture.admit('before'); + fixture.admit('after'); + + const { targets } = await cascade(fixture.host, { boundaryMs: 1_000 }); + + expect(ids(targets)).toEqual(['before']); + expect(fixture.tasks.get('after')!.status).toBe('running'); + }); + + it('excludes foreground subagent rows, which the aborted Turn already settles', async () => { + const fixture = harness([ + { ...runningTask('foreground'), kind: 'subagent', metadata: { executionMode: 'foreground' } }, + { ...runningTask('background'), kind: 'subagent', metadata: { executionMode: 'background' } }, + ]); + fixture.admit('foreground'); + fixture.admit('background'); + + const { targets } = await cascade(fixture.host); + + expect(ids(targets)).toEqual(['background']); + expect(fixture.metrics).not.toHaveBeenCalledWith(expect.anything(), expect.anything(), { + reason: 'cascade_skipped_not_owned', + }); + }); + + it('skips a non-terminal task this process does not own, leaving its row untouched', async () => { + // Another client process sharing the data directory still runs it; writing + // `canceled` here would lie about a task that keeps going over there. + const fixture = harness([runningTask('foreign')]); + + const { targets } = await cascade(fixture.host); + + expect(targets).toEqual([]); + expect(fixture.tasks.get('foreign')!.status).toBe('running'); + expect(fixture.stop).not.toHaveBeenCalled(); + expect(fixture.metrics).toHaveBeenCalledWith('background_task_session_cascade_total', 1, { + reason: 'cascade_skipped_not_owned', + }); + }); + + it('reports the re-read terminal state, so a task that finished normally is not "canceled"', async () => { + const fixture = harness([runningTask('racing')]); + fixture.admit('racing'); + // The task completes on its own while the stop is in flight. + fixture.stop.mockImplementation(async (taskId: string) => { + fixture.patch(taskId, 'succeeded'); + return fixture.tasks.get(taskId); + }); + + const { targets } = await cascade(fixture.host); + + expect(targets).toEqual([expect.objectContaining({ taskId: 'racing', status: 'succeeded' })]); + }); + + it('reports a stop that never settles as termination_requested without inventing a terminal', async () => { + const fixture = harness([runningTask('stuck')]); + fixture.admit('stuck'); + fixture.stop.mockImplementation(async () => undefined); + + const { targets } = await cascade(fixture.host); + + expect(targets).toEqual([ + expect.objectContaining({ taskId: 'stuck', status: 'termination_requested' }), + ]); + }); + + it('stops the subagent first, then sweeps its child session with no time boundary', async () => { + // The subagent's stop aborts its child Turn, so nothing new can appear in the + // child Session afterwards. Work the child started while winding down — after + // the root boundary — still belongs to the stopped tree and must be stopped. + const fixture = harness([ + { + ...runningTask('parent'), + kind: 'subagent', + metadata: { childSessionId: 'child', subTurnId: 'sub-turn-parent' }, + }, + ]); + fixture.tasks.set('late-grandchild', { + ...childTask('late-grandchild', 'child', 'sub-turn-parent'), + createdAt: 20_000, + }); + fixture.admit('parent'); + fixture.admit('late-grandchild'); + const order: string[] = []; + fixture.stop.mockImplementation(async (taskId: string) => { + order.push(taskId); + fixture.patch(taskId, 'canceled'); + return fixture.tasks.get(taskId); + }); + + // Root boundary 10_000 is EARLIER than the grandchild's createdAt 20_000. + const { targets } = await cascade(fixture.host, { boundaryMs: 10_000 }); + + expect(order).toEqual(['parent', 'late-grandchild']); + expect(fixture.tasks.get('late-grandchild')!.status).toBe('canceled'); + // Grandchild work lives in the child Session, which the model cannot read + // from here, so it is stopped but not listed in the owner's report. + expect(ids(targets)).toEqual(['parent']); + }); + + it('sweeps the child session of a subagent that already finished but was never read', async () => { + // The subagent itself is terminal-undelivered, so there is nothing to stop, + // but background work it spawned may still be running in its child Session. + const fixture = harness([ + { + ...terminalTask('finished-parent'), + kind: 'subagent', + metadata: { childSessionId: 'child', subTurnId: 'sub-turn-finished' }, + }, + ]); + fixture.tasks.set('orphan', childTask('orphan', 'child', 'sub-turn-finished')); + fixture.admit('orphan'); + + const { targets } = await cascade(fixture.host); + + expect(fixture.stop).toHaveBeenCalledTimes(1); + expect(fixture.stop).toHaveBeenCalledWith('orphan', expect.anything()); + expect(fixture.tasks.get('orphan')!.status).toBe('canceled'); + expect(ids(targets)).toEqual(['finished-parent']); + }); + + it('stops descending at the depth limit instead of recursing forever', async () => { + const fixture = harness([]); + fixture.tasks.set('deep', { + ...runningTask('deep'), + kind: 'subagent', + metadata: { childSessionId: OWNER }, + }); + fixture.admit('deep'); + + const { targets } = await cascade(fixture.host, { depth: 99 }); + + expect(targets).toEqual([]); + expect(fixture.stop).not.toHaveBeenCalled(); + }); + + it('does not re-enter a session already on the cascade path (a metadata cycle)', async () => { + const info = vi.spyOn(logger, 'info'); + try { + const fixture = harness([ + { + ...runningTask('loop'), + kind: 'subagent', + metadata: { childSessionId: OWNER, subTurnId: 'sub-turn-loop' }, + }, + ]); + fixture.admit('loop'); + fixture.stop.mockImplementation(async (taskId: string) => { + fixture.patch(taskId, 'canceled'); + return fixture.tasks.get(taskId); + }); + + const { targets } = await cascade(fixture.host); + + expect(ids(targets)).toEqual(['loop']); + expect(fixture.stop).toHaveBeenCalledTimes(1); + // Caught by the recursion path, whatever child Turn scope it is reached with. + expect(info).toHaveBeenCalledWith( + expect.objectContaining({ + event: 'background_task_cascade_cycle_skipped', + sessionId: OWNER, + depth: 1, + }), + expect.any(String), + ); + } finally { + info.mockRestore(); + } + }); +}); + +describe('user stop cascade: stale snapshot', () => { + // Targets come from one snapshot, but a runner of ours can settle before the + // ownership check. It drops its admission only after writing its terminal row, so + // a target without admission is re-read: terminal means "just finished here"; + // still active, or gone, means nothing this process can stop. + + it('sweeps the child session of a subagent that settled after the snapshot', async () => { + // Skipping it as "not owned" would leave the bash its child Turn spawned running + // after the stop succeeded. + const info = vi.spyOn(logger, 'info'); + try { + const fixture = harness([ + { + ...runningTask('settled-sub'), + kind: 'subagent', + metadata: { childSessionId: 'child-x', subTurnId: 'sub-turn-x' }, + }, + ]); + fixture.tasks.set('child-bash', childTask('child-bash', 'child-x', 'sub-turn-x')); + // The subagent's own admission is already gone; the bash it spawned is still ours. + fixture.admit('child-bash'); + afterSnapshot(fixture, () => fixture.patch('settled-sub', 'succeeded')); + + const { targets } = await cascade(fixture.host); + + expect(fixture.stop).toHaveBeenCalledTimes(1); + expect(fixture.stop).toHaveBeenCalledWith('child-bash', expect.anything()); + expect(fixture.tasks.get('child-bash')!.status).toBe('canceled'); + expect(targets).toEqual([ + expect.objectContaining({ taskId: 'settled-sub', kind: 'subagent', status: 'succeeded' }), + ]); + expect(fixture.metrics).not.toHaveBeenCalledWith(expect.anything(), expect.anything(), { + reason: 'cascade_skipped_not_owned', + }); + expect(info).toHaveBeenCalledWith( + { + event: 'background_task_cascade_target_settled_before_stop', + sessionId: OWNER, + taskId: 'settled-sub', + kind: 'subagent', + snapshotStatus: 'running', + status: 'succeeded', + }, + expect.any(String), + ); + expect(info).not.toHaveBeenCalledWith( + expect.objectContaining({ event: 'background_task_cascade_skipped_not_owned' }), + expect.any(String), + ); + } finally { + info.mockRestore(); + } + }); + + it('still skips a target whose re-read row is active, without sweeping its child session', async () => { + // Another client process holds it and moved it on (queued → running) meanwhile: + // it is neither stopped nor swept, and the log carries the re-read status. + const info = vi.spyOn(logger, 'info'); + try { + const fixture = harness([ + { + ...runningTask('foreign-sub'), + status: 'queued', + kind: 'subagent', + metadata: { childSessionId: 'child-f', subTurnId: 'sub-turn-f' }, + }, + ]); + afterSnapshot(fixture, () => fixture.patch('foreign-sub', 'running')); + + const { targets } = await cascade(fixture.host); + + expect(targets).toEqual([]); + expect(fixture.stop).not.toHaveBeenCalled(); + expect(fixture.list).not.toHaveBeenCalledWith( + expect.objectContaining({ ownerSessionId: 'child-f' }), + ); + expect(fixture.metrics).toHaveBeenCalledWith('background_task_session_cascade_total', 1, { + reason: 'cascade_skipped_not_owned', + }); + expect(info).toHaveBeenCalledWith( + expect.objectContaining({ + event: 'background_task_cascade_skipped_not_owned', + taskId: 'foreign-sub', + status: 'running', + }), + expect.any(String), + ); + } finally { + info.mockRestore(); + } + }); + + it('skips a target whose row is gone by the re-read, logging it as missing', async () => { + const info = vi.spyOn(logger, 'info'); + try { + const fixture = harness([runningTask('gone')]); + afterSnapshot(fixture, () => { + fixture.tasks.delete('gone'); + }); + + const { targets } = await cascade(fixture.host); + + expect(targets).toEqual([]); + expect(fixture.stop).not.toHaveBeenCalled(); + expect(fixture.metrics).toHaveBeenCalledWith('background_task_session_cascade_total', 1, { + reason: 'cascade_skipped_not_owned', + }); + expect(info).toHaveBeenCalledWith( + expect.objectContaining({ + event: 'background_task_cascade_skipped_not_owned', + taskId: 'gone', + status: 'missing', + }), + expect.any(String), + ); + } finally { + info.mockRestore(); + } + }); + + it('falls back to the snapshot and still skips when the re-read fails', async () => { + // A thrown re-read must not turn the skip into a reported stop + // (`termination_requested`) for a task nobody asked to stop. + const info = vi.spyOn(logger, 'info'); + try { + const fixture = harness([runningTask('unreadable')]); + const service = fixture.host.backgroundTaskService as unknown as { + get: (taskId: string) => Promise; + }; + service.get = async () => { + throw new Error('store unavailable'); + }; + + const { targets } = await cascade(fixture.host); + + expect(targets).toEqual([]); + expect(fixture.stop).not.toHaveBeenCalled(); + expect(info).toHaveBeenCalledWith( + expect.objectContaining({ + event: 'background_task_cascade_target_reread_failed', + taskId: 'unreadable', + error: 'store unavailable', + }), + expect.any(String), + ); + expect(info).toHaveBeenCalledWith( + expect.objectContaining({ + event: 'background_task_cascade_skipped_not_owned', + taskId: 'unreadable', + status: 'running', + }), + expect.any(String), + ); + } finally { + info.mockRestore(); + } + }); +}); + +describe('user stop cascade: read subagents', () => { + // A subagent the model already read (`deliveredAt` set) is not a target, but its + // child Session is still swept as a fallback. The row itself is left alone. + + it('sweeps the child session of a read subagent without touching the row', async () => { + const fixture = harness([ + { + ...terminalTask('read-sub'), + kind: 'subagent', + deliveredAt: 5, + metadata: { childSessionId: 'child-r', subTurnId: 'sub-turn-read' }, + }, + ]); + fixture.tasks.set('orphan', childTask('orphan', 'child-r', 'sub-turn-read')); + fixture.admit('orphan'); + const onTargets = vi.fn(); + + const { targets } = await stopSessionBackgroundWork({ + host: fixture.host, + sessionId: OWNER, + reason: 'owner_abort', + boundaryMs: 10_000, + ctx: { depth: 0, visited: new Set(), onTargets }, + }); + + expect(fixture.tasks.get('orphan')!.status).toBe('canceled'); + // Not stopped, not suppressed, not reported: only its child Session is swept. + expect(fixture.stop).not.toHaveBeenCalledWith('read-sub', expect.anything()); + expect(onTargets.mock.calls.flat(2)).not.toContain('read-sub'); + expect(ids(targets)).toEqual([]); + }); + + it('does not sweep a read subagent launched after the stop boundary', async () => { + // The boundary protects work started by the user's next message. + const fixture = harness([ + { + ...terminalTask('new-read-sub'), + kind: 'subagent', + deliveredAt: 5, + createdAt: 20_000, + metadata: { childSessionId: 'child-n', subTurnId: 'sub-turn-new' }, + }, + ]); + fixture.tasks.set('new-work', childTask('new-work', 'child-n', 'sub-turn-new')); + fixture.admit('new-work'); + + await cascade(fixture.host, { boundaryMs: 10_000 }); + + expect(fixture.stop).not.toHaveBeenCalled(); + expect(fixture.tasks.get('new-work')!.status).toBe('running'); + }); + + it('sweeps read subagents only after every target stop has settled', async () => { + // Defense in depth: sweeps are keyed per owning child Turn, so a running + // `append` target and the read row it continues no longer collide in the + // visited set, but every target's "stop first, then sweep" still finishes + // before any read row is traversed, keeping the order deterministic. + const fixture = harness([ + runningTask('bg'), + { + ...terminalTask('read-sub'), + kind: 'subagent', + deliveredAt: 5, + metadata: { childSessionId: 'child-r', subTurnId: 'sub-turn-read' }, + }, + ]); + fixture.tasks.set('orphan', childTask('orphan', 'child-r', 'sub-turn-read')); + fixture.admit('bg'); + fixture.admit('orphan'); + const order: string[] = []; + fixture.stop.mockImplementation(async (taskId: string) => { + if (taskId === 'bg') { + await new Promise((resolve) => setTimeout(resolve, 30)); + fixture.patch(taskId, 'canceled'); + order.push('bg-settled'); + return fixture.tasks.get(taskId); + } + order.push(`${taskId}-stop`); + fixture.patch(taskId, 'canceled'); + return fixture.tasks.get(taskId); + }); + + await cascade(fixture.host); + + expect(order).toEqual(['bg-settled', 'orphan-stop']); + }); +}); + +describe('user stop cascade: child session ownership', () => { + // A child Session is shared by every subagent row that ran in it (an `append` + // continues its source's), so a sweep stops only what the row's own child Turn + // spawned: child-Session rows whose `parentTurnId` is the row's `subTurnId`. + + it('leaves work of a newer Turn that continued the same child session untouched', async () => { + // `read-sub` ran child Turn sub-turn-0 in child-c. After the stop boundary the + // user's next message appended to it: `append-new` runs sub-turn-2 in the SAME + // child-c. Only sub-turn-0's work belongs to the stopped tree. + const info = vi.spyOn(logger, 'info'); + try { + const fixture = harness([ + { + ...terminalTask('read-sub'), + kind: 'subagent', + deliveredAt: 5, + metadata: { childSessionId: 'child-c', subTurnId: 'sub-turn-0' }, + }, + { + ...runningTask('append-new'), + kind: 'subagent', + createdAt: 20_000, + metadata: { + childSessionId: 'child-c', + subTurnId: 'sub-turn-2', + parentTurnId: 'turn-next', + executionMode: 'append', + }, + }, + ]); + fixture.tasks.set('bash-old', childTask('bash-old', 'child-c', 'sub-turn-0')); + fixture.tasks.set('bash-new', { + ...childTask('bash-new', 'child-c', 'sub-turn-2'), + createdAt: 20_000, + }); + for (const id of ['append-new', 'bash-old', 'bash-new']) fixture.admit(id); + const onTargets = vi.fn(); + + await stopSessionBackgroundWork({ + host: fixture.host, + sessionId: OWNER, + reason: 'owner_abort', + boundaryMs: 10_000, + ctx: { depth: 0, visited: new Set(), onTargets }, + }); + + expect(fixture.tasks.get('bash-old')!.status).toBe('canceled'); + expect(fixture.tasks.get('bash-new')!.status).toBe('running'); + expect(fixture.tasks.get('append-new')!.status).toBe('running'); + expect(fixture.stop).not.toHaveBeenCalledWith('bash-new', expect.anything()); + expect(fixture.stop).not.toHaveBeenCalledWith('append-new', expect.anything()); + // Not suppressed either: the newer Turn's results must still be delivered. + const registered = onTargets.mock.calls.flat(2); + expect(registered).toContain('bash-old'); + expect(registered).not.toContain('bash-new'); + expect(registered).not.toContain('append-new'); + expect(info).toHaveBeenCalledWith( + expect.objectContaining({ + event: 'background_task_cascade_child_sweep_scoped', + childSessionId: 'child-c', + ownerTurnId: 'sub-turn-0', + parentTaskId: 'read-sub', + matched: 1, + skippedOtherTurn: 1, + skippedMissingOwner: 0, + }), + expect.any(String), + ); + } finally { + info.mockRestore(); + } + }); + + it("stops a running subagent first, then only its own child Turn's work, however late", async () => { + const info = vi.spyOn(logger, 'info'); + try { + const fixture = harness([ + { + ...runningTask('sub'), + kind: 'subagent', + metadata: { childSessionId: 'child-t', subTurnId: 'sub-turn-t' }, + }, + ]); + // Spawned by the subagent's own child Turn while it wound down, after the + // root boundary: ownership, not time, bounds the child sweep. + fixture.tasks.set('own-late', { + ...childTask('own-late', 'child-t', 'sub-turn-t'), + createdAt: 20_000, + }); + // Same child Session, other child Turn, created well BEFORE the boundary: + // only ownership keeps it out. + fixture.tasks.set('other-turn', childTask('other-turn', 'child-t', 'sub-turn-other')); + // No owner recorded: cannot be attributed to this row, so never stopped. + fixture.tasks.set('no-owner', childTask('no-owner', 'child-t')); + for (const id of ['sub', 'own-late', 'other-turn', 'no-owner']) fixture.admit(id); + const order: string[] = []; + fixture.stop.mockImplementation(async (taskId: string) => { + order.push(taskId); + fixture.patch(taskId, 'canceled'); + return fixture.tasks.get(taskId); + }); + const onTargets = vi.fn(); + + const { targets } = await stopSessionBackgroundWork({ + host: fixture.host, + sessionId: OWNER, + reason: 'owner_abort', + boundaryMs: 10_000, + ctx: { depth: 0, visited: new Set(), onTargets }, + }); + + expect(order).toEqual(['sub', 'own-late']); + expect(fixture.tasks.get('other-turn')!.status).toBe('running'); + expect(fixture.tasks.get('no-owner')!.status).toBe('running'); + expect(onTargets.mock.calls.flat(2)).toEqual(['sub', 'own-late']); + expect(ids(targets)).toEqual(['sub']); + expect(info).toHaveBeenCalledWith( + expect.objectContaining({ + event: 'background_task_cascade_child_sweep_scoped', + childSessionId: 'child-t', + ownerTurnId: 'sub-turn-t', + parentTaskId: 'sub', + matched: 1, + skippedOtherTurn: 1, + skippedMissingOwner: 1, + }), + expect.any(String), + ); + } finally { + info.mockRestore(); + } + }); + + it('does not sweep the child session of a row without subTurnId, and logs why', async () => { + // Without the child Turn's id this row's work cannot be told apart from a + // continuation's, so its child Session is left exactly as it is today. + const info = vi.spyOn(logger, 'info'); + try { + const fixture = harness([ + { ...runningTask('legacy'), kind: 'subagent', metadata: { childSessionId: 'child-l' } }, + ]); + fixture.tasks.set('child-work', childTask('child-work', 'child-l', 'sub-turn-unknown')); + fixture.admit('legacy'); + fixture.admit('child-work'); + + const { targets } = await cascade(fixture.host); + + // The row itself is still a target and is stopped; only the sweep is skipped. + expect(ids(targets)).toEqual(['legacy']); + expect(fixture.tasks.get('legacy')!.status).toBe('canceled'); + expect(fixture.tasks.get('child-work')!.status).toBe('running'); + expect(fixture.stop).not.toHaveBeenCalledWith('child-work', expect.anything()); + expect(info).toHaveBeenCalledWith( + expect.objectContaining({ + event: 'background_task_cascade_child_sweep_skipped', + sessionId: OWNER, + taskId: 'legacy', + childSessionId: 'child-l', + reason: 'missing_sub_turn_id', + }), + expect.any(String), + ); + } finally { + info.mockRestore(); + } + }); + + it('sweeps a shared child session once per owning child Turn', async () => { + // `append-old` (before the boundary, still running) continues `read-sub`'s + // child Session; each ran its own child Turn there and left work behind. A + // per-Session visited key would skip the second sweep and leave `bash-o` alive. + const fixture = harness([ + { + ...runningTask('append-old'), + kind: 'subagent', + metadata: { childSessionId: 'child-s', subTurnId: 'sub-turn-a', executionMode: 'append' }, + }, + { + ...terminalTask('read-sub'), + kind: 'subagent', + deliveredAt: 5, + metadata: { childSessionId: 'child-s', subTurnId: 'sub-turn-o' }, + }, + ]); + fixture.tasks.set('bash-a', childTask('bash-a', 'child-s', 'sub-turn-a')); + fixture.tasks.set('bash-o', childTask('bash-o', 'child-s', 'sub-turn-o')); + for (const id of ['append-old', 'bash-a', 'bash-o']) fixture.admit(id); + + const { targets } = await cascade(fixture.host); + + expect(fixture.tasks.get('bash-a')!.status).toBe('canceled'); + expect(fixture.tasks.get('bash-o')!.status).toBe('canceled'); + expect(ids(targets)).toEqual(['append-old']); + }); +}); + +describe('user stop cascade: orchestration', () => { + it('does not cascade, open a window or pause the Goal for a child worker session', async () => { + const fixture = harness([runningTask('child-task')]); + fixture.session = childWorkerSession(); + const pauseActiveGoal = vi.fn(async () => undefined); + + const handle = await createUserStopCascade({ host: fixture.host, pauseActiveGoal }).begin( + OWNER, + ); + + expect(handle).toBeUndefined(); + expect(pauseActiveGoal).not.toHaveBeenCalled(); + // Delivery for that Session is untouched, exactly like today. + expect(fixture.stop).not.toHaveBeenCalled(); + }); + + it('does not pause the Goal in begin, only once the stop is accepted', async () => { + // `begin` runs before the controller's identity check; a stop it then rejects + // (turn-mismatch) must leave the Goal exactly as it was. + const fixture = harness([]); + const pauseActiveGoal = vi.fn(async () => undefined); + const handle = await createUserStopCascade({ host: fixture.host, pauseActiveGoal }).begin( + OWNER, + ); + expect(pauseActiveGoal).not.toHaveBeenCalled(); + + await handle!.accept('controller_accepted'); + // Idempotent: the Goal is paused once however many paths accept the stop. + await handle!.accept('controller_accepted'); + + expect(pauseActiveGoal).toHaveBeenCalledTimes(1); + expect(pauseActiveGoal).toHaveBeenCalledWith(OWNER); + handle!.cancel('test_cleanup'); + }); + + it('never pauses the Goal for a stop that was cancelled', async () => { + const fixture = harness([]); + const pauseActiveGoal = vi.fn(async () => undefined); + const handle = await createUserStopCascade({ host: fixture.host, pauseActiveGoal }).begin( + OWNER, + ); + + handle!.cancel('turn_mismatch'); + await handle!.accept('controller_accepted'); + + expect(pauseActiveGoal).not.toHaveBeenCalled(); + }); + + it('resolves accept and logs when pausing the Goal fails', async () => { + const info = vi.spyOn(logger, 'info'); + try { + const fixture = harness([]); + const pauseActiveGoal = vi.fn(async () => { + throw new Error('goal store unavailable'); + }); + const handle = await createUserStopCascade({ host: fixture.host, pauseActiveGoal }).begin( + OWNER, + ); + + await expect(handle!.accept('not_running')).resolves.toBeUndefined(); + + expect(info).toHaveBeenCalledWith( + expect.objectContaining({ + event: 'background_task_user_stop_cascade_accepted', + sessionId: OWNER, + how: 'not_running', + }), + expect.any(String), + ); + expect(info).toHaveBeenCalledWith( + expect.objectContaining({ + event: 'background_task_user_stop_cascade_goal_pause_failed', + sessionId: OWNER, + error: 'goal store unavailable', + }), + expect.any(String), + ); + handle!.cancel('test_cleanup'); + } finally { + info.mockRestore(); + } + }); + + it('pauses the Goal on accept before the cascade stops any task', async () => { + const fixture = harness([runningTask('bg')]); + fixture.admit('bg'); + const order: string[] = []; + const pauseActiveGoal = vi.fn(async () => { + order.push('goal-paused'); + }); + fixture.stop.mockImplementation(async (taskId: string) => { + order.push('task-stopped'); + fixture.patch(taskId, 'canceled'); + return fixture.tasks.get(taskId); + }); + + const handle = await createUserStopCascade({ host: fixture.host, pauseActiveGoal }).begin( + OWNER, + ); + // TurnService's order: accept (onAccepted / not-running), then complete. + await handle!.accept('controller_accepted'); + handle!.complete({ status: 'aborted', turnId: 'turn-1' }); + await vi.waitFor(() => expect(order).toContain('task-stopped')); + + expect(order).toEqual(['goal-paused', 'task-stopped']); + }); + + it('still suppresses the targets after an abort timeout', async () => { + const fixture = harness([runningTask('bg')]); + fixture.admit('bg'); + fixture.stop.mockImplementation(async (taskId: string) => { + fixture.patch(taskId, 'canceled'); + return fixture.tasks.get(taskId); + }); + + const handle = await createUserStopCascade({ host: fixture.host }).begin(OWNER); + handle!.complete({ status: 'abort-timeout', turnId: 'turn-1' }); + await vi.waitFor(() => expect(fixture.stop).toHaveBeenCalled()); + + await expect(startLocalBackgroundTaskDeliveryTurn(fixture.host, 'bg')).resolves.toBe('skipped'); + }); + + it('warns that the boundary may precede the release on an abort timeout, not on a clean abort', async () => { + // Known limit: on `abort-timeout` the stopped Turn has not released when the + // boundary is taken, so its later wind-down work is neither stopped nor + // suppressed (exactly today's behaviour). The warning makes that visible. + const warn = vi.spyOn(logger, 'warn'); + try { + const fixture = harness([]); + fixture.now = 1_234; + const port = createUserStopCascade({ host: fixture.host }); + + const clean = await port.begin(OWNER); + clean!.complete({ status: 'aborted', turnId: 'turn-1' }); + const timedOut = await port.begin(OWNER); + timedOut!.complete({ status: 'abort-timeout', turnId: 'turn-2' }); + await vi.waitFor(() => expect(isUserStopWindowOpen(fixture.host, OWNER)).toBe(false)); + + const boundaryWarnings = warn.mock.calls.filter( + ([fields]) => + (fields as { event?: string }).event === + 'background_task_user_stop_cascade_boundary_before_release', + ); + expect(boundaryWarnings).toEqual([ + [ + { + event: 'background_task_user_stop_cascade_boundary_before_release', + sessionId: OWNER, + boundaryMs: 1_234, + turnId: 'turn-2', + }, + expect.any(String), + ], + ]); + } finally { + warn.mockRestore(); + } + }); + + it('runs repeated stops of the same session one after another, idempotently', async () => { + const fixture = harness([runningTask('bg')]); + fixture.admit('bg'); + fixture.stop.mockImplementation(async (taskId: string) => { + fixture.patch(taskId, 'canceled'); + return fixture.tasks.get(taskId); + }); + const cascadePort = createUserStopCascade({ host: fixture.host }); + + const first = await cascadePort.begin(OWNER); + const second = await cascadePort.begin(OWNER); + first!.complete({ status: 'aborted', turnId: 'turn-1' }); + second!.complete({ status: 'aborted', turnId: 'turn-1' }); + await vi.waitFor(() => expect(isUserStopWindowOpen(fixture.host, OWNER)).toBe(false)); + + // The second cascade still runs, but the task is already terminal by then: + // it is re-reported, never stopped twice. + expect(fixture.stop).toHaveBeenCalledTimes(1); + }); + + it('queues a second stop behind the first and runs it with its own boundary', async () => { + // Stop 1 is still cascading when the user sends a new message, the new Turn + // starts background work, and the user stops again. Joining stop 1 would drop + // stop 2's later boundary and leave that new work running. + const info = vi.spyOn(logger, 'info'); + try { + const fixture = harness([runningTask('first')]); + fixture.admit('first'); + let releaseFirst!: () => void; + fixture.stop.mockImplementation((taskId: string) => { + if (taskId !== 'first') { + fixture.patch(taskId, 'canceled'); + return Promise.resolve(fixture.tasks.get(taskId)); + } + return new Promise((resolve) => { + releaseFirst = () => { + fixture.patch(taskId, 'canceled'); + resolve(fixture.tasks.get(taskId)); + }; + }); + }); + const port = createUserStopCascade({ host: fixture.host }); + + fixture.now = 1_000; + const firstStop = await port.begin(OWNER); + firstStop!.complete({ status: 'aborted', turnId: 'turn-1' }); + await vi.waitFor(() => expect(fixture.stop).toHaveBeenCalledWith('first', expect.anything())); + + // Created after stop 1's boundary (1000) but before stop 2's (1200). + fixture.now = 1_050; + fixture.tasks.set('second', { ...runningTask('second'), createdAt: 1_050 }); + fixture.admit('second'); + fixture.now = 1_200; + const secondStop = await port.begin(OWNER); + secondStop!.complete({ status: 'aborted', turnId: 'turn-2' }); + + expect(info).toHaveBeenCalledWith( + expect.objectContaining({ + event: 'background_task_user_stop_cascade_queued', + sessionId: OWNER, + boundaryMs: 1_200, + }), + expect.any(String), + ); + // Queued, not joined: nothing happens to it until stop 1 settles. + expect(fixture.stop).not.toHaveBeenCalledWith('second', expect.anything()); + + releaseFirst(); + + await vi.waitFor(() => expect(fixture.tasks.get('second')!.status).toBe('canceled')); + await vi.waitFor(() => expect(isUserStopWindowOpen(fixture.host, OWNER)).toBe(false)); + await expect(startLocalBackgroundTaskDeliveryTurn(fixture.host, 'second')).resolves.toBe( + 'skipped', + ); + } finally { + info.mockRestore(); + } + }); + + it('targets work the dying Turn created after the stop was accepted', async () => { + // The boundary is taken when `complete` runs, not when `begin` runs. Background + // bash and background subagents do not observe the parent Turn's signal, so a + // tool call still in flight can insert a row after the stop request returned. + // Reading the boundary in `begin` would leave that row unstopped and let its + // terminal wake the Session later. + const fixture = harness([]); + fixture.now = 1_000; + fixture.stop.mockImplementation(async (taskId: string) => { + fixture.patch(taskId, 'canceled'); + return fixture.tasks.get(taskId); + }); + + const handle = await createUserStopCascade({ host: fixture.host }).begin(OWNER); + // The aborted Turn is still winding down and starts one more background task. + fixture.now = 1_005; + fixture.tasks.set('late', { ...runningTask('late'), createdAt: 1_005 }); + fixture.admit('late'); + + fixture.now = 1_010; + handle!.complete({ status: 'aborted', turnId: 'turn-1' }); + await vi.waitFor(() => expect(fixture.stop).toHaveBeenCalled()); + + expect(fixture.tasks.get('late')!.status).toBe('canceled'); + await expect(startLocalBackgroundTaskDeliveryTurn(fixture.host, 'late')).resolves.toBe( + 'skipped', + ); + }); + + it('leaves stopped targets undelivered so the next background reminder reports them', async () => { + // This is how the model learns the tasks were stopped: the existing cadence + // reminder lists undelivered terminals with their real status (`canceled`). + // Latching them as delivered here would silently hide that from the model. + const fixture = harness([runningTask('bg')]); + fixture.admit('bg'); + fixture.stop.mockImplementation(async (taskId: string) => { + fixture.patch(taskId, 'canceled'); + return fixture.tasks.get(taskId); + }); + + const handle = await createUserStopCascade({ host: fixture.host }).begin(OWNER); + handle!.complete({ status: 'aborted', turnId: 'turn-1' }); + await vi.waitFor(() => expect(fixture.stop).toHaveBeenCalled()); + + expect(fixture.markDelivered).not.toHaveBeenCalled(); + expect(fixture.tasks.get('bg')!.deliveredAt).toBeUndefined(); + expect(fixture.tasks.get('bg')!.status).toBe('canceled'); + }); + + it('closes the window and suppresses nothing extra when collecting targets throws', async () => { + const fixture = harness([]); + fixture.list.mockRejectedValue(new Error('store unavailable')); + + const handle = await createUserStopCascade({ host: fixture.host }).begin(OWNER); + handle!.complete({ status: 'aborted', turnId: 'turn-1' }); + + // The window must reopen, otherwise unrelated tasks would never be announced. + fixture.list.mockResolvedValue({ items: [] }); + fixture.tasks.set('fresh', terminalTask('fresh')); + await vi.waitFor(async () => { + await expect(startLocalBackgroundTaskDeliveryTurn(fixture.host, 'fresh')).resolves.toBe( + 'delivered', + ); + }); + }); + + it('returns from `complete` without waiting for a stop that never resolves', async () => { + const fixture = harness([runningTask('hung')]); + fixture.admit('hung'); + fixture.stop.mockImplementation(() => new Promise(() => undefined)); + + const handle = await createUserStopCascade({ host: fixture.host }).begin(OWNER); + const startedAt = Date.now(); + handle!.complete({ status: 'aborted', turnId: 'turn-1' }); + + // `complete` is fire-and-forget: the stop request never waits for teardown. + expect(Date.now() - startedAt).toBeLessThan(200); + await vi.waitFor(() => expect(fixture.stop).toHaveBeenCalled()); + // The hung task is suppressed immediately, before its stop settles. + await expect(startLocalBackgroundTaskDeliveryTurn(fixture.host, 'hung')).resolves.toBe( + 'skipped', + ); + }); +}); + +describe('user stop cascade: window lifecycle', () => { + // A window that never closes holds every later completion notice of the Session + // as `busy` for the life of the process — worse than having no cascade at all. + // Each test below pins one path that used to be able to leave it open. + + it('releases the window when the stop is cancelled, so later work is delivered', async () => { + const fixture = harness([terminalTask('later')]); + const handle = await createUserStopCascade({ host: fixture.host }).begin(OWNER); + await expect(startLocalBackgroundTaskDeliveryTurn(fixture.host, 'later')).resolves.toBe('busy'); + + handle!.cancel('abort_failed'); + + await expect(startLocalBackgroundTaskDeliveryTurn(fixture.host, 'later')).resolves.toBe( + 'delivered', + ); + // A cancelled stop never cascades: nothing may be stopped. + expect(fixture.stop).not.toHaveBeenCalled(); + }); + + it('releases a queued handle once its own cascade ends', async () => { + const fixture = harness([runningTask('bg')]); + fixture.admit('bg'); + let finishStop!: () => void; + fixture.stop.mockImplementation( + (taskId: string) => + new Promise((resolve) => { + finishStop = () => { + fixture.patch(taskId, 'canceled'); + resolve(fixture.tasks.get(taskId)); + }; + }), + ); + const port = createUserStopCascade({ host: fixture.host }); + + const first = await port.begin(OWNER); + first!.complete({ status: 'aborted', turnId: 'turn-1' }); + await vi.waitFor(() => expect(fixture.stop).toHaveBeenCalled()); + // A second stop arrives while the first cascade is still running: it is + // queued, and its window must stay held until its own cascade has run. + const second = await port.begin(OWNER); + second!.complete({ status: 'aborted', turnId: 'turn-1' }); + expect(isUserStopWindowOpen(fixture.host, OWNER)).toBe(true); + + finishStop(); + + await vi.waitFor(() => expect(isUserStopWindowOpen(fixture.host, OWNER)).toBe(false)); + }); + + it('keeps the window open until every hold is released', () => { + const fixture = harness([]); + const first = openUserStopWindow(fixture.host, OWNER, 1_000); + const second = openUserStopWindow(fixture.host, OWNER, 1_000); + + first.close(); + expect(isUserStopWindowOpen(fixture.host, OWNER)).toBe(true); + // Idempotent: closing the same handle again must not drop the other hold. + first.close(); + expect(isUserStopWindowOpen(fixture.host, OWNER)).toBe(true); + + second.close(); + expect(isUserStopWindowOpen(fixture.host, OWNER)).toBe(false); + }); + + it('force-closes a window nobody released after the watchdog period, and warns', async () => { + vi.useFakeTimers(); + const warn = vi.spyOn(logger, 'warn'); + try { + const fixture = harness([]); + // Held but never completed nor cancelled: the unforeseen-path case. + const handle = await createUserStopCascade({ host: fixture.host }).begin(OWNER); + expect(handle).toBeDefined(); + expect(isUserStopWindowOpen(fixture.host, OWNER)).toBe(true); + + vi.advanceTimersByTime(59_999); + expect(isUserStopWindowOpen(fixture.host, OWNER)).toBe(true); + vi.advanceTimersByTime(1); + + expect(isUserStopWindowOpen(fixture.host, OWNER)).toBe(false); + expect(warn).toHaveBeenCalledWith( + expect.objectContaining({ + event: 'background_task_user_stop_window_watchdog_closed', + sessionId: OWNER, + reason: 'watchdog', + }), + expect.any(String), + ); + } finally { + warn.mockRestore(); + vi.useRealTimers(); + } + }); +}); + +// ── fixtures ──────────────────────────────────────────────────────────────── + +function cascade( + host: LocalTaskRunnerHostWithSessionLookup, + options: { boundaryMs?: number; depth?: number } = {}, +): ReturnType { + return stopSessionBackgroundWork({ + host, + sessionId: OWNER, + reason: 'owner_abort', + boundaryMs: options.boundaryMs ?? 10_000, + ctx: { depth: options.depth ?? 0, visited: new Set() }, + }); +} + +function ids(targets: readonly { taskId: string }[]): string[] { + return targets.map((target) => target.taskId).sort(); +} + +/** + * Applies `change` once, right after the cascade took its snapshot: the window in + * which a runner can settle, or a row vanish, before the ownership check. A + * cascade issues all of its collection reads together, so each one still sees the + * state from before the change. + */ +function afterSnapshot(fixture: ReturnType, change: () => void): void { + const read = fixture.list.getMockImplementation()!; + let changed = false; + fixture.list.mockImplementation(async (query) => { + const snapshot = await read(query); + if (!changed) { + changed = true; + change(); + } + return snapshot; + }); +} + +function harness(initial: readonly BackgroundTask[]) { + const tasks = new Map(initial.map((task) => [task.taskId, task])); + const steer = vi.fn(async (_input: SteerInput) => ({ turnId: 'turn-owner', mode: 'steered' })); + const markDelivered = vi.fn(async (ownerSessionId: string, taskIds: string[]) => { + for (const taskId of taskIds) { + const task = tasks.get(taskId); + if (task && task.ownerSessionId === ownerSessionId) task.deliveredAt = 9_999; + } + return []; + }); + const stop = vi.fn(async (taskId: string) => { + patch(taskId, 'canceled'); + return tasks.get(taskId); + }); + const list = vi.fn(async (query: { ownerSessionId?: string; statuses?: string[] }) => ({ + items: [...tasks.values()].filter( + (task) => + task.ownerSessionId === query.ownerSessionId && + (!query.statuses || query.statuses.includes(task.status)), + ), + })); + const metrics = vi.fn(); + + function patch(taskId: string, status: BackgroundTaskStatus): void { + const task = tasks.get(taskId); + if (task) tasks.set(taskId, { ...task, status, endedAt: 2_000 }); + } + + const fixture = { + tasks, + steer, + stop, + list, + markDelivered, + metrics, + patch, + /** Mutable so a test can place task creation before/after the stop boundary. */ + now: 1_000, + session: rootSession() as LocalSessionRecord, + admit(taskId: string) { + admitBackgroundTask(fixture.host).identify(taskId); + }, + deliverBoth: () => + startLocalBackgroundTaskDeliveryTurn(fixture.host, 'other').then((result) => result), + host: undefined as unknown as LocalTaskRunnerHostWithSessionLookup, + }; + + fixture.host = { + runtimeConversation: { ingress: { steer } }, + backgroundTaskService: { + get: async (taskId: string) => tasks.get(taskId), + list, + stop, + markDelivered, + reminderSnapshot: async () => ({ tasks: [], undeliveredTotal: 0, terminalTotal: 1 }), + }, + metricsClient: { counter: metrics }, + nowMs: () => fixture.now, + getSessionById: async () => fixture.session, + } as unknown as LocalTaskRunnerHostWithSessionLookup; + + return fixture; +} + +interface SteerInput { + readonly message: { + readonly content: string; + readonly origin: { readonly taskIds: readonly string[] }; + }; +} + +function rootSession(): Partial { + return { sessionId: OWNER, sessionType: 'root', visibility: 'visible' }; +} + +function childWorkerSession(): LocalSessionRecord { + return { + sessionId: OWNER, + sessionType: 'branch', + parentSessionId: 'parent', + visibility: 'hidden', + sessionKind: 'task', + } as unknown as LocalSessionRecord; +} + +function runningTask(taskId: string): BackgroundTask { + return { + taskId, + kind: 'bash', + status: 'running', + ownerSessionId: OWNER, + description: `run ${taskId}`, + createdAt: 1_000, + updatedAt: 1_000, + }; +} + +function terminalTask(taskId: string): BackgroundTask { + return { + taskId, + kind: 'bash', + status: 'succeeded', + ownerSessionId: OWNER, + description: `done ${taskId}`, + createdAt: 1_000, + updatedAt: 2_000, + endedAt: 2_000, + }; +} + +/** + * A running task in a subagent's child Session. `parentTurnId` is the child Turn + * that spawned it — what a real runner records from `toolCtx.turnId`. + */ +function childTask(taskId: string, childSessionId: string, parentTurnId?: string): BackgroundTask { + return { + ...runningTask(taskId), + ownerSessionId: childSessionId, + ...(parentTurnId ? { metadata: { parentTurnId } } : {}), + }; +} diff --git a/packages/local-runtime/test/unit/local-task-append.test.ts b/packages/local-runtime/test/unit/local-task-append.test.ts new file mode 100644 index 000000000..23e312bb2 --- /dev/null +++ b/packages/local-runtime/test/unit/local-task-append.test.ts @@ -0,0 +1,455 @@ +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { + ConversationTurnRejectedError, + RuntimeConversationShutdownError, + RuntimeConversationUnavailableError, + type ConversationSession, + type ConversationSteerInput, + type ConversationSteerResult, + type ConversationTurnResult, +} from '@mavis/conversation-contract'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + startBackgroundLocalBash, + stopBackgroundLocalBashTask, + type LocalBackgroundBashExecutor, +} from '../../src/background-task/bash-runner.js'; +import { scheduleLocalBackgroundTaskDelivery } from '../../src/background-task/delivery.js'; +import { + createBackgroundTaskId, + type BackgroundTask, + type TaskStore, +} from '../../src/background-task/domain.js'; +import { + beginBackgroundTaskShutdown, + drainBackgroundTasks, + isBackgroundTaskAdmittedHere, +} from '../../src/background-task/lifecycle.js'; +import { LocalBackgroundTaskService } from '../../src/background-task/service.js'; +import { stopSessionBackgroundWork } from '../../src/background-task/session-cascade.js'; +import { + FsLocalTaskOutputStore, + SqliteLocalBackgroundTaskStore, +} from '../../src/background-task/store.js'; +import { taskTurnId } from '../../src/background-task/turn-id.js'; +import { buildLocalTaskAppendAdapter } from '../../src/api/local-task-append.js'; +import type { LocalTaskRunnerHostWithSessionLookup } from '../../src/api/local-task-host.js'; +import { logger } from '../../src/common/logger.js'; +import { closeLocalRuntimeDb } from '../../src/persistence/db.js'; +import type { LocalSessionRecord } from '../../src/sessions/controller.js'; + +vi.mock('../../src/background-task/delivery.js', () => ({ + scheduleLocalBackgroundTaskDelivery: vi.fn(), +})); + +const mockedSchedule = vi.mocked(scheduleLocalBackgroundTaskDelivery); + +const NOW_MS = 90_000; +const OWNER_SESSION_ID = 'mvs_owner'; +const CHILD_SESSION_ID = 'mvs_child'; + +const toolCtx = { + sessionId: OWNER_SESSION_ID, + turnId: 'turn-owner', + toolCallId: 'call-1', +} as const; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +function childSession(overrides: Partial = {}): ConversationSession { + return { + sessionId: CHILD_SESSION_ID, + agentName: 'worker', + workspaceDir: '/workspace', + runtime: 'pi-agent', + sessionType: 'branch', + sessionKind: 'task', + archived: false, + status: 'idle', + parentSessionId: OWNER_SESSION_ID, + visibility: 'hidden', + purpose: 'local-task:turn-owner:call-0', + createdAtMs: 1, + updatedAtMs: 1, + ...overrides, + }; +} + +function turnResult(overrides: Partial = {}): ConversationTurnResult { + return { + turnId: 'turn-child', + status: 'completed', + messages: [{ role: 'assistant', text: 'appended answer' }], + ...overrides, + }; +} + +interface AppendHarness { + readonly host: LocalTaskRunnerHostWithSessionLookup; + readonly service: LocalBackgroundTaskService; + readonly store: TaskStore; + readonly steer: ReturnType; + readonly getSession: ReturnType; + readonly abort: ReturnType; + readonly warn: ReturnType; + readonly append: ( + input?: { + taskId?: string; + content?: string; + signal?: AbortSignal; + ctx?: Partial; + }, + ) => Promise<{ taskId: string; mode: string }>; + readonly seedSourceTask: (overrides?: Partial) => Promise; + readonly dispose: () => Promise; +} + +async function appendHarness( + options: { + steer?: (input: ConversationSteerInput) => Promise; + session?: ConversationSession | undefined; + } = {}, +): Promise { + const dataDir = await mkdtemp(join(tmpdir(), 'local-task-append-')); + const store = new SqliteLocalBackgroundTaskStore(dataDir, () => NOW_MS); + const abort = vi.fn(async (_sessionId: string, _reason: string, _turnId?: string) => true); + const service = new LocalBackgroundTaskService({ + store, + outputStore: new FsLocalTaskOutputStore(dataDir), + nowMs: () => NOW_MS, + // Mirrors LocalRuntimeApiHost's stopRuntime (api/host.ts): stop a bash runner, + // then abort the exact child Turn a subagent row records. + stopRuntime: async (task, reason) => { + await stopBackgroundLocalBashTask(task, reason); + const { childSessionId, subTurnId } = task.metadata ?? {}; + if (typeof childSessionId === 'string' && typeof subTurnId === 'string') { + await abort(childSessionId, 'lifecycle', subTurnId); + } + }, + }); + const rawSteer = + options.steer ?? + (async (input: ConversationSteerInput): Promise => ({ + turnId: input.requestedTurnId!, + mode: 'activated', + completion: Promise.resolve(turnResult()), + })); + const steer = vi.fn(async (input: ConversationSteerInput): Promise => { + const result = await rawSteer(input); + if (result.mode !== 'duplicate') { + await input.preDelivery?.accept({ mode: result.mode, turnId: result.turnId }); + } + return result; + }); + const target = options.session === undefined ? childSession() : options.session; + const getSession = vi.fn(async (sessionId: string) => + target && target.sessionId === sessionId ? target : undefined, + ); + const warn = vi.fn(); + const host = { + runtimeConversation: { query: { getSession }, ingress: { steer, abort } }, + backgroundTaskService: service, + matrixLogger: { warn, info: vi.fn() }, + nowMs: () => NOW_MS, + resolveDefaultWorkspaceDir: () => dataDir, + getSessionById: async () => undefined, + } as unknown as LocalTaskRunnerHostWithSessionLookup; + const adapter = buildLocalTaskAppendAdapter(host); + let seeded = 0; + return { + host, + service, + store, + steer, + getSession, + abort, + warn, + append: (input = {}) => + adapter.append( + { ...toolCtx, ...input.ctx }, + { taskId: input.taskId ?? 'bg_source', content: input.content ?? 'more work' }, + input.signal, + ), + seedSourceTask: async (overrides = {}) => { + seeded += 1; + const taskId = overrides.taskId ?? 'bg_source'; + return service.create({ + taskId, + kind: 'subagent', + status: 'succeeded', + ownerSessionId: OWNER_SESSION_ID, + description: `Seeded task ${seeded}`, + toolCallId: 'call-0', + createdAt: NOW_MS, + updatedAt: NOW_MS, + ...overrides, + metadata: { + childSessionId: CHILD_SESSION_ID, + executionMode: 'foreground', + agentName: 'worker', + resolvedAgentName: 'worker', + requestedAgentName: 'worker', + canonicalRole: 'worker', + subTurnId: taskTurnId(taskId), + ...overrides.metadata, + }, + }); + }, + dispose: async () => { + closeLocalRuntimeDb(dataDir); + // A completion handler may still be flushing output for a task this test + // deliberately left running, so cleanup tolerates a concurrent write. + await rm(dataDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 25 }).catch( + () => undefined, + ); + }, + }; +} + +async function listOwnerTasks(store: TaskStore): Promise { + return (await store.list({ ownerSessionId: OWNER_SESSION_ID })).items; +} + +async function captureError(promise: Promise): Promise<{ code: string; status: number }> { + try { + await promise; + } catch (error) { + return error as { code: string; status: number }; + } + throw new Error('Expected task_append to reject'); +} + +describe('task_append process claim', () => { + // An activated append runs a new child Turn under its own task row, so this + // process must claim it like any background task it runs. Without the claim a + // Session stop cascade skips the row as another process's work — the append and + // what its child Turn spawned keep running while their notices are suppressed. + + it('lets a Session stop cascade stop an activated append and sweep its child Turn', async () => { + const harness = await appendHarness({ steer: activatedUntilAborted }); + const info = vi.spyOn(logger, 'info'); + try { + await harness.seedSourceTask({ deliveredAt: NOW_MS }); + harness.abort.mockImplementation(async (_sessionId, reason, turnId) => { + finishAbortedTurn(turnId, reason); + return true; + }); + + const { taskId, mode } = await harness.append(); + const turnId = taskTurnId(taskId); + expect(mode).toBe('activated'); + expect(isBackgroundTaskAdmittedHere(harness.host, taskId)).toBe(true); + expect(info).toHaveBeenCalledWith( + expect.objectContaining({ + event: 'task_append_admission_identified', + taskId, + childSessionId: CHILD_SESSION_ID, + turnId, + }), + expect.any(String), + ); + // Background bash the append's own child Turn started, via the real runner. + const bashTaskId = await startChildBash(harness, turnId); + const onTargets = vi.fn(); + + const { targets } = await stopSessionBackgroundWork({ + host: harness.host, + sessionId: OWNER_SESSION_ID, + reason: 'owner_abort', + boundaryMs: NOW_MS, + ctx: { depth: 0, visited: new Set(), onTargets }, + }); + + expect(onTargets.mock.calls.flat(2)).toContain(taskId); + expect(harness.abort).toHaveBeenCalledWith(CHILD_SESSION_ID, 'lifecycle', turnId); + expect(targets).toEqual([expect.objectContaining({ taskId, status: 'canceled' })]); + await expect(harness.store.get(bashTaskId)).resolves.toMatchObject({ status: 'canceled' }); + // The claim outlives the terminal write: drained means the row is terminal. + await drainBackgroundTasks(harness.host); + expect(isBackgroundTaskAdmittedHere(harness.host, taskId)).toBe(false); + await expect(harness.store.get(taskId)).resolves.toMatchObject({ status: 'canceled' }); + } finally { + info.mockRestore(); + await harness.dispose(); + } + }); + + it('aborts a claimed append at shutdown so the host drain finishes', async () => { + const harness = await appendHarness({ steer: activatedUntilAborted }); + try { + await harness.seedSourceTask(); + harness.abort.mockImplementation(async (_sessionId, reason, turnId) => { + finishAbortedTurn(turnId, reason); + return true; + }); + const { taskId } = await harness.append(); + + beginBackgroundTaskShutdown(harness.host, 'runtime-shutdown'); + + expect(harness.abort).toHaveBeenCalledWith(CHILD_SESSION_ID, 'lifecycle', taskTurnId(taskId)); + await expect(drainsWithin(harness.host, 2_000)).resolves.toBe('drained'); + await expect(harness.store.get(taskId)).resolves.toMatchObject({ status: 'canceled' }); + } finally { + await harness.dispose(); + } + }); + + it('rejects an activation during shutdown with the append contract error, claiming nothing', async () => { + const harness = await appendHarness(); + const warn = vi.spyOn(logger, 'warn'); + try { + await harness.seedSourceTask(); + beginBackgroundTaskShutdown(harness.host, 'runtime-shutdown'); + + await expect(captureError(harness.append())).resolves.toMatchObject({ + code: 'TASK_APPEND_UNAVAILABLE', + status: 503, + }); + await expect(listOwnerTasks(harness.store)).resolves.toHaveLength(1); + expect(warn).toHaveBeenCalledWith( + expect.objectContaining({ + event: 'task_append_admission_closing_rejected', + childSessionId: CHILD_SESSION_ID, + error: 'Local Runtime is shutting down', + }), + expect.any(String), + ); + await expect(drainsWithin(harness.host, 200)).resolves.toBe('drained'); + } finally { + warn.mockRestore(); + await harness.dispose(); + } + }); + + it.each(['steered', 'duplicate'] as const)( + 'claims nothing for a %s append, which starts no new Turn', + async (mode) => { + const activeTaskId = createBackgroundTaskId(); + const harness = await appendHarness({ + steer: async () => ({ turnId: taskTurnId(activeTaskId), mode }), + }); + try { + await harness.seedSourceTask(); + await harness.seedSourceTask({ taskId: activeTaskId, status: 'running' }); + + await expect(harness.append()).resolves.toEqual({ taskId: activeTaskId, mode }); + + await expect(drainsWithin(harness.host, 200)).resolves.toBe('drained'); + } finally { + await harness.dispose(); + } + }, + ); + + it.each([ + { + label: 'the task row cannot be persisted', + reason: 'row_persist_failed', + steer: undefined, + failRowCreate: true, + }, + { + label: 'delivery fails after the row was created', + reason: 'activation_failed', + steer: async (input: ConversationSteerInput): Promise => { + await input.preDelivery?.accept({ mode: 'activated', turnId: input.requestedTurnId! }); + throw new Error('compatibility failed after activation'); + }, + failRowCreate: false, + }, + ])('releases the claim when $label', async ({ reason, steer, failRowCreate }) => { + const harness = await appendHarness(steer ? { steer } : {}); + const info = vi.spyOn(logger, 'info'); + try { + await harness.seedSourceTask(); + if (failRowCreate) { + vi.spyOn(harness.service, 'create').mockRejectedValueOnce(new Error('task store closed')); + } + + await expect(harness.append()).rejects.toThrow(); + + await expect(drainsWithin(harness.host, 200)).resolves.toBe('drained'); + expect(info).toHaveBeenCalledWith( + expect.objectContaining({ + event: 'task_append_admission_released', + childSessionId: CHILD_SESSION_ID, + reason, + }), + expect.any(String), + ); + // A created row is terminal before its claim ends, as the cascade re-read needs. + const candidates = (await listOwnerTasks(harness.store)).filter( + (task) => task.taskId !== 'bg_source', + ); + for (const task of candidates) expect(task.status).toBe('failed'); + } finally { + info.mockRestore(); + await harness.dispose(); + } + }); +}); + +/** Activation whose child Turn runs until `finishAbortedTurn` ends it. */ +const pendingTurns = new Map void>(); + +async function activatedUntilAborted( + input: ConversationSteerInput, +): Promise { + const turnId = input.requestedTurnId!; + const completion = new Promise((resolve) => { + pendingTurns.set(turnId, resolve); + }); + return { turnId, mode: 'activated', completion }; +} + +function finishAbortedTurn(turnId: string | undefined, reason: string): void { + if (!turnId) return; + pendingTurns.get(turnId)?.( + turnResult({ turnId, status: 'aborted', messages: [], error: reason }), + ); + pendingTurns.delete(turnId); +} + +/** Starts a background bash in the child Session as the given child Turn's tool call. */ +async function startChildBash(harness: AppendHarness, childTurnId: string): Promise { + // Runs until aborted, like a long command the child Turn left behind. + const executor: LocalBackgroundBashExecutor = { + execute: ({ signal }) => + new Promise((_resolve, reject) => { + signal.addEventListener('abort', () => reject(new Error(String(signal.reason)))); + }), + }; + const started = await startBackgroundLocalBash({ + host: harness.host, + parentSession: { sessionId: CHILD_SESSION_ID, workspaceDir: '/workspace' } as LocalSessionRecord, + toolCtx: { sessionId: CHILD_SESSION_ID, turnId: childTurnId, toolCallId: 'call-bash' }, + bashInput: { command: 'sleep 600', run_in_background: true }, + executor, + }); + expect(isBackgroundTaskAdmittedHere(harness.host, started.taskId!)).toBe(true); + return started.taskId!; +} + +/** `drained` once no claim of this host is still open; `pending` after `timeoutMs`. */ +async function drainsWithin( + host: LocalTaskRunnerHostWithSessionLookup, + timeoutMs: number, +): Promise<'drained' | 'pending'> { + let timer: ReturnType | undefined; + try { + return await Promise.race([ + drainBackgroundTasks(host).then(() => 'drained' as const), + new Promise<'pending'>((resolve) => { + timer = setTimeout(() => resolve('pending'), timeoutMs); + }), + ]); + } finally { + clearTimeout(timer); + } +} diff --git a/packages/tui/package.json b/packages/tui/package.json index 3224a4335..f2bb73efc 100644 --- a/packages/tui/package.json +++ b/packages/tui/package.json @@ -1,6 +1,6 @@ { "name": "@minimax/code", - "version": "0.5.7", + "version": "0.5.8", "private": true, "description": "Minimax Code CLI and TUI product entry.", "type": "module", diff --git a/packages/tui/src/runtime/stream-events.ts b/packages/tui/src/runtime/stream-events.ts index 1cb59313e..04a1dab85 100644 --- a/packages/tui/src/runtime/stream-events.ts +++ b/packages/tui/src/runtime/stream-events.ts @@ -112,7 +112,14 @@ export type TuiStreamEvent = ( message?: string; turnId?: string; } - | { type: 'generic'; eventType: string; data: Record; turnId?: string } + | { + type: 'generic'; + eventType: string; + data: Record; + turnId?: string; + messageId?: string; + timestamp?: number; + } | { type: 'messages-replaced'; messages: TuiMessage[]; turnId?: string } | { type: 'messages-rewound'; messageIds: string[]; turnId?: string } | { type: 'resync-required'; turnId?: string } @@ -253,7 +260,14 @@ function projectWireSystemEvent( } const data = { ...parsed }; delete data.eventType; - return { type: 'generic', eventType, data, turnId }; + return compact({ + type: 'generic' as const, + eventType, + data, + turnId: readString(message, ['turn_id', 'turnId']) ?? turnId, + messageId: readString(message, ['msg_id', 'msgId', 'id']), + timestamp: readNumber(message, ['timestamp']), + }); } function projectLegacySessionStatus( diff --git a/packages/tui/src/tui/app-composition.ts b/packages/tui/src/tui/app-composition.ts index 1303a7314..73760ada9 100644 --- a/packages/tui/src/tui/app-composition.ts +++ b/packages/tui/src/tui/app-composition.ts @@ -136,6 +136,7 @@ export function createTuiApplicationRenderer(options: CreateTuiAppOptions) { const renderer = new McodeInteractiveRenderer({ terminal, initialMode: options.tuiMode ?? 'regular', + clearScrollbackOnStart: options.clearScrollbackOnStart, logDirectory: options.runtimeLogDirectory, incidentReporter: options.incidentReporter, altScreen: { diff --git a/packages/tui/src/tui/app.ts b/packages/tui/src/tui/app.ts index 9f51e7e5f..cfe3f1358 100644 --- a/packages/tui/src/tui/app.ts +++ b/packages/tui/src/tui/app.ts @@ -746,7 +746,10 @@ export function createTuiApp(options: CreateTuiAppOptions): TuiApp { addInputListener: (listener) => renderer.addInputListener(listener), renderLifecycle: { start: () => renderer.start(), - stop: () => renderer.stop(), + stop: async () => { + renderer.stop(); + await terminal.drainOutput?.(); + }, requestRender: (force) => tui.requestRender(force), }, editor, @@ -896,6 +899,7 @@ export function createTuiApp(options: CreateTuiAppOptions): TuiApp { } await terminal.drainInput(1000).catch(() => undefined); renderer.dispose(); + await terminal.drainOutput?.().catch(() => undefined); await draftStopPromise; await bashStopped; stateStore.dispatch({ type: 'lifecycle/stopped' }); @@ -912,6 +916,7 @@ export function createTuiApp(options: CreateTuiAppOptions): TuiApp { terminalNotifications.setActive(false); terminalTitle.setActive(false); renderer.stop(); + await terminal.drainOutput?.(); await draftLifecycle?.suspend(); } async function resume(): Promise { diff --git a/packages/tui/src/tui/controller/interaction/external-editor-flow.ts b/packages/tui/src/tui/controller/interaction/external-editor-flow.ts index a0bfb5268..f1a678552 100644 --- a/packages/tui/src/tui/controller/interaction/external-editor-flow.ts +++ b/packages/tui/src/tui/controller/interaction/external-editor-flow.ts @@ -17,7 +17,7 @@ interface ExternalEditorDraft { interface ExternalEditorTuiLifecycle { start(): void; - stop(): void; + stop(): void | Promise; requestRender(force?: boolean): void; } @@ -63,8 +63,9 @@ export class TuiExternalEditorFlow { if (shouldResumeTui) { this.options.setHint('External editor open · save and close to return.'); this.options.onChanged(); - this.options.tui.stop(); + await this.options.tui.stop(); } + if (this.options.isAppStopped()) return; const edited = await (this.options.editDraft ?? editTuiDraftInExternalEditor)({ command, draft: original, diff --git a/packages/tui/src/tui/controller/projection/turn-history-projection.ts b/packages/tui/src/tui/controller/projection/turn-history-projection.ts index 88a2a4942..e8e0fb1fe 100644 --- a/packages/tui/src/tui/controller/projection/turn-history-projection.ts +++ b/packages/tui/src/tui/controller/projection/turn-history-projection.ts @@ -5,6 +5,7 @@ import { isQuestionnaireTool, type TuiToolProjection } from './turn-tool-project import type { TuiTodoProjection } from './turn-todo-projection.js'; import type { TuiUserProjection } from './turn-user-projection.js'; import { projectTuiReviewMessage } from '../../../review/projection.js'; +import { projectHookSystemMessage } from './turn-hook-message-projection.js'; interface HydrateTuiHistoryOptions { messages: readonly TuiMessage[]; @@ -29,9 +30,16 @@ export function hydrateTuiHistory(options: HydrateTuiHistoryOptions): void { todoProjection.apply(turnId, systemEvent.data); return; } - // Runtime warnings remain available in durable events for diagnostics, but - // TUI intentionally leaves their product presentation to a future UX design. - if (systemEvent?.eventType === 'runtime.warning') return; + if (systemEvent?.eventType === 'runtime.warning') { + const hookMessage = projectHookSystemMessage({ + ...systemEvent, + messageId: message.id, + turnId, + timestamp, + }); + if (hookMessage) transcript.upsert(hookMessage); + return; + } if (isCompactionMessage(message)) { const status = message.kind === 'compaction_start' diff --git a/packages/tui/src/tui/controller/projection/turn-hook-message-projection.ts b/packages/tui/src/tui/controller/projection/turn-hook-message-projection.ts new file mode 100644 index 000000000..a48696f58 --- /dev/null +++ b/packages/tui/src/tui/controller/projection/turn-hook-message-projection.ts @@ -0,0 +1,37 @@ +import { sanitizeTerminalText } from '../../rendering/terminal-text.js'; +import type { TranscriptCell } from '../../transcript/model.js'; + +/** Shared live/history projection. These product messages never become model turns. */ +export function projectHookSystemMessage(input: { + eventType: string; + data: Readonly>; + messageId?: string; + turnId: string; + timestamp: number; +}): TranscriptCell | undefined { + const { data, messageId } = input; + if ( + input.eventType !== 'runtime.warning' || + data.source !== 'plugin-hook' || + data.category !== 'system-message' || + typeof data.message !== 'string' || + !messageId?.trim() + ) { + return undefined; + } + const content = sanitizeTerminalText(data.message); + if (!content.trim()) return undefined; + const hookEvent = + typeof data.hookEvent === 'string' ? sanitizeTerminalText(data.hookEvent).trim() : ''; + return { + id: `hook-message:${messageId}`, + kind: 'warning', + status: 'succeeded', + title: hookEvent ? `Hook · ${hookEvent}` : 'Hook', + content, + sourceMessageId: messageId, + turnId: input.turnId, + createdAtMs: input.timestamp, + updatedAtMs: input.timestamp, + }; +} diff --git a/packages/tui/src/tui/controller/projection/turn-projection.ts b/packages/tui/src/tui/controller/projection/turn-projection.ts index b036d5db9..b0dce1b68 100644 --- a/packages/tui/src/tui/controller/projection/turn-projection.ts +++ b/packages/tui/src/tui/controller/projection/turn-projection.ts @@ -7,6 +7,7 @@ import type { import type { TranscriptStore } from '../../transcript/store.js'; import type { TuiTodoItem } from '../../todo/model.js'; import { hydrateTuiHistory } from './turn-history-projection.js'; +import { projectHookSystemMessage } from './turn-hook-message-projection.js'; import { TuiLiveTurnProjection } from './turn-live-projection.js'; import { TuiToolProjection } from './turn-tool-projection.js'; import { TuiTodoProjection } from './turn-todo-projection.js'; @@ -62,6 +63,11 @@ export class TuiTurnProjection { changed = this.removePreviousTerminalDuration(turnId) || changed; // One-time feedback survives history refreshes, so dismiss it when a new run starts. for (const cell of this.transcript.snapshot()) { + // Runtime terminal errors survive history reconciliation, but belong only + // to the failed run. Otherwise a successful retry can reproject them again. + if (cell.kind === 'error' && cell.ephemeral && cell.turnId && cell.turnId !== turnId) { + changed = this.transcript.remove(cell.id) || changed; + } if ( cell.kind === 'shell' && cell.ephemeral && @@ -157,6 +163,16 @@ export class TuiTurnProjection { return undefined; } if (event.type === 'generic') { + const hookMessage = projectHookSystemMessage({ + ...event, + turnId: event.turnId ?? turnId, + timestamp: event.timestamp ?? this.now(), + }); + if (hookMessage) { + this.transcript.upsert(hookMessage); + this.keepPendingSteersTrailing(); + this.onChange(); + } if ( event.eventType === 'todo_updated' && this.todoProjection.apply(event.turnId ?? turnId, event.data) diff --git a/packages/tui/src/tui/controller/session-lifecycle-bridge.ts b/packages/tui/src/tui/controller/session-lifecycle-bridge.ts index 676266b98..c00adb8b7 100644 --- a/packages/tui/src/tui/controller/session-lifecycle-bridge.ts +++ b/packages/tui/src/tui/controller/session-lifecycle-bridge.ts @@ -34,7 +34,10 @@ export function createTuiSessionLifecycleBridge(runtime: CreateTuiAppOptions['ru reason: 'clear' | 'resume_other', ): Promise { await abortLocalPluginHookSessionTurn(sessionId, async (executionSessionId) => { - await runtime.abortSession({ id: executionSessionId, reason: 'user_stop' }); + // `session_leave`, not `user_stop`: switching Sessions or running `/clear` + // still stops the live Turn and pauses the Goal/Queue, but it is not a stop + // request and must not tear down background work left running there. + await runtime.abortSession({ id: executionSessionId, reason: 'session_leave' }); }); await endLocalPluginHookSession(sessionId, reason); }, diff --git a/packages/tui/src/tui/engine/LOCAL_CHANGES.json b/packages/tui/src/tui/engine/LOCAL_CHANGES.json index 5bbc3cb62..555bbaa33 100644 --- a/packages/tui/src/tui/engine/LOCAL_CHANGES.json +++ b/packages/tui/src/tui/engine/LOCAL_CHANGES.json @@ -77,10 +77,10 @@ }, { "path": "index.ts", - "currentSha256": "a82c68598f45fbc8b24978399f0599529ed2f238bb22d7dcb82ad68cdae9b6a1", - "changeIds": ["L009", "L021"], - "reason": "Export the generic Editor state snapshot type and the terminal capability override helper through the product-facing Engine barrel.", - "behaviorImpact": "Product adapters can type serializable Pi Editor state and inject deterministic terminal capability behavior without importing Engine internals." + "currentSha256": "f45ae2e2a6fdf78015b9877745ee374fe69462c89db88b8c92b5b790a0343cdf", + "changeIds": ["L009", "L021", "L045"], + "reason": "Export the generic scrollback layout contract through the public Engine boundary.", + "behaviorImpact": "Product transcript adapters can identify retained source blocks and rendered rows without importing engine internals." }, { "path": "keybindings.ts", @@ -148,10 +148,10 @@ }, { "path": "terminal.ts", - "currentSha256": "3f53e011ddd18872691c9fe21d9f2309995a74eeb0a1f7f6023a9cc9e7b13553", - "changeIds": ["L014", "L028"], - "reason": "Use the package-aware native module resolver for Windows virtual-terminal input initialization. 终端焦点报告生命周期", - "behaviorImpact": "Packaged Windows builds retain modified-key escape sequences such as Shift+Tab instead of silently falling back to plain keys. 焦点报告转发给 Host,cmux 抑制策略仍归 Host。" + "currentSha256": "11fca3d510dc827a2a91d05425aeb9ddf3a1829ce1f57415a3c000a8d9a9fd17", + "changeIds": ["L014", "L028", "L044"], + "reason": "Use the package-aware native module resolver for Windows virtual-terminal input initialization. 终端焦点报告生命周期 Preserve ordered asynchronous terminal output under POSIX TTY backpressure.", + "behaviorImpact": "Packaged Windows builds retain modified-key escape sequences such as Shift+Tab instead of silently falling back to plain keys. 焦点报告转发给 Host,cmux 抑制策略仍归 Host。 Slow terminal output does not block input; pending frames coalesce and terminal handoffs drain ordered output." }, { "path": "tui-alt-screen.ts", @@ -163,18 +163,41 @@ }, { "path": "tui-main-screen.ts", - "currentSha256": "9d5c2b83b6b3f0b614f9746a398025eb2c9d324a83959138e77728a08679939c", - "changeIds": ["L005", "L017", "L027", "L033", "L034", "L037", "L038", "L039", "L041"], + "currentSha256": "0095b75384b9f99cc979e55c5ef40a94c58d79ea9e9394ad978ad61f0c2db3d8", + "changeIds": [ + "L005", + "L017", + "L027", + "L033", + "L034", + "L037", + "L038", + "L039", + "L041", + "L044", + "L045" + ], "upstreamCommit": "6c4f360264397c59801f6da2bdac13e3b1fcbe91", - "reason": "Keep strict TypeScript fixes and stream full and differential renders through Pi's bounded terminal writer. 缩放期间仅重绘可见尾部 常规模式在差分比较前剥离行首 OSC 133 zone 标记。 内容收缩或历史内容变化触发回退重绘时仅更新可见区域。", - "behaviorImpact": "Regular viewport redraws erase rows in place so hosts that save an erased screen to scrollback do not retain stale transcript or footer rows. Visible text-only shrink with unchanged historical text temporarily pads the active screen to preserve host scrolling and the input position; later output reuses this space. Historical text replacement or removal still reconstructs the session to avoid stale or duplicate history. Rebuilding clears pre-launch shell scrollback. Genuine resize retains delayed history replay; redundant same-size notifications are ignored." + "reason": "Align bounded regular-mode projections with emitted row identities and preserve native history across folding, footer shrink, redraw and resize.", + "behaviorImpact": "Native scrollback retains emitted snapshots and pre-launch shell history. Automatic folding and footer updates preserve scrolling; unmatched projections archive the visible body and start a labelled snapshot. Resize retains pending rows, forced redraw retains physical state, and historical image resources survive viewport repaints. Transient overlays use a separate terminal buffer and return to main on close or stop, preserving main geometry across resize." }, { "path": "tui.ts", - "currentSha256": "96591553bc244dbe431de5bff46f57a874d83cd5fe623c94b47b637f83afcec0", - "changeIds": ["L005", "L015", "L023", "L027", "L028", "L031", "L040", "L041"], - "reason": "Keep strict TypeScript fixes, expose Pi's existing immediate scheduler as a non-destructive product interaction contract, dispatch the input left over after terminal color sequences are removed, and coalesce synchronous submission renders. 提供 resize hook 及焦点输入过滤 独立面板声明键盘分页归属。", - "behaviorImpact": "Urgent product interactions render immediately without resetting differential state or clearing native scrollback, and a coalesced color answer no longer discards the keystrokes sharing its chunk. A synchronous submission frame dismisses the previous interrupted footer without a second input render. 焦点先交给 viewport listener,不进入编辑器。 handlesViewportKeys 为 true 时,fullscreen 分页交给焦点面板;默认仍由外层视口处理。" + "currentSha256": "03ef4b80122f127860dddf6c4ff51643f4b7ec47c8ce18d0e1855d27ce77ec57", + "changeIds": [ + "L005", + "L015", + "L023", + "L027", + "L028", + "L031", + "L040", + "L041", + "L044", + "L045" + ], + "reason": "Expose bounded row anchors, source-block retention and the transcript/footer boundary for regular-mode output snapshots.", + "behaviorImpact": "Regular renderers can distinguish projection eviction from retained-block presentation changes." }, { "path": "utils.ts", diff --git a/packages/tui/src/tui/engine/LOCAL_CHANGES.md b/packages/tui/src/tui/engine/LOCAL_CHANGES.md index 2a59f78e9..181a7103e 100644 --- a/packages/tui/src/tui/engine/LOCAL_CHANGES.md +++ b/packages/tui/src/tui/engine/LOCAL_CHANGES.md @@ -17,7 +17,7 @@ MCode-owned difference from that baseline. | L011 | Fullscreen interaction | `layout.ts`, `tui-alt-screen.ts` and `public.ts` mouse dispatch | Route SGR press, drag and release events through the rendered Pi layout before Alt text selection, using layout-local coordinates and deepest-target precedence. | Fullscreen product interactions such as Plan Review remain mouse-operable without restoring the deleted product-owned selection or renderer path. | Alt renderer integration; Chat layout; Inline panel; Plan Review mouse tests | | L013 | Pi maintenance | `components/text.ts` and `components/markdown.ts` | Adopt Pi post-0.84.2 fixes for adaptive narrow-width padding and wrapped table style restoration. | Narrow panes stay within terminal width, and wrapped links do not leak styles into table borders or adjacent cells. | Engine local-delta narrow-width regression; Pi Markdown corpus | | L014 | Native packaging | `native-module-path.ts`, `native-modifiers.ts` and `terminal.ts` | Resolve native helpers from the installed `@minimax/code` package root before standalone archive fallbacks. | Packaged Apple Terminal and Windows modifier/VT helpers load from the actual release layout. | Native candidate unit contract; package layout inspection | -| L015 | Render scheduling | `tui.ts` | Expose Pi's existing immediate scheduler separately from the destructive `force` reset path. | Product interactions remain immediate without resetting differential state or clearing native scrollback. | Engine local-delta immediate-render regression; Surface Host focused tests | +| L015 | Render scheduling | `tui.ts` | Expose Pi's existing immediate scheduler separately from the destructive `force` reset path. | Product interactions schedule immediate rendering without resetting differential state or clearing native scrollback; L044 governs delivery under backpressure. | Engine local-delta immediate-render regression; Surface Host focused tests | | L016 | Process resilience | `autocomplete.ts` fd child output streams | Handle stdout/stderr stream errors at the file-autocomplete owner, terminate the failed child and resolve the scan with no suggestions. | A broken background fd pipe cannot escalate through `uncaughtException` and stop the active TUI Session. | `tui-autocomplete-process-streams.test.ts`; process-guard focused tests | | L017 | Pi maintenance | `tui-main-screen.ts` | Stream full and differential renders through Pi's bounded terminal writer instead of constructing one unbounded output string (Pi `6c4f360264397c59801f6da2bdac13e3b1fcbe91`). | Large regular-mode renders preserve output order without exceeding V8's maximum string length. | Pi 0.84.4 render regression; bounded-write focused test | | L018 | Pi maintenance | `autocomplete.ts` | Search direct children separately, merge them with recursive matches, and sort equal-score results by depth, length, then path (Pi `b37ebb7f22ec1a8fbf882366dc20ae6d6010a6e2`). | Direct workspace paths remain visible and stable before deeply nested matches when recursive results are abundant. | Pi 0.84.4 autocomplete regression; autocomplete focused test | @@ -118,6 +118,8 @@ Remove `L024` when the selected Pi baseline natively matches legacy-terminal `Ct ## L034: Regular viewport reconstruction after document changes +> L045 supersedes this entry's native-history reconstruction and clearing policy; the original behavior below is retained as historical context. + - Product contract: after running content or a feature panel closes, show the complete current chat viewport with its Composer and status line. Every current-session row must occur once in native history. - Minimal difference: when a shorter document would move the viewport origin backwards, or changed visible text is already in scrollback, clear and replay the complete current projection, except for addressable text-only shrink covered by L038. Compare changed historical rows without terminal sequences so style-only updates preserve scrollback. Other updates retain differential rendering and genuine resize retains the existing delayed history replay. - Tradeoff: structural reconstruction clears native scrollback, including shell history from before TUI startup. Initial short chat documents retain natural document placement. L038 keeps freed visible rows temporarily blank instead of reconstructing unchanged history. @@ -141,6 +143,8 @@ Remove `L024` when the selected Pi baseline natively matches legacy-terminal `Ct ## L038: Preserve native scrolling during visible content shrink +> L045 supersedes this entry's native-history reconstruction and clearing policy; the original behavior below is retained as historical context. + - Product contract: settling visible activity rows must not clear native scrollback or pin a scrolled host viewport to the top. The Composer and status remain at the bottom, and historical content remains unique. - Minimal difference: when terminal geometry, the text already in scrollback and the declared transient layout keys are unchanged, absorb visible text-only shrink with blank rows at the current screen boundary before cursor extraction and differential rendering. L041 makes this an explicit background-content policy; unclassified layouts restore exposed rows. Subsequent output consumes the space before advancing native history. Ignore redundant same-size resize notifications without cancelling a genuine pending resize replay. - Boundary: padding is confined to the active screen. Historical text replacement/removal, real resize, overlays and image reflow retain the structural reconstruction path. Blank rows can temporarily separate native history from the visible tail; this is preferable to clearing and replaying the terminal's scrollback during ordinary completion. No mouse capture is enabled in regular mode. @@ -149,6 +153,8 @@ Remove `L024` when the selected Pi baseline natively matches legacy-terminal `Ct ## L039: Erase regular viewport redraws in place +> L045 supersedes this entry's native-history reconstruction and clearing policy; the original behavior below is retained as historical context. + - Product contract: repainting the visible regular-mode screen must not append the previous transcript, Composer or status line to native history. - Minimal difference: viewport-only full redraws home the cursor, erase each screen row with EL 2 using cursor-down movement, and return home before painting. This avoids ED 2, which saves the old screen to scrollback in Apple Terminal. Full structural reconstruction still clears and rebuilds history. - Evidence: local-delta tests exercise xterm and a clear-to-scrollback host model, covering historical style changes, simultaneous growth, short-document shrink, subsequent differential output, host scrolling and resize preview/replay. Native Apple Terminal replay of synthetic renderer output reproduces duplicate rows before the fix and preserves the exact document afterward. @@ -157,7 +163,7 @@ Remove `L024` when the selected Pi baseline natively matches legacy-terminal `Ct ## L040: Coalesce synchronous submission renders -- Product contract: sending the next message removes the previous interrupted duration from the physical terminal before the input callback returns, without rendering the same frame twice. +- Product contract: sending the next message removes the previous interrupted duration from the next presented frame, without rendering the same frame twice. When output is ready, submission generates that frame before the input callback returns; L044 permits asynchronous delivery and coalesces frames under backpressure. - Minimal difference: queue the normal immediate input render before dispatching a focused component's key. A synchronous `renderNow()` during submission clears that queued request; ordinary keys still render on the next tick. - User impact: the previous interrupted footer disappears with the submitted message, and the extra no-op render after Enter is avoided. - Evidence: `tui-app.test.ts` checks every presented frame across interrupt and resend; `tui-engine-local-deltas.test.ts` checks that a synchronous input render has no second pass. @@ -165,6 +171,8 @@ Remove `L024` when the selected Pi baseline natively matches legacy-terminal `Ct ## L041: Restore chat rows after transient layout shrink +> L045 supersedes this entry's native-history reconstruction and clearing policy; the original behavior below is retained as historical context. + - Product contract: shrinking a transient UI region restores the conversation instead of leaving released rows blank above it. Background activity shrink with unchanged transient layout retains L038's native scrolling behavior. - Minimal difference: components may expose the layout key of their last rendered frame. MainScreen permits L038 padding only when every root explicitly supplies the same key and no overlay was present. ChatLayout includes every transient section's height and interaction state, while SurfaceHost includes the active feature. Unknown or changed layouts use L034 reconstruction only when scrolled rows must return. Keys are captured with native render state and cleared on reset. This replaces the earlier completion-specific resize callback and full-viewport close exception. - Evidence: application tests replay `/theme`, `/settings`, prompt-history search, image-preview dismissal, multi-line draft clearing and completion filtering. Engine tests repeatedly expand/shrink each transient section under xterm and an ED 2 clear-to-scrollback model, compare the complete viewport, verify unique history, and retain positive background-activity scroll preservation. Short documents avoid unnecessary clearing. @@ -184,3 +192,26 @@ Remove `L024` when the selected Pi baseline natively matches legacy-terminal `Ct - Minimal difference: `ScrollView.updateLayout` clamps the scroll position without changing follow state. Explicit scrolling and follow requests retain their existing behavior. - Evidence: `tui-scrollbar-interaction.test.ts` covers wheel detachment, viewport growth, footer/content shrink, temporarily fitting all content, subsequent output and re-arming with End. - Removal condition: the selected Pi baseline preserves follow state through layout clamping. + +## L044: Keep terminal backpressure off the input loop + +> L045 supersedes this entry's native-history reconstruction and clearing policy; the original behavior below is retained as historical context. + +- Product contract: slow or paused POSIX terminal output must not block input or cancellation during long Regular-mode history reconstruction. +- Minimal difference: `terminal.ts` serializes POSIX TTY output through asynchronous, bounded `fs.write` operations, preserves partial UTF-8 writes, and reports output failures through the existing stdout error path. Controls share the same queue. `tui.ts` defers subsequent frames until output drains and then renders the latest model once. Ordinary stop queues any deferred final frame before terminal cleanup; mode switches preserve their previously captured render state. Mandatory final resize replay remains ordered before cleanup. +- Host integration: the observed terminal forwards the output state; shutdown, suspension and external-editor handoff drain queued output before another process owns the terminal. Input draining starts its idle window after keyboard-disable output is sent, and editor handoff rechecks shutdown after the drain. +- Evidence: terminal-output regressions cover held/partial writes, input dispatch, control ordering, transient and permanent failures, frame coalescing, final transcript preservation, keyboard input draining and editor handoff/shutdown races; application tests cover shutdown/suspend drains. A local macOS PTY with 10,000 synthetic history rows and a paused consumer no longer blocks the input loop while reconstructing history. +- Boundary: history reconstruction still transmits the complete ordered document. A stalled connection can delay visible output; it no longer synchronously stalls the JavaScript event loop. Windows and non-TTY output retain their existing writer. Native remote SSH and native Windows acceptance remain separate. +- Removal condition: the upstream terminal writer provides ordered asynchronous POSIX TTY writes and its render scheduler observes output backpressure. + + +## L045: Preserve regular-mode output snapshots + +- Product contract: automatic projection folding and footer completion preserve host scrolling and shell history. Native scrollback records emitted snapshots; it is not a mutable copy of the latest canonical projection. +- Minimal difference: an optional `ScrollbackLayout` carries stable transcript row identities and a body/footer boundary through the public component API. MainScreen aligns a bounded logical cache at a shared nonblank row with unchanged text. Read groups identify individual member rows rather than the group's changing first cell. Later edits to already-emitted history do not replay it. Reclaimed rows remain between body and footer, preserving tables and the input cursor. +- Reconstruction: an unmatched projection commits the old visible body, erases addressable rows in place, and starts a labelled snapshot. It never emits ED 3 or uses ED 2. Footer overflow retains its physical offset; newly appended body rows are emitted before replacing the footer. Historical Kitty resources are not freed by a viewport repaint. +- Geometry: resize uses the hardware cursor's screen row to determine the retained origin. Safe anchors preserve every pending row, including a burst arriving during resize; an unalignable reflow starts a labelled snapshot. Forced redraw and regular/fullscreen state transfers retain physical coordinates and anchors. +- Transient screens: visible overlays share an alternate buffer until the last one closes. Main-buffer anchors and cursor geometry are retained independently through every resize; stop restores the main buffer before returning terminal ownership. Panel rows never enter native history. +- Evidence: engine and application regressions verify host scroll position, unique emitted rows, Todo completion, grouped reads and long-turn/turn-window eviction, contiguous tables, transient panels, cursor placement, bounded caches, oversized footers, resize bursts, image-resource lifetime, forced refresh, and mode-state restore under xterm and an ED 2 clear-to-scrollback model. +- Boundary: old native rows retain their historical text and wrapping. Explicit resume-start clearing is unchanged. Native remote-host and cross-platform acceptance remain separate from local emulator verification. +- Removal condition: the selected upstream engine supports immutable native output with bounded anchored live projections. diff --git a/packages/tui/src/tui/engine/index.ts b/packages/tui/src/tui/engine/index.ts index 0e61b689d..c50d64886 100644 --- a/packages/tui/src/tui/engine/index.ts +++ b/packages/tui/src/tui/engine/index.ts @@ -142,6 +142,7 @@ export { type OverlayOptions, type OverlayUnfocusOptions, type SizeValue, + type ScrollbackLayout, type TUI, type TuiInputListener, type TuiInputListenerResult, diff --git a/packages/tui/src/tui/engine/terminal.ts b/packages/tui/src/tui/engine/terminal.ts index 8ab808003..f58866619 100644 --- a/packages/tui/src/tui/engine/terminal.ts +++ b/packages/tui/src/tui/engine/terminal.ts @@ -74,6 +74,10 @@ export interface Terminal { // Write output to terminal write(data: string): void; + /** True while ordered asynchronous terminal output is still in flight. */ + readonly outputPending?: boolean; + /** Finish terminal output before handing its descriptor to another process. */ + drainOutput?(): Promise; // Get terminal dimensions get columns(): number; @@ -131,6 +135,77 @@ export class ProcessTerminal implements Terminal { get focused(): boolean | undefined { return this.terminalFocused; } + // POSIX TTY stdout writes are synchronous in Node. Use the fs worker pool so + // SSH backpressure cannot block input, cancellation or runtime event handling. + private outputQueue: Buffer[] = []; + private outputOffset = 0; + private outputError: Error | undefined; + private outputWaiters: { resolve: () => void; reject: (error: Error) => void }[] = []; + + get outputPending(): boolean { + return this.outputQueue.length > 0; + } + + drainOutput(): Promise { + if (this.outputError) return Promise.reject(this.outputError); + if (!this.outputPending) return Promise.resolve(); + return new Promise((resolve, reject) => this.outputWaiters.push({ resolve, reject })); + } + + private writeOutput(data: string): void { + // The first failure was already reported. Keep shutdown cleanup usable. + if (this.outputError) return; + if (process.platform === "win32" || !process.stdout.isTTY) { + process.stdout.write(data); + return; + } + if (!data) return; + this.outputQueue.push(Buffer.from(data, "utf8")); + if (this.outputQueue.length === 1) this.writeNextOutput(); + } + + private failOutput(error: Error): void { + this.outputError = error; + this.outputQueue = []; + this.outputOffset = 0; + for (const waiter of this.outputWaiters.splice(0)) waiter.reject(error); + // Keep the existing process-guard / dead-terminal reporting path. + process.stdout.emit("error", error); + } + + private writeNextOutput(): void { + const buffer = this.outputQueue[0]; + if (!buffer) { + for (const waiter of this.outputWaiters.splice(0)) waiter.resolve(); + return; + } + const length = Math.min(64 * 1024, buffer.length - this.outputOffset); + try { + fs.write(process.stdout.fd, buffer, this.outputOffset, length, null, (error, written) => { + if (error) { + if (error.code === "EINTR" || error.code === "EAGAIN") { + setTimeout(() => this.writeNextOutput(), 10); + return; + } + this.failOutput(error); + return; + } + if (written <= 0) { + this.failOutput(Object.assign(new Error("Terminal output made no progress"), { code: "EIO" })); + return; + } + this.outputOffset += written; + if (this.outputOffset === buffer.length) { + this.outputQueue.shift(); + this.outputOffset = 0; + } + this.writeNextOutput(); + }); + } catch (error) { + this.failOutput(error instanceof Error ? error : new Error(String(error))); + } + } + private wasRaw = false; private inputHandler?: (data: string) => void; private resizeHandler?: () => void; @@ -179,8 +254,8 @@ export class ProcessTerminal implements Terminal { process.stdin.resume(); // Enable bracketed paste mode - terminal will wrap pastes in \x1b[200~ ... \x1b[201~ - process.stdout.write("\x1b[?2004h"); - process.stdout.write("\x1b[?1004h"); + this.writeOutput("\x1b[?2004h"); + this.writeOutput("\x1b[?1004h"); // Set up resize handler immediately process.stdout.on("resize", this.resizeHandler); @@ -258,7 +333,7 @@ export class ProcessTerminal implements Terminal { process.stdin.on("data", this.stdinDataHandler!); this.keyboardProtocolPushed = true; this.clearKeyboardProtocolNegotiationBuffer(); - process.stdout.write(KITTY_KEYBOARD_PROTOCOL_QUERY); + this.writeOutput(KITTY_KEYBOARD_PROTOCOL_QUERY); } private handleKeyboardProtocolNegotiationSequence( @@ -361,13 +436,13 @@ export class ProcessTerminal implements Terminal { private enableModifyOtherKeys(): void { if (this._kittyProtocolActive || this._modifyOtherKeysActive) return; - process.stdout.write("\x1b[>4;2m"); + this.writeOutput("\x1b[>4;2m"); this._modifyOtherKeysActive = true; } private disableModifyOtherKeys(): void { if (!this._modifyOtherKeysActive) return; - process.stdout.write("\x1b[>4;0m"); + this.writeOutput("\x1b[>4;0m"); this._modifyOtherKeysActive = false; } @@ -407,7 +482,7 @@ export class ProcessTerminal implements Terminal { if (shouldDisableKittyProtocol) { // Disable Kitty keyboard protocol first so any late key releases // do not generate new Kitty escape sequences. - process.stdout.write("\x1b[ 0) { // Move down - process.stdout.write(`\x1b[${lines}B`); + this.writeOutput(`\x1b[${lines}B`); } else if (lines < 0) { // Move up - process.stdout.write(`\x1b[${-lines}A`); + this.writeOutput(`\x1b[${-lines}A`); } // lines === 0: no movement } hideCursor(): void { - process.stdout.write("\x1b[?25l"); + this.writeOutput("\x1b[?25l"); } showCursor(): void { - process.stdout.write("\x1b[?25h"); + this.writeOutput("\x1b[?25h"); } clearLine(): void { - process.stdout.write("\x1b[K"); + this.writeOutput("\x1b[K"); } clearFromCursor(): void { - process.stdout.write("\x1b[J"); + this.writeOutput("\x1b[J"); } clearScreen(): void { - process.stdout.write("\x1b[2J\x1b[H"); // Clear screen and move to home (1,1) + this.writeOutput("\x1b[2J\x1b[H"); // Clear screen and move to home (1,1) } setTitle(title: string): void { // OSC 0;title BEL - set terminal window title - process.stdout.write(`\x1b]0;${title}\x07`); + this.writeOutput(`\x1b]0;${title}\x07`); } setProgress(active: boolean): void { if (active) { // OSC 9;4;3 - indeterminate progress - process.stdout.write(TERMINAL_PROGRESS_ACTIVE_SEQUENCE); + this.writeOutput(TERMINAL_PROGRESS_ACTIVE_SEQUENCE); if (!this.progressInterval) { this.progressInterval = setInterval(() => { - process.stdout.write(TERMINAL_PROGRESS_ACTIVE_SEQUENCE); + this.writeOutput(TERMINAL_PROGRESS_ACTIVE_SEQUENCE); }, TERMINAL_PROGRESS_KEEPALIVE_MS); } } else { this.clearProgressInterval(); // OSC 9;4;0 - clear progress - process.stdout.write(TERMINAL_PROGRESS_CLEAR_SEQUENCE); + this.writeOutput(TERMINAL_PROGRESS_CLEAR_SEQUENCE); } } diff --git a/packages/tui/src/tui/engine/tui-main-screen.ts b/packages/tui/src/tui/engine/tui-main-screen.ts index 8d4d17418..4ed8836d2 100644 --- a/packages/tui/src/tui/engine/tui-main-screen.ts +++ b/packages/tui/src/tui/engine/tui-main-screen.ts @@ -1,7 +1,7 @@ import * as fs from "node:fs"; import * as path from "node:path"; import { deleteKittyImage, isImageLine } from "./terminal-image.js"; -import { type Component, type TUI, TuiBase, type TuiStopOptions } from "./tui.js"; +import { type Component, type ScrollbackLayout, type TUI, TuiBase, type TuiStopOptions } from "./tui.js"; import { stripTerminalSequences, visibleWidth } from "./utils.js"; const KITTY_SEQUENCE_PREFIX = "\x1b_G"; @@ -116,13 +116,14 @@ export interface TuiMainScreenRenderState { previousViewportTop: number; viewportLayouts: { component: Component; key: string | undefined }[]; hadOverlays: boolean; + scrollbackLayout?: ScrollbackLayout; + segmentHeader?: boolean; } /** TUI implementation that renders into the terminal's main screen and scrollback. */ export class TuiMainScreen extends TuiBase implements TUI { readonly mode = "regular" as const; private previousLines: string[] = []; - private previousKittyImageIds = new Set(); private previousWidth = 0; private previousHeight = 0; private cursorRow = 0; @@ -130,18 +131,29 @@ export class TuiMainScreen extends TuiBase implements TUI { private maxLinesRendered = 0; private previousViewportTop = 0; private resizeTimer: ReturnType | undefined; - private historyReplayPending = false; + private resizePending = false; private viewportLayouts: TuiMainScreenRenderState['viewportLayouts'] = []; private hadOverlays = false; + private scrollbackLayout: ScrollbackLayout | undefined; + private segmentHeader = false; + private overlayScreenActive = false; + private overlayScreenWidth = 0; + private overlayMainReflowed = false; + private renderingMainOnStop = false; protected override onTerminalResize(): void { + if (this.overlayScreenActive) { + this.updateOverlayMainGeometry(); + this.requestRender(); + return; + } // Some hosts repeat resize notifications while scrolling or reconnecting. // An unchanged geometry must not clear and replay native scrollback. if (this.previousWidth === this.terminal.columns && this.previousHeight === this.terminal.rows) return; - // Render the visible tail now; replay native scrollback only after the drag settles. + // Repaint the active document now and settle the final geometry after a drag. if (this.previousLines.length > 0 && !isTermuxSession()) { if (this.resizeTimer) clearTimeout(this.resizeTimer); - this.historyReplayPending = true; + this.resizePending = true; this.resizeTimer = setTimeout(() => { this.resizeTimer = undefined; this.requestRender(); @@ -156,10 +168,17 @@ export class TuiMainScreen extends TuiBase implements TUI { } override stop(options: TuiStopOptions = {}): void { - // Restore the ordered document before handing the main screen back to its host. - this.cancelResize(); - if (this.historyReplayPending && !this.stopped) this.renderNow(); - super.stop(options); + this.renderingMainOnStop = true; + try { + // Flush the main document and exit 1049 before returning terminal ownership. + this.cancelResize(); + if (!this.stopped && (this.overlayScreenActive || this.resizePending || (!options.preserveScreen && this.hasPendingRender()))) { + this.doRender(); + } + super.stop(options); + } finally { + this.renderingMainOnStop = false; + } } captureRenderState(): TuiMainScreenRenderState { @@ -173,14 +192,15 @@ export class TuiMainScreen extends TuiBase implements TUI { previousViewportTop: this.previousViewportTop, viewportLayouts: this.viewportLayouts.map((layout) => ({ ...layout })), hadOverlays: this.hadOverlays, + scrollbackLayout: this.scrollbackLayout, + segmentHeader: this.segmentHeader, }; } restoreRenderState(state: TuiMainScreenRenderState): void { this.cancelResize(); - this.historyReplayPending = false; + this.resizePending = false; this.previousLines = state.previousLines.map((line) => (isImageLine(line) ? "" : line)); - this.previousKittyImageIds = new Set(); this.previousWidth = state.previousWidth; this.previousHeight = state.previousHeight; this.cursorRow = state.cursorRow; @@ -189,20 +209,16 @@ export class TuiMainScreen extends TuiBase implements TUI { this.previousViewportTop = state.previousViewportTop; this.viewportLayouts = state.viewportLayouts.map((layout) => ({ ...layout })); this.hadOverlays = state.hadOverlays; + this.scrollbackLayout = state.scrollbackLayout; + this.segmentHeader = state.segmentHeader ?? false; } protected override resetRenderState(): void { - this.viewportLayouts = []; - this.hadOverlays = false; + // A forced repaint still owns an existing physical screen. Retain its + // coordinates and anchors so refreshing cannot append a duplicate frame. this.cancelResize(); - this.historyReplayPending = false; - this.previousLines = []; + this.resizePending = false; this.previousWidth = -1; - this.previousHeight = -1; - this.cursorRow = 0; - this.hardwareCursorRow = 0; - this.maxLinesRendered = 0; - this.previousViewportTop = 0; } protected override beforeTerminalStop(options: TuiStopOptions): void { @@ -225,6 +241,10 @@ export class TuiMainScreen extends TuiBase implements TUI { return ids; } + private hasImageAtOrBelow(lines: string[], top: number): boolean { + return lines.some((line, row) => isImageLine(line) && row + this.getKittyImageReservedRows(lines, row) > top); + } + private deleteKittyImages(ids: Iterable): string { let buffer = ""; for (const id of ids) { @@ -275,7 +295,7 @@ export class TuiMainScreen extends TuiBase implements TUI { const ids = new Set(); const maxLine = Math.min(lastChanged, this.previousLines.length - 1); - for (let i = firstChanged; i <= maxLine; i++) { + for (let i = Math.max(firstChanged, this.previousViewportTop); i <= maxLine; i++) { for (const id of extractKittyImageIds(this.previousLines[i] ?? "")) { ids.add(id); } @@ -284,14 +304,81 @@ export class TuiMainScreen extends TuiBase implements TUI { return this.deleteKittyImages(ids); } + private renderOverlayScreen(): void { + const width = this.terminal.columns; + const height = this.terminal.rows; + if (!this.overlayScreenActive) { + this.terminal.write("\x1b[?1049h"); + this.overlayScreenActive = true; + this.overlayScreenWidth = this.previousWidth > 0 ? this.previousWidth : width; + } + this.updateOverlayMainGeometry(); + const background = this.render(width).slice(-height).map((line) => isImageLine(line) ? "" : line.replace(OSC133_ZONE_PREFIX, "")); + const lines = this.compositeOverlays(background, width, height).slice(-height); + const cursor = this.extractCursorPosition(lines, height); + this.applyLineResets(lines); + const output = new BoundedTerminalWriter((data) => this.terminal.write(data)); + output.append("\x1b[?2026h"); + for (let row = 0; row < height; row++) { + output.append(`\x1b[${row + 1};1H\x1b[2K${lines[row] ?? ""}`); + } + if (cursor) output.append(`\x1b[${cursor.row + 1};${cursor.col + 1}H`); + output.append(cursor && this.getShowHardwareCursor() ? "\x1b[?25h" : "\x1b[?25l"); + output.append("\x1b[?2026l"); + output.flush(); + } + + private updateOverlayMainGeometry(): void { + const width = this.terminal.columns; + const height = this.terminal.rows; + // The inactive main buffer still resizes. Track its cursor-based origin + // independently of the cursor used by the transient overlay screen. + const oldCursor = Math.max(0, this.hardwareCursorRow - this.previousViewportTop); + if (this.previousHeight > 0 && height !== this.previousHeight) { + if (height < this.previousHeight) this.previousViewportTop += Math.max(0, oldCursor - height + 1); + else if (oldCursor === this.previousHeight - 1) { + this.previousViewportTop = Math.max(0, this.previousViewportTop - (height - this.previousHeight)); + } + } + if (this.overlayScreenWidth !== width && this.previousLines.some((line) => + !isImageLine(line) && visibleWidth(line) > Math.min(width, this.overlayScreenWidth))) { + this.overlayMainReflowed = true; + } + this.overlayScreenWidth = width; + this.previousHeight = height; + } + protected doRender(): void { if (this.stopped) return; + // Transient screens must never enter native history, even when the host + // shrinks before delivering its resize notification. Keep them on 1049. + if (!this.renderingMainOnStop && this.hasOverlay()) { + this.renderOverlayScreen(); + return; + } + const returningFromOverlay = this.overlayScreenActive; + if (returningFromOverlay) { + this.updateOverlayMainGeometry(); + this.terminal.write("\x1b[?1049l"); + this.overlayScreenActive = false; + } + const overlayMainReflowed = this.overlayMainReflowed; + this.overlayMainReflowed = false; const width = this.terminal.columns; const height = this.terminal.rows; const widthChanged = this.previousWidth !== 0 && this.previousWidth !== width; const heightChanged = this.previousHeight !== 0 && this.previousHeight !== height; - const previousBufferLength = this.previousHeight > 0 ? this.previousViewportTop + this.previousHeight : height; - let prevViewportTop = heightChanged ? Math.max(0, previousBufferLength - height) : this.previousViewportTop; + // A host shrinks around the hardware cursor, which is usually in the + // composer rather than the final status row. Assuming it sits at the + // bottom skips an uncommitted body row during a resize plus output burst. + const oldScreenCursor = Math.max(0, this.hardwareCursorRow - this.previousViewportTop); + let prevViewportTop = this.previousViewportTop; + if (heightChanged && this.previousHeight > 0) { + if (height < this.previousHeight) prevViewportTop += Math.max(0, oldScreenCursor - height + 1); + else if (oldScreenCursor === this.previousHeight - 1) { + prevViewportTop = Math.max(0, prevViewportTop - (height - this.previousHeight)); + } + } let viewportTop = prevViewportTop; let hardwareCursorRow = this.hardwareCursorRow; const computeLineDiff = (targetRow: number): number => { @@ -303,6 +390,80 @@ export class TuiMainScreen extends TuiBase implements TUI { // Render all components to get new lines. Strip OSC 133 zone sentinels before the // differential compare so they never enter previousLines or any terminal write. let newLines = this.render(width).map((line) => line.replace(OSC133_ZONE_PREFIX, "")); + const segmentLabel = "── Transcript refreshed · earlier output retained ──"; + if (this.segmentHeader) newLines.unshift(segmentLabel.slice(0, width)); + const rootLayout = this.children.length === 1 ? this.children[0]?.getScrollbackLayout?.() : undefined; + const nextScrollbackLayout: ScrollbackLayout | undefined = rootLayout && { + ...rootLayout, + bodyEnd: rootLayout.bodyEnd + Number(this.segmentHeader), + anchors: rootLayout.anchors.map((anchor) => ({ ...anchor, row: anchor.row + Number(this.segmentHeader) })), + }; + const oldScrollbackLayout = this.scrollbackLayout; + let previousBodyEnd = oldScrollbackLayout?.bodyEnd ?? this.previousLines.length; + const currentAnchorIds = new Set(nextScrollbackLayout?.anchors.map(({ id }) => id)); + // A pinned request may survive even when the entire visible tool tail was + // evicted. That is a snapshot boundary, not proof of output continuity. + const currentBlocks = nextScrollbackLayout?.blocks; + const evictedVisibleBlock = !!currentBlocks && oldScrollbackLayout?.anchors.some( + ({ row, blockId }) => row >= prevViewportTop && blockId !== undefined && + !currentBlocks.has(blockId) && nextScrollbackLayout?.containsBlock?.(blockId)); + const replacedTranscript = !!nextScrollbackLayout && !!oldScrollbackLayout?.anchors.length && + (evictedVisibleBlock || !oldScrollbackLayout.anchors.some(({ id }) => currentAnchorIds.has(id))); + let anchored = false; + if (!overlayMainReflowed && !replacedTranscript && nextScrollbackLayout && oldScrollbackLayout && this.viewportLayouts[0]?.component === this.children[0] && prevViewportTop > 0 && + this.previousLines.every((line) => isImageLine(line) || visibleWidth(line) <= width)) { + const currentRows = new Map(nextScrollbackLayout.anchors.map(({ id, row }) => [id, row])); + let match: { before: number; after: number } | undefined; + for (const { id, row } of oldScrollbackLayout.anchors) { + if (row > prevViewportTop || (match && row <= match.before)) continue; + const after = currentRows.get(id); + if (after === undefined) continue; + const text = stripTerminalSequences(this.previousLines[row] ?? ""); + if (!text.trim() || text !== stripTerminalSequences(newLines[after] ?? "")) continue; + match = { before: row, after }; + } + if (match && !nextScrollbackLayout.blocks) { + for (let row = match.before; row < prevViewportTop; row++) { + if (stripTerminalSequences(this.previousLines[row] ?? "") !== stripTerminalSequences(newLines[match.after + row - match.before] ?? "")) { + match = undefined; + break; + } + } + } + if (match) { + // Native history is immutable. Rebase the bounded logical cache at the + // last shared transcript row; projection eviction must not replay it. + const shiftedBodyEnd = match.after + oldScrollbackLayout.bodyEnd - match.before; + const lastPossibleTop = Math.max(nextScrollbackLayout.bodyEnd, newLines.length - height); + let top = Math.min(lastPossibleTop, Math.max(0, match.after + prevViewportTop - match.before)); + // An oversized footer may already occupy native history. Newly appended + // body rows must start before that old footer, not be skipped as history. + if (prevViewportTop > oldScrollbackLayout.bodyEnd && nextScrollbackLayout.bodyEnd > shiftedBodyEnd) { + top = Math.min(top, shiftedBodyEnd); + } + // Keep surviving visible rows and newly introduced blocks addressable. + // A distant anchor must never classify unseen output as native history. + for (const { id, row } of oldScrollbackLayout.anchors) { + const currentRow = currentRows.get(id); + if (row >= prevViewportTop && currentRow !== undefined) top = Math.min(top, currentRow); + } + if (oldScrollbackLayout.blocks) { + for (const { row, blockId } of nextScrollbackLayout.anchors) { + if (blockId !== undefined && !oldScrollbackLayout.blocks.has(blockId)) top = Math.min(top, row); + } + } + const shift = top - prevViewportTop; + this.previousLines = [...this.applyLineResets(newLines.slice(0, top)), ...this.previousLines.slice(prevViewportTop)]; + this.cursorRow += shift; + this.hardwareCursorRow += shift; + this.maxLinesRendered += shift; + previousBodyEnd += shift; + hardwareCursorRow += shift; + prevViewportTop = viewportTop = this.previousViewportTop = top; + anchored = true; + } + } + this.scrollbackLayout = nextScrollbackLayout; const viewportLayouts = this.children.map((component) => ({ component, key: component.getViewportLayoutKey?.(), @@ -313,23 +474,17 @@ export class TuiMainScreen extends TuiBase implements TUI { key !== undefined && component === this.viewportLayouts[index]?.component && key === this.viewportLayouts[index]?.key); this.viewportLayouts = viewportLayouts; - const hadOverlays = this.hadOverlays; this.hadOverlays = this.hasOverlayEntries; - // Composite overlays into the rendered lines (before differential compare) - if (this.hasOverlayEntries) { - newLines = this.compositeOverlays(newLines, width, height); - } - // A native scrollback viewport cannot move backwards without clearing history. - // When only addressable rows shrink, absorb the freed rows at the top of the + // When only addressable rows shrink, absorb the freed space in the active // screen instead. The composer stays at the bottom, historical rows stay unique, // and later output consumes this temporary space before scrolling again. if ( - stableLayout && !hadOverlays && !widthChanged && !heightChanged && !this.historyReplayPending && !this.hasOverlayEntries && + (stableLayout || anchored || !nextScrollbackLayout) && ((!widthChanged && !heightChanged && !this.resizePending) || anchored || (returningFromOverlay && !overlayMainReflowed)) && prevViewportTop > 0 && newLines.length > prevViewportTop && newLines.length < prevViewportTop + height && - this.previousKittyImageIds.size === 0 && !newLines.some(isImageLine) + !this.hasImageAtOrBelow(this.previousLines, prevViewportTop) && !this.hasImageAtOrBelow(newLines, prevViewportTop) ) { let unchangedHistory = true; for (let i = 0; i < prevViewportTop; i++) { @@ -340,12 +495,15 @@ export class TuiMainScreen extends TuiBase implements TUI { } if (unchangedHistory) { const padding = Array(prevViewportTop + height - newLines.length).fill(""); - newLines = [...newLines.slice(0, prevViewportTop), ...padding, ...newLines.slice(prevViewportTop)]; + // Keep a table/code block contiguous. Known document layouts reserve + // the reclaimed space between transcript and footer, not inside prose. + const insertion = nextScrollbackLayout?.bodyEnd ?? prevViewportTop; + newLines = [...newLines.slice(0, insertion), ...padding, ...newLines.slice(insertion)]; } } // Extract cursor position before applying line resets (marker must be found first) - const cursorPos = this.extractCursorPosition(newLines, height); + let cursorPos = this.extractCursorPosition(newLines, height); newLines = this.applyLineResets(newLines); @@ -354,34 +512,49 @@ export class TuiMainScreen extends TuiBase implements TUI { const fullRender = (clear: boolean, viewportOnly = false): void => { // Native scrollback cannot move backwards with a shrinking document. Keep the // previous viewport origin so rows already scrolled out are not painted twice, - // and growth still writes every row before it scrolls out. Resize previews are - // temporary: they show the new tail until the pending full history replay. - if (viewportOnly && !this.historyReplayPending && newLines.length <= prevViewportTop) { + // and growth still writes every row before it scrolls out. + if (viewportOnly && !this.resizePending && newLines.length <= prevViewportTop) { // Nothing remains addressable on screen; rebuild with one consistent origin. viewportOnly = false; } - const start = viewportOnly - ? this.historyReplayPending - ? Math.max(0, newLines.length - height) - : prevViewportTop - : 0; + const start = viewportOnly ? prevViewportTop : 0; + if (clear && !viewportOnly && this.previousLines.length > 0 && (prevViewportTop > 0 || replacedTranscript || overlayMainReflowed) && !this.segmentHeader) { + this.segmentHeader = true; + newLines.unshift(segmentLabel.slice(0, width)); + if (cursorPos) cursorPos = { ...cursorPos, row: cursorPos.row + 1 }; + if (this.scrollbackLayout) this.scrollbackLayout = { + bodyEnd: this.scrollbackLayout.bodyEnd + 1, + blocks: this.scrollbackLayout.blocks, + containsBlock: this.scrollbackLayout.containsBlock, + anchors: this.scrollbackLayout.anchors.map((anchor) => ({ ...anchor, row: anchor.row + 1 })), + }; + } this.fullRedrawCount += 1; const output = new BoundedTerminalWriter((data) => this.terminal.write(data)); output.append("\x1b[?2026h"); // Begin synchronized output if (clear) { - output.append(this.deleteKittyImages(this.previousKittyImageIds)); - if (viewportOnly) { - // ED 2 saves the old screen to scrollback in Apple Terminal. Erase - // each row in place so old transcript/footer rows cannot survive there. - output.append("\x1b[H"); - for (let row = 0; row < height; row++) { - if (row > 0) output.append("\x1b[1B"); - output.append("\x1b[2K"); + // Before starting a new snapshot, commit the old visible transcript. + // Otherwise a projection with no overlap would silently discard rows + // that had been displayed but had not reached native history yet. + const archiveRows = !viewportOnly && (prevViewportTop > 0 || replacedTranscript || overlayMainReflowed) + ? widthChanged || overlayMainReflowed ? height : Math.min(height, Math.max(0, previousBodyEnd - prevViewportTop)) : 0; + if (archiveRows > 0) { + for (let row = archiveRows; row < height; row++) { + output.append(`\x1b[${row + 1};1H\x1b[2K`); } - output.append("\x1b[H"); - } else { - output.append("\x1b[2J\x1b[H\x1b[3J"); + output.append(`\x1b[${height};1H${"\r\n".repeat(archiveRows)}`); + } + // Native-history images belong to the retained output snapshot. + // Free only images whose headers are still addressable on screen. + output.append(this.deleteKittyImages(this.collectKittyImageIds(this.previousLines.slice(prevViewportTop + archiveRows)))); + // ED 2 may save the old screen on some hosts; ED 3 destroys shell + // history. Erase addressable rows in place for every redraw. + output.append("\x1b[H"); + for (let row = 0; row < height; row++) { + if (row > 0) output.append("\x1b[1B"); + output.append("\x1b[2K"); } + output.append("\x1b[H"); } for (let i = start; i < newLines.length; i++) { if (i > start) output.append("\r\n"); @@ -413,7 +586,6 @@ export class TuiMainScreen extends TuiBase implements TUI { output.append("\x1b[?2026l"); // Present only after restoring the input cursor. output.flush(); this.previousLines = newLines; - this.previousKittyImageIds = this.collectKittyImageIds(newLines); this.previousWidth = width; this.previousHeight = height; }; @@ -427,10 +599,22 @@ export class TuiMainScreen extends TuiBase implements TUI { fs.appendFileSync(logPath, msg); }; - if (this.historyReplayPending) { - const viewportOnly = this.resizeTimer !== undefined; - fullRender(true, viewportOnly); - if (!viewportOnly) this.historyReplayPending = false; + // Preserve the emitted prefix only when it can still be aligned. A genuine + // reflow without a safe anchor starts a labelled snapshot rather than dropping + // pending output by painting only the final screenful. + const resizeOriginPreserved = !overlayMainReflowed && (anchored || this.previousLines.slice(0, prevViewportTop).every((line, row) => + visibleWidth(line) <= width && stripTerminalSequences(line) === stripTerminalSequences(newLines[row] ?? ""))); + if (replacedTranscript) { + fullRender(true); + return; + } + if (returningFromOverlay) { + fullRender(true, resizeOriginPreserved); + return; + } + if (this.resizePending) { + fullRender(true, resizeOriginPreserved); + if (this.resizeTimer === undefined) this.resizePending = false; return; } @@ -444,7 +628,7 @@ export class TuiMainScreen extends TuiBase implements TUI { // Width changes always need a full re-render because wrapping changes. if (widthChanged) { logRedraw(`terminal width changed (${this.previousWidth} -> ${width})`); - fullRender(true); + fullRender(true, this.previousLines.length > 0 && resizeOriginPreserved); return; } @@ -453,7 +637,7 @@ export class TuiMainScreen extends TuiBase implements TUI { // In that environment, a full redraw causes the entire history to replay on every toggle. if (heightChanged && !isTermuxSession()) { logRedraw(`terminal height changed (${this.previousHeight} -> ${height})`); - fullRender(true); + fullRender(true, this.previousLines.length > 0 && resizeOriginPreserved); return; } @@ -555,7 +739,6 @@ export class TuiMainScreen extends TuiBase implements TUI { output.flush(); } this.previousLines = newLines; - this.previousKittyImageIds = this.collectKittyImageIds(newLines); this.previousWidth = width; this.previousHeight = height; this.previousViewportTop = prevViewportTop; @@ -731,7 +914,6 @@ export class TuiMainScreen extends TuiBase implements TUI { output.flush(); this.previousLines = newLines; - this.previousKittyImageIds = this.collectKittyImageIds(newLines); this.previousWidth = width; this.previousHeight = height; } diff --git a/packages/tui/src/tui/engine/tui.ts b/packages/tui/src/tui/engine/tui.ts index babec4717..98aa704d1 100644 --- a/packages/tui/src/tui/engine/tui.ts +++ b/packages/tui/src/tui/engine/tui.ts @@ -19,6 +19,17 @@ import { extractSegments, normalizeTerminalOutput, sliceByColumn, sliceWithWidth /** * Component interface - all components must implement this */ +export interface ScrollbackLayout { + /** Stable row identities in the last rendered document (never inferred from text). */ + readonly anchors: readonly { readonly id: string; readonly row: number; readonly blockId?: string }[]; + /** Source blocks still present, including blocks whose current view has no rows. */ + readonly blocks?: ReadonlySet; + /** Test current source membership to distinguish projection eviction from deletion. */ + containsBlock?(id: string): boolean; + /** First footer row. Rows before this belong to the transcript. */ + readonly bodyEnd: number; +} + export interface Component { /** * Render the component to lines for the given viewport width @@ -30,10 +41,14 @@ export interface Component { /** * Opt into preserving native scrolling when only background content shrinks. * Return a key for the last rendered transient layout (menus, editor, banners). - * A changed or missing key restores exposed document rows instead of padding. + * Anchored document layouts additionally preserve their emitted history across + * transient changes; unclassified layouts require an unchanged historical prefix. */ getViewportLayoutKey?(): string | undefined; + /** Opt into immutable native history with an editable, anchored viewport. */ + getScrollbackLayout?(): ScrollbackLayout | undefined; + /** * Optional handler for keyboard input when component has focus */ @@ -347,6 +362,8 @@ export abstract class TuiBase extends Container implements TUI { /** Global callback for debug key (Shift+Ctrl+D). Called before input is forwarded to focused component. */ public onDebug?: () => void; private renderRequested = false; + private hasRenderedFrame = false; + private outputDrainPending = false; private immediateRenderScheduled = false; private renderTimer: NodeJS.Timeout | undefined; private lastRenderAt = 0; @@ -778,7 +795,7 @@ export abstract class TuiBase extends Container implements TUI { this.renderRequested = false; this.cancelRenderTimer(); this.lastRenderAt = performance.now(); - this.doRender(); + this.renderFrame(); } requestRender(force = false): void { @@ -805,10 +822,35 @@ export abstract class TuiBase extends Container implements TUI { this.cancelRenderTimer(); this.renderRequested = false; this.lastRenderAt = performance.now(); - this.doRender(); + this.renderFrame(); }); } + protected hasPendingRender(): boolean { + return this.renderRequested || this.outputDrainPending; + } + + private renderFrame(): void { + // The initial frame may follow startup controls. Later frames wait for the + // previous output and render only the latest model, rather than queueing + // obsolete history replays while an SSH peer is slow or paused. + if (this.hasRenderedFrame && this.terminal.outputPending && this.terminal.drainOutput) { + if (!this.outputDrainPending) { + this.outputDrainPending = true; + void this.terminal.drainOutput().then(() => { + this.outputDrainPending = false; + if (!this.stopped) this.requestRender(); + }, () => { + // ProcessTerminal reports the failure through stdout's error event. + this.outputDrainPending = false; + }); + } + return; + } + this.doRender(); + this.hasRenderedFrame = true; + } + private cancelRenderTimer(): void { if (!this.renderTimer) return; clearTimeout(this.renderTimer); @@ -828,7 +870,7 @@ export abstract class TuiBase extends Container implements TUI { } this.renderRequested = false; this.lastRenderAt = performance.now(); - this.doRender(); + this.renderFrame(); if (this.renderRequested) { this.scheduleRender(); } diff --git a/packages/tui/src/tui/launcher.ts b/packages/tui/src/tui/launcher.ts index 4d6bb905a..50fee9b03 100644 --- a/packages/tui/src/tui/launcher.ts +++ b/packages/tui/src/tui/launcher.ts @@ -410,6 +410,7 @@ export async function launchTui( homeDir: homeDirectory, terminal, tuiMode, + clearScrollbackOnStart: Boolean(options.sessionId?.trim() || options.continueLatestSession), persistTuiMode: (mode) => (dependencies.writeTuiMode ?? writeTuiModeSetting)(dataDir, mode), ...(theme ? { theme } : {}), persistTheme: (value) => diff --git a/packages/tui/src/tui/platform/observed-terminal.ts b/packages/tui/src/tui/platform/observed-terminal.ts index d5ecf0fa2..56aa0a900 100644 --- a/packages/tui/src/tui/platform/observed-terminal.ts +++ b/packages/tui/src/tui/platform/observed-terminal.ts @@ -33,6 +33,10 @@ export function createObservedTerminal( get kittyProtocolActive() { return terminal.kittyProtocolActive; }, + get outputPending() { + return terminal.outputPending; + }, + drainOutput: () => terminal.drainOutput?.() ?? Promise.resolve(), get focused() { return terminal.focused; }, diff --git a/packages/tui/src/tui/renderer/interactive-renderer.ts b/packages/tui/src/tui/renderer/interactive-renderer.ts index 7d0fc1c7f..061299283 100644 --- a/packages/tui/src/tui/renderer/interactive-renderer.ts +++ b/packages/tui/src/tui/renderer/interactive-renderer.ts @@ -15,6 +15,7 @@ import { captureTuiIncidentBestEffort, type TuiIncidentSink } from '../../observ export interface McodeInteractiveRendererOptions { readonly terminal: Terminal; readonly initialMode?: TuiMode; + readonly clearScrollbackOnStart?: boolean; readonly showHardwareCursor?: boolean; readonly logDirectory?: string; readonly altScreen?: TuiAltScreenOptions; @@ -87,6 +88,10 @@ export class McodeInteractiveRenderer { this.bindInputListeners(); if (this.renderer.mode === 'regular' && !this.initialRegularViewportCleared) { this.options.terminal.clearScreen(); + // A resumed process replays its own banner and transcript. Discard the old + // process's native history first, including viewport rows saved by ED 2 on + // hosts such as Apple Terminal. Ordinary launches retain shell scrollback. + if (this.options.clearScrollbackOnStart) this.options.terminal.write('\x1b[3J'); } this.renderer.start(); if (this.renderer.mode === 'regular') this.initialRegularViewportCleared = true; diff --git a/packages/tui/src/tui/shell/chat-layout.ts b/packages/tui/src/tui/shell/chat-layout.ts index a8c4df225..c872ae096 100644 --- a/packages/tui/src/tui/shell/chat-layout.ts +++ b/packages/tui/src/tui/shell/chat-layout.ts @@ -2,6 +2,7 @@ import { LAYOUT_NODE, ScrollView, type Terminal, + type ScrollbackLayout, type TuiMouseEvent, VStack, } from '../engine/public.js'; @@ -41,6 +42,12 @@ interface MouseAwareComponent extends Component { } export class TuiChatLayout implements Component { + private scrollbackLayout: ScrollbackLayout | undefined; + + getScrollbackLayout(): ScrollbackLayout | undefined { + return this.scrollbackLayout; + } + private viewportLayoutKey: string | undefined; getViewportLayoutKey(): string | undefined { @@ -176,6 +183,7 @@ export class TuiChatLayout implements Component { } render(width: number): string[] { + this.scrollbackLayout = undefined; const frame = resolveChatFrame(width, this.terminal.rows); const surface = this.getSurface(); const renderPart = (component: Component): readonly string[] => @@ -242,7 +250,7 @@ export class TuiChatLayout implements Component { // section participates here so new controls cannot silently leave blank rows. const viewportLayout = [ surface, interactionActive, interaction.length, composer.length, followUp.length, - goal.length, notice.length, tasks.length, status.length, + goal.length, notice.length, status.length, ]; if (surface === 'welcome') { if (interactionActive) { @@ -294,6 +302,16 @@ export class TuiChatLayout implements Component { this.viewportLayoutKey = JSON.stringify([...viewportLayout, welcome.length]); const prelude = joinWelcomeAndTranscript(welcome, transcript); const bodyEntries = [prelude, transcript.length > 0 ? [''] : []]; + const transcriptLayout = this.parts.transcript.getScrollbackLayout?.(); + if (this.viewport() === 'document' && transcriptLayout) { + const offset = prelude.length - transcript.length; + this.scrollbackLayout = { + blocks: transcriptLayout.blocks, + containsBlock: transcriptLayout.containsBlock, + anchors: transcriptLayout.anchors.map((anchor) => ({ ...anchor, row: anchor.row + offset })), + bodyEnd: bodyEntries.reduce((sum, lines) => sum + lines.length, 0), + }; + } return this.fitDocumentFrame([...bodyEntries, ...footerEntries]); } diff --git a/packages/tui/src/tui/shell/surface-host.ts b/packages/tui/src/tui/shell/surface-host.ts index a13f18df8..0e7682844 100644 --- a/packages/tui/src/tui/shell/surface-host.ts +++ b/packages/tui/src/tui/shell/surface-host.ts @@ -220,6 +220,10 @@ export class TuiSurfaceHost implements Component { else this.chatComponent.invalidate(); } + getScrollbackLayout() { + return this.currentMode() === 'regular' ? this.chatComponent.getScrollbackLayout?.() : undefined; + } + getViewportLayoutKey(): string | undefined { const key = this.chatComponent.getViewportLayoutKey?.(); return key === undefined ? undefined : JSON.stringify([this.activeFeature()?.screen.id, key]); diff --git a/packages/tui/src/tui/transcript/presentation/content.ts b/packages/tui/src/tui/transcript/presentation/content.ts index 390dd86e8..8e0388ff6 100644 --- a/packages/tui/src/tui/transcript/presentation/content.ts +++ b/packages/tui/src/tui/transcript/presentation/content.ts @@ -74,7 +74,7 @@ function transcriptCellTitle(cell: TranscriptCell): string { if (cell.kind === 'compaction') return cell.title ?? 'Context compacted'; if (cell.kind === 'usage') return 'Usage'; if (cell.kind === 'inspection') return cell.inspection?.title ?? cell.title ?? 'Inspection'; - if (cell.kind === 'warning') return 'Warning'; + if (cell.kind === 'warning') return sanitizeTerminalText(cell.title ?? 'Warning'); if (cell.kind === 'error') return 'Error'; if (cell.kind === 'diff') return 'Changes'; if (cell.kind === 'final-summary') return 'Summary'; diff --git a/packages/tui/src/tui/transcript/view.ts b/packages/tui/src/tui/transcript/view.ts index 2d263bd50..534530c35 100644 --- a/packages/tui/src/tui/transcript/view.ts +++ b/packages/tui/src/tui/transcript/view.ts @@ -48,12 +48,21 @@ const TOOL_ROW_CONNECTOR_WIDTH = 2; const MIN_SHELL_COMMAND_WIDTH = 12; export class TranscriptView implements Component { + private scrollbackAnchors: { id: string; row: number; blockId: string }[] = []; + private renderedBlockIds: ReadonlySet = new Set(); + private sourceContainsBlock: ((id: string) => boolean) | undefined; + + getScrollbackLayout() { + return { anchors: this.scrollbackAnchors, blocks: this.renderedBlockIds, containsBlock: this.sourceContainsBlock, bodyEnd: this.frameCache?.lines.length ?? 0 }; + } + private readonly unitCache = new Map< string, { signature: string; width: number; lines: readonly string[]; + anchors: readonly { id: string; row: number; blockId: string }[]; } >(); private readonly markdownComponents = new Map(); @@ -132,6 +141,13 @@ export class TranscriptView implements Component { ? undefined : (sourceCells as TranscriptProjectionSource); const sourceRevision = source?.revision; + let sourceIds: Set | undefined; + this.sourceContainsBlock = source + ? (id) => source.locateCell(id) !== undefined + : (id) => { + sourceIds ??= new Set((sourceCells as readonly TranscriptCell[]).map((cell) => cell.id)); + return sourceIds.has(id); + }; if (this.frameCache && this.frameCache.source !== source) this.invalidate(); const presentationRevision = this.displayModes.revision; if ( @@ -169,14 +185,19 @@ export class TranscriptView implements Component { ' ', ] : []; + this.scrollbackAnchors = []; + this.renderedBlockIds = new Set(projection.cells.map((cell) => cell.id)); units.forEach((unit, index) => { const next = units[index + 1]; const key = renderUnitKey(unit); activeUnitKeys.add(key); const rendered = this.renderUnit(unit, next, normalizedWidth, unitSignatures[index] ?? ''); const connectedToNext = isConnectedRenderUnit(unit, next); - lines.push(...rendered); - if (next && rendered.length > 0 && !connectedToNext && !isPendingSteerUnit(next)) { + for (const anchor of rendered.anchors) { + this.scrollbackAnchors.push({ ...anchor, row: lines.length + anchor.row }); + } + lines.push(...rendered.lines); + if (next && rendered.lines.length > 0 && !connectedToNext && !isPendingSteerUnit(next)) { lines.push(' '); } }); @@ -232,7 +253,7 @@ export class TranscriptView implements Component { next: TranscriptRenderUnit | undefined, width: number, dataSignature: string, - ): readonly string[] { + ): { lines: readonly string[]; anchors: readonly { id: string; row: number; blockId: string }[] } { const connectedToNext = isConnectedRenderUnit(unit, next); const key = renderUnitKey(unit); const presentationSignature = this.presentationSignature(unit); @@ -242,16 +263,17 @@ export class TranscriptView implements Component { const cached = this.unitCache.get(key); if (cached?.width === width && cached.signature === signature) { this.frameUnitCacheHits += 1; - return cached.lines; + return cached; } this.frameUnitCacheMisses += 1; + const anchors: { id: string; row: number; blockId: string }[] = []; const content = unit.kind === 'cell' && (unit.cell.kind === 'assistant' || unit.cell.kind === 'assistant-preamble') ? this.renderAssistant(unit.cell, width) : unit.kind === 'read-group' ? renderReadGroup(unit.cells, width, connectedToNext, (cell) => - this.resolveDisplayMode(cell), + this.resolveDisplayMode(cell), anchors, ) : renderCell( unit.cell, @@ -261,12 +283,18 @@ export class TranscriptView implements Component { this.workspaceDir, ); const rendered = content; + if (unit.kind === 'cell' && !unit.cell.id.startsWith('projection-fold:')) { + for (let row = 0; row < rendered.length; row += 1) { + anchors.push({ id: JSON.stringify([key, row]), row, blockId: unit.cell.id }); + } + } this.unitCache.set(key, { signature, width, lines: rendered, + anchors, }); - return rendered; + return { lines: rendered, anchors }; } private renderAssistant(cell: TranscriptCell, width: number): readonly string[] { @@ -402,6 +430,7 @@ function renderReadGroup( width: number, connectedToNext: boolean, resolveDisplayMode: (cell: TranscriptCell) => ReturnType, + anchors: { id: string; row: number; blockId: string }[], ): string[] { const entries = groupedToolEntries(cells); const running = entries.filter( @@ -443,6 +472,7 @@ function renderReadGroup( const displayMode = resolveDisplayMode(cell); if (displayMode === 'collapsed') return; + const start = lines.length; const isLast = index === entries.length - 1; const tail = cell.status === 'pending' || cell.status === 'running' @@ -484,6 +514,9 @@ function renderReadGroup( )), ); } + for (let row = start; row < lines.length; row += 1) { + anchors.push({ id: JSON.stringify([`cell:${cell.id}`, row - start]), row, blockId: cell.id }); + } }); return lines; } @@ -726,9 +759,9 @@ function renderCell( } if (cell.kind === 'warning') { - return [ - ...new Text(chalk.hex(colors.warning)(`! Warning ${cell.content}`), 2, 0).render(width), - ]; + const title = sanitizeTerminalText(cell.title ?? 'Warning'); + const content = sanitizeTerminalText(cell.content); + return [...new Text(chalk.hex(colors.warning)(`! ${title} ${content}`), 2, 0).render(width)]; } return [...new Text(cell.content, 2, 0).render(width)]; diff --git a/packages/tui/src/types/tui-app.ts b/packages/tui/src/types/tui-app.ts index 1b41b4be7..ba6d991d6 100644 --- a/packages/tui/src/types/tui-app.ts +++ b/packages/tui/src/types/tui-app.ts @@ -37,6 +37,8 @@ export interface CreateTuiAppOptions extends TuiUpdateOptions { terminal?: Terminal; terminalCapabilities?: TerminalCapabilities; tuiMode?: TuiMode; + /** Rebuild terminal history when restoring a Session at process startup. */ + clearScrollbackOnStart?: boolean; persistTuiMode?: (mode: TuiMode) => void; /** Saved theme selection, e.g. `aurora` or `aurora/dark`. */ theme?: string; diff --git a/packages/tui/test/unit/tui-app.test.ts b/packages/tui/test/unit/tui-app.test.ts index 04710bccd..b9958b6a4 100644 --- a/packages/tui/test/unit/tui-app.test.ts +++ b/packages/tui/test/unit/tui-app.test.ts @@ -200,6 +200,10 @@ class FakeTerminal implements Terminal { // Apple Terminal can save ED 2 clears into scrollback before the renderer clears history. class ClearToScrollbackTerminal extends VirtualTerminal { + override clearScreen(): void { + this.write("\x1b[2J\x1b[H"); + } + override write(data: string): void { super.write(data.replaceAll("\x1b[2J", `\x1b[${this.rows};1H${"\r\n".repeat(this.rows)}\x1b[2J`)); } @@ -1529,6 +1533,25 @@ describe("createTuiApp", () => { ); }); + it.each(["stop", "suspend"] as const)("waits for output cleanup before %s completes", async (operation) => { + let release: () => void = () => {}; + const flushed = new Promise((resolve) => { release = resolve; }); + const drainOutput = vi.fn(() => flushed); + const terminal = Object.assign(new FakeTerminal(), { drainOutput }); + const app = createTuiApp({ runtime: createRuntime(), terminal, version: "0.1.0", workspaceDir: "/workspace" }); + app.start(); + await app.ready; + let finished = false; + const result = app[operation]().then(() => { finished = true; }); + await vi.waitFor(() => expect(drainOutput).toHaveBeenCalledOnce()); + expect(finished).toBe(false); + expect(terminal.stopped).toBe(true); + release(); + await result; + expect(finished).toBe(true); + await app.stop(); + }); + it("shows the Runtime initialization error text when the Session catalog is unavailable", async () => { const runtime = createRuntime(); vi.mocked(runtime.listSessions).mockRejectedValue( @@ -3149,8 +3172,10 @@ describe("createTuiApp", () => { await terminal.flush(); expect(app.editor.getText()).toBe(draft); expect(app.editor.render(78).length).toBeLessThan(menuRows); - const expected = app.tui.render(80).map((line) => stripAnsi(line).trimEnd()).slice(-24); - expect(terminal.getViewport().map((line) => line.trimEnd())).toEqual(expected); + // Released footer rows remain blank instead of replaying native history. + const visible = terminal.getViewport().map((line) => line.trimEnd()).filter((line) => line.trim()); + const document = app.tui.render(80).map((line) => stripAnsi(line).trimEnd()).filter((line) => line.trim()); + expect(visible).toEqual(document.slice(-visible.length)); }); const history = terminal.getScrollBuffer(); for (let index = 0; index < 12; index++) { @@ -4432,6 +4457,66 @@ describe("createTuiApp", () => { await app.stop(); }); + it.each(["/retry", "Continue the task"])( + "dismisses a previous terminal failure when %s succeeds and history refreshes", + async (submission) => { + const terminal = new FakeTerminal(); + const runtime = createRuntime(); + let handled = false; + vi.mocked(runtime.watchEvents).mockImplementation(async function* (signal) { + yield runtimeEvent({ + type: "session.error", + timestamp: Date.now(), + source: "runtime", + payload: { + sessionId: "session-1", + turnId: "failed-turn", + error: "provider unavailable", + errorCode: 50151, + }, + }); + handled = true; + await new Promise((resolve) => + signal.addEventListener("abort", () => resolve(), { once: true }), + ); + }); + const app = createTuiApp({ + runtime, + terminal, + version: "0.1.0", + workspaceDir: "/workspace", + }); + try { + await app.ready; + await app.openSession("session-1"); + app.start(); + await vi.waitFor(() => expect(handled).toBe(true)); + expect(app.tui.render(100).join("\n")).toContain("Code: 50151"); + // The failure must survive reconciliation until another run starts. + await app.controller.refreshCurrentSessionHistory(); + expect(app.tui.render(100).join("\n")).toContain("Run /retry"); + + await app.submit(submission); + vi.mocked(runtime.getMessages).mockResolvedValue([ + { + id: "recovered-answer", + role: "assistant", + content: "Hello from the Agent", + turnId: "recovered-turn", + }, + ]); + await app.controller.refreshCurrentSessionHistory(); + const rendered = app.tui.render(100).join("\n"); + expect(rendered).toContain("Hello from the Agent"); + expect(rendered).not.toContain("Code: 50151"); + expect(rendered).not.toContain("Run /retry"); + expect(app.transcript.latestSettledFailure()).toBeUndefined(); + } finally { + await app.stop(); + } + }, + ); + it("uses Runtime retry-continuation when a terminal failure has no local submission snapshot", async () => { const terminal = new FakeTerminal(); const runtime = createRuntime(); @@ -6314,8 +6399,9 @@ describe("createTuiApp", () => { app.tui.renderNow(); await terminal.flush(); expect(app.interaction.isActive()).toBe(false); - const expected = app.tui.render(80).map((line) => stripAnsi(line).trimEnd()).slice(-24); - expect(terminal.getViewport().map((line) => line.trimEnd())).toEqual(expected); + const visible = terminal.getViewport().map((line) => line.trimEnd()).filter((line) => line.trim()); + const document = app.tui.render(80).map((line) => stripAnsi(line).trimEnd()).filter((line) => line.trim()); + expect(visible).toEqual(document.slice(-visible.length)); for (let index = 0; index < 12; index++) { expect(terminal.getScrollBuffer().filter((line) => line.trimEnd().endsWith(`› Message ${index}`))).toHaveLength(1); } @@ -6348,8 +6434,9 @@ describe("createTuiApp", () => { await terminal.flush(); if (kind === "multiline draft") expect(app.editor.getText()).toBe(""); else expect(app.editor.getAttachmentPreview()).toBeUndefined(); - const expected = app.tui.render(80).map((line) => stripAnsi(line).trimEnd()).slice(-24); - expect(terminal.getViewport().map((line) => line.trimEnd())).toEqual(expected); + const visible = terminal.getViewport().map((line) => line.trimEnd()).filter((line) => line.trim()); + const document = app.tui.render(80).map((line) => stripAnsi(line).trimEnd()).filter((line) => line.trim()); + expect(visible).toEqual(document.slice(-visible.length)); } finally { await app.stop(); } @@ -6400,7 +6487,7 @@ describe("createTuiApp", () => { expect(viewport).toContain("Message 1"); expect(viewport).toContain("Message"); expect(app.tui.render(80).findIndex((line) => line.trim().length > 0)).toBe(firstContentRowBefore); - expect(terminal.getViewport().findIndex((line) => line.trim().length > 0)).toBe(firstContentRowBefore); + expect(terminal.getViewport().findIndex((line) => line.trim().length > 0)).toBeLessThanOrEqual(firstContentRowBefore); } finally { await app.stop(); } @@ -11981,7 +12068,7 @@ describe("createTuiApp", () => { terminal.write = (data) => { write(data); screen.feed(data); - if (data.includes("\x1b[?2026l")) frames.push(screen.text()); + if (data.includes("\x1b[?2026l")) frames.push(screen.viewportText()); }; const runtime = createRuntime(); const busEvents: TuiRuntimeEvent[] = []; @@ -12094,7 +12181,7 @@ describe("createTuiApp", () => { ); expect(app.editor.getText()).toBe(""); app.tui.renderNow(); - expect(screen.text()).toContain("Interrupted after"); + expect(screen.viewportText()).toContain("Interrupted after"); terminal.input?.("\x1b"); terminal.input?.("\x1b"); @@ -12102,12 +12189,12 @@ describe("createTuiApp", () => { expect(app.editor.getText()).toBe("Original query"), ); app.tui.renderNow(); - expect(screen.text()).not.toContain("Interrupted after"); + expect(screen.viewportText()).not.toContain("Interrupted after"); if (action === "cancel") { terminal.input?.("\x1b"); app.tui.renderNow(); - expect(screen.text()).toContain("Interrupted after"); + expect(screen.viewportText()).toContain("Interrupted after"); expect(runtime.editSessionMessage).not.toHaveBeenCalled(); return; } @@ -12126,9 +12213,9 @@ describe("createTuiApp", () => { action === "failed-submit-cancel" || action === "new-operation-cancel" ) { - expect(screen.text()).toContain("Interrupted after"); + expect(screen.viewportText()).toContain("Interrupted after"); } else { - expect(screen.text()).not.toContain("Interrupted after"); + expect(screen.viewportText()).not.toContain("Interrupted after"); } return; } @@ -12143,16 +12230,16 @@ describe("createTuiApp", () => { if (action === "early-rewind") { // Inspect the actual screen while the edit RPC is still pending. expect(finishEdit).toBeDefined(); - expect(screen.text()).not.toContain("Interrupted after"); + expect(screen.viewportText()).not.toContain("Interrupted after"); finishEdit?.(); await vi.waitFor(() => expect(app.editor.getText()).toBe("")); app.tui.renderNow(); } expect(runtime.editSessionMessage).toHaveBeenCalledOnce(); - expect(screen.text()).toContain("Edited query"); - expect(screen.text()).not.toContain("Interrupted after"); + expect(screen.viewportText()).toContain("Edited query"); + expect(screen.viewportText()).not.toContain("Interrupted after"); if (action === "fast-completion") - expect(screen.text()).toContain("Completed in 2s"); + expect(screen.viewportText()).toContain("Completed in 2s"); expect( frames.filter((frame) => frame.includes("Interrupted after")), ).toEqual([]); @@ -14288,7 +14375,7 @@ describe("createTuiApp", () => { }); it.each([1, 100])( - "settles an auto-drained follow-up of %i lines with unique terminal history", + "settles an auto-drained follow-up of %i lines with current viewport and retained history", async (lineCount) => { const queuedText = Array.from( { length: lineCount }, @@ -14382,7 +14469,9 @@ describe("createTuiApp", () => { .map((line) => line.trimEnd()) .join("\n") .trimEnd(); - expect(screen.text()).toBe(expected); + const visible = screen.viewportText().split("\n").filter((line) => line.trim()); + const current = expected.split("\n").filter((line) => line.trim()); + expect(visible).toEqual(current.slice(-visible.length)); }; app.start(); try { @@ -14836,6 +14925,85 @@ describe("createTuiApp", () => { }); }); +describe.each([ + ["xterm", VirtualTerminal], + ["clear-to-scrollback terminal", ClearToScrollbackTerminal], +] as const)("startup banner anchor on %s", (_name, Terminal) => { + it.each([ + ["explicit session", { sessionId: "existing" }, "regular", 2], + ["continue", { continueLatestSession: true }, "regular", 2], + ["long session", { sessionId: "existing" }, "regular", 20], + ["new session", {}, "regular", 0], + ["fullscreen session", { sessionId: "existing" }, "fullscreen", 2], + ] as const)("rebuilds %s without stale transcript above the banner", async (_scenario, resume, mode, count) => { + const dataDir = await mkdtemp(join(tmpdir(), "mcode-banner-startup-")); + const terminal = new Terminal(100, 50); + // Model the inherited terminal after the old process exits: both native + // scrollback and the viewport still contain the previous conversation. + terminal.write(Array.from({ length: 80 }, (_, i) => `OLD TRANSCRIPT ${i}\r\n`).join("")); + await terminal.flush(); + const runtime = createRuntime(); + runtime.listBackgroundTasks = vi.fn(async () => []); + vi.mocked(runtime.listSessions).mockResolvedValue([ + { sessionId: "existing", workspaceDir: "/workspace", updatedAt: 10 }, + ]); + const messages = Array.from({ length: count }, (_, i) => ({ + id: `restored-${i}`, role: "user" as const, content: `Restored message ${i}`, + })); + vi.mocked(runtime.getMessages).mockResolvedValue(messages); + vi.mocked(runtime.listMessagePage).mockResolvedValue({ messages, hasMore: false }); + let app: ReturnType | undefined; + const running = launchTui( + { version: "0.5.7", dataDir, terminal, workspaceDir: "/workspace", tuiMode: mode, ...resume }, + { + createObservability, + readTelemetryEnabled: () => false, + installProcessGuards: () => () => undefined, + loadRuntimeLifecycle: async () => ({ + createTuiRuntime: async () => ({ adapter: runtime }) as never, + shutdownTuiRuntime: async () => false, + }), + loadUpdateApplication: async () => + ({ inspect: async () => ({ status: "up-to-date" }) }) as never, + createApp: (options) => { + app = createTuiApp({ ...options, productFeatures: { queue: false } }); + return app; + }, + writeExitMessage: () => undefined, + }, + ); + try { + await vi.waitFor(() => expect(app?.editor.disableSubmit).toBe(false)); + app!.tui.renderNow(); + await terminal.flush(); + const history = terminal.getScrollBuffer().join("\n"); + if (mode === "regular" && count > 0) { + expect(history).not.toContain("OLD TRANSCRIPT"); + expect(history.match(/Tips for getting started/gu)).toHaveLength(1); + const banner = history.indexOf("Tips for getting started"); + for (let i = 0; i < count; i++) { + const line = `› Restored message ${i}`; + expect(terminal.getScrollBuffer().filter((row) => row.trim() === line), line).toHaveLength(1); + expect(history.indexOf(line)).toBeGreaterThan(banner); + } + expect(app!.controller.snapshot().session?.sessionId).toBe("existing"); + } else if (mode === "regular") { + expect(history).toContain("OLD TRANSCRIPT 0"); + } + // Alternate-screen startup must leave the underlying shell buffer alone. + if (mode === "fullscreen") { + await app!.suspend(); + await terminal.flush(); + expect(terminal.getScrollBuffer().join("\n")).toContain("OLD TRANSCRIPT 0"); + } + } finally { + await app?.stop(); + await running; + await rm(dataDir, { recursive: true, force: true }); + } + }); +}); + describe("interactive CLI model startup", () => { it.each([ [ diff --git a/packages/tui/test/unit/tui-background-work-panel.test.ts b/packages/tui/test/unit/tui-background-work-panel.test.ts index 592553fea..866165ae3 100644 --- a/packages/tui/test/unit/tui-background-work-panel.test.ts +++ b/packages/tui/test/unit/tui-background-work-panel.test.ts @@ -375,6 +375,7 @@ describe('Tasks in the regular terminal viewport', () => { expect(terminal.getViewport()).toEqual( afterShrink, ); + const historyAfterShrink = terminal.getScrollBuffer(); const presenter = new TuiOverlayRegularFeaturePresenter(terminal, tui, () => tui.requestRender(), ); @@ -387,10 +388,8 @@ describe('Tasks in the regular terminal viewport', () => { handle.close(); tui.renderNow(); await terminal.flush(); - expect(terminal.getViewport()).toEqual( - [...chatLines, 'COMPOSER', 'STATUS'].slice(-terminal.rows), - ); - expect(terminal.getScrollBuffer()).toEqual([...chatLines, 'COMPOSER', 'STATUS']); + expect(terminal.getViewport()).toEqual(afterShrink); + expect(terminal.getScrollBuffer()).toEqual(historyAfterShrink); handle = presenter.show(panel, panel); tui.setFocus(handle.focus); tui.renderNow(); @@ -428,8 +427,13 @@ describe('Tasks in the regular terminal viewport', () => { await terminal.flush(); expect(terminal.getScrollBuffer().join('\n')).not.toContain('task-row-'); }); - for (const line of chatLines) - expect(terminal.getScrollBuffer().filter((row) => row === line)).toHaveLength(1); + const history = terminal.getScrollBuffer(); + const boundary = history.findLastIndex((line) => line.startsWith('── Transcript refreshed')); + const currentSnapshot = history.slice(boundary + 1).filter(Boolean); + expect(currentSnapshot).toEqual([...chatLines, 'COMPOSER', 'STATUS']); + if (shrinkRows === 15) { + expect(history.slice(0, boundary)).toEqual(historyAfterShrink.slice(0, boundary)); + } expect(terminal.getViewport().join('\n')).toContain('STATUS'); } finally { tui.stop(); diff --git a/packages/tui/test/unit/tui-chat-controller.test.ts b/packages/tui/test/unit/tui-chat-controller.test.ts index e2723eb38..674214422 100644 --- a/packages/tui/test/unit/tui-chat-controller.test.ts +++ b/packages/tui/test/unit/tui-chat-controller.test.ts @@ -348,6 +348,37 @@ describe('TuiChatController', () => { await expect(sending).resolves.toBe('succeeded'); }); + it('dismisses only older turn-scoped ephemeral errors when a Runtime turn starts', () => { + const transcript = new TranscriptStore(); + for (const cell of [ + { id: 'old-terminal-error', turnId: 'old-turn', ephemeral: true }, + { id: 'current-terminal-error', turnId: 'current-turn', ephemeral: true }, + { id: 'local-action-error', ephemeral: true }, + { id: 'historical-error', turnId: 'old-turn' }, + ]) { + transcript.upsert({ + ...cell, + kind: 'error', + status: 'failed', + content: cell.id, + createdAtMs: 1, + }); + } + const controller = new ProductionTuiChatController({ + runtime: {} as never, + transcript, + workspaceDir: '/workspace', + }); + + controller.beginRuntimeTurn('current-turn', 2); + + expect(transcript.snapshot().map((cell) => cell.id)).toEqual([ + 'current-terminal-error', + 'local-action-error', + 'historical-error', + ]); + }); + it('keeps the previous duration while projecting a steer for the current turn', () => { const transcript = new TranscriptStore(); transcript.upsert({ diff --git a/packages/tui/test/unit/tui-engine-local-deltas.test.ts b/packages/tui/test/unit/tui-engine-local-deltas.test.ts index 6d17679e9..556aa57ab 100644 --- a/packages/tui/test/unit/tui-engine-local-deltas.test.ts +++ b/packages/tui/test/unit/tui-engine-local-deltas.test.ts @@ -146,7 +146,7 @@ describe('MCode Pi Engine local deltas', () => { tui.renderNow(); await terminal.flush(); const after = terminal.getScrollBuffer(); - expect(after).toEqual(original); + expect(after.filter((line) => line.trim())).toEqual(original.filter((line) => line.trim())); expect(terminal.getCursorPosition().y).toBe(22); const tableStart = original.findIndex((line) => line.includes('┌')); const tableEnd = original.findIndex((line) => line.includes('└')); @@ -155,6 +155,7 @@ describe('MCode Pi Engine local deltas', () => { expect(after.slice(tableStart, tableEnd + 1)).toEqual( original.slice(tableStart, tableEnd + 1), ); + expect(terminal.takeWrites()).not.toContain('\x1b[3J'); expect(after.filter((line) => line.trim())).toEqual(original.filter((line) => line.trim())); tui.renderNow(); await terminal.flush(); @@ -352,8 +353,9 @@ describe('MCode Pi Engine local deltas', () => { await terminal.flush(); const logicalDocument = layout.render(terminal.columns).map((line) => line.replace(CURSOR_MARKER, '')); - expect(terminal.getViewport()).toEqual(logicalDocument.slice(-terminal.rows)); - expect(terminal.getScrollBuffer()).toEqual(expected); + const visible = terminal.getViewport().filter((line) => line.trim()); + expect(visible).toEqual(logicalDocument.filter((line) => line.trim()).slice(-visible.length)); + expect(terminal.getScrollBuffer().filter((line) => line.trim())).toEqual(expected.filter((line) => line.trim())); for (const line of history) { expect(terminal.getScrollBuffer().filter((row) => row.trim() === line)).toHaveLength(1); } @@ -382,7 +384,7 @@ describe('MCode Pi Engine local deltas', () => { tui.renderNow(); await terminal.flush(); - expect(terminal.getScrollBuffer()).toEqual(expected); + expect(terminal.getScrollBuffer().filter((line) => line.trim())).toEqual(expected.filter((line) => line.trim())); expect(terminal.takeWrites()).not.toContain('\x1b[3J'); }); @@ -419,14 +421,14 @@ describe('MCode Pi Engine local deltas', () => { tui.renderNow(); await terminal.flush(); - expect(terminal.getScrollBuffer()).toEqual(expected); + expect(terminal.getScrollBuffer().filter((line) => line.trim())).toEqual(expected.filter((line) => line.trim())); expect(terminal.getViewport().join('\n')).not.toContain('Overlay contents'); if (historyRows < terminal.rows) expect(terminal.takeWrites()).not.toContain('\x1b[3J'); // Hidden overlays remain registered; removing one must keep the restored frame. handle.hide(); tui.renderNow(); await terminal.flush(); - expect(terminal.getScrollBuffer()).toEqual(expected); + expect(terminal.getScrollBuffer().filter((line) => line.trim())).toEqual(expected.filter((line) => line.trim())); } }); @@ -451,8 +453,9 @@ describe('MCode Pi Engine local deltas', () => { tui.renderNow(); await terminal.flush(); - expect(terminal.getScrollBuffer()).toEqual(expected); - expect(terminal.getViewport()).toEqual(component.lines.slice(-terminal.rows)); + expect(terminal.getScrollBuffer().filter((line) => line.trim())).toEqual(expected.filter((line) => line.trim())); + const visible = terminal.getViewport().filter((line) => line.trim()); + expect(visible).toEqual(component.lines.filter((line) => line.trim()).slice(-visible.length)); }); it('tracks layout changes even when they produce an identical frame', async () => { @@ -479,8 +482,9 @@ describe('MCode Pi Engine local deltas', () => { tui.renderNow(); await terminal.flush(); - expect(terminal.getScrollBuffer()).toEqual(expected); - expect(terminal.getViewport()).toEqual(component.lines.slice(-terminal.rows)); + expect(terminal.getScrollBuffer().filter((line) => line.trim())).toEqual(expected.filter((line) => line.trim())); + const visible = terminal.getViewport().filter((line) => line.trim()); + expect(visible).toEqual(component.lines.filter((line) => line.trim()).slice(-visible.length)); }); it('preserves a scrolled host viewport when only chat activity settles', async () => { @@ -534,7 +538,7 @@ describe('MCode Pi Engine local deltas', () => { } }); - it('still reconstructs corrected history after a viewport shrink was absorbed', async () => { + it('separates corrected history from the retained output snapshot after visible shrink', async () => { const terminal = new RecordingVirtualTerminal(60, 12); const tui = new TuiMainScreen(terminal); const component = new MutableLines(); @@ -551,11 +555,12 @@ describe('MCode Pi Engine local deltas', () => { await terminal.flush(); expect(terminal.takeWrites()).not.toContain('\x1b[3J'); + const retained = terminal.getScrollBuffer(); component.lines[0] = 'Corrected answer'; tui.renderNow(); await terminal.flush(); - expect(terminal.takeWrites()).toContain('\x1b[3J'); - expect(terminal.getScrollBuffer()).toEqual(['Corrected answer', ...answer.slice(1), 'composer', 'status']); + expect(terminal.takeWrites()).not.toContain('\x1b[3J'); + expect(terminal.getScrollBuffer()).toEqual([...retained, '── Transcript refreshed · earlier output retained ──', 'Corrected answer', ...answer.slice(1), 'composer', 'status']); expect(terminal.getCursorPosition()).toEqual({ x: 8, y: 10 }); }); @@ -567,7 +572,7 @@ describe('MCode Pi Engine local deltas', () => { } }); - it('rebuilds when a transient collapse changes rows already in scrollback', async () => { + it('retains the old snapshot behind an explicit boundary when unanchored history changes', async () => { const terminal = new RecordingVirtualTerminal(40, 5); const tui = new TuiMainScreen(terminal); const component = new MutableLines(); @@ -591,11 +596,11 @@ describe('MCode Pi Engine local deltas', () => { await terminal.flush(); const writes = terminal.takeWrites(); - expect(writes).toContain('\x1b[2J\x1b[H'); - expect(writes).toContain('\x1b[3J'); - expect(terminal.getScrollBuffer()).not.toContain('activity-1'); - expect(terminal.getViewport()).toEqual(['header', 'status', 'composer', '', '']); - expect(terminal.getScrollBuffer().filter((line) => line === 'header')).toHaveLength(1); + expect(writes).not.toContain('\x1b[2J'); + expect(writes).not.toContain('\x1b[3J'); + expect(terminal.getScrollBuffer().slice(0, 2)).toEqual(['header', 'activity-1']); + expect(terminal.getViewport()).toEqual(['── Transcript refreshed · earlier output'.slice(0, 40), 'header', 'status', 'composer', '']); + expect(terminal.getScrollBuffer().filter((line) => line === 'header')).toHaveLength(2); expect(tui.fullRedraws).toBe(2); }); @@ -673,7 +678,7 @@ describe('MCode Pi Engine local deltas', () => { ); expect(terminal.getCursorPosition()).toEqual({ x: 8, y: 42 }); expect(terminal.takeWrites()).not.toContain('\x1b[3J'); - expect(terminal.getScrollBuffer()).toEqual(expected); + expect(terminal.getScrollBuffer().filter((line) => line.trim())).toEqual(expected.filter((line) => line.trim())); if (added <= activityRows) component.lines.splice(-2, 0, `New answer ${added}`); } expect(terminal.getScrollBuffer()).not.toContain(''); @@ -701,7 +706,7 @@ describe('MCode Pi Engine local deltas', () => { expect(terminal.getScrollBuffer()).toEqual([...answer, ...more, 'composer']); }); - it.each([0, 30])('rebuilds changed scrollback text even when the document grows by %i rows', async (growth) => { + it.each([0, 30])('separates changed unanchored history and retains all %i newly appended rows', async (growth) => { const terminal = new RecordingVirtualTerminal(67, 24); const tui = new TuiMainScreen(terminal); const component = new MutableLines(); @@ -712,6 +717,7 @@ describe('MCode Pi Engine local deltas', () => { await terminal.flush(); terminal.takeWrites(); + const retained = terminal.getScrollBuffer(); component.lines = [ 'Recovered context', ...Array.from({ length: growth }, (_, index) => `Recovered row ${index}`), @@ -723,8 +729,8 @@ describe('MCode Pi Engine local deltas', () => { await terminal.flush(); const expected = component.lines.map((line) => line.replace(CURSOR_MARKER, '')); - expect(terminal.takeWrites()).toContain('\x1b[3J'); - expect(terminal.getScrollBuffer()).toEqual(expected); + expect(terminal.takeWrites()).not.toContain('\x1b[3J'); + expect(terminal.getScrollBuffer()).toEqual([...retained, '── Transcript refreshed · earlier output retained ──', ...expected]); expect(terminal.getViewport()).toEqual(expected.slice(-terminal.rows)); expect(terminal.getCursorPosition()).toEqual({ x: 8, y: 22 }); @@ -734,6 +740,7 @@ describe('MCode Pi Engine local deltas', () => { await terminal.flush(); expect(terminal.takeWrites()).not.toContain('\x1b[3J'); expect(terminal.getScrollBuffer()).toEqual([ + ...retained, '── Transcript refreshed · earlier output retained ──', ...expected.slice(0, -2), 'Next response', 'composer', 'status', ]); }); @@ -751,14 +758,17 @@ describe('MCode Pi Engine local deltas', () => { tui.renderNow(); await terminal.flush(); - expect(terminal.getScrollBuffer().filter(Boolean)).toEqual(['answer', 'composer']); - expect(terminal.getCursorPosition()).toEqual({ x: 8, y: 1 }); + expect(terminal.getScrollBuffer().filter(Boolean)).toEqual([ + ...Array.from({ length: 100 }, (_, i) => `Old line ${i}`), + '── Transcript refreshed · earlier output retained ──', 'answer', 'composer', + ]); + expect(terminal.getCursorPosition()).toEqual({ x: 8, y: 2 }); }); it.each([ ['xterm', RecordingVirtualTerminal], ['clear-to-scrollback host', ClearToScrollbackTerminal], - ] as const)('previews the resized tail and restores ordered scrollback on %s', async (_name, Terminal) => { + ] as const)('repaints the resized tail without replaying native scrollback on %s', async (_name, Terminal) => { const terminal = new Terminal(67, 44); const tui = new TuiMainScreen(terminal); const component = new MutableLines(); @@ -788,13 +798,13 @@ describe('MCode Pi Engine local deltas', () => { 'composer', ]); - // A redundant notification must not discard the pending genuine resize replay. + // A redundant notification must not restart or clear the settled viewport. terminal.resize(60, 30); await new Promise((resolve) => setTimeout(resolve, 200)); tui.renderNow(); await terminal.flush(); - expect(terminal.takeWrites()).toContain('\x1b[3J'); + expect(terminal.takeWrites()).not.toContain('\x1b[3J'); expect(terminal.getScrollBuffer()).toEqual([ ...Array.from({ length: 80 }, (_, index) => `Answer line ${index}`), 'composer', diff --git a/packages/tui/test/unit/tui-feature-viewport-contract.test.ts b/packages/tui/test/unit/tui-feature-viewport-contract.test.ts index 032b7b4d0..7d055c1e7 100644 --- a/packages/tui/test/unit/tui-feature-viewport-contract.test.ts +++ b/packages/tui/test/unit/tui-feature-viewport-contract.test.ts @@ -220,8 +220,22 @@ describe.each(Object.entries(factories))('%s regular viewport lifecycle', (_name await vi.waitFor(async () => { await terminal.flush(); const expected = [...chat(), ...Array(Math.max(0, rows - chat().length)).fill('')]; - expect(terminal.getViewport()).toEqual(expected.slice(-rows)); - expect(terminal.getScrollBuffer()).toEqual(expected); + const visible = terminal.getViewport().filter(Boolean); + expect(visible).toEqual(chat().slice(-visible.length)); + const history = terminal.getScrollBuffer().filter(Boolean); + const boundary = history.findIndex((line) => line.startsWith('── Transcript refreshed')); + if (chatCount === 80 && rows === 8) { + // Deletion reached native history: retain the old snapshot, then label + // the current document. Transient panels must occur in neither segment. + expect(history.slice(0, boundary)).toEqual([ + ...Array.from({ length: 80 }, (_, index) => `BACKGROUND-${index}`), + 'COMPOSER', 'STATUS', + ]); + expect(history.slice(boundary + 1)).toEqual(chat()); + } else { + expect(boundary).toBe(-1); + expect(history).toEqual(expected.filter(Boolean)); + } }); } finally { host.dispose(); diff --git a/packages/tui/test/unit/tui-hook-system-message.test.ts b/packages/tui/test/unit/tui-hook-system-message.test.ts new file mode 100644 index 000000000..35dce1b70 --- /dev/null +++ b/packages/tui/test/unit/tui-hook-system-message.test.ts @@ -0,0 +1,179 @@ +import { describe, expect, it } from 'vitest'; +import { + normalizeTuiMessage, + projectTuiSessionStreamFrame, +} from '../../src/runtime/stream-events.js'; +import { TuiTurnProjection } from '../../src/tui/controller/projection/turn-projection.js'; +import { TranscriptStore } from '../../src/tui/transcript/store.js'; +import { + formatTuiSessionMarkdown, + formatTuiTranscriptMarkdown, + latestAssistantReply, +} from '../../src/tui/transcript/export.js'; + +const marker = 'HOOK_DISPLAY_ONLY_SENTINEL'; +function notice(id = 'notice-1', data: Record = {}) { + return { + msg_id: id, + turn_id: 'turn-1', + timestamp: 123, + msg_type: 3, + msg_content: JSON.stringify({ + eventType: 'runtime.warning', + source: 'plugin-hook', + category: 'system-message', + hookEvent: 'Stop', + message: marker, + ...data, + }), + }; +} +function harness() { + const transcript = new TranscriptStore(); + const projection = new TuiTurnProjection({ + transcript, + now: () => 999, + onChange: () => {}, + }); + return { transcript, projection }; +} +function stream(message: ReturnType) { + return projectTuiSessionStreamFrame( + { dataJson: JSON.stringify({ type: 2, agent_message: message }) }, + 'fallback-turn', + )!; +} + +describe('Hook-only TUI messages', () => { + it('preserves source identity, time and turn through the live transport', () => { + expect(stream(notice())).toMatchObject({ + type: 'generic', + eventType: 'runtime.warning', + messageId: 'notice-1', + turnId: 'turn-1', + timestamp: 123, + }); + }); + + it.each(['live-first', 'history-first'])( + 'deduplicates %s delivery and history refresh', + (order) => { + const { transcript, projection } = harness(); + const live = () => projection.applyStreamEvent('fallback-turn', stream(notice())); + const hydrate = () => projection.hydrateHistory([normalizeTuiMessage(notice())]); + if (order === 'live-first') { + live(); + hydrate(); + } else { + hydrate(); + live(); + } + live(); + expect(transcript.snapshot()).toEqual([ + expect.objectContaining({ + id: 'hook-message:notice-1', + kind: 'warning', + status: 'succeeded', + content: marker, + title: 'Hook · Stop', + turnId: 'turn-1', + sourceMessageId: 'notice-1', + createdAtMs: 123, + }), + ]); + expect(transcript.snapshot()[0].ephemeral).toBeUndefined(); + transcript.replaceDurableProjection(hydrate); + live(); + expect(transcript.length).toBe(1); + projection.applyStreamEvent('turn-1', stream(notice('notice-2'))); + expect(transcript.length).toBe(2); + }, + ); + + it.each([ + { category: undefined }, + { category: 'diagnostic' }, + { category: 'terminal-control' }, + { category: 'runtime-message' }, + { source: 'other' }, + { message: '' }, + { message: 42 }, + { message: '\u0007\u001b[31m' }, + ])('keeps unsupported or empty payloads hidden: %j', (data) => { + const { transcript, projection } = harness(); + projection.applyStreamEvent('turn-1', stream(notice('invalid', data))); + projection.hydrateHistory([normalizeTuiMessage(notice('invalid', data))]); + expect(transcript.length).toBe(0); + }); + + it('requires a real message ID for live and history projection', () => { + const { transcript, projection } = harness(); + projection.applyStreamEvent('turn-1', { + type: 'generic', + eventType: 'runtime.warning', + data: { + source: 'plugin-hook', + category: 'system-message', + message: marker, + }, + }); + projection.hydrateHistory([{ role: 'system', content: notice().msg_content }]); + expect(transcript.length).toBe(0); + }); + + it('sanitizes text and titles before exposing them to transcript consumers', () => { + const { transcript, projection } = harness(); + projection.applyStreamEvent( + 'turn-1', + stream( + notice('safe', { + hookEvent: '\u001b]0;unsafe-title\u0007Stop', + message: '\u001b]52;c;clipboard\u0007中文\nsecond line\u0007', + }), + ), + ); + expect(transcript.snapshot()[0]).toMatchObject({ + title: 'Hook · Stop', + content: '中文\nsecond line', + }); + }); + + it('removes notices when their turn is rewound or replaced', () => { + const { transcript, projection } = harness(); + projection.applyStreamEvent('turn-1', stream(notice())); + projection.applyStreamEvent('turn-1', { + type: 'messages-rewound', + messageIds: ['notice-1'], + turnId: 'turn-1', + }); + expect(transcript.length).toBe(0); + projection.applyStreamEvent('turn-1', stream(notice())); + projection.applyStreamEvent('turn-1', { + type: 'messages-replaced', + messages: [], + turnId: 'turn-1', + }); + expect(transcript.length).toBe(0); + }); + + it('excludes durable notices from both exporters and final-answer extraction', () => { + const { transcript, projection } = harness(); + const answer = { + id: 'answer-1', + role: 'assistant' as const, + content: 'OK', + turnId: 'turn-1', + }; + const messages = [answer, normalizeTuiMessage(notice())]; + projection.hydrateHistory(messages); + expect(transcript.length).toBe(2); + expect(latestAssistantReply(transcript)).toBe('OK'); + expect(formatTuiTranscriptMarkdown(transcript)).not.toContain(marker); + expect( + formatTuiSessionMarkdown(messages, { + sessionId: 'session-1', + exportedAtMs: 1, + }), + ).not.toContain(marker); + }); +}); diff --git a/packages/tui/test/unit/tui-scrollback-stability.test.ts b/packages/tui/test/unit/tui-scrollback-stability.test.ts new file mode 100644 index 000000000..80599d761 --- /dev/null +++ b/packages/tui/test/unit/tui-scrollback-stability.test.ts @@ -0,0 +1,762 @@ +import { describe, expect, it } from "vitest"; +import { + CURSOR_MARKER, + TuiMainScreen, + type Component, +} from "../../src/tui/engine/public.js"; +import { TuiChatLayout } from "../../src/tui/shell/chat-layout.js"; +import { TuiTaskPanel } from "../../src/tui/shell/task-panel.js"; +import { createTranscriptCell } from "../../src/tui/transcript/model.js"; +import { TranscriptStore } from "../../src/tui/transcript/store.js"; +import { TranscriptVisibilityProjection } from "../../src/tui/transcript/presentation/visibility.js"; +import { TranscriptView } from "../../src/tui/transcript/view.js"; +import { VirtualTerminal } from "../pi-084-upstream/virtual-terminal.js"; + +class RecordingTerminal extends VirtualTerminal { + private writes: string[] = []; + override write(data: string): void { + this.writes.push(data); + super.write(data); + } + takeWrites(): string { + const result = this.writes.join(""); + this.writes = []; + return result; + } +} + +class ClearToScrollbackTerminal extends RecordingTerminal { + override write(data: string): void { + super.write( + data.replaceAll( + "\x1b[2J", + `\x1b[${this.rows};1H${"\r\n".repeat(this.rows)}\x1b[2J`, + ), + ); + } +} + +class Lines implements Component { + constructor(public lines: string[] = []) {} + render(): string[] { + return [...this.lines]; + } + invalidate(): void {} +} + +function fixture( + terminal: RecordingTerminal, + options: ConstructorParameters[1] = {}, +) { + const store = new TranscriptStore(); + const view = new TranscriptView( + new TranscriptVisibilityProjection(store), + options, + ); + const tasks = new TuiTaskPanel(); + const activity = new Lines(["Running"]); + const tui = new TuiMainScreen(terminal); + const empty = new Lines(); + const layout = new TuiChatLayout(terminal, { + surface: () => "conversation", + welcome: new Lines(["WELCOME"]), + transcript: view, + interaction: empty, + activity, + tasks, + followUp: empty, + composer: new Lines([`composer${CURSOR_MARKER}`]), + status: new Lines(["status"]), + }); + tui.addChild(layout); + const render = async () => { + tui.renderNow(); + await terminal.flush(); + return terminal.takeWrites(); + }; + return { terminal, store, view, tasks, activity, tui, layout, render }; +} + +function step(index: number, title = "Bash", turnId = "long-turn") { + return createTranscriptCell({ + id: `step-${index}`, + turnId, + kind: "tool", + title, + content: + title === "Read" + ? `src/STEP_${String(index).padStart(3, "0")}.ts` + : `echo STEP_${String(index).padStart(3, "0")}`, + status: "succeeded", + createdAtMs: index, + displayMode: "expanded", + }); +} + +function expectNoReplay(output: string): void { + expect(output).not.toContain("\x1b[3J"); + expect(output).not.toContain("\x1b[2J"); + expect(output).not.toContain("WELCOME"); + expect(output).not.toContain("Transcript refreshed"); +} + +async function seedShell(terminal: RecordingTerminal): Promise { + terminal.write(`SHELL_HISTORY_SENTINEL\r\n${"shell line\r\n".repeat(50)}`); + await terminal.flush(); + terminal.takeWrites(); +} + +describe.each([ + ["xterm", RecordingTerminal], + ["clear-to-scrollback host", ClearToScrollbackTerminal], +] as const)("%s regular scrollback stability", (_name, Terminal) => { + it.each(["Bash", "Read"])( + "retains every emitted %s step while the live projection rolls", + async (title) => { + const f = fixture(new Terminal(100, 24)); + if (title === "Read") f.view.toggleDetailMode(); + await seedShell(f.terminal); + for (let i = 0; i < 120; i++) f.store.upsert(step(i, title)); + await f.render(); + f.terminal.scrollLines(-15); + const before = f.terminal.getScrollPosition().viewport; + for (let i = 120; i < 145; i++) { + f.store.upsert(step(i, title)); + expectNoReplay(await f.render()); + expect(f.terminal.getScrollPosition().viewport).toBe(before); + expect( + f.view.getPerformanceSnapshot().projectedCells, + ).toBeLessThanOrEqual(121); + expect( + f.tui.captureRenderState().previousLines.length, + ).toBeLessThanOrEqual(f.layout.render(100).length + 24); + } + const history = f.terminal.getScrollBuffer(); + expect(history).toContain("SHELL_HISTORY_SENTINEL"); + for (let i = 0; i < 145; i++) { + const token = `STEP_${String(i).padStart(3, "0")}`; + expect( + history.filter((line) => line.includes(token)), + token, + ).toHaveLength(1); + } + f.terminal.scrollLines(10000); + expect( + f.terminal + .getViewport() + .slice(-3) + .map((line) => line.trim()), + ).toEqual(["Running", "composer", "status"]); + expect(f.terminal.getCursorPosition().y).toBe(22); + expectNoReplay(await f.render()); + }, + ); + + it("preserves emitted turns when the bounded turn window evicts its prefix", async () => { + const f = fixture(new Terminal(100, 12), { + maxInitialTurns: 2, + maxProjectedTurns: 4, + }); + for (let i = 0; i < 40; i++) { + for (let j = 0; j < 12; j++) + f.store.upsert(step(i * 12 + j, "Bash", `turn-${i}`)); + const output = await f.render(); + if (i > 0) expectNoReplay(output); + expect( + f.view.getPerformanceSnapshot().projectedCells, + ).toBeLessThanOrEqual(48); + } + const history = f.terminal.getScrollBuffer(); + for (let i = 0; i < 480; i++) { + const token = `STEP_${String(i).padStart(3, "0")}`; + expect( + history.filter((line) => line.includes(token)), + token, + ).toHaveLength(1); + } + }); + + it.each([18, 44])( + "keeps shell history and cursor stable when expanded Todo finishes in %i rows", + async (rows) => { + const f = fixture(new Terminal(100, rows)); + await seedShell(f.terminal); + for (let i = 0; i < 100; i++) f.store.upsert(step(i)); + const items = Array.from({ length: 11 }, (_, i) => ({ + content: `Task ${i}`, + status: "in_progress" as const, + })); + f.tasks.setItems(items); + f.tasks.toggleExpanded(); + await f.render(); + f.terminal.scrollLines(-10); + const before = f.terminal.getScrollPosition().viewport; + f.tasks.setItems(items.map((item) => ({ ...item, status: "completed" }))); + expectNoReplay(await f.render()); + expect(f.terminal.getScrollPosition().viewport).toBe(before); + expect(f.terminal.getScrollBuffer()).toContain("SHELL_HISTORY_SENTINEL"); + f.terminal.scrollLines(10000); + expect( + f.terminal + .getViewport() + .slice(-3) + .map((line) => line.trim()), + ).toEqual(["Running", "composer", "status"]); + expect(f.terminal.getCursorPosition().y).toBe(rows - 2); + for (let i = 100; i < 120; i++) { + f.store.upsert(step(i)); + expectNoReplay(await f.render()); + } + const history = f.terminal.getScrollBuffer(); + for (let i = 0; i < 120; i++) { + expect( + history.filter((line) => + line.includes(`STEP_${String(i).padStart(3, "0")}`), + ), + ).toHaveLength(1); + } + }, + ); + + it("retains anchors when regular render state is restored after a mode switch", async () => { + const terminal = new Terminal(100, 24); + const f = fixture(terminal); + for (let i = 0; i < 120; i++) f.store.upsert(step(i)); + await f.render(); + const state = f.tui.captureRenderState(); + const restored = new TuiMainScreen(terminal); + restored.addChild(f.layout); + restored.restoreRenderState(state); + f.store.upsert(step(120)); + restored.renderNow(); + await terminal.flush(); + expectNoReplay(terminal.takeWrites()); + for (let i = 0; i <= 120; i++) { + expect( + terminal + .getScrollBuffer() + .filter((line) => + line.includes(`STEP_${String(i).padStart(3, "0")}`), + ), + ).toHaveLength(1); + } + }); +}); + +describe("regular renderer boundary regressions", () => { + it("idle render with footer taller than screen produces no output growth", async () => { + const f = fixture(new RecordingTerminal(100, 8)); + for (let i = 0; i < 100; i++) f.store.upsert(step(i)); + f.tasks.setItems( + Array.from({ length: 11 }, (_, i) => ({ + content: `Task ${i}`, + status: "in_progress" as const, + })), + ); + f.tasks.toggleExpanded(); + await f.render(); + const before = f.terminal.getScrollBuffer(); + const output = await f.render(); + const after = f.terminal.getScrollBuffer(); + expect(after).toEqual(before); + }); + it.each([false, true])( + "preserves output arriving during resize (live resize callback: %s)", + async (started) => { + const f = fixture(new RecordingTerminal(100, 24)); + for (let i = 0; i < 40; i++) f.store.upsert(step(i)); + if (started) f.tui.start(); + await f.render(); + f.terminal.resize(90, 12); + for (let i = 40; i < 80; i++) f.store.upsert(step(i)); + await f.render(); + const history = f.terminal.getScrollBuffer(); + for (let i = 0; i < 80; i++) + expect( + history.filter((line) => + line.includes(`STEP_${String(i).padStart(3, "0")}`), + ), + `step ${i}`, + ).toHaveLength(1); + if (started) f.tui.stop(); + }, + ); + + it("preserves retained native-history image resources across resize", async () => { + const terminal = new RecordingTerminal(100, 24); + const tui = new TuiMainScreen(terminal); + const image = "\x1b_Ga=T,f=100,i=42,r=1;PNGDATA\x1b\\"; + const component = new Lines([ + image, + ...Array.from({ length: 50 }, (_, i) => `Line ${i}`), + `composer${CURSOR_MARKER}`, + ]); + tui.addChild(component); + tui.renderNow(); + await terminal.flush(); + terminal.takeWrites(); + terminal.resize(90, 24); + tui.renderNow(); + await terminal.flush(); + const output = terminal.takeWrites(); + expect(output).not.toContain("d=I,i=42"); + }); + + it.each([12, 50])( + "preserves old visible body when new turn evicts all anchors in %i rows", + async (rows) => { + const f = fixture(new RecordingTerminal(100, rows), { + maxInitialTurns: 1, + maxProjectedTurns: 1, + }); + for (let i = 0; i < 12; i++) f.store.upsert(step(i, "Bash", "turn-1")); + await f.render(); + for (let i = 12; i < 24; i++) f.store.upsert(step(i, "Bash", "turn-2")); + await f.render(); + const history = f.terminal.getScrollBuffer(); + for (let i = 0; i < 24; i++) + expect( + history.some((line) => + line.includes(`STEP_${String(i).padStart(3, "0")}`), + ), + `step ${i}`, + ).toBe(true); + }, + ); + + it("keeps step output across changing oversized footers", async () => { + const f = fixture(new RecordingTerminal(100, 24)); + for (let i = 0; i < 80; i++) f.store.upsert(step(i)); + f.tasks.toggleExpanded(); + await f.render(); + let total = 80; + for (const count of [30, 5, 35, 0, 24, 10, 45, 1, 30]) { + f.tasks.setItems( + Array.from({ length: count }, (_, i) => ({ + content: `Task ${i}`, + status: "in_progress" as const, + })), + ); + await f.render(); + f.store.upsert(step(total++)); + await f.render(); + const history = f.terminal.getScrollBuffer(); + for (let i = 0; i < total; i++) + expect( + history.filter((line) => + line.includes(`STEP_${String(i).padStart(3, "0")}`), + ), + `count ${count} step ${i}`, + ).toHaveLength(1); + } + }); +}); + +describe("explicit redraw", () => { + it("refreshes the active screen without re-emitting its existing transcript", async () => { + const f = fixture(new RecordingTerminal(100, 24)); + for (let i = 0; i < 120; i++) f.store.upsert(step(i)); + await f.render(); + const before = f.terminal.getScrollBuffer(); + f.tui.renderNow(true); + await f.terminal.flush(); + expectNoReplay(f.terminal.takeWrites()); + expect(f.terminal.getScrollBuffer()).toEqual(before); + f.store.upsert(step(120)); + expectNoReplay(await f.render()); + expect( + f.terminal.getScrollBuffer().filter((line) => line.includes("STEP_120")), + ).toHaveLength(1); + }); +}); + +describe("projection gaps and retained images", () => { + it("does not mistake the pinned first cell for continuity across a large rolling burst", async () => { + const f = fixture(new RecordingTerminal(100, 24)); + for (let i = 0; i < 120; i++) f.store.upsert(step(i)); + await f.render(); + for (let i = 120; i < 230; i++) f.store.upsert(step(i)); + await f.render(); + const history = f.terminal.getScrollBuffer(); + for (let i = 0; i < 230; i++) + expect( + history.some((line) => + line.includes(`STEP_${String(i).padStart(3, "0")}`), + ), + `step ${i}`, + ).toBe(true); + }); + + it("does not mistake the pinned user for continuity across a large rolling burst", async () => { + const f = fixture(new RecordingTerminal(100, 24)); + f.store.upsert( + createTranscriptCell({ + id: "user", + turnId: "long-turn", + kind: "user", + content: "Start task", + status: "succeeded", + createdAtMs: 0, + }), + ); + for (let i = 0; i < 119; i++) f.store.upsert(step(i)); + await f.render(); + for (let i = 119; i < 229; i++) f.store.upsert(step(i)); + await f.render(); + const history = f.terminal.getScrollBuffer(); + for (let i = 0; i < 229; i++) + expect( + history.some((line) => + line.includes(`STEP_${String(i).padStart(3, "0")}`), + ), + `step ${i}`, + ).toBe(true); + }); + + it("does not replay historical images when only text footer collapses", async () => { + const terminal = new RecordingTerminal(100, 24); + const tui = new TuiMainScreen(terminal); + const body = [ + "\x1b_Ga=T,f=100,i=42,r=1;PNGDATA\x1b\\", + ...Array.from({ length: 60 }, (_, i) => `Body ${i}`), + ]; + let footer = [ + ...Array.from({ length: 10 }, (_, i) => `Activity ${i}`), + `composer${CURSOR_MARKER}`, + "status", + ]; + const component = { + render: () => [...body, ...footer], + invalidate: () => {}, + getScrollbackLayout: () => ({ + bodyEnd: body.length, + anchors: body.map((_, row) => ({ id: `body-${row}`, row })), + }), + }; + tui.addChild(component); + tui.renderNow(); + await terminal.flush(); + terminal.takeWrites(); + footer = [`composer${CURSOR_MARKER}`, "status"]; + tui.renderNow(); + await terminal.flush(); + const output = terminal.takeWrites(); + expect(output).not.toContain("Transcript refreshed"); + expect( + terminal.getScrollBuffer().filter((line) => line === "Body 0"), + ).toHaveLength(1); + }); +}); + +describe("settled thinking", () => { + it("does not replay history when a retained thinking preview collapses across the viewport boundary", async () => { + const f = fixture(new RecordingTerminal(100, 18)); + for (let i = 0; i < 50; i++) + f.store.upsert( + createTranscriptCell({ + id: `history-${i}`, + turnId: "turn", + kind: "assistant", + status: "succeeded", + content: `History ${i}`, + createdAtMs: i, + }), + ); + const thinking = createTranscriptCell({ + id: "thinking", + turnId: "turn", + kind: "thinking", + status: "running", + content: "First thought\nSecond thought\nThird thought\nFourth thought", + createdAtMs: 51, + }); + f.store.upsert(thinking); + f.tasks.setItems( + Array.from({ length: 11 }, (_, i) => ({ + content: `Task ${i}`, + status: "in_progress" as const, + })), + ); + f.tasks.toggleExpanded(); + await f.render(); + f.store.upsert({ + ...thinking, + status: "succeeded", + updatedAtMs: 1001, + durationMs: 1000, + }); + expectNoReplay(await f.render()); + for (let i = 0; i < 50; i++) + expect( + f.terminal + .getScrollBuffer() + .filter((line) => line.trim() === `● History ${i}`), + ).toHaveLength(1); + }); +}); + +describe("transient transcript cleanup", () => { + it("does not replay history when the previous turn duration is removed", async () => { + const f = fixture(new RecordingTerminal(100, 24)); + for (let i = 0; i < 80; i++) f.store.upsert(step(i)); + f.store.upsert( + createTranscriptCell({ + id: "turn-duration:long-turn", + turnId: "long-turn", + kind: "turn-duration", + ephemeral: true, + status: "succeeded", + durationMs: 1000, + content: "", + createdAtMs: 90, + }), + ); + await f.render(); + f.store.remove("turn-duration:long-turn"); + f.store.upsert( + createTranscriptCell({ + id: "new-request", + turnId: "new-turn", + kind: "user", + status: "succeeded", + content: "Next request", + createdAtMs: 91, + }), + ); + const output = await f.render(); + expectNoReplay(output); + }); +}); + +describe("projection reads between physical frames", () => { + it("compares eviction to the last emitted frame after offscreen projection reads", async () => { + const f = fixture(new RecordingTerminal(100, 24)); + f.store.upsert( + createTranscriptCell({ + id: "user", + turnId: "long-turn", + kind: "user", + content: "Start task", + status: "succeeded", + createdAtMs: 0, + }), + ); + for (let i = 0; i < 119; i++) f.store.upsert(step(i)); + await f.render(); + for (let i = 119; i < 269; i++) f.store.upsert(step(i)); + // Fullscreen/inspection can render projections without updating the saved + // regular-mode screen. Eviction must be relative to that physical screen. + f.layout.render(100); + f.store.upsert(step(269)); + await f.render(); + const history = f.terminal.getScrollBuffer(); + for (let i = 0; i < 119; i++) + expect( + history.some((line) => + line.includes(`STEP_${String(i).padStart(3, "0")}`), + ), + `previously emitted ${i}`, + ).toBe(true); + expect(history.some((line) => line.includes("STEP_269"))).toBe(true); + }); +}); + +describe("isolated regular overlays", () => { + it("does not treat a forced first overlay frame as a width reflow", async () => { + const f = fixture(new RecordingTerminal(100, 24)); + for (let i = 0; i < 60; i++) f.store.upsert(step(i)); + await f.render(); + const before = f.terminal.getScrollBuffer(); + const overlay = f.tui.showOverlay(new Lines(["TRANSIENT_PANEL"])); + f.tui.renderNow(true); + await f.terminal.flush(); + f.terminal.takeWrites(); + overlay.hide(); + expectNoReplay(await f.render()); + expect(f.terminal.getScrollBuffer()).toEqual(before); + }); + + it("returns the main buffer on stop with a pending render and does not re-enter after stop", async () => { + const f = fixture(new RecordingTerminal(100, 24)); + for (let i = 0; i < 30; i++) f.store.upsert(step(i)); + await f.render(); + f.tui.showOverlay(new Lines(["TRANSIENT_PANEL"]), { + width: "100%", + maxHeight: "100%", + row: 0, + }); + await f.render(); + f.store.upsert(step(30)); + f.tui.requestRender(); + f.tui.stop(); + await f.terminal.flush(); + const output = f.terminal.takeWrites(); + expect(output).toContain("\x1b[?1049l"); + expect(output).not.toContain("\x1b[?1049h"); + expect(f.terminal.getScrollBuffer().join("\n")).not.toContain( + "TRANSIENT_PANEL", + ); + expect( + f.terminal.getScrollBuffer().filter((line) => line.includes("STEP_030")), + ).toHaveLength(1); + f.tui.renderNow(); + expect(f.terminal.takeWrites()).toBe(""); + }); + + it("tracks shrink and grow events even when no overlay frame is rendered between them", async () => { + const f = fixture(new RecordingTerminal(100, 24)); + for (let i = 0; i < 60; i++) f.store.upsert(step(i)); + f.tui.start(); + try { + await f.render(); + const overlay = f.tui.showOverlay(new Lines(["TRANSIENT_PANEL"]), { + width: "100%", + maxHeight: "100%", + row: 0, + }); + await f.render(); + f.terminal.resize(100, 8); + f.terminal.resize(100, 24); + overlay.hide(); + await f.render(); + const history = f.terminal.getScrollBuffer(); + expect(history.join("\n")).not.toContain("TRANSIENT_PANEL"); + expect(history.join("\n")).not.toContain("Transcript refreshed"); + for (let i = 0; i < 60; i++) { + expect( + history.filter((line) => + line.includes(`STEP_${String(i).padStart(3, "0")}`), + ), + ).toHaveLength(1); + } + expect(f.terminal.getViewport().at(-1)?.trim()).toBe("status"); + } finally { + f.tui.stop(); + } + }); +}); + +describe("isolated overlay width reflow", () => { + it.each([12, 40])( + "preserves wrapping main text through overlay width roundtrip with %i body rows", + async (count) => { + const terminal = new RecordingTerminal(100, 24); + const tui = new TuiMainScreen(terminal); + const body = Array.from( + { length: count }, + (_, i) => `WIDE_TOKEN_${String(i).padStart(3, "0")} ${"x".repeat(70)}`, + ); + const component = new Lines([ + ...body, + `composer${CURSOR_MARKER}`, + "status", + ]); + tui.addChild(component); + tui.start(); + try { + tui.renderNow(); + await terminal.flush(); + terminal.takeWrites(); + const overlay = tui.showOverlay(new Lines(["TRANSIENT_OVERLAY"]), { + width: "100%", + maxHeight: "100%", + row: 0, + col: 0, + }); + tui.renderNow(); + await terminal.flush(); + terminal.resize(30, 24); + tui.renderNow(); + await terminal.flush(); + terminal.resize(100, 24); + tui.renderNow(); + await terminal.flush(); + overlay.hide(); + tui.renderNow(); + await terminal.flush(); + const history = terminal.getScrollBuffer(); + expect(history.join("\n")).not.toContain("TRANSIENT_OVERLAY"); + for (let i = 0; i < count; i++) + expect( + history.some((line) => + line.includes(`WIDE_TOKEN_${String(i).padStart(3, "0")}`), + ), + `old row ${i}`, + ).toBe(true); + const boundary = history.findLastIndex((line) => + line.startsWith("── Transcript refreshed"), + ); + expect(boundary).toBeGreaterThanOrEqual(0); + expect(history.slice(boundary + 1).filter(Boolean)).toEqual([ + ...body, + "composer", + "status", + ]); + } finally { + tui.stop(); + } + }, + ); + + it.each([12, 40])( + "archives old wrapping text before changed document resumes after overlay width roundtrip (%i)", + async (count) => { + const terminal = new RecordingTerminal(100, 24); + const tui = new TuiMainScreen(terminal); + const body = Array.from( + { length: count }, + (_, i) => `WIDE_TOKEN_${String(i).padStart(3, "0")} ${"x".repeat(70)}`, + ); + const component = new Lines([ + ...body, + `composer${CURSOR_MARKER}`, + "status", + ]); + tui.addChild(component); + tui.start(); + try { + tui.renderNow(); + await terminal.flush(); + terminal.takeWrites(); + const overlay = tui.showOverlay(new Lines(["TRANSIENT_OVERLAY"]), { + width: "100%", + maxHeight: "100%", + row: 0, + col: 0, + }); + tui.renderNow(); + await terminal.flush(); + terminal.resize(30, 24); + tui.renderNow(); + await terminal.flush(); + terminal.resize(100, 24); + tui.renderNow(); + await terminal.flush(); + component.lines = ["NEW_BODY", `composer${CURSOR_MARKER}`, "status"]; + overlay.hide(); + tui.renderNow(); + await terminal.flush(); + const history = terminal.getScrollBuffer(); + expect(history.join("\n")).not.toContain("TRANSIENT_OVERLAY"); + for (let i = 0; i < count; i++) + expect( + history.some((line) => + line.includes(`WIDE_TOKEN_${String(i).padStart(3, "0")}`), + ), + `old row ${i}`, + ).toBe(true); + const boundary = history.findLastIndex((line) => + line.startsWith("── Transcript refreshed"), + ); + expect(boundary).toBeGreaterThanOrEqual(0); + expect(history.slice(boundary + 1).filter(Boolean)).toEqual([ + "NEW_BODY", + "composer", + "status", + ]); + } finally { + tui.stop(); + } + }, + ); +}); diff --git a/packages/tui/test/unit/tui-terminal-output.test.ts b/packages/tui/test/unit/tui-terminal-output.test.ts new file mode 100644 index 000000000..35f66125b --- /dev/null +++ b/packages/tui/test/unit/tui-terminal-output.test.ts @@ -0,0 +1,358 @@ +import { PassThrough } from "node:stream"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + ProcessTerminal, + TuiMainScreen, + CURSOR_MARKER, +} from "../../src/tui/engine/public.js"; +import { createObservedTerminal } from "../../src/tui/platform/observed-terminal.js"; +import { TuiExternalEditorFlow } from "../../src/tui/controller/interaction/external-editor-flow.js"; +import { VirtualTerminal } from "../pi-084-upstream/virtual-terminal.js"; + +type WriteCallback = ( + error: NodeJS.ErrnoException | null, + bytes: number, +) => void; +const { write } = vi.hoisted(() => ({ + write: + vi.fn< + ( + fd: number, + buffer: Buffer, + offset: number, + length: number, + position: null, + callback: WriteCallback, + ) => void + >(), +})); +vi.mock("node:fs", async (original) => ({ + ...(await original()), + write, +})); + +afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + vi.unstubAllEnvs(); + write.mockReset(); +}); + +function outputHarness() { + const stdout = Object.assign(new PassThrough(), { + isTTY: true, + fd: 1, + columns: 80, + rows: 24, + }); + const synchronousWrite = vi.spyOn(stdout, "write").mockReturnValue(true); + vi.spyOn(process, "stdout", "get").mockReturnValue( + stdout as typeof process.stdout, + ); + vi.spyOn(process, "platform", "get").mockReturnValue("linux"); + const pending: { + buffer: Buffer; + offset: number; + length: number; + callback: WriteCallback; + }[] = []; + const delivered: Buffer[] = []; + write.mockImplementation( + (_fd, buffer, offset, length, _position, callback) => { + pending.push({ buffer, offset, length, callback }); + }, + ); + const complete = (limit = Infinity) => { + const next = pending.shift(); + if (!next) throw new Error("No pending write"); + const length = Math.min(limit, next.length); + delivered.push(next.buffer.subarray(next.offset, next.offset + length)); + next.callback(null, length); + }; + return { stdout, synchronousWrite, pending, delivered, complete }; +} + +describe("terminal output backpressure", () => { + it("keeps input responsive and orders control output behind a partially written replay", async () => { + const h = outputHarness(); + const stdin = Object.assign(new PassThrough(), { + isRaw: false, + setRawMode: vi.fn(), + }); + vi.spyOn(process, "stdin", "get").mockReturnValue( + stdin as typeof process.stdin, + ); + vi.spyOn(process, "kill").mockReturnValue(true); + vi.stubEnv("TERM_PROGRAM", ""); + const terminal = new ProcessTerminal(); + const input = vi.fn(); + terminal.start(input, () => {}); + while (h.pending.length) h.complete(); + h.delivered.length = 0; + h.synchronousWrite.mockClear(); + const frame = `\x1b[?2026h${"中文😀\r\n".repeat(10000)}INPUT_READY\x1b[?2026l`; + try { + terminal.write(frame); + expect(h.synchronousWrite.mock.calls.length).toBe(0); + expect(terminal.outputPending).toBe(true); + stdin.emit("data", "x"); + expect(input).toHaveBeenCalledWith("x"); + terminal.setTitle("after frame"); + terminal.stop(); + let drained = false; + const done = terminal.drainOutput().then(() => { + drained = true; + }); + h.complete(7); // Split a UTF-8 code point across OS writes. + expect(drained).toBe(false); + expect(h.pending).toHaveLength(1); + while (h.pending.length) h.complete(); + await done; + const wire = Buffer.concat(h.delivered).toString("utf8"); + expect(wire.startsWith(frame)).toBe(true); + expect(wire.slice(frame.length)).toContain("\x1b]0;after frame\x07"); + expect(wire.endsWith("\x1b[ { + const h = outputHarness(); + const onError = vi.fn(); + h.stdout.on("error", onError); + const terminal = new ProcessTerminal(); + terminal.write("x".repeat(100000)); + expect(h.pending).toHaveLength(1); + const error = Object.assign(new Error("closed"), { code: "EPIPE" }); + const failed = expect(terminal.drainOutput()).rejects.toBe(error); + h.pending.shift()!.callback(error, 0); + await failed; + expect(onError).toHaveBeenCalledExactlyOnceWith(error); + expect(terminal.outputPending).toBe(false); + expect(() => terminal.write("later")).not.toThrow(); + expect(h.pending).toHaveLength(0); + }); + + it("retries a transient write failure at the same byte offset", async () => { + const h = outputHarness(); + const terminal = new ProcessTerminal(); + terminal.write("中文 and ordered output"); + const first = h.pending.shift()!; + first.callback( + Object.assign(new Error("interrupted"), { code: "EINTR" }), + 0, + ); + await vi.waitFor(() => expect(h.pending).toHaveLength(1)); + expect(h.pending[0]!.offset).toBe(0); + h.complete(1); + expect(h.pending[0]!.offset).toBe(1); + while (h.pending.length) h.complete(); + await terminal.drainOutput(); + expect(Buffer.concat(h.delivered).toString()).toBe( + "中文 and ordered output", + ); + }); + + it("waits for terminal output before starting the external editor", async () => { + let release: () => void = () => {}; + const stopped = new Promise((resolve) => { + release = resolve; + }); + const editDraft = vi.fn(async () => "edited draft"); + const start = vi.fn(); + const editor = { getExpandedText: () => "draft", setText: vi.fn() }; + const flow = new TuiExternalEditorFlow({ + editor, + tui: { stop: () => stopped, start, requestRender: vi.fn() }, + workspaceDir: "/workspace", + configuredCommand: "editor", + editDraft, + isAppStopped: () => false, + append: vi.fn(), + setHint: vi.fn(), + onChanged: vi.fn(), + }); + const opened = flow.open(); + await Promise.resolve(); + expect(editDraft).not.toHaveBeenCalled(); + release(); + await opened; + expect(editDraft).toHaveBeenCalledOnce(); + expect(editor.setText).toHaveBeenCalledWith("edited draft"); + expect(start).toHaveBeenCalledOnce(); + }); + + it("does not launch an editor if shutdown occurs during the output drain", async () => { + let release!: () => void; + const drained = new Promise((resolve) => { + release = resolve; + }); + let appStopped = false; + const editDraft = vi.fn(async () => "edited"); + const start = vi.fn(); + const flow = new TuiExternalEditorFlow({ + editor: { getExpandedText: () => "draft", setText: vi.fn() }, + tui: { stop: () => drained, start, requestRender: vi.fn() }, + workspaceDir: "/workspace", + configuredCommand: "editor", + editDraft, + isAppStopped: () => appStopped, + append: vi.fn(), + setHint: vi.fn(), + onChanged: vi.fn(), + }); + const opened = flow.open(); + appStopped = true; + release(); + await opened; + expect(editDraft).not.toHaveBeenCalled(); + expect(start).not.toHaveBeenCalled(); + }); + + it.each([ + { deferred: true, preserveScreen: false }, + { deferred: false, preserveScreen: false }, + { deferred: true, preserveScreen: true }, + { deferred: false, preserveScreen: true }, + ])( + "preserves final output without changing mode-switch snapshots: %j", + async ({ deferred, preserveScreen }) => { + class HeldTerminal extends VirtualTerminal { + outputPending = false; + writes: string[] = []; + release: (() => void) | undefined; + drainOutput() { + return new Promise((resolve) => { + this.release = resolve; + }); + } + override write(data: string) { + this.writes.push(data); + super.write(data); + } + } + const terminal = new HeldTerminal(80, 24); + const tui = new TuiMainScreen(terminal); + let response = "initial response"; + tui.addChild({ + render: () => [response, `input${CURSOR_MARKER}`], + invalidate() {}, + }); + tui.start(); + tui.renderNow(); + await terminal.flush(); + terminal.writes = []; + terminal.outputPending = true; + response = "FINAL_RESPONSE"; + if (deferred) tui.renderNow(); + else tui.requestImmediateRender(); + const captured = tui.captureRenderState(); + tui.stop({ preserveScreen }); + terminal.outputPending = false; + terminal.release?.(); + await new Promise((resolve) => setTimeout(resolve, 50)); + await terminal.flush(); + expect(terminal.writes.join("").includes(response)).toBe(!preserveScreen); + if (preserveScreen) expect(tui.captureRenderState()).toEqual(captured); + else expect(terminal.getScrollBuffer().join("\n")).toContain(response); + }, + ); + + it("waits for keyboard-disable output before starting the input drain idle window", async () => { + vi.useFakeTimers(); + const h = outputHarness(); + const stdin = Object.assign(new PassThrough(), { + isRaw: false, + setRawMode: vi.fn(), + }); + vi.spyOn(process, "stdin", "get").mockReturnValue( + stdin as typeof process.stdin, + ); + vi.spyOn(process, "kill").mockReturnValue(true); + vi.stubEnv("TERM_PROGRAM", ""); + const terminal = new ProcessTerminal(); + const input = vi.fn(); + terminal.start(input, () => {}); + while (h.pending.length) h.complete(); + terminal.write("held replay"); + let drained = false; + const done = terminal.drainInput(1000, 50).then(() => { + drained = true; + }); + try { + await vi.advanceTimersByTimeAsync(100); + expect(drained).toBe(false); + stdin.emit("data", "x"); + expect(input).not.toHaveBeenCalled(); + while (h.pending.length) h.complete(); + await vi.advanceTimersByTimeAsync(25); + expect(drained).toBe(false); + stdin.emit("data", "y"); + await vi.advanceTimersByTimeAsync(75); + await done; + expect(input).not.toHaveBeenCalled(); + stdin.emit("data", "z"); + expect(input).toHaveBeenCalledExactlyOnceWith("z"); + } finally { + while (h.pending.length) h.complete(); + await vi.advanceTimersByTimeAsync(1000); + await done; + terminal.stop(); + while (h.pending.length) h.complete(); + } + }); + + it("coalesces frames through the observed terminal while retaining the latest input and history", async () => { + class BackpressuredTerminal extends VirtualTerminal { + outputPending = false; + writes: string[] = []; + release: (() => void) | undefined; + drainOutput(): Promise { + return new Promise((resolve) => { + this.release = resolve; + }); + } + override write(data: string): void { + this.writes.push(data); + super.write(data); + } + } + const terminal = new BackpressuredTerminal(80, 24); + const tui = new TuiMainScreen(createObservedTerminal(terminal, vi.fn())); + let tail = "first"; + const history = Array.from({ length: 80 }, (_, i) => `History ${i}`); + tui.addChild({ + render: () => [...history, `${tail}${CURSOR_MARKER}`, "status"], + invalidate() {}, + }); + tui.renderNow(); + await terminal.flush(); + terminal.writes = []; + terminal.outputPending = true; + for (const value of ["stale 1", "stale 2", "latest input"]) { + tail = value; + tui.renderNow(); + } + expect(terminal.writes).toEqual([]); + terminal.outputPending = false; + terminal.release?.(); + await vi.waitFor(() => + expect(terminal.writes.join("")).toContain("latest input"), + ); + await terminal.flush(); + terminal.scrollLines(10000); + expect(terminal.getViewport().slice(-2)).toEqual([ + "latest input", + "status", + ]); + expect( + terminal.getScrollBuffer().filter((line) => line.startsWith("History ")), + ).toEqual(history); + expect(terminal.writes.join("")).not.toContain("stale"); + tui.stop(); + }); +}); diff --git a/packages/tui/test/unit/tui-transcript-presentation.test.ts b/packages/tui/test/unit/tui-transcript-presentation.test.ts index 4f8846fda..1a1e3f48e 100644 --- a/packages/tui/test/unit/tui-transcript-presentation.test.ts +++ b/packages/tui/test/unit/tui-transcript-presentation.test.ts @@ -4,6 +4,17 @@ import { createTranscriptCell } from '../../src/tui/transcript/model.js'; import { TranscriptPresentationController } from '../../src/tui/transcript/presentation/state.js'; describe('TranscriptPresentationController', () => { + it('uses the sanitized Hook title in presentation and search', () => { + const presentation = presentTranscriptCell(createTranscriptCell({ + id: 'hook', kind: 'warning', status: 'succeeded', createdAtMs: 1, + title: 'Hook · Stop\x1b]2;PRIVATE\x07', content: 'Report ready', + })); + expect(presentation.title).toBe('Hook · Stop'); + expect(presentation.searchText).toContain('hook · stop'); + expect(presentation.searchText).not.toContain('private'); + expect(presentation.rawText).toBe('Report ready'); + }); + it('uses the run timer format in duration summaries and search text', () => { const presentation = presentTranscriptCell( createTranscriptCell({ diff --git a/packages/tui/test/unit/tui-transcript-view.test.ts b/packages/tui/test/unit/tui-transcript-view.test.ts index 9e38bb670..d2bbd2e64 100644 --- a/packages/tui/test/unit/tui-transcript-view.test.ts +++ b/packages/tui/test/unit/tui-transcript-view.test.ts @@ -21,6 +21,21 @@ const previewDisplayModes = { }; describe('TranscriptView', () => { + it('renders Hook warning titles and multiline text without terminal controls', () => { + const view = new TranscriptView(() => [createTranscriptCell({ + id: 'hook', kind: 'warning', status: 'succeeded', createdAtMs: 1, + title: 'Hook · Stop\x1b]2;FORGED\x07', content: '报告已生成\nSafe\x1b]52;c;PRIVATE\x07', + }), createTranscriptCell({ id: 'old-warning', kind: 'warning', content: 'Existing warning', createdAtMs: 2 })]); + const rendered = view.render(30).join('\n'); + expect(stripVTControlCharacters(rendered)).toContain('Hook · Stop'); + expect(stripVTControlCharacters(rendered)).toContain('报告已生成'); + expect(stripVTControlCharacters(rendered)).toContain('Safe'); + expect(stripVTControlCharacters(rendered).replace(/\s+/gu, ' ')).toContain('Warning Existing warning'); + expect(rendered).not.toContain('FORGED'); + expect(rendered).not.toContain('PRIVATE'); + expect(rendered).not.toContain('\x07'); + }); + it.each(['assistant', 'assistant-preamble', 'thinking'] as const)( 'removes control strings from %s content without changing stored model output', (kind) => { diff --git a/release/public-source.json b/release/public-source.json index d6c368d03..fe70ada5f 100644 --- a/release/public-source.json +++ b/release/public-source.json @@ -44,8 +44,11 @@ "docker/entrypoint.sh", "docs/README.md", "docs/architecture.md", + "docs/assets/hook-system-message-tui.png", "docs/assets/license.svg", "docs/assets/node.svg", + "docs/assets/pocket-pet-demo.mp4", + "docs/assets/pocket-pet-demo.png", "docs/assets/social-preview.png", "docs/assets/social-preview.svg", "docs/assets/source-preview.svg", @@ -56,6 +59,7 @@ "docs/assets/wordmark-light.svg", "docs/demo.md", "docs/examples.md", + "docs/hooks.md", "docs/installation.md", "docs/maintainers.md", "docs/open-source-status.md", @@ -75,6 +79,20 @@ "examples/clamp/README.md", "examples/clamp/clamp.mjs", "examples/clamp/clamp.test.mjs", + "examples/pocket-pet/README.md", + "examples/pocket-pet/finished/app.js", + "examples/pocket-pet/finished/hold.test.mjs", + "examples/pocket-pet/finished/index.html", + "examples/pocket-pet/finished/style.css", + "examples/pocket-pet/finished/timer.mjs", + "examples/pocket-pet/finished/timer.test.mjs", + "examples/pocket-pet/prompt-1.txt", + "examples/pocket-pet/prompt-2.txt", + "examples/pocket-pet/serve.mjs", + "examples/pocket-pet/setup.mjs", + "examples/pocket-pet/starter/app.js", + "examples/pocket-pet/starter/index.html", + "examples/pocket-pet/starter/style.css", "package.json", "packages/agent-core/package.json", "packages/agent-core/src/bash-subprocess-env.ts", @@ -1301,6 +1319,7 @@ "packages/local-runtime-v2/src/service/turn-system/agent-host/assembly/local-turn-payload-transform.ts", "packages/local-runtime-v2/src/service/turn-system/agent-host/assembly/local-turn-plugin-capabilities.test.ts", "packages/local-runtime-v2/src/service/turn-system/agent-host/assembly/local-turn-plugin-capabilities.ts", + "packages/local-runtime-v2/src/service/turn-system/agent-host/assembly/local-turn-plugin-hooks.test.ts", "packages/local-runtime-v2/src/service/turn-system/agent-host/assembly/local-turn-plugin-hooks.ts", "packages/local-runtime-v2/src/service/turn-system/agent-host/assembly/local-turn-tool-catalog-source.ts", "packages/local-runtime-v2/src/service/turn-system/agent-host/assembly/local-turn-tool-catalog.ts", @@ -1410,12 +1429,16 @@ "packages/local-runtime-v2/src/service/turn-system/compaction/local-context-compactor.ts", "packages/local-runtime-v2/src/service/turn-system/contracts.ts", "packages/local-runtime-v2/src/service/turn-system/execution/contracts.ts", + "packages/local-runtime-v2/src/service/turn-system/execution/execution.coordinator.test.ts", "packages/local-runtime-v2/src/service/turn-system/execution/execution.coordinator.ts", "packages/local-runtime-v2/src/service/turn-system/execution/message-query-authorship.ts", + "packages/local-runtime-v2/src/service/turn-system/execution/queue-pause-on-abort.test.ts", + "packages/local-runtime-v2/src/service/turn-system/execution/queue-pause-on-abort.ts", "packages/local-runtime-v2/src/service/turn-system/execution/reminder/background-cadence-reminder.ts", "packages/local-runtime-v2/src/service/turn-system/execution/reminder/reminder-admission.ts", "packages/local-runtime-v2/src/service/turn-system/execution/reminder/todo-cadence-reminder.ts", "packages/local-runtime-v2/src/service/turn-system/execution/steering/steer-session.service.ts", + "packages/local-runtime-v2/src/service/turn-system/execution/steering/steering-requeue.test.ts", "packages/local-runtime-v2/src/service/turn-system/execution/steering/steering-requeue.ts", "packages/local-runtime-v2/src/service/turn-system/execution/steering/steering-teardown-commit.ts", "packages/local-runtime-v2/src/service/turn-system/execution/submission-preparation.ts", @@ -1423,6 +1446,7 @@ "packages/local-runtime-v2/src/service/turn-system/execution/turn-controller/contracts.ts", "packages/local-runtime-v2/src/service/turn-system/execution/turn-controller/turn.controller.ts", "packages/local-runtime-v2/src/service/turn-system/execution/turn-controller/wait-for-release.ts", + "packages/local-runtime-v2/src/service/turn-system/execution/turn-execution.service.test.ts", "packages/local-runtime-v2/src/service/turn-system/execution/turn-execution.service.ts", "packages/local-runtime-v2/src/service/turn-system/execution/turn-input-identity.ts", "packages/local-runtime-v2/src/service/turn-system/index.ts", @@ -1489,6 +1513,7 @@ "packages/local-runtime-v2/test/integration/btw-settled-history.integration.test.ts", "packages/local-runtime-v2/test/integration/mcp/local-mcp-public-facade.integration.test.ts", "packages/local-runtime-v2/test/integration/mcp/project-mcp.integration.test.ts", + "packages/local-runtime-v2/test/integration/session-stop-cascade.integration.test.ts", "packages/local-runtime-v2/test/integration/session-system-queue-repository.integration.test.ts", "packages/local-runtime-v2/test/unit/agent/agent-import.test.ts", "packages/local-runtime-v2/test/unit/agent/builtin/catalog.test.ts", @@ -2158,6 +2183,8 @@ "packages/local-runtime/src/background-task/runner.ts", "packages/local-runtime/src/background-task/schema.ts", "packages/local-runtime/src/background-task/service.ts", + "packages/local-runtime/src/background-task/session-cascade-targets.ts", + "packages/local-runtime/src/background-task/session-cascade.ts", "packages/local-runtime/src/background-task/startup-recovery.ts", "packages/local-runtime/src/background-task/store.ts", "packages/local-runtime/src/background-task/subagent-task-row.ts", @@ -2166,6 +2193,8 @@ "packages/local-runtime/src/background-task/task-waiter-registry.ts", "packages/local-runtime/src/background-task/terminal.ts", "packages/local-runtime/src/background-task/turn-id.ts", + "packages/local-runtime/src/background-task/user-stop-cascade.ts", + "packages/local-runtime/src/background-task/user-stop-suppression.ts", "packages/local-runtime/src/browser/api.ts", "packages/local-runtime/src/channels/access-control-policy.ts", "packages/local-runtime/src/channels/access-control-routes.ts", @@ -2675,12 +2704,14 @@ "packages/local-runtime/test/unit/content-safety-turn-v2.test.ts", "packages/local-runtime/test/unit/error-reporting-privacy.test.ts", "packages/local-runtime/test/unit/local-background-task-task-output-polling-reminder.test.ts", + "packages/local-runtime/test/unit/local-background-task-user-stop-cascade.test.ts", "packages/local-runtime/test/unit/local-memory-edit-literal.test.ts", "packages/local-runtime/test/unit/local-native-tools.test.ts", "packages/local-runtime/test/unit/local-output-safety-writer-v2-guide-review.test.ts", "packages/local-runtime/test/unit/local-permission-facade.test.ts", "packages/local-runtime/test/unit/local-runtime-host-input-safety-v2.test.ts", "packages/local-runtime/test/unit/local-runtime-host-output-safety-concurrency.test.ts", + "packages/local-runtime/test/unit/local-task-append.test.ts", "packages/local-runtime/test/unit/local-thread-goal-orchestrator.test.ts", "packages/local-runtime/test/unit/local-thread-goal-store.test.ts", "packages/local-runtime/test/unit/local-thread-goal-wiring.test.ts", @@ -3015,6 +3046,7 @@ "packages/tui/src/tui/controller/projection/status-metrics-flow.ts", "packages/tui/src/tui/controller/projection/tool-payload-budget.ts", "packages/tui/src/tui/controller/projection/turn-history-projection.ts", + "packages/tui/src/tui/controller/projection/turn-hook-message-projection.ts", "packages/tui/src/tui/controller/projection/turn-live-projection.ts", "packages/tui/src/tui/controller/projection/turn-live-state.ts", "packages/tui/src/tui/controller/projection/turn-output-rate.ts", @@ -3305,6 +3337,7 @@ "packages/tui/test/unit/tui-engine-local-deltas.test.ts", "packages/tui/test/unit/tui-feature-viewport-contract.test.ts", "packages/tui/test/unit/tui-goal-banner.test.ts", + "packages/tui/test/unit/tui-hook-system-message.test.ts", "packages/tui/test/unit/tui-image-preview.test.ts", "packages/tui/test/unit/tui-ime-cursor.test.ts", "packages/tui/test/unit/tui-keybindings.test.ts", @@ -3315,6 +3348,7 @@ "packages/tui/test/unit/tui-provider-manager.test.ts", "packages/tui/test/unit/tui-provider-onboarding.test.ts", "packages/tui/test/unit/tui-runtime-adapter.test.ts", + "packages/tui/test/unit/tui-scrollback-stability.test.ts", "packages/tui/test/unit/tui-scrollbar-interaction.test.ts", "packages/tui/test/unit/tui-session-history-flow.test.ts", "packages/tui/test/unit/tui-session-manager.test.ts", @@ -3324,6 +3358,7 @@ "packages/tui/test/unit/tui-stream-events.test.ts", "packages/tui/test/unit/tui-surface-host.test.ts", "packages/tui/test/unit/tui-terminal-image-paste.test.ts", + "packages/tui/test/unit/tui-terminal-output.test.ts", "packages/tui/test/unit/tui-terminal-presentation.test.ts", "packages/tui/test/unit/tui-terminal-text-paste.test.ts", "packages/tui/test/unit/tui-thinking-preview.test.ts", diff --git a/scripts/check-windows-source-location.mjs b/scripts/check-windows-source-location.mjs index 964f31b3d..d7ef441de 100644 --- a/scripts/check-windows-source-location.mjs +++ b/scripts/check-windows-source-location.mjs @@ -28,30 +28,47 @@ export function checkWindowsSourceLocation({ if (!/^[a-z]:\\$/iu.test(root) || root.startsWith("\\\\")) { return fail("The checkout root is not a local drive-letter path."); } - // `fsutil` accepts a drive letter more reliably than a root path with a - // trailing backslash across Windows runner images. const volume = root.slice(0, 2); - let driveType; - let volumeInfo; + let disk; try { - driveType = execFile("fsutil", ["fsinfo", "drivetype", volume], { - encoding: "utf8", - stdio: ["ignore", "pipe", "pipe"], - }); - volumeInfo = execFile("fsutil", ["fsinfo", "volumeinfo", volume], { + // CIM exposes numeric drive types and filesystem identifiers independent of + // display language. fsutil prints localized text (e.g. "E: - Fixed Drive") + // and its volumeinfo command can require elevation. Query CIM directly so + // ordinary contributor shells use the same structured policy as CI. + // Only the validated drive letter above is interpolated, never the cwd. + const output = execFile("powershell.exe", [ + "-NoLogo", "-NoProfile", "-NonInteractive", "-Command", + "$ErrorActionPreference = 'Stop'; " + + `Get-CimInstance -ClassName Win32_LogicalDisk -Filter "DeviceID='${volume}'" | ` + + "Select-Object DeviceID, DriveType, FileSystem | ConvertTo-Json -Compress", + ], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], + timeout: 15_000, + windowsHide: true, }); + disk = JSON.parse(output.trim()); + if ( + !disk || Array.isArray(disk) || + typeof disk.DeviceID !== "string" || + disk.DeviceID.toUpperCase() !== volume.toUpperCase() || + !Number.isInteger(disk.DriveType) || + disk.DriveType < 0 || disk.DriveType > 6 || + typeof disk.FileSystem !== "string" + ) { + throw new Error("CIM returned incomplete or unexpected volume information"); + } } catch (error) { const detail = error instanceof Error ? ` (${error.message})` : ""; return fail(`Windows could not verify the checkout volume${detail}.`); } - if (!allowNonFixed && !/:\s*DRIVE_FIXED(?:\r?\n|$)/iu.test(driveType)) { + // Win32_LogicalDisk.DriveType: 3 = local disk, 4 = network drive. + if (!allowNonFixed && disk.DriveType !== 3) { return fail("The checkout volume is not a local fixed drive."); } - if (!/:\s*NTFS(?:\r?\n|$)/iu.test(volumeInfo)) { + if (disk.FileSystem.toUpperCase() !== "NTFS") { return fail("The checkout volume is not formatted as NTFS."); } diff --git a/test/byok.test.mjs b/test/byok.test.mjs index 15feaf545..d02d010c5 100644 --- a/test/byok.test.mjs +++ b/test/byok.test.mjs @@ -7,6 +7,7 @@ import { createRequire } from "node:module"; import { mkdtempSync, mkdirSync, + chmodSync, rmSync, existsSync, readFileSync, @@ -154,6 +155,18 @@ test( : "Unexpected external request", ); } finally { + // Hook snapshots deliberately use read-only directories. Restore only + // this isolated fixture's directory permissions before removing it. + const hookCache = path.join(dataDir, "v2", "plugin-hook-cache"); + if (existsSync(hookCache)) { + const writable = (directory) => { + chmodSync(directory, 0o700); + for (const entry of readdirSync(directory, { withFileTypes: true })) { + if (entry.isDirectory()) writable(path.join(directory, entry.name)); + } + }; + writable(hookCache); + } rmSync(fixtureDir, { recursive: true, force: true }); } }); @@ -431,6 +444,30 @@ test( "--effort", "medium", "--timeout", "20s", "--max-steps", "1"]), /Available levels: low, high, max/); assert.equal(requests.length, beforeInvalidEffort, "Invalid effort must fail before transport"); + // Real Plugin packages exercise command execution and all three output adapters. + const hookMarkers = ["minimax", "claude", "codex"].map((format) => { + const name = `hook-notice-${format}`; + const root = path.join(dataDir, "plugins", name); + const manifestDir = format === "minimax" ? ".minimax-plugin" : `.${format}-plugin`; + mkdirSync(path.join(root, manifestDir), { recursive: true }); + mkdirSync(path.join(root, "hooks")); + const marker = `HOOK_DISPLAY_ONLY_${format.toUpperCase()}`; + const manifest = format === "minimax" ? { + schemaVersion: 1, name, version: "1.0.0", description: "Synthetic Hook display fixture", + author: "Test", icon: "icon.png", category: "Code", exampleQueries: ["Reply OK"], + apps: [], mcpServers: [], skills: [], hooks: ["hooks/hooks.json"], + } : { name, version: "1.0.0" }; + writeFileSync(path.join(root, manifestDir, "plugin.json"), JSON.stringify(manifest)); + writeFileSync(path.join(root, "icon.png"), Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+a5N8AAAAASUVORK5CYII=", "base64")); + writeFileSync(path.join(root, "hooks", "hooks.json"), JSON.stringify({ hooks: { Stop: [{ hooks: [{ + type: "command", command: 'node "${PLUGIN_ROOT}/notice.cjs"', timeout: 5, + }] }] } })); + writeFileSync(path.join(root, "notice.cjs"), + `process.stdout.write(JSON.stringify({systemMessage: ${JSON.stringify(marker)}}));`); + return marker; + }); + const beforeFirst = requests.length; const modelArgs = ["--model", `${selected.providerId}/fixture-model`]; // The first run must work through the saved default, without --model or managed login. const first = await run([ @@ -442,6 +479,9 @@ test( "1", ]); assert.match(first, /LOCAL_BYOK_OK/); + assert.equal(requests.slice(beforeFirst).filter((r) => r.body.stream).length, 1, + "Display-only Hooks must not start another model request"); + for (const marker of hookMarkers) assert.ok(!first.includes(marker)); await assert.rejects(run([ 'exec', 'Managed model still requires its own login', '--model', 'minimax/MiniMax-M3', '--timeout', '20s', '--max-steps', '1', @@ -470,6 +510,34 @@ test( requests.map((r) => ({ url: r.url, keys: Object.keys(r.body) })), ), ); + assert.equal(requests.slice(beforeResume).filter((r) => r.body.stream).length, 1); + for (const marker of hookMarkers) { + assert.ok(!second.includes(marker), "Hook notices must not become the final answer"); + assert.ok(!JSON.stringify(requests.slice(beforeResume)).includes(marker), + "The resumed provider request must not contain Hook display messages"); + } + const displayDb = new Database(dbPath, { readonly: true }); + try { + const notices = displayDb.prepare("SELECT data_json FROM local_runtime_message_rows").all() + .map((row) => JSON.parse(row.data_json)) + .filter((message) => typeof message.msg_content === "string" && + hookMarkers.some((marker) => message.msg_content.includes(marker))); + assert.equal(notices.length, 2, "Both turns must persist a Hook notice before CLI shutdown"); + assert.notEqual(notices[0].msg_id, notices[1].msg_id); + for (const message of notices) { + const notice = JSON.parse(message.msg_content); + assert.equal(notice.eventType, "runtime.warning"); + assert.equal(notice.category, "system-message"); + for (const marker of hookMarkers) assert.ok(notice.message.includes(marker)); + } + } finally { displayDb.close(); } + const historyRoot = path.join(dataDir, "v2", "sessions"); + const canonicalFiles = readdirSync(historyRoot, { recursive: true }).filter((file) => file.endsWith("messages.jsonl")); + assert.ok(canonicalFiles.length > 0); + const canonical = canonicalFiles.map((file) => readFileSync(path.join(historyRoot, file), "utf8")).join("\n"); + assert.ok(canonical.includes("SOURCE_REPOSITORY_TEST")); + for (const marker of hookMarkers) assert.ok(!canonical.includes(marker), + "Canonical history used by subsequent turns and compaction must not contain Hook notices"); const toolStart = requests.length; await run([ "exec", diff --git a/test/source-sync.test.mjs b/test/source-sync.test.mjs index 8625adf70..1d3784064 100644 --- a/test/source-sync.test.mjs +++ b/test/source-sync.test.mjs @@ -24,65 +24,100 @@ import { checkWindowsSourceLocation, runWindowsSourceLocationCheck } from '../sc import { collectTestIsolationViolations, formatTestIsolationViolations } from '../scripts/test-isolation-lint.check.mjs'; import { scanTmpLeaks, formatTmpLeaks, verifyPrefixRegistry, formatPrefixRegistry } from '../scripts/test-tmp-leak.check.mjs'; -test('Windows source preflight accepts localized fsutil labels', () => { - const result = checkWindowsSourceLocation({ - platform: 'win32', - cwd: 'C:\\repo', - execFile: (_command, args) => args[1] === 'drivetype' - ? 'Laufwerkstyp: DRIVE_FIXED\n' - : 'Dateisystemname: NTFS\n', - }); - assert.deepEqual(result, { ok: true, skipped: false }); +test('Windows source preflight accepts local NTFS without parsing fsutil display text', () => { + for (const fsutilOutput of ['E: - Fixed Drive\r\n', 'E: - Festplatte\r\n', new Error('Error 5: Access is denied')]) { + const calls = []; + const result = checkWindowsSourceLocation({ + platform: 'win32', cwd: 'E:\\src\\minimax-code', allowNonFixed: false, + execFile: (command, args, options) => { + calls.push(command); + if (command === 'fsutil') { + if (fsutilOutput instanceof Error) throw fsutilOutput; + return args[1] === 'drivetype' ? fsutilOutput : 'File System Name : NTFS\r\n'; + } + assert.equal(command, 'powershell.exe'); + assert.deepEqual(args.slice(0, 4), ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command']); + assert.match(args[4], /\$ErrorActionPreference\s*=\s*'Stop'/); + assert.match(args[4], /Get-CimInstance -ClassName Win32_LogicalDisk -Filter "DeviceID='E:'"/); + assert.match(args[4], /ConvertTo-Json -Compress/); + assert.equal(options.encoding, 'utf8'); + assert.ok(options.timeout > 0); + return '\r\n{"DeviceID":"E:","DriveType":3,"FileSystem":"NTFS"}\r\n'; + }, + }); + assert.deepEqual(result, { ok: true, skipped: false }); + assert.deepEqual(calls, ['powershell.exe']); + } }); -test('Windows source preflight requires a local NTFS checkout', () => { - const calls = []; - const execFile = (command, args) => { - calls.push([command, args]); - if (args[1] === 'drivetype') return 'Drive type is : DRIVE_FIXED\n'; - return 'File System Name : NTFS\n'; - }; - assert.deepEqual( - checkWindowsSourceLocation({ platform: 'win32', cwd: 'C:\\repo', execFile }), - { ok: true, skipped: false }, - ); - assert.deepEqual(calls.map(([command, args]) => [command, args[1], args[2]]), [ - ['fsutil', 'drivetype', 'C:'], - ['fsutil', 'volumeinfo', 'C:'], - ]); +const checkWindowsVolume = (disk, allowNonFixed = false) => checkWindowsSourceLocation({ + platform: 'win32', cwd: 'C:\\repo', allowNonFixed, + execFile: () => JSON.stringify({ DeviceID: 'C:', DriveType: 3, FileSystem: 'NTFS', ...disk }), }); -test('Windows source preflight rejects unsupported volumes clearly', () => { - const run = (driveType, volumeInfo, cwd = 'C:\\repo', allowNonFixed = false) => checkWindowsSourceLocation({ - platform: 'win32', cwd, allowNonFixed, - execFile: (_command, args) => args[1] === 'drivetype' ? driveType : volumeInfo, - }); - assert.match(run('Drive type is : DRIVE_REMOTE\n', 'File System Name : NTFS\n').reason, /not a local fixed drive/); - assert.deepEqual( - run('Drive type is : DRIVE_REMOTE\n', 'File System Name : NTFS\n', 'C:\\repo', true), - { ok: true, skipped: false }, - ); - assert.match(run('Drive type is : DRIVE_FIXED\n', 'File System Name : NTFS\n', '\\\\server\\share\\repo').reason, /not a local drive-letter path/); +test('Windows source preflight rejects unsupported drive types and file systems', () => { + for (const DriveType of [0, 1, 2, 4, 5, 6]) { + assert.match(checkWindowsVolume({ DriveType }).reason, /not a local fixed drive/); + } + for (const FileSystem of ['FAT32', 'exFAT', 'ReFS', '']) { + for (const allowNonFixed of [false, true]) { + assert.match(checkWindowsVolume({ FileSystem }, allowNonFixed).reason, /not formatted as NTFS/); + } + } + // Preserve the existing explicit runner override, but never use it in the + // positive fixed-drive regression or native-host acceptance case. + assert.deepEqual(checkWindowsVolume({ DriveType: 4 }, true), { ok: true, skipped: false }); +}); + +test('Windows source preflight rejects UNC paths before querying Windows', () => { + for (const allowNonFixed of [false, true]) { + const result = checkWindowsSourceLocation({ + platform: 'win32', cwd: '\\\\server\\share\\repo', allowNonFixed, + execFile: () => assert.fail('must not query a UNC path'), + }); + assert.match(result.reason, /not a local drive-letter path/); + } +}); + +test('Windows source preflight fails closed on missing, malformed or mismatched CIM data', () => { + for (const output of [ + '', 'not JSON', 'null', '[]', '{}', + '{"DeviceID":"D:","DriveType":3,"FileSystem":"NTFS"}', + '{"DeviceID":"C:","DriveType":"3","FileSystem":"NTFS"}', + '{"DeviceID":"C:","DriveType":3,"FileSystem":null}', + '{"DeviceID":"C:","DriveType":null,"FileSystem":"NTFS"}', + '[{"DeviceID":"C:","DriveType":3,"FileSystem":"NTFS"}]', + ]) { + for (const allowNonFixed of [false, true]) { + const result = checkWindowsSourceLocation({ + platform: 'win32', cwd: 'C:\\repo', allowNonFixed, execFile: () => output, + }); + assert.equal(result.ok, false, output); + assert.match(result.reason, /could not verify the checkout volume/); + } + } + assert.deepEqual(checkWindowsVolume({ DeviceID: 'c:', FileSystem: 'ntfs' }), { ok: true, skipped: false }); }); test('Windows source preflight is a no-op on non-Windows platforms', () => { assert.deepEqual( - checkWindowsSourceLocation({ platform: 'linux', execFile: () => assert.fail('must not run fsutil') }), + checkWindowsSourceLocation({ platform: 'linux', execFile: () => assert.fail('must not query Windows') }), { ok: true, skipped: true }, ); }); -test('Windows source preflight propagates a failed check to the CLI', () => { - const messages = []; - const result = runWindowsSourceLocationCheck({ - platform: 'win32', - cwd: 'C:\\repo', - execFile: () => { throw new Error('fsutil unavailable'); }, - report: (message) => messages.push(message), - }); - assert.equal(result.ok, false); - assert.equal(messages.length, 1); - assert.match(messages[0], /fsutil unavailable/); +test('Windows source preflight reports command, permission and timeout failures to the CLI', () => { + for (const error of [new Error('spawn powershell.exe ENOENT'), new Error('Access is denied'), new Error('ETIMEDOUT')]) { + const messages = []; + const result = runWindowsSourceLocationCheck({ + platform: 'win32', cwd: 'C:\\repo', allowNonFixed: false, + execFile: () => { throw error; }, + report: (message) => messages.push(message), + }); + assert.equal(result.ok, false); + assert.equal(messages.length, 1); + assert.ok(messages[0].includes(error.message)); + } }); test('artifact download recovers from TLS reset and interrupted response bodies', async () => { diff --git a/test/vitest-suites.json b/test/vitest-suites.json index ae7cc6a40..dce989777 100644 --- a/test/vitest-suites.json +++ b/test/vitest-suites.json @@ -57,6 +57,7 @@ "packages/local-runtime-v2/src/service/plugin-system/cloud-transport.test.ts", "packages/local-runtime-v2/src/service/session-system/fork/side-history-boundary.test.ts", "packages/local-runtime-v2/src/service/turn-system/agent-host/assembly/local-turn-plugin-capabilities.test.ts", + "packages/local-runtime-v2/src/service/turn-system/agent-host/assembly/local-turn-plugin-hooks.test.ts", "packages/local-runtime-v2/src/service/turn-system/agent-host/compaction/context-compaction.test.ts", "packages/local-runtime-v2/src/service/turn-system/agent-host/execution/executor.test.ts", "packages/local-runtime-v2/src/service/turn-system/compaction/algorithm/compact-context.test.ts", @@ -64,11 +65,16 @@ "packages/local-runtime-v2/src/service/turn-system/compaction/execution/checkpoint-prompt.test.ts", "packages/local-runtime-v2/src/service/turn-system/compaction/execution/checkpoint-provider.test.ts", "packages/local-runtime-v2/src/service/turn-system/compaction/local-context-compactor.test.ts", + "packages/local-runtime-v2/src/service/turn-system/execution/execution.coordinator.test.ts", + "packages/local-runtime-v2/src/service/turn-system/execution/queue-pause-on-abort.test.ts", + "packages/local-runtime-v2/src/service/turn-system/execution/steering/steering-requeue.test.ts", + "packages/local-runtime-v2/src/service/turn-system/execution/turn-execution.service.test.ts", "packages/local-runtime-v2/src/service/turn-system/queue.dispatcher.test.ts", "packages/local-runtime-v2/src/services.test.ts", "packages/local-runtime-v2/test/integration/btw-settled-history.integration.test.ts", "packages/local-runtime-v2/test/integration/mcp/local-mcp-public-facade.integration.test.ts", "packages/local-runtime-v2/test/integration/mcp/project-mcp.integration.test.ts", + "packages/local-runtime-v2/test/integration/session-stop-cascade.integration.test.ts", "packages/local-runtime-v2/test/integration/session-system-queue-repository.integration.test.ts", "packages/local-runtime-v2/test/unit/agent/agent-import.test.ts", "packages/local-runtime-v2/test/unit/agent/builtin/catalog.test.ts", @@ -81,11 +87,13 @@ "packages/local-runtime/test/unit/content-safety-turn-v2.test.ts", "packages/local-runtime/test/unit/error-reporting-privacy.test.ts", "packages/local-runtime/test/unit/local-background-task-task-output-polling-reminder.test.ts", + "packages/local-runtime/test/unit/local-background-task-user-stop-cascade.test.ts", "packages/local-runtime/test/unit/local-memory-edit-literal.test.ts", "packages/local-runtime/test/unit/local-native-tools.test.ts", "packages/local-runtime/test/unit/local-output-safety-writer-v2-guide-review.test.ts", "packages/local-runtime/test/unit/local-runtime-host-input-safety-v2.test.ts", "packages/local-runtime/test/unit/local-runtime-host-output-safety-concurrency.test.ts", + "packages/local-runtime/test/unit/local-task-append.test.ts", "packages/local-runtime/test/unit/local-thread-goal-orchestrator.test.ts", "packages/local-runtime/test/unit/local-thread-goal-store.test.ts", "packages/local-runtime/test/unit/local-thread-goal-wiring.test.ts", @@ -147,6 +155,7 @@ "packages/tui/test/unit/tui-engine-local-deltas.test.ts", "packages/tui/test/unit/tui-feature-viewport-contract.test.ts", "packages/tui/test/unit/tui-goal-banner.test.ts", + "packages/tui/test/unit/tui-hook-system-message.test.ts", "packages/tui/test/unit/tui-image-preview.test.ts", "packages/tui/test/unit/tui-ime-cursor.test.ts", "packages/tui/test/unit/tui-keybindings.test.ts", @@ -157,6 +166,7 @@ "packages/tui/test/unit/tui-provider-manager.test.ts", "packages/tui/test/unit/tui-provider-onboarding.test.ts", "packages/tui/test/unit/tui-runtime-adapter.test.ts", + "packages/tui/test/unit/tui-scrollback-stability.test.ts", "packages/tui/test/unit/tui-scrollbar-interaction.test.ts", "packages/tui/test/unit/tui-session-history-flow.test.ts", "packages/tui/test/unit/tui-session-manager.test.ts", @@ -166,6 +176,7 @@ "packages/tui/test/unit/tui-stream-events.test.ts", "packages/tui/test/unit/tui-surface-host.test.ts", "packages/tui/test/unit/tui-terminal-image-paste.test.ts", + "packages/tui/test/unit/tui-terminal-output.test.ts", "packages/tui/test/unit/tui-terminal-presentation.test.ts", "packages/tui/test/unit/tui-terminal-text-paste.test.ts", "packages/tui/test/unit/tui-thinking-preview.test.ts", diff --git a/test/windows-contract.test.mjs b/test/windows-contract.test.mjs index 0284ff679..2bc367ac4 100644 --- a/test/windows-contract.test.mjs +++ b/test/windows-contract.test.mjs @@ -19,16 +19,16 @@ afterEach(() => { }); describe.skipIf(process.platform !== "win32")("Windows source contract", () => { - // Two *synchronous* `fsutil` spawns. Vitest's default 5s budget is not enough - // for that on a loaded Windows runner — measured 8.4s in CI, where the failure - // is a timeout rather than a wrong answer. The first spawn of a binary on a - // Defender-scanned volume pays the scan; on main's own run the same test - // passed inside the default, which is what makes this look flaky rather than - // broken. A generous explicit budget keeps what the test actually asserts — - // that a real Windows host accepts this checkout on a local NTFS volume — - // without turning it into a stopwatch on the runner. + // One *synchronous* `powershell.exe` spawn. Vitest's default 5s budget is not + // enough for PowerShell cold start on a loaded Windows runner, where the + // failure is a timeout rather than a wrong answer. A generous explicit budget + // keeps what the test actually asserts — that a real Windows host accepts this + // checkout on a local NTFS volume — without turning it into a stopwatch on the + // runner. `allowNonFixed: false` keeps the assertion on a local fixed volume + // rather than inheriting the GITHUB_ACTIONS default, which permits a network + // drive and would let a non-local checkout pass a test named for a local one. it("accepts the Windows checkout on a local NTFS volume", { timeout: 60_000 }, () => { - assert.deepEqual(checkWindowsSourceLocation(), { + assert.deepEqual(checkWindowsSourceLocation({ allowNonFixed: false }), { ok: true, skipped: false, });