diff --git a/.github/dependabot.yml b/.github/dependabot.yml index a567bc791..bea720360 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -2,6 +2,13 @@ # package ecosystems to update and where the package manifests are located. # Please see the documentation for all configuration options: # https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file +# +# Every ecosystem below declares `cooldown`. `npm ci` installs package-lock.json +# verbatim, so the `min-release-age=2` gate in the committed .npmrc only binds +# when the lockfile is *updated*. Without a matching cooldown here an automated +# bump could open a PR against a release younger than the local gate would ever +# install, and merging it would write that version into the lockfile. The two +# controls express the same policy from opposite sides and must stay in step. version: 2 updates: @@ -10,22 +17,26 @@ updates: directory: "/" schedule: interval: "weekly" + cooldown: + default-days: 2 commit-message: prefix: "ci" labels: - "dependencies" - "github-actions" - # Bun packages (root) - - package-ecosystem: "bun" + # npm packages (root workspace) + - package-ecosystem: "npm" directory: "/" schedule: interval: "weekly" + cooldown: + default-days: 2 commit-message: prefix: "deps" labels: - "dependencies" - - "bun" + - "npm" ignore: # @types/node is pinned via a root `overrides` entry to match # upstream Pi's resolved lockfile (see #1489); bumps here are no-ops. @@ -36,8 +47,10 @@ updates: directory: "/" schedule: interval: "weekly" + cooldown: + default-days: 2 commit-message: prefix: "deps" labels: - "dependencies" - - "rust" \ No newline at end of file + - "rust" diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 7937b7779..481c17ce2 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -110,7 +110,7 @@ jobs: bun-version: 1.3.14 - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: - node-version: 24 + node-version: 22 # rustup needs the bare target; Linux's napi build separately receives the # glibc-suffixed target consumed by cargo-zigbuild. - uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # v1 @@ -125,7 +125,7 @@ jobs: toolchain_bin="$(dirname "$(rustup which cargo)")" echo "$toolchain_bin" >> "$GITHUB_PATH" - name: Install dependencies - run: bun install --frozen-lockfile + run: npm ci --ignore-scripts - name: Resolve explicit native build target id: target shell: bash @@ -238,7 +238,7 @@ jobs: bun-version: 1.3.14 - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: - node-version: 24 + node-version: 22 - uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # v1 timeout-minutes: 4 with: @@ -282,7 +282,7 @@ jobs: bun-version: 1.3.14 - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: - node-version: 24 + node-version: 22 - uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # v1 timeout-minutes: 4 with: @@ -329,12 +329,12 @@ jobs: bun-version: 1.3.14 - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: - node-version: 24 + node-version: 22 registry-url: https://registry.npmjs.org - name: Install dependencies and verify shrinkwrap run: | - bun install --frozen-lockfile - bun run check:shrinkwrap + npm ci --ignore-scripts + npm run check:shrinkwrap - name: Download native bindings uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: @@ -445,7 +445,7 @@ jobs: path: payload - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: - node-version: 24 + node-version: 22 registry-url: https://registry.npmjs.org - name: Upgrade npm for trusted publishing run: npm install -g npm@11.16.0 --ignore-scripts diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 6325b5e54..9031d776a 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -57,33 +57,37 @@ jobs: with: lfs: true fetch-depth: 0 + # Bun is still a declared engine: it compiles the release binaries and runs + # scripts/*.ts, including the flaky-suite wrapper invoked below. It no + # longer installs dependencies or runs any suite. - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: # Pinned to match publish.yml; `latest` cannot be cached by setup-bun. bun-version: 1.3.14 - # Node is required by installed-package-node-extensions.test.ts, which - # smoke-tests the built npm package under the Node runtime. + # Node now installs dependencies and runs every suite, so it is required + # here rather than only by installed-package-node-extensions.test.ts. - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: - node-version: 24 + node-version: 22 + cache: npm - name: Install dependencies - run: bun install --frozen-lockfile + run: npm ci --ignore-scripts # Both suites below consume this build. test/unit/pi-0.82.1-artifacts.test.ts # silently degrades to test.skip when packages/coding-agent/dist is absent, # so moving the unit suite out of this job would lose coverage without # failing anything. - name: Build @bastani/atomic package working-directory: packages/coding-agent - run: bun run build + run: npm run build - name: Unit tests (one bounded flake retry) run: >- bun run scripts/run-flaky-test-suite.ts --label "unit tests (${{ matrix.binary_platform }})" --no-retry-file flaky-test-suite-runner.test.ts - -- bun run test:unit + -- npm run test:unit - name: Integration tests (one bounded flake retry) run: >- bun run scripts/run-flaky-test-suite.ts --label "integration tests (${{ matrix.binary_platform }})" - -- bun run test:integration + -- npm run test:integration env: # Hard-require the installed-package Node smoke where the package # build above guarantees dist/ exists on both supported hosts. @@ -129,25 +133,30 @@ jobs: with: lfs: true fetch-depth: 0 + # Bun still compiles binaries and runs scripts/*.ts, including the wrapper. - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: bun-version: 1.3.14 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 22 + cache: npm - uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # v1 timeout-minutes: 4 with: # Required once pinned by SHA: the version otherwise comes from the ref. toolchain: stable - name: Install dependencies - run: bun install --frozen-lockfile + run: npm ci --ignore-scripts # packages/coding-agent/test/native-binding-exports.test.ts is hard-required # by ATOMIC_REQUIRE_NATIVE_BINDING_SMOKE below, so the vitest suite must # stay behind this build. - name: Build native bindings for package tests - run: bun run --cwd packages/natives build + run: npm run build --workspace=@bastani/atomic-natives - name: coding-agent vitest suite (one bounded flake retry) run: >- bun run scripts/run-flaky-test-suite.ts --label "coding-agent tests (${{ matrix.binary_platform }})" - -- bun run --cwd packages/coding-agent --bun test + -- npm run test --workspace=@bastani/atomic env: ATOMIC_REQUIRE_NATIVE_BINDING_SMOKE: "1" - name: Upload flaky-test diagnostics @@ -187,9 +196,14 @@ jobs: with: lfs: true fetch-depth: 0 + # `bun build --compile` is still the binary compiler, exactly as upstream. - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: bun-version: 1.3.14 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 22 + cache: npm # scripts/build-binaries.sh reuses packages/natives/native/*.node when they # exist and otherwise builds them, so this job needs the Rust toolchain and # pays the native build again rather than waiting on agent-suite. That is a @@ -199,10 +213,10 @@ jobs: with: toolchain: stable - name: Install dependencies - run: bun install --frozen-lockfile + run: npm ci --ignore-scripts - name: Build @bastani/atomic package working-directory: packages/coding-agent - run: bun run build + run: npm run build - name: Build native release binary shell: bash run: ./scripts/build-binaries.sh --skip-install --skip-package-build --platform "${{ matrix.binary_platform }}" @@ -310,8 +324,8 @@ jobs: static-checks: name: static-checks (linux-x64) - # Platform-independent checks. They cost 30s in total and need neither the - # Rust toolchain nor Node, so they run once on Linux instead of twice. + # Platform-independent checks. They need no Rust toolchain, so they run once + # on Linux instead of twice. runs-on: blacksmith-4vcpu-ubuntu-2404 timeout-minutes: 6 steps: @@ -319,27 +333,41 @@ jobs: with: lfs: true fetch-depth: 0 + # Still required here: packages/coding-agent's docs:check is a Bun + # TypeScript script, and scripts/*.ts run under Bun by design. - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: bun-version: 1.3.14 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 22 + cache: npm - name: Install dependencies - run: bun install --frozen-lockfile - - name: Typecheck - run: bun run typecheck + run: npm ci --ignore-scripts + - name: Check + run: npm run check - name: Docs link validation working-directory: packages/coding-agent - run: bun run docs:check + run: npm run docs:check - name: Mintlify docs validation if: github.event_name == 'pull_request' working-directory: packages/coding-agent/docs # Pinned: mintlify@4.2.732 pulls a package missing from the registry. # Unpin once upstream publishes a resolvable release. + # + # Run through `bunx --bun`, not npx: mintlify refuses to start on Node 25+ + # and this job installs no Node toolchain, so npx picks up whatever the + # runner ships. Bun hosts it regardless of the runner's Node version. timeout-minutes: 5 run: | bunx --bun mintlify@4.2.731 validate bunx --bun mintlify@4.2.731 broken-links + # pi parity: repository scripts that Node can run are tested with Node's + # own runner rather than through the workspace suites. + - name: Script tests + run: npm run test:scripts - name: Deterministic CI and release contracts - run: bun run test:ci-contracts + run: npm run test:ci-contracts # Result gate. This job exists to carry the two contexts required by # repository ruleset 9310196: diff --git a/.gitignore b/.gitignore index d60fca3ed..2e5838083 100644 --- a/.gitignore +++ b/.gitignore @@ -292,3 +292,8 @@ evals/jobs/ # CI flake-retry and duration-headroom artifacts (uploaded from CI, never tracked) .ci-diagnostics/ + +# Scratch workspaces test/unit/flaky-test-suite-runner.test.ts creates under the +# repository root, so vitest and node_modules resolve by the ordinary upward +# walk. Removed on success; ignored so an interrupted run leaves nothing tracked. +.tmp-flake-real-*/ diff --git a/.npmrc b/.npmrc new file mode 100644 index 000000000..31d23879f --- /dev/null +++ b/.npmrc @@ -0,0 +1,5 @@ +# Committed so the supply-chain gate binds every contributor's install, not just +# CI. Mirrors upstream pi's .npmrc, and replaces the `[install]` block bunfig.toml +# carried before the toolchain moved to npm. +save-exact=true +min-release-age=2 diff --git a/AGENTS.md b/AGENTS.md index e274c75fe..35ced9b19 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,7 +2,7 @@ ## Overview -This repo is the private `atomic-monorepo` Bun workspace. It currently houses: +This repo is the private `atomic-monorepo` npm workspace. It currently houses: - `@bastani/atomic` in `packages/coding-agent` — the Atomic-branded fork of pi's coding-agent CLI and the only independently published package. - `@bastani/workflows` in `packages/workflows` — a first-party extension for Atomic/pi that brings multi-stage, DAG-driven workflow execution to agent sessions. @@ -15,19 +15,32 @@ Companion packages under `packages/*` ship as **raw TypeScript** (no compile ste ## Tech Stack -This repo runs a **hybrid toolchain**, like upstream pi. Each tool is used where it is -actually better, rather than one runtime being mandated everywhere. +This repo runs a **hybrid toolchain, matching upstream `earendil-works/pi` task for task**. +Each tool is used where it is actually better, rather than one runtime being mandated +everywhere. Where the split differs from pi, the reason is written down. | Task | Tool | Why | | --- | --- | --- | -| Dependency install | `bun install` | `bun.lock` is the lockfile, and `bunfig.toml` carries the `minimumReleaseAge` supply-chain gate and `linker = "hoisted"` | -| Scripts, dev runtime, TS execution | `bun run`, `bun ` | Runs `.ts` directly and resolves `.js` specifiers to `.ts` source with no loader hook | -| Root test suites | `bun test` (`test/unit`, `test/integration`, `test/ci`) | `bun:test` + `node:assert/strict` | -| `packages/coding-agent` suite | `vitest --run` | Inherited from upstream pi, which uses vitest for its workspace tests | -| npm-package smoke tests | Node (`node-version: 24` in CI) | `test/integration/installed-package-node-extensions.test.ts` verifies the shipped `atomic` bin under `#!/usr/bin/env node`, which is how npm/bun installs run it | -| Binary compilation | `bun build --compile` | Cross-compiles the single-file executables; upstream pi uses Bun for exactly this step too | +| Dependency install | `npm ci --ignore-scripts` | `package-lock.json` is the single verified lockfile. `npm ci` refuses to install when it and `package.json` disagree; nothing enforced that while two lockfiles coexisted | +| Supply-chain gate | committed `.npmrc` | Byte-identical to pi's: `save-exact=true` and `min-release-age=2`. Binds every contributor's install, not just CI. `.github/dependabot.yml` carries the matching `cooldown` | +| Build | `npm run build` | tsgo, not Bun; no behaviour change | +| Lint / format | `biome check` (`npm run check`, `npm run format`) | pi's rule set exactly: recommended preset plus the same six overrides. Tab indent width 3, line width 120 | +| Typecheck / check | `npm run check` (biome + `tsc --noEmit` + shrinkwrap check) | pi runs biome + tsgo here | +| Root test suites | `vitest --run --project {unit,integration,ci}` | pi uses vitest for its workspace tests, with a shared `vitest.base.ts` setting only `resolve.alias` | +| `packages/coding-agent` suite | `vitest --run` | already parity; it now runs under Node rather than `bun --bun`, SQLite selectors included | +| Script tests | `node --test scripts/*.test.mjs` | pi parity. Scripts Node can run are tested with Node's own runner | +| Repository scripts | `bun run scripts/*.ts` | Bun executes `.ts` directly and resolves `.js` specifiers to `.ts` source with no loader hook. Bare `node` cannot; scripts meant for `node --test` are `.mjs` | +| Binary compilation | `bun build --compile` | Cross-compiles the single-file executables; upstream pi uses Bun for exactly this step too. Bun pinned to 1.3.14 | +| npm-package smoke tests | Node (`node-version: 22` in CI, matching pi) | `test/integration/installed-package-node-extensions.test.ts` verifies the shipped `atomic` bin under `#!/usr/bin/env node`, which is how npm installs run it | | Registry publish | `npm publish --provenance` | npm's OIDC-signed provenance lives in the npm CLI, and npm trusted publishing requires a GitHub-hosted runner | +**Where this repository deliberately declines pi's shape:** pi's CI is one `ubuntu-latest` +job with no matrix and no `timeout-minutes`. Do not copy it. This workflow produces nine +check contexts including full Windows coverage, runs on Blacksmith runners, and carries +per-job timeout budgets that `test/ci/test-workflow-topology.test.ts` asserts. Adopting pi's +topology would delete Windows coverage and orphan the two required check contexts. Parity is +a *toolchain* goal, not a CI-topology goal. + - TypeScript ≥ 5.x (strict, `noUnusedLocals`, `noUnusedParameters`) - `@sinclair/typebox` for schema definitions - `jiti` for runtime TS loading where needed @@ -36,17 +49,21 @@ actually better, rather than one runtime being mandated everywhere. ### Commands -- `bun install` — install dependencies (writes `bun.lock`) -- `bun run typecheck` / `bun run lint` — `tsc --noEmit` -- `bun run test:unit`, `bun run test:integration`, `bun run test:ci-contracts`, `bun run test:all` -- `bun run --cwd packages/coding-agent test` — the vitest suite -- `bun run hooks:install`, `bun run hooks:run` -- `bunx ` for one-off tools -- Git hooks are configured in `prek.toml`; `bun install` runs the root `prepare` script to install hooks with `prek install --prepare-hooks` using `default_install_hook_types`. - -**One hard rule remains:** do not run `npm install`, `yarn install`, or `pnpm install` in this -workspace. They write a competing lockfile, desync `bun.lock`, and bypass the `minimumReleaseAge` -gate in `bunfig.toml`. Every other npm/Node use above is deliberate. +- `npm ci --ignore-scripts` — install dependencies from `package-lock.json` +- `npm install ` — add a dependency; `.npmrc` applies the 3-day release-age gate and `save-exact` +- `npm run check` — `tsc --noEmit` plus the published-shrinkwrap check. `npm run typecheck` is the typecheck alone +- `npm run test:unit`, `npm run test:integration`, `npm run test:ci-contracts`, `npm run test:all` +- `npm run test --workspace=@bastani/atomic` — the coding-agent vitest suite, under Node +- `npm run test:scripts` — `node --test scripts/*.test.mjs` +- `npm run hooks:install`, `npm run hooks:run` +- `bun run scripts/.ts` — repository scripts stay on Bun; see the Tech Stack table +- Git hooks are configured in `prek.toml`; `npm install` runs the root `prepare` script to install hooks with `prek install --prepare-hooks` using `default_install_hook_types`. + +**Do not run `yarn install` or `pnpm install`,** and do not reintroduce `bun install`: each +writes a competing lockfile that `npm ci` neither reads nor verifies, and bypasses the +`.npmrc` release-age gate. `bun.lock` and `packageManager: bun@…` were removed for this +reason. Bun remains a declared engine and is still the right tool for the rows above that +name it. ## Best Practices @@ -65,10 +82,10 @@ Follow [`CONTRIBUTING.md`](CONTRIBUTING.md) for external-contributor coordinatio ## Testing -Use `bun run test:unit` (or `test:integration`, `test:all`) and make use of your tdd skill to write high quality tests. Tests use `bun:test` + `node:assert/strict`: +Use `npm run test:unit` (or `test:integration`, `test:all`) and make use of your tdd skill to write high quality tests. The suites run under **vitest**; the assertion style stays `node:assert/strict`: ```ts#test/unit/index.test.ts -import { test } from "bun:test"; +import { test } from "vitest"; import assert from "node:assert/strict"; test("hello world", () => { @@ -76,34 +93,81 @@ test("hello world", () => { }); ``` -### Per-test timeout policy +### Replacing Bun globals in tests -- The suite-wide per-test budget is **30000 ms**, declared once as `--timeout 30000` in the `test:unit`, `test:integration`, and `test:ci-contracts` scripts in the root `package.json`. `test/ci/ci-workflow-contracts.test.ts` enforces that all three still declare it and still agree. -- Do **not** move the budget to `bunfig.toml`. Bun 1.3.14 silently ignores `[test] timeout`; it looks correct and does nothing. Do not set it only in `.github/workflows/test.yml` either: CI reaches every suite through `bun run ")}&error_description=${encodeURIComponent("")}`, - ); - status = response.status; - html = await response.text(); - } finally { - server.close(); - } + let html = ""; + let status = 0; + try { + const response = await fetch( + `http://127.0.0.1:${address.port}${OAUTH_CALLBACK_PATH}?state=state-x&error=${encodeURIComponent("")}&error_description=${encodeURIComponent("")}`, + ); + status = response.status; + html = await response.text(); + } finally { + server.close(); + } - assert.equal(status, 200); - assert.doesNotMatch(html, /pt>hidden"), - false, - ); - assert.equal(svelteModule.svelteMarkupHasVisibleContent(">"), false); - assert.equal( - svelteModule.parseSvelteComponentFile("\n
visible
").markup, - "
visible
", - ); + test("tracks every bundled skill file instead of silently ignoring part of a synced tree", () => { + const skillFiles = [...collectFiles(subagentSkills), ...collectFiles(workflowSkills)]; + assert.ok(skillFiles.length > 100, `expected a complete bundled skill inventory, saw ${skillFiles.length}`); + // `--no-index` reports ignore rules even for already-tracked paths, so a broad + // rule (such as the Python packaging `lib/`) cannot silently truncate a sync. + const ignored = spawnSyncCollect(["git", "check-ignore", "--no-index", "--stdin"], { + cwd: root, + env: createGitEnvironment(), + stdin: Buffer.from(`${skillFiles.join("\n")}\n`), + }); + assert.equal(ignored.stdout.toString().trim(), "", "bundled skill files are excluded by .gitignore"); + const tracked = spawnSyncCollect(["git", "ls-files", "packages/workflows/skills/impeccable"], { + cwd: root, + env: createGitEnvironment(), + }); + assert.equal(tracked.exitCode, 0, tracked.stderr.toString()); + for (const path of ["scripts/lib/staleness.mjs", "scripts/lib/surface-briefs.mjs", "scripts/lib/provider.mjs"]) { + assert.ok( + tracked.stdout.toString().includes(`packages/workflows/skills/impeccable/${path}\n`), + `untracked bundled file: ${path}`, + ); + } + }); - const pageModulePath = join(workflowSkills, "impeccable/scripts/detector/shared/page.mjs"); - const pageModule = await import(pageModulePath) as { isFullPage(content: string): boolean }; - assert.equal(pageModule.isFullPage(">
partial
"), false); - // Upstream's regex comment strip misses the permissive `--!>` ending and reports this as a full page. - assert.equal(pageModule.isFullPage(""), false); - }); - test("keeps Impeccable visual-evidence detection blind to commented-out markup", async () => { - const contextModule = await import(join(workflowSkills, "impeccable/scripts/context.mjs")) as { - hasVisualImplementation(projectRoot: string): boolean; - }; - const filler = "

Plain authored copy with no styling evidence at all.

\n".repeat(16); - const hiddenStyle = ""; - const fixtures: ReadonlyArray = [ - ["nested-opener", `${filler}-- ${hiddenStyle} -->`, false], - ["permissive-closer", `${filler}`, false], - ["visible-style", `${filler}${hiddenStyle}`, true], - ]; - for (const [name, markup, expected] of fixtures) { - const projectRoot = mkdtempSync(join(tmpdir(), `atomic-impeccable-visual-${name}-`)); - try { - assert.ok(markup.length > 600, `fixture ${name} is under the HTML evidence threshold`); - writeFileSync(join(projectRoot, "index.html"), markup); - assert.equal(contextModule.hasVisualImplementation(projectRoot), expected, `visual evidence: ${name}`); - } finally { - rmSync(projectRoot, { recursive: true, force: true }); - } - } - }); + test("keeps synced HTML filtering robust against nested sanitization and permissive closing tags", async () => { + const svelteModulePath = join(workflowSkills, "impeccable/scripts/live/svelte-component.mjs"); + const svelteModule = (await import(svelteModulePath)) as { + parseSvelteComponentFile(content: string): { markup: string }; + svelteMarkupHasVisibleContent(markup: string): boolean; + }; + assert.equal(svelteModule.svelteMarkupHasVisibleContent("xpt>hidden"), false); + assert.equal(svelteModule.svelteMarkupHasVisibleContent(">"), false); + assert.equal( + svelteModule.parseSvelteComponentFile("\n
visible
") + .markup, + "
visible
", + ); + const pageModulePath = join(workflowSkills, "impeccable/scripts/detector/shared/page.mjs"); + const pageModule = (await import(pageModulePath)) as { isFullPage(content: string): boolean }; + assert.equal(pageModule.isFullPage(">
partial
"), false); + // Upstream's regex comment strip misses the permissive `--!>` ending and reports this as a full page. + assert.equal(pageModule.isFullPage(""), false); + }); + test("keeps Impeccable visual-evidence detection blind to commented-out markup", async () => { + const contextModule = (await import(join(workflowSkills, "impeccable/scripts/context.mjs"))) as { + hasVisualImplementation(projectRoot: string): boolean; + }; + const filler = "

Plain authored copy with no styling evidence at all.

\n".repeat(16); + const hiddenStyle = ""; + const fixtures: ReadonlyArray = [ + ["nested-opener", `${filler}-- ${hiddenStyle} -->`, false], + ["permissive-closer", `${filler}`, false], + ["visible-style", `${filler}${hiddenStyle}`, true], + ]; + for (const [name, markup, expected] of fixtures) { + const projectRoot = mkdtempSync(join(tmpdir(), `atomic-impeccable-visual-${name}-`)); + try { + assert.ok(markup.length > 600, `fixture ${name} is under the HTML evidence threshold`); + writeFileSync(join(projectRoot, "index.html"), markup); + assert.equal(contextModule.hasVisualImplementation(projectRoot), expected, `visual evidence: ${name}`); + } finally { + rmSync(projectRoot, { recursive: true, force: true }); + } + } + }); - test("keeps synced live-preview selector and CSS-property hardening", () => { - const browser = readFileSync(join(workflowSkills, "impeccable/scripts/live-browser.js"), "utf8"); - // A backslash-bearing session ID must be escaped before it reaches the attribute selector. - assert.ok( - browser.includes("String(sessionId).replace(/\\\\/g, '\\\\\\\\').replace(/\"/g, '\\\\\"')"), - "preview selector lost its backslash escaping", - ); - // The ineffective `-ms-` self-replacement stays removed (CodeQL useless-assignment fix). - assert.doesNotMatch(browser, /replace\(\/\^-ms-\/, '-ms-'\)/u); - }); + test("keeps synced live-preview selector and CSS-property hardening", () => { + const browser = readFileSync(join(workflowSkills, "impeccable/scripts/live-browser.js"), "utf8"); + // A backslash-bearing session ID must be escaped before it reaches the attribute selector. + assert.ok( + browser.includes("String(sessionId).replace(/\\\\/g, '\\\\\\\\').replace(/\"/g, '\\\\\"')"), + "preview selector lost its backslash escaping", + ); + // The ineffective `-ms-` self-replacement stays removed (CodeQL useless-assignment fix). + assert.doesNotMatch(browser, /replace\(\/\^-ms-\/, '-ms-'\)/u); + }); - test("initializes its fixture without mutating an ambient linked worktree", () => { - const fixtureRoot = mkdtempSync(join(tmpdir(), "atomic-impeccable-git-env-")); - const primary = join(fixtureRoot, "primary"); - const linked = join(fixtureRoot, "linked"); - const target = join(fixtureRoot, "target"); - mkdirSync(primary); - mkdirSync(target); - try { - runFixtureGit(primary, ["init", "--initial-branch=main", "--quiet"]); - writeFileSync(join(primary, "tracked.txt"), "primary\n"); - runFixtureGit(primary, ["add", "tracked.txt"]); - runFixtureGit(primary, [ - "-c", "user.name=Atomic Test", "-c", "user.email=atomic-test@example.com", - "commit", "--no-gpg-sign", "--message=initial", "--quiet", - ]); - runFixtureGit(primary, ["worktree", "add", "--detach", linked, "--quiet"]); - const linkedGitDir = runFixtureGit(linked, ["rev-parse", "--absolute-git-dir"]); - const commonGitDir = runFixtureGit(linked, ["rev-parse", "--path-format=absolute", "--git-common-dir"]); - const primaryContents = readFileSync(join(primary, "tracked.txt"), "utf8"); - const linkedContents = readFileSync(join(linked, "tracked.txt"), "utf8"); + test("initializes its fixture without mutating an ambient linked worktree", () => { + const fixtureRoot = mkdtempSync(join(tmpdir(), "atomic-impeccable-git-env-")); + const primary = join(fixtureRoot, "primary"); + const linked = join(fixtureRoot, "linked"); + const target = join(fixtureRoot, "target"); + mkdirSync(primary); + mkdirSync(target); + try { + runFixtureGit(primary, ["init", "--initial-branch=main", "--quiet"]); + writeFileSync(join(primary, "tracked.txt"), "primary\n"); + runFixtureGit(primary, ["add", "tracked.txt"]); + runFixtureGit(primary, [ + "-c", + "user.name=Atomic Test", + "-c", + "user.email=atomic-test@example.com", + "commit", + "--no-gpg-sign", + "--message=initial", + "--quiet", + ]); + runFixtureGit(primary, ["worktree", "add", "--detach", linked, "--quiet"]); + const linkedGitDir = runFixtureGit(linked, ["rev-parse", "--absolute-git-dir"]); + const commonGitDir = runFixtureGit(linked, ["rev-parse", "--path-format=absolute", "--git-common-dir"]); + const primaryContents = readFileSync(join(primary, "tracked.txt"), "utf8"); + const linkedContents = readFileSync(join(linked, "tracked.txt"), "utf8"); - initializeFixtureRepository(target, { - ...process.env, - GIT_DIR: linkedGitDir, - GIT_WORK_TREE: linked, - GIT_INDEX_FILE: join(linkedGitDir, "index"), - }); + initializeFixtureRepository(target, { + ...process.env, + GIT_DIR: linkedGitDir, + GIT_WORK_TREE: linked, + GIT_INDEX_FILE: join(linkedGitDir, "index"), + }); - assert.equal(existsSync(join(target, ".git")), true, "fixture repository was not initialized at its cwd"); - const coreWorktree = Bun.spawnSync( - ["git", `--git-dir=${commonGitDir}`, "config", "--get-all", "core.worktree"], - { env: createGitEnvironment() }, - ); - assert.equal(coreWorktree.exitCode, 1, `ambient shared config gained core.worktree=${coreWorktree.stdout.toString().trim()}`); - const resolvedPrimary = runFixtureGit(primary, ["rev-parse", "--show-toplevel"]); - assert.equal(lstatSync(join(resolvedPrimary, ".git")).isDirectory(), true, "primary Git commands resolved to a linked worktree"); - assert.equal(readFileSync(join(primary, "tracked.txt"), "utf8"), primaryContents); - assert.equal(readFileSync(join(linked, "tracked.txt"), "utf8"), linkedContents); - } finally { - rmSync(fixtureRoot, { recursive: true, force: true }); - } - }); + assert.equal(existsSync(join(target, ".git")), true, "fixture repository was not initialized at its cwd"); + const coreWorktree = spawnSyncCollect( + ["git", `--git-dir=${commonGitDir}`, "config", "--get-all", "core.worktree"], + { env: createGitEnvironment() }, + ); + assert.equal( + coreWorktree.exitCode, + 1, + `ambient shared config gained core.worktree=${coreWorktree.stdout.toString().trim()}`, + ); + const resolvedPrimary = runFixtureGit(primary, ["rev-parse", "--show-toplevel"]); + assert.equal( + lstatSync(join(resolvedPrimary, ".git")).isDirectory(), + true, + "primary Git commands resolved to a linked worktree", + ); + assert.equal(readFileSync(join(primary, "tracked.txt"), "utf8"), primaryContents); + assert.equal(readFileSync(join(linked, "tracked.txt"), "utf8"), linkedContents); + } finally { + rmSync(fixtureRoot, { recursive: true, force: true }); + } + }); - test("does not execute shell substitutions from Impeccable project paths", async () => { - const cwd = mkdtempSync(join(tmpdir(), "atomic-impeccable-generated-")); - const marker = join(cwd, "command-injection-marker"); - const crafted = join(cwd, `page-$(touch command-injection-marker).html`); - try { - initializeFixtureRepository(cwd); - writeFileSync(crafted, "
source
\n"); - const modulePath = join(workflowSkills, "impeccable/scripts/lib/is-generated.mjs"); - const module = await import(modulePath) as { isGeneratedFile(path: string, options: { cwd: string }): boolean }; - assert.equal(module.isGeneratedFile(crafted, { cwd }), false); - assert.equal(existsSync(marker), false, "project-controlled filename executed shell syntax"); - } finally { - rmSync(cwd, { recursive: true, force: true }); - } - }); + test("does not execute shell substitutions from Impeccable project paths", async () => { + const cwd = mkdtempSync(join(tmpdir(), "atomic-impeccable-generated-")); + const marker = join(cwd, "command-injection-marker"); + const crafted = join(cwd, `page-$(touch command-injection-marker).html`); + try { + initializeFixtureRepository(cwd); + writeFileSync(crafted, "
source
\n"); + const modulePath = join(workflowSkills, "impeccable/scripts/lib/is-generated.mjs"); + const module = (await import(modulePath)) as { + isGeneratedFile(path: string, options: { cwd: string }): boolean; + }; + assert.equal(module.isGeneratedFile(crafted, { cwd }), false); + assert.equal(existsSync(marker), false, "project-controlled filename executed shell syntax"); + } finally { + rmSync(cwd, { recursive: true, force: true }); + } + }); }); diff --git a/test/unit/validate-inputs.test.ts b/test/unit/validate-inputs.test.ts index ce981745b..1f8848c88 100644 --- a/test/unit/validate-inputs.test.ts +++ b/test/unit/validate-inputs.test.ts @@ -3,139 +3,117 @@ * and programmatic SDK dispatch paths before starting a run. */ -import { describe, test } from "bun:test"; import assert from "node:assert/strict"; import { Type } from "typebox"; +import { describe, test } from "vitest"; import { validateInputs } from "../../packages/workflows/src/runs/shared/validate-inputs.js"; import type { WorkflowInputSchema } from "../../packages/workflows/src/shared/types.js"; const schema = (obj: Record): Readonly> => obj; describe("validateInputs", () => { - test("no errors for well-formed inputs", () => { - const errors = validateInputs( - schema({ - prompt: Type.String(), - count: Type.Number({ default: 3 }), - }), - { prompt: "hi", count: 5 }, - ); - assert.deepEqual(errors, []); - }); + test("no errors for well-formed inputs", () => { + const errors = validateInputs( + schema({ + prompt: Type.String(), + count: Type.Number({ default: 3 }), + }), + { prompt: "hi", count: 5 }, + ); + assert.deepEqual(errors, []); + }); - test("rejects wrong type: number", () => { - const errors = validateInputs( - schema({ count: Type.Optional(Type.Number()) }), - { count: "three" }, - ); - assert.equal(errors.length, 1); - assert.equal(errors[0]!.key, "count"); - assert.match(errors[0]!.reason, /number/); - }); + test("rejects wrong type: number", () => { + const errors = validateInputs(schema({ count: Type.Optional(Type.Number()) }), { count: "three" }); + assert.equal(errors.length, 1); + assert.equal(errors[0]!.key, "count"); + assert.match(errors[0]!.reason, /number/); + }); - test("rejects wrong type: boolean", () => { - const errors = validateInputs( - schema({ dry: Type.Optional(Type.Boolean()) }), - { dry: "true" }, - ); - assert.equal(errors.length, 1); - assert.equal(errors[0]!.key, "dry"); - assert.match(errors[0]!.reason, /boolean/); - }); + test("rejects wrong type: boolean", () => { + const errors = validateInputs(schema({ dry: Type.Optional(Type.Boolean()) }), { dry: "true" }); + assert.equal(errors.length, 1); + assert.equal(errors[0]!.key, "dry"); + assert.match(errors[0]!.reason, /boolean/); + }); - test("rejects wrong type: text/string", () => { - const errors = validateInputs( - schema({ prompt: Type.Optional(Type.String()) }), - { prompt: 42 }, - ); - assert.equal(errors.length, 1); - assert.equal(errors[0]!.key, "prompt"); - }); + test("rejects wrong type: text/string", () => { + const errors = validateInputs(schema({ prompt: Type.Optional(Type.String()) }), { prompt: 42 }); + assert.equal(errors.length, 1); + assert.equal(errors[0]!.key, "prompt"); + }); - test("rejects select value not in choices", () => { - const errors = validateInputs( - schema({ mode: Type.Optional(Type.Union([Type.Literal("a"), Type.Literal("b")])) }), - { mode: "c" }, - ); - assert.equal(errors.length, 1); - assert.match(errors[0]!.reason, /a/); - assert.match(errors[0]!.reason, /b/); - }); + test("rejects select value not in choices", () => { + const errors = validateInputs( + schema({ mode: Type.Optional(Type.Union([Type.Literal("a"), Type.Literal("b")])) }), + { mode: "c" }, + ); + assert.equal(errors.length, 1); + assert.match(errors[0]!.reason, /a/); + assert.match(errors[0]!.reason, /b/); + }); - test("accepts select value when in choices", () => { - const errors = validateInputs( - schema({ mode: Type.Optional(Type.Union([Type.Literal("a"), Type.Literal("b")])) }), - { mode: "a" }, - ); - assert.deepEqual(errors, []); - }); + test("accepts select value when in choices", () => { + const errors = validateInputs( + schema({ mode: Type.Optional(Type.Union([Type.Literal("a"), Type.Literal("b")])) }), + { mode: "a" }, + ); + assert.deepEqual(errors, []); + }); - test("rejects unknown input keys (catches typos)", () => { - const errors = validateInputs( - schema({ prompt: Type.Optional(Type.String()) }), - { prompt: "hi", propmt: "typo" }, - ); - assert.equal(errors.length, 1); - assert.equal(errors[0]!.key, "propmt"); - assert.match(errors[0]!.reason.toLowerCase(), /unknown/); - }); + test("rejects unknown input keys (catches typos)", () => { + const errors = validateInputs(schema({ prompt: Type.Optional(Type.String()) }), { prompt: "hi", propmt: "typo" }); + assert.equal(errors.length, 1); + assert.equal(errors[0]!.key, "propmt"); + assert.match(errors[0]!.reason.toLowerCase(), /unknown/); + }); - test("reports missing required inputs", () => { - const errors = validateInputs( - schema({ prompt: Type.String() }), - {}, - ); - assert.equal(errors.length, 1); - assert.equal(errors[0]!.key, "prompt"); - assert.match(errors[0]!.reason.toLowerCase(), /required/); - }); + test("reports missing required inputs", () => { + const errors = validateInputs(schema({ prompt: Type.String() }), {}); + assert.equal(errors.length, 1); + assert.equal(errors[0]!.key, "prompt"); + assert.match(errors[0]!.reason.toLowerCase(), /required/); + }); - test("does NOT report missing optional inputs", () => { - const errors = validateInputs( - schema({ count: Type.Optional(Type.Number()) }), - {}, - ); - assert.deepEqual(errors, []); - }); + test("does NOT report missing optional inputs", () => { + const errors = validateInputs(schema({ count: Type.Optional(Type.Number()) }), {}); + assert.deepEqual(errors, []); + }); - test("collects multiple errors", () => { - const errors = validateInputs( - schema({ - prompt: Type.String(), - count: Type.Optional(Type.Number()), - }), - { count: "x", unknown: 1 }, - ); - // missing prompt + count wrong type + unknown key = 3 - assert.equal(errors.length, 3); - }); + test("collects multiple errors", () => { + const errors = validateInputs( + schema({ + prompt: Type.String(), + count: Type.Optional(Type.Number()), + }), + { count: "x", unknown: 1 }, + ); + // missing prompt + count wrong type + unknown key = 3 + assert.equal(errors.length, 3); + }); - test("NaN rejected as non-serializable number", () => { - const errors = validateInputs( - schema({ count: Type.Optional(Type.Number()) }), - { count: Number.NaN }, - ); - assert.equal(errors.length, 1); - assert.equal(errors[0]!.key, "count"); - assert.match(errors[0]!.reason, /finite number/); - }); + test("NaN rejected as non-serializable number", () => { + const errors = validateInputs(schema({ count: Type.Optional(Type.Number()) }), { count: Number.NaN }); + assert.equal(errors.length, 1); + assert.equal(errors[0]!.key, "count"); + assert.match(errors[0]!.reason, /finite number/); + }); - test("Infinity rejected as non-serializable number", () => { - const errors = validateInputs( - schema({ count: Type.Optional(Type.Number()) }), - { count: Number.POSITIVE_INFINITY }, - ); - assert.equal(errors.length, 1); - assert.equal(errors[0]!.key, "count"); - assert.match(errors[0]!.reason, /finite number/); - }); + test("Infinity rejected as non-serializable number", () => { + const errors = validateInputs(schema({ count: Type.Optional(Type.Number()) }), { + count: Number.POSITIVE_INFINITY, + }); + assert.equal(errors.length, 1); + assert.equal(errors[0]!.key, "count"); + assert.match(errors[0]!.reason, /finite number/); + }); - test("rejects non-plain object instances as non-serializable", () => { - for (const value of [new Date(), new Map(), /pattern/]) { - const errors = validateInputs(schema({ value: Type.Unknown() }), { value: value as never }); - assert.equal(errors.length, 1, `${value.constructor.name} should be rejected`); - assert.equal(errors[0]!.key, "value"); - assert.match(errors[0]!.reason, /JSON-serializable/); - } - }); + test("rejects non-plain object instances as non-serializable", () => { + for (const value of [new Date(), new Map(), /pattern/]) { + const errors = validateInputs(schema({ value: Type.Unknown() }), { value: value as never }); + assert.equal(errors.length, 1, `${value.constructor.name} should be rejected`); + assert.equal(errors[0]!.key, "value"); + assert.match(errors[0]!.reason, /JSON-serializable/); + } + }); }); diff --git a/test/unit/web-access-subprocess.test.ts b/test/unit/web-access-subprocess.test.ts index eb67e3bf0..0e15c7f59 100644 --- a/test/unit/web-access-subprocess.test.ts +++ b/test/unit/web-access-subprocess.test.ts @@ -1,11 +1,18 @@ -import { test } from "bun:test"; import assert from "node:assert/strict"; import { chmodSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { delimiter, join } from "node:path"; +import { test } from "vitest"; import { runBunSubprocess } from "../../packages/web-access/subprocess.ts"; -import { getLocalVideoDuration, extractVideoFrame } from "../../packages/web-access/video-extract.ts"; +import { extractVideoFrame, getLocalVideoDuration } from "../../packages/web-access/video-extract.ts"; import { getYouTubeStreamInfo } from "../../packages/web-access/youtube-extract.ts"; +import { bunExecutable, installBunGlobal } from "../helpers/runtime.js"; + +// packages/web-access/subprocess.ts is shipped Bun-binary code that calls +// Bun.spawn/Bun.sleep unguarded, and this suite imports it in-process. See +// installBunGlobal for why the primitives are supplied rather than the file +// re-executed under Bun. +installBunGlobal(); function executable(path: string, body: string): void { writeFileSync(path, `#!/usr/bin/env bun\n${body}\n`, "utf8"); @@ -14,12 +21,18 @@ function executable(path: string, body: string): void { test("Bun subprocess execution drains binary output without blocking the event loop", async () => { let ticks = 0; - const timer = setInterval(() => { ticks += 1; }, 1); + const timer = setInterval(() => { + ticks += 1; + }, 1); try { - const result = await runBunSubprocess(process.execPath, ["-e", "await Bun.sleep(25); process.stdout.write(Buffer.from([0,1,2,255]))"], { - timeoutMs: 1_000, - maxStdoutBytes: 1024, - }); + const result = await runBunSubprocess( + bunExecutable(), + ["-e", "await Bun.sleep(25); process.stdout.write(Buffer.from([0,1,2,255]))"], + { + timeoutMs: 1_000, + maxStdoutBytes: 1024, + }, + ); assert.deepEqual([...result.stdout], [0, 1, 2, 255]); assert.ok(ticks > 5); } finally { @@ -29,11 +42,14 @@ test("Bun subprocess execution drains binary output without blocking the event l test("Bun subprocess execution enforces timeout and output byte caps", async () => { await assert.rejects( - runBunSubprocess(process.execPath, ["-e", "setInterval(() => {}, 1000)"], { timeoutMs: 20, maxStdoutBytes: 1024 }), + runBunSubprocess(bunExecutable(), ["-e", "setInterval(() => {}, 1000)"], { timeoutMs: 20, maxStdoutBytes: 1024 }), (error: Error & { code?: string; killed?: boolean }) => error.code === "ETIMEDOUT" && error.killed === true, ); await assert.rejects( - runBunSubprocess(process.execPath, ["-e", "process.stdout.write('x'.repeat(2048))"], { timeoutMs: 1_000, maxStdoutBytes: 1024 }), + runBunSubprocess(bunExecutable(), ["-e", "process.stdout.write('x'.repeat(2048))"], { + timeoutMs: 1_000, + maxStdoutBytes: 1024, + }), (error: Error & { code?: string }) => error.code === "ENOBUFS", ); }); @@ -41,7 +57,7 @@ test("Bun subprocess execution enforces timeout and output byte caps", async () test("Bun subprocess execution aborts the child on caller signal with tree-kill escalation", async () => { const controller = new AbortController(); const started = performance.now(); - const pending = runBunSubprocess(process.execPath, ["-e", "setInterval(() => {}, 1000)"], { + const pending = runBunSubprocess(bunExecutable(), ["-e", "setInterval(() => {}, 1000)"], { timeoutMs: 10_000, maxStdoutBytes: 1024, signal: controller.signal, @@ -60,12 +76,15 @@ test("Bun subprocess execution maps spawn ENOENT and non-zero exits with stderr" (error: Error & { code?: string }) => error.code === "ENOENT", ); await assert.rejects( - runBunSubprocess(process.execPath, ["-e", "process.stderr.write('boom'); process.exit(3)"], { timeoutMs: 1_000, maxStdoutBytes: 1024 }), + runBunSubprocess(bunExecutable(), ["-e", "process.stderr.write('boom'); process.exit(3)"], { + timeoutMs: 1_000, + maxStdoutBytes: 1024, + }), (error: Error & { code?: string; stderr?: string }) => error.code === "3" && error.stderr === "boom", ); }); -test.serial("video and YouTube command paths use asynchronous Bun subprocesses", async () => { +test.sequential("video and YouTube command paths use asynchronous Bun subprocesses", async () => { if (process.platform === "win32") return; const bin = mkdtempSync(join(tmpdir(), "atomic-web-bin-")); const previousPath = process.env.PATH; diff --git a/test/unit/widget-rendering.test.ts b/test/unit/widget-rendering.test.ts index 3d2f47873..374637cde 100644 --- a/test/unit/widget-rendering.test.ts +++ b/test/unit/widget-rendering.test.ts @@ -13,68 +13,60 @@ * cross-ref: src/tui/widget.ts · orchestrator-panel-ui.png · DESIGN.md §5 */ -import { describe, test } from "bun:test"; import assert from "node:assert/strict"; -import { - renderWidgetLines, - buildThemedWidgetLines, - formatDuration, - nextWidgetRefreshDelayMs, - RECENT_ENDED_WINDOW_MS, -} from "../../packages/workflows/src/tui/widget.js"; +import { describe, test } from "vitest"; +import type { RunSnapshot, StageSnapshot, StoreSnapshot } from "../../packages/workflows/src/shared/store-types.js"; import { hexToAnsi } from "../../packages/workflows/src/tui/color-utils.js"; import { deriveGraphTheme } from "../../packages/workflows/src/tui/graph-theme.js"; import { visibleWidth } from "../../packages/workflows/src/tui/text-helpers.js"; -import type { - StoreSnapshot, - RunSnapshot, - StageSnapshot, -} from "../../packages/workflows/src/shared/store-types.js"; +import { + buildThemedWidgetLines, + formatDuration, + nextWidgetRefreshDelayMs, + RECENT_ENDED_WINDOW_MS, + renderWidgetLines, +} from "../../packages/workflows/src/tui/widget.js"; // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- -function makeStage( - id: string, - name: string, - status: StageSnapshot["status"], -): StageSnapshot { - return { id, name, status, parentIds: [], toolEvents: [] }; +function makeStage(id: string, name: string, status: StageSnapshot["status"]): StageSnapshot { + return { id, name, status, parentIds: [], toolEvents: [] }; } function makeRun( - id: string, - name: string, - status: RunSnapshot["status"], - stages: StageSnapshot[] = [], - startedAt = Date.now() - 5000, - endedAt?: number, + id: string, + name: string, + status: RunSnapshot["status"], + stages: StageSnapshot[] = [], + startedAt = Date.now() - 5000, + endedAt?: number, ): RunSnapshot { - return { - id, - name, - inputs: {}, - status, - stages, - startedAt, - endedAt, - durationMs: endedAt !== undefined ? endedAt - startedAt : undefined, - }; + return { + id, + name, + inputs: {}, + status, + stages, + startedAt, + endedAt, + durationMs: endedAt !== undefined ? endedAt - startedAt : undefined, + }; } function makeSnap(runs: RunSnapshot[]): StoreSnapshot { - return { runs, notices: [], version: 1 }; + return { runs, notices: [], version: 1 }; } const ANSI_RE = /\x1b\[[0-9;]*m/g; function stripAnsi(s: string): string { - return s.replace(ANSI_RE, ""); + return s.replace(ANSI_RE, ""); } const NULL_PI_THEME = { - fg: (_c: string, t: string) => t, - bold: (t: string) => t, + fg: (_c: string, t: string) => t, + bold: (t: string) => t, }; // --------------------------------------------------------------------------- @@ -82,26 +74,26 @@ const NULL_PI_THEME = { // --------------------------------------------------------------------------- describe("formatDuration", () => { - test("< 60 s → just seconds", () => { - assert.equal(formatDuration(0), "0s"); - assert.equal(formatDuration(5000), "5s"); - assert.equal(formatDuration(59_000), "59s"); - }); - - test(">= 60 s → minutes + seconds (no trailing 0s)", () => { - assert.equal(formatDuration(60_000), "1m"); - assert.equal(formatDuration(84_000), "1m 24s"); - assert.equal(formatDuration(3540_000), "59m"); - }); - - test(">= 1 hour → hours + minutes (no trailing 0m)", () => { - assert.equal(formatDuration(3600_000), "1h"); - assert.equal(formatDuration(3720_000), "1h 2m"); - }); - - test("negative ms is clamped to zero", () => { - assert.equal(formatDuration(-100), "0s"); - }); + test("< 60 s → just seconds", () => { + assert.equal(formatDuration(0), "0s"); + assert.equal(formatDuration(5000), "5s"); + assert.equal(formatDuration(59_000), "59s"); + }); + + test(">= 60 s → minutes + seconds (no trailing 0s)", () => { + assert.equal(formatDuration(60_000), "1m"); + assert.equal(formatDuration(84_000), "1m 24s"); + assert.equal(formatDuration(3540_000), "59m"); + }); + + test(">= 1 hour → hours + minutes (no trailing 0m)", () => { + assert.equal(formatDuration(3600_000), "1h"); + assert.equal(formatDuration(3720_000), "1h 2m"); + }); + + test("negative ms is clamped to zero", () => { + assert.equal(formatDuration(-100), "0s"); + }); }); // --------------------------------------------------------------------------- @@ -109,17 +101,15 @@ describe("formatDuration", () => { // --------------------------------------------------------------------------- describe("renderWidgetLines — hidden states", () => { - test("no runs → empty array (widget hides)", () => { - assert.deepEqual(renderWidgetLines(makeSnap([])), []); - }); - - test("all runs ended over 30s ago → empty array", () => { - const now = Date.now(); - const snap = makeSnap([ - makeRun("r1", "wf", "completed", [], now - 90_000, now - 60_000), - ]); - assert.deepEqual(renderWidgetLines(snap), []); - }); + test("no runs → empty array (widget hides)", () => { + assert.deepEqual(renderWidgetLines(makeSnap([])), []); + }); + + test("all runs ended over 30s ago → empty array", () => { + const now = Date.now(); + const snap = makeSnap([makeRun("r1", "wf", "completed", [], now - 90_000, now - 60_000)]); + assert.deepEqual(renderWidgetLines(snap), []); + }); }); // --------------------------------------------------------------------------- @@ -127,277 +117,282 @@ describe("renderWidgetLines — hidden states", () => { // --------------------------------------------------------------------------- describe("renderWidgetLines — standard form", () => { - test("single active run → rounded panel + 2-line entry (4 lines total)", () => { - const snap = makeSnap([makeRun("abc123uuid", "my-wf", "running")]); - const lines = renderWidgetLines(snap, 120).map(stripAnsi); - // top border + 2 content rows + bottom border = 4 total - assert.equal(lines.length, 4); - assert.ok(lines[0]!.includes("BACKGROUND"), "header should include BACKGROUND label"); - assert.ok(lines[0]!.includes("1 run"), "header should include 1 run subtitle"); - assert.ok(lines[1]!.includes("abc123"), "line 1 should include short id"); - assert.ok(lines[1]!.includes("my-wf"), "line 1 should include workflow name"); - assert.ok(lines[2]!.includes("single"), "line 2 should describe mode"); - }); - - test("quit run renders resumable quit badge and note", () => { - const run: RunSnapshot = { - ...makeRun("quit1234", "resume-me", "paused"), - exitReason: "quit", - resumable: true, - }; - const lines = renderWidgetLines(makeSnap([run]), 120).map(stripAnsi); - const joined = lines.join("\n"); - assert.ok(lines[0]!.includes("BACKGROUND 1 run 1 quit")); - assert.ok(joined.includes("quit · resumable via /workflow resume")); - }); - - test("running run shows chain mode when multi-stage", () => { - const run = makeRun("xyz000aaaa", "deep-research", "running", [ - makeStage("s1", "scout", "completed"), - makeStage("s2", "specialist", "running"), - makeStage("s3", "aggregate", "pending"), - ]); - const lines = renderWidgetLines(makeSnap([run]), 120).map(stripAnsi); - const metaLine = lines[2]!; - assert.ok(metaLine.includes("chain"), "multi-stage run reads as chain"); - assert.ok(metaLine.includes("1/3"), "progress count includes done/total"); - }); - test("active recoverable block renders as blocked and resumable, not running", () => { - const run: RunSnapshot = { - ...makeRun("blocked1", "recoverable-auth", "running", [makeStage("s1", "provider", "failed")]), - blockedAt: Date.now(), - error: "Configure credentials and resume.", - failureKind: "auth", - failureRecoverability: "recoverable", - failureDisposition: "active_blocked", - failureMessage: "No API key for provider", - resumable: true, - }; - const snapshot = makeSnap([run]); - const lines = renderWidgetLines(snapshot, 120).map(stripAnsi); - const text = lines.join("\n"); - - assert.match(lines[0] ?? "", /↑ 1 blocked/u); - assert.doesNotMatch(lines[0] ?? "", /running/u); - assert.match(text, /↑ blocke recoverable-auth/u); - assert.match(text, /blocked · resumable via \/workflow resume/u); - assert.equal(nextWidgetRefreshDelayMs(snapshot), undefined); - }); - - - test("multiple active runs → header subtitle pluralises, entries stacked with blank separators", () => { - const t = Date.now(); - const r1 = makeRun("aaa111zzz", "wf-one", "running", [], t - 2000); - const r2 = makeRun("bbb222zzz", "wf-two", "running", [], t - 100); - const lines = renderWidgetLines(makeSnap([r1, r2]), 120).map(stripAnsi); - assert.ok(lines[0]!.includes("2 runs")); - const joined = lines.join("\n"); - assert.ok(joined.includes("wf-one")); - assert.ok(joined.includes("wf-two")); - // Most-recently-started run is shown first. - const wfTwoIdx = lines.findIndex((l) => l.includes("wf-two")); - const wfOneIdx = lines.findIndex((l) => l.includes("wf-one")); - assert.ok(wfTwoIdx < wfOneIdx, "most recently started run renders first"); - }); - - test("hides nested child workflow runs, showing only the top-level run", () => { - const t = Date.now(); - const root = makeRun("root1111", "contract-hil-nested-root", "running", [], t - 3000); - const parent: RunSnapshot = { - ...makeRun("parent22", "contract-hil-nested-parent", "running", [], t - 2000), - parentRunId: "root1111", - parentStageId: "hil-parent:imported-composition", - rootRunId: "root1111", - }; - const child: RunSnapshot = { - ...makeRun("child333", "contract-hil-nested-child", "running", [], t - 1000), - parentRunId: "parent22", - parentStageId: "hil-child:imported", - rootRunId: "root1111", - }; - const lines = renderWidgetLines(makeSnap([child, parent, root]), 120).map(stripAnsi); - const joined = lines.join("\n"); - // Only the top-level root is listed; the count reflects one run, not three. - assert.ok(lines[0]!.includes("1 run"), `expected "1 run" subtitle, got: ${lines[0]}`); - assert.ok(joined.includes("contract-hil-nested-root")); - assert.ok(!joined.includes("contract-hil-nested-parent"), "nested parent run must be hidden"); - assert.ok(!joined.includes("contract-hil-nested-child"), "nested child run must be hidden"); - }); - - test("surfaces a hidden nested child's awaiting-input (HiL) state on the top-level run", () => { - const t = Date.now(); - // Root is running and blocked on its imported composition; the actual HiL - // prompt is awaiting in the nested child run, which the widget hides. - const root = makeRun("root1111", "contract-hil-nested-root", "running", [], t - 3000); - const parent: RunSnapshot = { - ...makeRun("parent22", "contract-hil-nested-parent", "running", [], t - 2000), - parentRunId: "root1111", - rootRunId: "root1111", - }; - const child: RunSnapshot = { - ...makeRun("child333", "contract-hil-nested-child", "running", [ - makeStage("s1", "ask", "awaiting_input"), - ], t - 1000), - parentRunId: "parent22", - rootRunId: "root1111", - }; - const lines = renderWidgetLines(makeSnap([child, parent, root]), 120).map(stripAnsi); - const header = lines[0]!; - // Only the root is listed, but its hidden descendant's awaiting state still - // raises the "needs attention" badge so the HiL prompt is discoverable. - assert.ok(header.includes("1 run"), `expected "1 run" subtitle, got: ${header}`); - assert.ok( - header.includes("↵ 1 needs attention (attach to workflow with `/workflow connect`)"), - `expected nested HiL to surface a needs-attention badge, got: ${header}`, - ); - assert.ok(!lines.join("\n").includes("contract-hil-nested-child"), "nested child stays hidden"); - }); - - test("count badges include stage-local awaiting input", () => { - const awaiting = makeRun("r1xxxxxx", "wf-await", "running", [ - makeStage("s1", "ask", "awaiting_input"), - ]); - const lines = renderWidgetLines(makeSnap([awaiting]), 120).map(stripAnsi); - const header = lines[0]!; - assert.ok(header.includes("● 1 running"), "run remains active"); - assert.ok( - header.includes("? ↵ 1 needs attention (attach to workflow with `/workflow connect`)"), - "awaiting-input badge is labeled with status and attach action", - ); - }); - - test("count badges reflect status mix", () => { - const t = Date.now(); - const running = makeRun("r1xxxxxx", "wf-r", "running", [], t - 1000); - const paused = makeRun("r4xxxxxx", "wf-p", "paused", [], t - 3000); - const done = makeRun("r2xxxxxx", "wf-d", "completed", [], t - 5000, t - 1000); - const failed = makeRun("r3xxxxxx", "wf-f", "failed", [], t - 4000, t - 500); - const lines = renderWidgetLines(makeSnap([running, paused, done, failed]), 120).map(stripAnsi); - const header = lines[0]!; - assert.ok(header.includes("● 1 running"), "running badge"); - assert.ok(header.includes("❚❚ 1 paused"), "paused badge"); - assert.ok(header.includes("✓ 1 complete"), "completed badge"); - assert.ok(header.includes("✗ 1 failed"), "failed badge"); - }); - - test("ctx.exit blocked remains distinct from completed exit statuses", () => { - const t = Date.now(); - const skipped = makeRun("s1xxxxxx", "wf-s", "skipped", [], t - 5000, t - 3000); - const cancelled = makeRun("c1xxxxxx", "wf-c", "cancelled", [], t - 4000, t - 2000); - const blocked = makeRun("b1xxxxxx", "wf-b", "blocked", [], t - 3000, t - 1000); - const lines = renderWidgetLines(makeSnap([skipped, cancelled, blocked]), 120).map(stripAnsi); - const header = lines[0]!; - - assert.ok(header.includes("3 runs"), `expected exited runs in header total, got: ${header}`); - assert.ok(header.includes("✓ 2 complete"), `expected completed exit badge, got: ${header}`); - assert.ok(header.includes("↑ 1 blocked"), `expected blocked exit badge, got: ${header}`); - assert.ok(lines.join("\n").includes("skipped · 2s"), "skipped row remains visible"); - assert.ok(lines.join("\n").includes("cancelled · 2s"), "cancelled row remains visible"); - assert.ok(lines.join("\n").includes("blocked · 2s"), "blocked row remains visible"); - }); - - test("terminal rows render final duration without ticking ago labels", () => { - const originalNow = Date.now; - try { - const startedAt = 1_000; - const endedAt = 11_000; - const completed = makeRun("r2xxxxxx", "wf-d", "completed", [], startedAt, endedAt); - const failed = makeRun("r3xxxxxx", "wf-f", "failed", [], startedAt, endedAt); - const killed = makeRun("r4xxxxxx", "wf-k", "killed", [], startedAt, endedAt); - completed.durationMs = undefined; - failed.durationMs = undefined; - killed.durationMs = undefined; - - Date.now = () => 12_000; - const at12s = renderWidgetLines(makeSnap([completed, failed, killed]), 120).map(stripAnsi).join("\n"); - Date.now = () => 29_000; - const at29s = renderWidgetLines(makeSnap([completed, failed, killed]), 120).map(stripAnsi).join("\n"); - - assert.match(at12s, /complete · 10s/); - assert.match(at12s, /failed · 10s/); - assert.match(at12s, /killed · 10s/); - assert.doesNotMatch(at12s, /ago/); - assert.equal(at29s, at12s); - } finally { - Date.now = originalNow; - } - }); - - test("paused run renders pause status and frozen active elapsed time", () => { - const originalNow = Date.now; - try { - Date.now = () => 71_000; - const paused = makeRun("r4xxxxxx", "wf-p", "paused", [], 1_000); - paused.pausedAt = 11_000; - const lines = renderWidgetLines(makeSnap([paused]), 120).map(stripAnsi); - assert.ok(lines.join("\n").includes("❚❚"), "paused glyph"); - assert.ok(lines[0]!.includes("❚❚ 1 paused"), "paused badge"); - assert.match(lines[2]!, /10s/); - assert.doesNotMatch(lines[2]!, /1m/); - - Date.now = () => 76_000; - const later = renderWidgetLines(makeSnap([paused]), 120).map(stripAnsi); - assert.equal(later[2], lines[2]); - } finally { - Date.now = originalNow; - } - }); - - test("terminal and fully paused widgets do not schedule second-boundary refreshes", () => { - const now = 1_000_000; - const terminal = makeRun("r2xxxxxx", "wf-d", "completed", [], now - 20_000, now - 10_000); - const terminalDelay = nextWidgetRefreshDelayMs(makeSnap([terminal]), now); - assert.equal(terminalDelay, RECENT_ENDED_WINDOW_MS - 10_000 + 1); - - const paused = makeRun("r4xxxxxx", "wf-p", "paused", [], now - 20_000); - paused.pausedAt = now - 5_000; - assert.equal(nextWidgetRefreshDelayMs(makeSnap([paused]), now), undefined); - }); - - test("active runs schedule the next exact elapsed-second refresh", () => { - const now = 1_000_000; - const active = makeRun("r1xxxxxx", "wf-a", "running", [], now - 5_000); - assert.equal(nextWidgetRefreshDelayMs(makeSnap([active]), now), 1_000); - - const offsetActive = makeRun("r3xxxxxx", "wf-b", "running", [], now - 5_250); - assert.equal(nextWidgetRefreshDelayMs(makeSnap([offsetActive]), now), 750); - - const ended = makeRun("r2xxxxxx", "wf-d", "completed", [], now - 20_000, now - 10_000); - assert.equal(nextWidgetRefreshDelayMs(makeSnap([offsetActive, ended]), now), 750); - }); - - test("standard panel scales to the provided terminal width", () => { - const width = 120; - const snap = makeSnap([makeRun("abc123uuid", "my-wf", "running")]); - const lines = renderWidgetLines(snap, width); - for (const line of lines) { - assert.equal(visibleWidth(line), width); - } - }); - - test("running run uses static ● glyph, never a braille spinner frame", () => { - // The widget is the canonical 'workflow status' surface; per DESIGN.md - // 'no spinners on prompt; no flash' it must render the same static - // vocabulary as `renderStatusList`/`renderRunDetail` (statusIcon → '●'). - const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; - const t = Date.now(); - const snap = makeSnap([ - makeRun("r1xxxxxx", "wf-r", "running", [makeStage("s1", "stage-1", "running")], t - 1000), - ]); - // Sample several `now` offsets — a frame-cycling glyph would land on - // a different braille character at each tick. - for (let dt = 0; dt < 800; dt += 80) { - const lines = renderWidgetLines(snap, 120).map(stripAnsi); - const joined = lines.join("\n"); - assert.ok(joined.includes("●"), `static ● glyph at +${dt}ms`); - for (const frame of SPINNER_FRAMES) { - assert.ok( - !joined.includes(frame), - `widget must not emit braille spinner frame ${JSON.stringify(frame)} at +${dt}ms`, - ); - } - } - }); + test("single active run → rounded panel + 2-line entry (4 lines total)", () => { + const snap = makeSnap([makeRun("abc123uuid", "my-wf", "running")]); + const lines = renderWidgetLines(snap, 120).map(stripAnsi); + // top border + 2 content rows + bottom border = 4 total + assert.equal(lines.length, 4); + assert.ok(lines[0]!.includes("BACKGROUND"), "header should include BACKGROUND label"); + assert.ok(lines[0]!.includes("1 run"), "header should include 1 run subtitle"); + assert.ok(lines[1]!.includes("abc123"), "line 1 should include short id"); + assert.ok(lines[1]!.includes("my-wf"), "line 1 should include workflow name"); + assert.ok(lines[2]!.includes("single"), "line 2 should describe mode"); + }); + + test("quit run renders resumable quit badge and note", () => { + const run: RunSnapshot = { + ...makeRun("quit1234", "resume-me", "paused"), + exitReason: "quit", + resumable: true, + }; + const lines = renderWidgetLines(makeSnap([run]), 120).map(stripAnsi); + const joined = lines.join("\n"); + assert.ok(lines[0]!.includes("BACKGROUND 1 run 1 quit")); + assert.ok(joined.includes("quit · resumable via /workflow resume")); + }); + + test("running run shows chain mode when multi-stage", () => { + const run = makeRun("xyz000aaaa", "deep-research", "running", [ + makeStage("s1", "scout", "completed"), + makeStage("s2", "specialist", "running"), + makeStage("s3", "aggregate", "pending"), + ]); + const lines = renderWidgetLines(makeSnap([run]), 120).map(stripAnsi); + const metaLine = lines[2]!; + assert.ok(metaLine.includes("chain"), "multi-stage run reads as chain"); + assert.ok(metaLine.includes("1/3"), "progress count includes done/total"); + }); + test("active recoverable block renders as blocked and resumable, not running", () => { + const run: RunSnapshot = { + ...makeRun("blocked1", "recoverable-auth", "running", [makeStage("s1", "provider", "failed")]), + blockedAt: Date.now(), + error: "Configure credentials and resume.", + failureKind: "auth", + failureRecoverability: "recoverable", + failureDisposition: "active_blocked", + failureMessage: "No API key for provider", + resumable: true, + }; + const snapshot = makeSnap([run]); + const lines = renderWidgetLines(snapshot, 120).map(stripAnsi); + const text = lines.join("\n"); + + assert.match(lines[0] ?? "", /↑ 1 blocked/u); + assert.doesNotMatch(lines[0] ?? "", /running/u); + assert.match(text, /↑ {2}blocke {2}recoverable-auth/u); + assert.match(text, /blocked · resumable via \/workflow resume/u); + assert.equal(nextWidgetRefreshDelayMs(snapshot), undefined); + }); + + test("multiple active runs → header subtitle pluralises, entries stacked with blank separators", () => { + const t = Date.now(); + const r1 = makeRun("aaa111zzz", "wf-one", "running", [], t - 2000); + const r2 = makeRun("bbb222zzz", "wf-two", "running", [], t - 100); + const lines = renderWidgetLines(makeSnap([r1, r2]), 120).map(stripAnsi); + assert.ok(lines[0]!.includes("2 runs")); + const joined = lines.join("\n"); + assert.ok(joined.includes("wf-one")); + assert.ok(joined.includes("wf-two")); + // Most-recently-started run is shown first. + const wfTwoIdx = lines.findIndex((l) => l.includes("wf-two")); + const wfOneIdx = lines.findIndex((l) => l.includes("wf-one")); + assert.ok(wfTwoIdx < wfOneIdx, "most recently started run renders first"); + }); + + test("hides nested child workflow runs, showing only the top-level run", () => { + const t = Date.now(); + const root = makeRun("root1111", "contract-hil-nested-root", "running", [], t - 3000); + const parent: RunSnapshot = { + ...makeRun("parent22", "contract-hil-nested-parent", "running", [], t - 2000), + parentRunId: "root1111", + parentStageId: "hil-parent:imported-composition", + rootRunId: "root1111", + }; + const child: RunSnapshot = { + ...makeRun("child333", "contract-hil-nested-child", "running", [], t - 1000), + parentRunId: "parent22", + parentStageId: "hil-child:imported", + rootRunId: "root1111", + }; + const lines = renderWidgetLines(makeSnap([child, parent, root]), 120).map(stripAnsi); + const joined = lines.join("\n"); + // Only the top-level root is listed; the count reflects one run, not three. + assert.ok(lines[0]!.includes("1 run"), `expected "1 run" subtitle, got: ${lines[0]}`); + assert.ok(joined.includes("contract-hil-nested-root")); + assert.ok(!joined.includes("contract-hil-nested-parent"), "nested parent run must be hidden"); + assert.ok(!joined.includes("contract-hil-nested-child"), "nested child run must be hidden"); + }); + + test("surfaces a hidden nested child's awaiting-input (HiL) state on the top-level run", () => { + const t = Date.now(); + // Root is running and blocked on its imported composition; the actual HiL + // prompt is awaiting in the nested child run, which the widget hides. + const root = makeRun("root1111", "contract-hil-nested-root", "running", [], t - 3000); + const parent: RunSnapshot = { + ...makeRun("parent22", "contract-hil-nested-parent", "running", [], t - 2000), + parentRunId: "root1111", + rootRunId: "root1111", + }; + const child: RunSnapshot = { + ...makeRun( + "child333", + "contract-hil-nested-child", + "running", + [makeStage("s1", "ask", "awaiting_input")], + t - 1000, + ), + parentRunId: "parent22", + rootRunId: "root1111", + }; + const lines = renderWidgetLines(makeSnap([child, parent, root]), 120).map(stripAnsi); + const header = lines[0]!; + // Only the root is listed, but its hidden descendant's awaiting state still + // raises the "needs attention" badge so the HiL prompt is discoverable. + assert.ok(header.includes("1 run"), `expected "1 run" subtitle, got: ${header}`); + assert.ok( + header.includes("↵ 1 needs attention (attach to workflow with `/workflow connect`)"), + `expected nested HiL to surface a needs-attention badge, got: ${header}`, + ); + assert.ok(!lines.join("\n").includes("contract-hil-nested-child"), "nested child stays hidden"); + }); + + test("count badges include stage-local awaiting input", () => { + const awaiting = makeRun("r1xxxxxx", "wf-await", "running", [makeStage("s1", "ask", "awaiting_input")]); + const lines = renderWidgetLines(makeSnap([awaiting]), 120).map(stripAnsi); + const header = lines[0]!; + assert.ok(header.includes("● 1 running"), "run remains active"); + assert.ok( + header.includes("? ↵ 1 needs attention (attach to workflow with `/workflow connect`)"), + "awaiting-input badge is labeled with status and attach action", + ); + }); + + test("count badges reflect status mix", () => { + const t = Date.now(); + const running = makeRun("r1xxxxxx", "wf-r", "running", [], t - 1000); + const paused = makeRun("r4xxxxxx", "wf-p", "paused", [], t - 3000); + const done = makeRun("r2xxxxxx", "wf-d", "completed", [], t - 5000, t - 1000); + const failed = makeRun("r3xxxxxx", "wf-f", "failed", [], t - 4000, t - 500); + const lines = renderWidgetLines(makeSnap([running, paused, done, failed]), 120).map(stripAnsi); + const header = lines[0]!; + assert.ok(header.includes("● 1 running"), "running badge"); + assert.ok(header.includes("❚❚ 1 paused"), "paused badge"); + assert.ok(header.includes("✓ 1 complete"), "completed badge"); + assert.ok(header.includes("✗ 1 failed"), "failed badge"); + }); + + test("ctx.exit blocked remains distinct from completed exit statuses", () => { + const t = Date.now(); + const skipped = makeRun("s1xxxxxx", "wf-s", "skipped", [], t - 5000, t - 3000); + const cancelled = makeRun("c1xxxxxx", "wf-c", "cancelled", [], t - 4000, t - 2000); + const blocked = makeRun("b1xxxxxx", "wf-b", "blocked", [], t - 3000, t - 1000); + const lines = renderWidgetLines(makeSnap([skipped, cancelled, blocked]), 120).map(stripAnsi); + const header = lines[0]!; + + assert.ok(header.includes("3 runs"), `expected exited runs in header total, got: ${header}`); + assert.ok(header.includes("✓ 2 complete"), `expected completed exit badge, got: ${header}`); + assert.ok(header.includes("↑ 1 blocked"), `expected blocked exit badge, got: ${header}`); + assert.ok(lines.join("\n").includes("skipped · 2s"), "skipped row remains visible"); + assert.ok(lines.join("\n").includes("cancelled · 2s"), "cancelled row remains visible"); + assert.ok(lines.join("\n").includes("blocked · 2s"), "blocked row remains visible"); + }); + + test("terminal rows render final duration without ticking ago labels", () => { + const originalNow = Date.now; + try { + const startedAt = 1_000; + const endedAt = 11_000; + const completed = makeRun("r2xxxxxx", "wf-d", "completed", [], startedAt, endedAt); + const failed = makeRun("r3xxxxxx", "wf-f", "failed", [], startedAt, endedAt); + const killed = makeRun("r4xxxxxx", "wf-k", "killed", [], startedAt, endedAt); + completed.durationMs = undefined; + failed.durationMs = undefined; + killed.durationMs = undefined; + + Date.now = () => 12_000; + const at12s = renderWidgetLines(makeSnap([completed, failed, killed]), 120) + .map(stripAnsi) + .join("\n"); + Date.now = () => 29_000; + const at29s = renderWidgetLines(makeSnap([completed, failed, killed]), 120) + .map(stripAnsi) + .join("\n"); + + assert.match(at12s, /complete · 10s/); + assert.match(at12s, /failed · 10s/); + assert.match(at12s, /killed · 10s/); + assert.doesNotMatch(at12s, /ago/); + assert.equal(at29s, at12s); + } finally { + Date.now = originalNow; + } + }); + + test("paused run renders pause status and frozen active elapsed time", () => { + const originalNow = Date.now; + try { + Date.now = () => 71_000; + const paused = makeRun("r4xxxxxx", "wf-p", "paused", [], 1_000); + paused.pausedAt = 11_000; + const lines = renderWidgetLines(makeSnap([paused]), 120).map(stripAnsi); + assert.ok(lines.join("\n").includes("❚❚"), "paused glyph"); + assert.ok(lines[0]!.includes("❚❚ 1 paused"), "paused badge"); + assert.match(lines[2]!, /10s/); + assert.doesNotMatch(lines[2]!, /1m/); + + Date.now = () => 76_000; + const later = renderWidgetLines(makeSnap([paused]), 120).map(stripAnsi); + assert.equal(later[2], lines[2]); + } finally { + Date.now = originalNow; + } + }); + + test("terminal and fully paused widgets do not schedule second-boundary refreshes", () => { + const now = 1_000_000; + const terminal = makeRun("r2xxxxxx", "wf-d", "completed", [], now - 20_000, now - 10_000); + const terminalDelay = nextWidgetRefreshDelayMs(makeSnap([terminal]), now); + assert.equal(terminalDelay, RECENT_ENDED_WINDOW_MS - 10_000 + 1); + + const paused = makeRun("r4xxxxxx", "wf-p", "paused", [], now - 20_000); + paused.pausedAt = now - 5_000; + assert.equal(nextWidgetRefreshDelayMs(makeSnap([paused]), now), undefined); + }); + + test("active runs schedule the next exact elapsed-second refresh", () => { + const now = 1_000_000; + const active = makeRun("r1xxxxxx", "wf-a", "running", [], now - 5_000); + assert.equal(nextWidgetRefreshDelayMs(makeSnap([active]), now), 1_000); + + const offsetActive = makeRun("r3xxxxxx", "wf-b", "running", [], now - 5_250); + assert.equal(nextWidgetRefreshDelayMs(makeSnap([offsetActive]), now), 750); + + const ended = makeRun("r2xxxxxx", "wf-d", "completed", [], now - 20_000, now - 10_000); + assert.equal(nextWidgetRefreshDelayMs(makeSnap([offsetActive, ended]), now), 750); + }); + + test("standard panel scales to the provided terminal width", () => { + const width = 120; + const snap = makeSnap([makeRun("abc123uuid", "my-wf", "running")]); + const lines = renderWidgetLines(snap, width); + for (const line of lines) { + assert.equal(visibleWidth(line), width); + } + }); + + test("running run uses static ● glyph, never a braille spinner frame", () => { + // The widget is the canonical 'workflow status' surface; per DESIGN.md + // 'no spinners on prompt; no flash' it must render the same static + // vocabulary as `renderStatusList`/`renderRunDetail` (statusIcon → '●'). + const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; + const t = Date.now(); + const snap = makeSnap([ + makeRun("r1xxxxxx", "wf-r", "running", [makeStage("s1", "stage-1", "running")], t - 1000), + ]); + // Sample several `now` offsets — a frame-cycling glyph would land on + // a different braille character at each tick. + for (let dt = 0; dt < 800; dt += 80) { + const lines = renderWidgetLines(snap, 120).map(stripAnsi); + const joined = lines.join("\n"); + assert.ok(joined.includes("●"), `static ● glyph at +${dt}ms`); + for (const frame of SPINNER_FRAMES) { + assert.ok( + !joined.includes(frame), + `widget must not emit braille spinner frame ${JSON.stringify(frame)} at +${dt}ms`, + ); + } + } + }); }); // --------------------------------------------------------------------------- @@ -405,15 +400,15 @@ describe("renderWidgetLines — standard form", () => { // --------------------------------------------------------------------------- describe("renderWidgetLines — collapsed form", () => { - test("returns single line summary under threshold", () => { - const r1 = makeRun("aaa", "wf-a", "running"); - const r2 = makeRun("bbb", "wf-b", "running"); - const lines = renderWidgetLines(makeSnap([r1, r2]), 60).map(stripAnsi); - assert.equal(lines.length, 1); - assert.ok(lines[0]!.includes("▾")); - assert.ok(lines[0]!.includes("2 background")); - assert.ok(lines[0]!.includes("2 ●")); - }); + test("returns single line summary under threshold", () => { + const r1 = makeRun("aaa", "wf-a", "running"); + const r2 = makeRun("bbb", "wf-b", "running"); + const lines = renderWidgetLines(makeSnap([r1, r2]), 60).map(stripAnsi); + assert.equal(lines.length, 1); + assert.ok(lines[0]!.includes("▾")); + assert.ok(lines[0]!.includes("2 background")); + assert.ok(lines[0]!.includes("2 ●")); + }); }); // --------------------------------------------------------------------------- @@ -421,29 +416,27 @@ describe("renderWidgetLines — collapsed form", () => { // --------------------------------------------------------------------------- describe("buildThemedWidgetLines — themed path", () => { - test("when piTheme is provided, output carries ANSI escape sequences", () => { - const snap = makeSnap([makeRun("zzz", "themed-wf", "running")]); - const lines = buildThemedWidgetLines(snap, NULL_PI_THEME, 120); - assert.ok(lines.length >= 4, "themed render returns panel + entry lines"); - const joined = lines.join(""); - assert.ok(joined.includes("\x1b["), "themed lines include ANSI escapes"); - }); - - test("awaiting-input title badge uses info blue and question mark", () => { - const awaiting = makeRun("r1xxxxxx", "wf-await", "running", [ - makeStage("s1", "ask", "awaiting_input"), - ]); - const lines = buildThemedWidgetLines(makeSnap([awaiting]), NULL_PI_THEME, 160); - const joined = lines.join("\n"); - const infoBlue = hexToAnsi(deriveGraphTheme({}).info); - - assert.ok( - joined.includes(`${infoBlue}? ↵ 1 needs attention`), - "awaiting-input badge should be styled with the graph info blue", - ); - assert.ok( - stripAnsi(joined).includes("? ↵ 1 needs attention (attach to workflow with `/workflow connect`)"), - "awaiting-input badge should keep the status/question mark and attach copy", - ); - }); + test("when piTheme is provided, output carries ANSI escape sequences", () => { + const snap = makeSnap([makeRun("zzz", "themed-wf", "running")]); + const lines = buildThemedWidgetLines(snap, NULL_PI_THEME, 120); + assert.ok(lines.length >= 4, "themed render returns panel + entry lines"); + const joined = lines.join(""); + assert.ok(joined.includes("\x1b["), "themed lines include ANSI escapes"); + }); + + test("awaiting-input title badge uses info blue and question mark", () => { + const awaiting = makeRun("r1xxxxxx", "wf-await", "running", [makeStage("s1", "ask", "awaiting_input")]); + const lines = buildThemedWidgetLines(makeSnap([awaiting]), NULL_PI_THEME, 160); + const joined = lines.join("\n"); + const infoBlue = hexToAnsi(deriveGraphTheme({}).info); + + assert.ok( + joined.includes(`${infoBlue}? ↵ 1 needs attention`), + "awaiting-input badge should be styled with the graph info blue", + ); + assert.ok( + stripAnsi(joined).includes("? ↵ 1 needs attention (attach to workflow with `/workflow connect`)"), + "awaiting-input badge should keep the status/question mark and attach copy", + ); + }); }); diff --git a/test/unit/wiring-adapters-01.test.ts b/test/unit/wiring-adapters-01.test.ts index 3c3cb9089..e6dbafa82 100644 --- a/test/unit/wiring-adapters-01.test.ts +++ b/test/unit/wiring-adapters-01.test.ts @@ -7,420 +7,372 @@ * store-backed background adapter (see `background-ui-adapter.test.ts`). */ -import { describe, test } from "bun:test"; import assert from "node:assert/strict"; import { join } from "node:path"; import { - buildRuntimeAdapters, - prepareAtomicStageSessionOptions, -} from "../../packages/workflows/src/extension/wiring.js"; -import { StageUiBroker } from "../../packages/workflows/src/shared/stage-ui-broker.js"; -import { createStore } from "../../packages/workflows/src/shared/store.js"; -import { - DefaultResourceLoader, - type CreateAgentSessionOptions, - type DefaultResourceLoaderInheritanceSnapshot, - type PackageSource, + DefaultResourceLoader, + type DefaultResourceLoaderInheritanceSnapshot, + type PackageSource, } from "@bastani/atomic"; +import { describe, test } from "vitest"; import type { - PiCodingAgentSdk, - PiSdkResourceLoader, - PiSdkSettingsManager, + PiCodingAgentSdk, + PiSdkResourceLoader, + PiSdkSettingsManager, } from "../../packages/workflows/src/extension/wiring.js"; +import { prepareAtomicStageSessionOptions } from "../../packages/workflows/src/extension/wiring.js"; import type { StageSessionRuntime } from "../../packages/workflows/src/runs/foreground/stage-runner.js"; -import type { StageExecutionMeta } from "../../packages/workflows/src/shared/types.js"; function fakeSession(): StageSessionRuntime { - let last = ""; - return { - async prompt(text: string): Promise { - last = `reply:${text}`; - return last; - }, - async steer(text: string): Promise { - last = `steer:${text}`; - }, - async followUp(text: string): Promise { - last = `follow:${text}`; - }, - subscribe: () => () => {}, - sessionFile: undefined, - sessionId: "session-1", - async setModel(): Promise {}, - setThinkingLevel(): void {}, - async cycleModel(): Promise { - return undefined; - }, - cycleThinkingLevel(): undefined { - return undefined; - }, - agent: {} as StageSessionRuntime["agent"], - model: undefined, - thinkingLevel: "medium" as StageSessionRuntime["thinkingLevel"], - messages: [], - isStreaming: false, - async navigateTree(): Promise<{ cancelled: boolean }> { - return { cancelled: true }; - }, - async compact(): ReturnType { - return undefined as unknown as Awaited< - ReturnType - >; - }, - abortCompaction(): void {}, - async abort(): Promise {}, - dispose(): void {}, - getLastAssistantText(): string | undefined { - return last; - }, - }; + let last = ""; + return { + async prompt(text: string): Promise { + last = `reply:${text}`; + return last; + }, + async steer(text: string): Promise { + last = `steer:${text}`; + }, + async followUp(text: string): Promise { + last = `follow:${text}`; + }, + subscribe: () => () => {}, + sessionFile: undefined, + sessionId: "session-1", + async setModel(): Promise {}, + setThinkingLevel(): void {}, + async cycleModel(): Promise { + return undefined; + }, + cycleThinkingLevel(): undefined { + return undefined; + }, + agent: {} as StageSessionRuntime["agent"], + model: undefined, + thinkingLevel: "medium" as StageSessionRuntime["thinkingLevel"], + messages: [], + isStreaming: false, + async navigateTree(): Promise<{ cancelled: boolean }> { + return { cancelled: true }; + }, + async compact(): ReturnType { + return undefined as unknown as Awaited>; + }, + abortCompaction(): void {}, + async abort(): Promise {}, + dispose(): void {}, + getLastAssistantText(): string | undefined { + return last; + }, + }; } function deferred(): { - readonly promise: Promise; - readonly resolve: () => void; - readonly reject: (reason?: unknown) => void; + readonly promise: Promise; + readonly resolve: () => void; + readonly reject: (reason?: unknown) => void; } { - let resolvePromise: (() => void) | undefined; - let rejectPromise: ((reason?: unknown) => void) | undefined; - const promise = new Promise((resolve, reject) => { - resolvePromise = resolve; - rejectPromise = reject; - }); - return { - promise, - resolve: () => resolvePromise?.(), - reject: (reason?: unknown) => rejectPromise?.(reason), - }; + let resolvePromise: (() => void) | undefined; + let rejectPromise: ((reason?: unknown) => void) | undefined; + const promise = new Promise((resolve, reject) => { + resolvePromise = resolve; + rejectPromise = reject; + }); + return { + promise, + resolve: () => resolvePromise?.(), + reject: (reason?: unknown) => rejectPromise?.(reason), + }; } async function waitUntil(predicate: () => boolean, message: string): Promise { - for (let attempt = 0; attempt < 50; attempt += 1) { - if (predicate()) return; - await new Promise((resolve) => setTimeout(resolve, 0)); - } - assert.fail(message); + for (let attempt = 0; attempt < 50; attempt += 1) { + if (predicate()) return; + await new Promise((resolve) => setTimeout(resolve, 0)); + } + assert.fail(message); } function makeFakeAtomicSdk( - defaultAgentDir: string, - builtinPackagePaths: string[] = [], + defaultAgentDir: string, + builtinPackagePaths: string[] = [], ): { - readonly sdk: PiCodingAgentSdk; - readonly loaderOptions: Array<{ - cwd: string; - agentDir: string; - settingsManager?: PiSdkSettingsManager; - builtinPackagePaths?: PackageSource[]; - resourceLoaderInheritanceSnapshot?: DefaultResourceLoaderInheritanceSnapshot; - }>; - readonly settingsCalls: Array<{ - cwd?: string; - agentDir?: string; - options?: { projectTrusted?: boolean }; - }>; - readonly reloads: PiSdkResourceLoader[]; + readonly sdk: PiCodingAgentSdk; + readonly loaderOptions: Array<{ + cwd: string; + agentDir: string; + settingsManager?: PiSdkSettingsManager; + builtinPackagePaths?: PackageSource[]; + resourceLoaderInheritanceSnapshot?: DefaultResourceLoaderInheritanceSnapshot; + }>; + readonly settingsCalls: Array<{ + cwd?: string; + agentDir?: string; + options?: { projectTrusted?: boolean }; + }>; + readonly reloads: PiSdkResourceLoader[]; } { - const loaderOptions: Array<{ - cwd: string; - agentDir: string; - settingsManager?: PiSdkSettingsManager; - builtinPackagePaths?: PackageSource[]; - resourceLoaderInheritanceSnapshot?: DefaultResourceLoaderInheritanceSnapshot; - }> = []; - const settingsCalls: Array<{ - cwd?: string; - agentDir?: string; - options?: { projectTrusted?: boolean }; - }> = []; - const reloads: PiSdkResourceLoader[] = []; - - class FakeResourceLoader implements PiSdkResourceLoader { - constructor(options: { - cwd: string; - agentDir: string; - settingsManager?: PiSdkSettingsManager; - builtinPackagePaths?: PackageSource[]; - resourceLoaderInheritanceSnapshot?: DefaultResourceLoaderInheritanceSnapshot; - }) { - loaderOptions.push(options); - } - - async reload(): Promise { - reloads.push(this); - } - } - - const sdk: PiCodingAgentSdk = { - getAgentDir: () => defaultAgentDir, - getBuiltinPackagePaths: () => builtinPackagePaths, - SettingsManager: { - create( - cwd?: string, - agentDir?: string, - options?: { projectTrusted?: boolean }, - ): PiSdkSettingsManager { - settingsCalls.push({ cwd, agentDir, options }); - return { - getCodexFastModeSettings: () => ({ - chat: false, - workflow: false, - }), - }; - }, - }, - DefaultResourceLoader: FakeResourceLoader, - async createAgentSession(): Promise<{ session: StageSessionRuntime }> { - return { session: fakeSession() }; - }, - }; - - return { sdk, loaderOptions, settingsCalls, reloads }; + const loaderOptions: Array<{ + cwd: string; + agentDir: string; + settingsManager?: PiSdkSettingsManager; + builtinPackagePaths?: PackageSource[]; + resourceLoaderInheritanceSnapshot?: DefaultResourceLoaderInheritanceSnapshot; + }> = []; + const settingsCalls: Array<{ + cwd?: string; + agentDir?: string; + options?: { projectTrusted?: boolean }; + }> = []; + const reloads: PiSdkResourceLoader[] = []; + + class FakeResourceLoader implements PiSdkResourceLoader { + constructor(options: { + cwd: string; + agentDir: string; + settingsManager?: PiSdkSettingsManager; + builtinPackagePaths?: PackageSource[]; + resourceLoaderInheritanceSnapshot?: DefaultResourceLoaderInheritanceSnapshot; + }) { + loaderOptions.push(options); + } + + async reload(): Promise { + reloads.push(this); + } + } + + const sdk: PiCodingAgentSdk = { + getAgentDir: () => defaultAgentDir, + getBuiltinPackagePaths: () => builtinPackagePaths, + SettingsManager: { + create(cwd?: string, agentDir?: string, options?: { projectTrusted?: boolean }): PiSdkSettingsManager { + settingsCalls.push({ cwd, agentDir, options }); + return { + getCodexFastModeSettings: () => ({ + chat: false, + workflow: false, + }), + }; + }, + }, + DefaultResourceLoader: FakeResourceLoader, + async createAgentSession(): Promise<{ session: StageSessionRuntime }> { + return { session: fakeSession() }; + }, + }; + + return { sdk, loaderOptions, settingsCalls, reloads }; } describe("prepareAtomicStageSessionOptions", () => { - test("uses the Atomic default agent dir for resource loading without turning it into a user override", async () => { - const projectDir = join("/tmp", "project"); - const atomicAgentDir = join("/home", "user", ".atomic", "agent"); - const { sdk, loaderOptions, settingsCalls, reloads } = - makeFakeAtomicSdk(atomicAgentDir); - - const options = await prepareAtomicStageSessionOptions( - { cwd: projectDir }, - sdk, - ); - - assert.equal(options?.cwd, projectDir); - assert.equal(options?.agentDir, undefined); - assert.equal(loaderOptions[0]?.cwd, projectDir); - assert.equal(loaderOptions[0]?.agentDir, atomicAgentDir); - assert.equal(settingsCalls[0]?.cwd, projectDir); - assert.equal(settingsCalls[0]?.agentDir, atomicAgentDir); - assert.equal(reloads.length, 1); - }); - - test("preserves a user-provided agentDir as an explicit single-directory override", async () => { - const projectDir = join("/tmp", "project"); - const atomicAgentDir = join("/home", "user", ".atomic", "agent"); - const customAgentDir = join("/tmp", "custom-agent"); - const { sdk, loaderOptions } = makeFakeAtomicSdk(atomicAgentDir); - - const options = await prepareAtomicStageSessionOptions( - { cwd: projectDir, agentDir: customAgentDir }, - sdk, - ); - - assert.equal(options?.agentDir, customAgentDir); - assert.equal(loaderOptions[0]?.agentDir, customAgentDir); - }); - - test("disables only the recursive workflow extension for workflow stage sessions", async () => { - const projectDir = join("/tmp", "project"); - const atomicAgentDir = join("/home", "user", ".atomic", "agent"); - const builtinPackagePaths = [ - "/repo/packages/workflows", - "/repo/packages/subagents", - "/repo/packages/mcp", - "/repo/packages/web-access", - "/repo/packages/intercom", - ]; - const { sdk, loaderOptions } = makeFakeAtomicSdk( - atomicAgentDir, - builtinPackagePaths, - ); - - await prepareAtomicStageSessionOptions({ cwd: projectDir }, sdk); - - assert.deepEqual(loaderOptions[0]?.builtinPackagePaths, [ - { source: "/repo/packages/workflows", extensions: [] }, - "/repo/packages/subagents", - "/repo/packages/mcp", - "/repo/packages/web-access", - "/repo/packages/intercom", - ]); - }); - - test("passes inherited atomic -e resource options to fresh workflow stage loaders", async () => { - const projectDir = join("/tmp", "project"); - const atomicAgentDir = join("/home", "user", ".atomic", "agent"); - const inheritedSnapshot: DefaultResourceLoaderInheritanceSnapshot = { - projectTrusted: false, - additionalExtensionPaths: ["/external-package/extensions/index.ts"], - additionalSkillPaths: ["/external-package/.atomic/skills/inherited/SKILL.md"], - additionalPromptTemplatePaths: ["/external-package/.atomic/prompts/review.md"], - additionalThemePaths: ["/external-package/.atomic/themes/theme.json"], - builtinPackagePaths: [ - "/repo/packages/workflows", - { - source: "/repo/packages/subagents", - skills: ["skills/**"], - }, - ], - trustedBorrowedProjectLocalSources: ["/external-package"], - }; - const { sdk, loaderOptions, settingsCalls, reloads } = makeFakeAtomicSdk( - atomicAgentDir, - ["/should/not/use/sdk/builtins"], - ); - - await prepareAtomicStageSessionOptions({ cwd: projectDir }, sdk, { - resourceLoaderInheritanceSnapshot: inheritedSnapshot, - }); - - assert.equal(settingsCalls[0]?.options?.projectTrusted, false); - assert.deepEqual( - loaderOptions[0]?.resourceLoaderInheritanceSnapshot, - inheritedSnapshot, - ); - assert.deepEqual(loaderOptions[0]?.builtinPackagePaths, [ - { source: "/repo/packages/workflows", extensions: [] }, - { source: "/repo/packages/subagents", skills: ["skills/**"] }, - ]); - assert.equal(reloads.length, 1); - }); - - test("preserves explicit resourceLoader overrides instead of inheriting parent resources", async () => { - const projectDir = join("/tmp", "project"); - const atomicAgentDir = join("/home", "user", ".atomic", "agent"); - const { sdk, loaderOptions, settingsCalls, reloads } = - makeFakeAtomicSdk(atomicAgentDir); - const explicitResourceLoader = new DefaultResourceLoader({ - cwd: projectDir, - agentDir: atomicAgentDir, - }); - - const options = await prepareAtomicStageSessionOptions( - { cwd: projectDir, resourceLoader: explicitResourceLoader }, - sdk, - { - resourceLoaderInheritanceSnapshot: { - additionalExtensionPaths: ["/external-package"], - builtinPackagePaths: ["/repo/packages/workflows"], - }, - }, - ); - - assert.equal(options?.resourceLoader, explicitResourceLoader); - assert.equal(loaderOptions.length, 0); - assert.equal(settingsCalls.length, 0); - assert.equal(reloads.length, 0); - }); - - test("serializes workflow stage resource reload env isolation", async () => { - const projectDir = join("/tmp", "project"); - const atomicAgentDir = join("/home", "user", ".atomic", "agent"); - const envKeys = [ - "ATOMIC_SUBAGENT_CHILD", - "ATOMIC_SUBAGENT_FANOUT_CHILD", - "PI_SUBAGENT_CHILD", - "PI_SUBAGENT_FANOUT_CHILD", - ] as const; - const savedEnv = new Map( - envKeys.map((key) => [key, process.env[key]]), - ); - const reloadGates: Array<{ - readonly release: ReturnType; - readonly envDuringReload: ReadonlyMap; - }> = []; - - class GatedResourceLoader implements PiSdkResourceLoader { - constructor(_options: { - cwd: string; - agentDir: string; - settingsManager?: PiSdkSettingsManager; - builtinPackagePaths?: PackageSource[]; - }) {} - - async reload(): Promise { - const release = deferred(); - reloadGates.push({ - release, - envDuringReload: new Map( - envKeys.map((key) => [key, process.env[key]]), - ), - }); - await release.promise; - } - } - - const sdk: PiCodingAgentSdk = { - getAgentDir: () => atomicAgentDir, - getBuiltinPackagePaths: () => [], - SettingsManager: { - create(): PiSdkSettingsManager { - return { - getCodexFastModeSettings: () => ({ - chat: false, - workflow: false, - }), - }; - }, - }, - DefaultResourceLoader: GatedResourceLoader, - async createAgentSession(): Promise<{ session: StageSessionRuntime }> { - return { session: fakeSession() }; - }, - }; - - let first: ReturnType | undefined; - let second: ReturnType | undefined; - try { - process.env.ATOMIC_SUBAGENT_CHILD = "1"; - process.env.ATOMIC_SUBAGENT_FANOUT_CHILD = "0"; - process.env.PI_SUBAGENT_CHILD = "legacy-child"; - delete process.env.PI_SUBAGENT_FANOUT_CHILD; - - first = prepareAtomicStageSessionOptions({ cwd: projectDir }, sdk); - await waitUntil( - () => reloadGates.length >= 1, - "expected the first resource reload to start", - ); - second = prepareAtomicStageSessionOptions({ cwd: projectDir }, sdk); - await new Promise((resolve) => setTimeout(resolve, 0)); - - assert.deepEqual( - Object.fromEntries(reloadGates[0]!.envDuringReload), - { - ATOMIC_SUBAGENT_CHILD: undefined, - ATOMIC_SUBAGENT_FANOUT_CHILD: undefined, - PI_SUBAGENT_CHILD: undefined, - PI_SUBAGENT_FANOUT_CHILD: undefined, - }, - ); - - reloadGates[0]!.release.resolve(); - await waitUntil( - () => reloadGates.length >= 2, - "expected the second resource reload to start after the first completes", - ); - assert.deepEqual( - Object.fromEntries(reloadGates[1]!.envDuringReload), - { - ATOMIC_SUBAGENT_CHILD: undefined, - ATOMIC_SUBAGENT_FANOUT_CHILD: undefined, - PI_SUBAGENT_CHILD: undefined, - PI_SUBAGENT_FANOUT_CHILD: undefined, - }, - ); - - reloadGates[1]!.release.resolve(); - await Promise.all([first, second]); - - assert.equal(process.env.ATOMIC_SUBAGENT_CHILD, "1"); - assert.equal(process.env.ATOMIC_SUBAGENT_FANOUT_CHILD, "0"); - assert.equal(process.env.PI_SUBAGENT_CHILD, "legacy-child"); - assert.equal(process.env.PI_SUBAGENT_FANOUT_CHILD, undefined); - } finally { - for (const gate of reloadGates) gate.release.resolve(); - const pendingReloads: Array> = []; - if (first !== undefined) pendingReloads.push(first); - if (second !== undefined) pendingReloads.push(second); - await Promise.allSettled(pendingReloads); - for (const key of envKeys) { - const value = savedEnv.get(key); - if (value === undefined) delete process.env[key]; - else process.env[key] = value; - } - } - }); + test("uses the Atomic default agent dir for resource loading without turning it into a user override", async () => { + const projectDir = join("/tmp", "project"); + const atomicAgentDir = join("/home", "user", ".atomic", "agent"); + const { sdk, loaderOptions, settingsCalls, reloads } = makeFakeAtomicSdk(atomicAgentDir); + + const options = await prepareAtomicStageSessionOptions({ cwd: projectDir }, sdk); + + assert.equal(options?.cwd, projectDir); + assert.equal(options?.agentDir, undefined); + assert.equal(loaderOptions[0]?.cwd, projectDir); + assert.equal(loaderOptions[0]?.agentDir, atomicAgentDir); + assert.equal(settingsCalls[0]?.cwd, projectDir); + assert.equal(settingsCalls[0]?.agentDir, atomicAgentDir); + assert.equal(reloads.length, 1); + }); + + test("preserves a user-provided agentDir as an explicit single-directory override", async () => { + const projectDir = join("/tmp", "project"); + const atomicAgentDir = join("/home", "user", ".atomic", "agent"); + const customAgentDir = join("/tmp", "custom-agent"); + const { sdk, loaderOptions } = makeFakeAtomicSdk(atomicAgentDir); + + const options = await prepareAtomicStageSessionOptions({ cwd: projectDir, agentDir: customAgentDir }, sdk); + + assert.equal(options?.agentDir, customAgentDir); + assert.equal(loaderOptions[0]?.agentDir, customAgentDir); + }); + + test("disables only the recursive workflow extension for workflow stage sessions", async () => { + const projectDir = join("/tmp", "project"); + const atomicAgentDir = join("/home", "user", ".atomic", "agent"); + const builtinPackagePaths = [ + "/repo/packages/workflows", + "/repo/packages/subagents", + "/repo/packages/mcp", + "/repo/packages/web-access", + "/repo/packages/intercom", + ]; + const { sdk, loaderOptions } = makeFakeAtomicSdk(atomicAgentDir, builtinPackagePaths); + + await prepareAtomicStageSessionOptions({ cwd: projectDir }, sdk); + + assert.deepEqual(loaderOptions[0]?.builtinPackagePaths, [ + { source: "/repo/packages/workflows", extensions: [] }, + "/repo/packages/subagents", + "/repo/packages/mcp", + "/repo/packages/web-access", + "/repo/packages/intercom", + ]); + }); + + test("passes inherited atomic -e resource options to fresh workflow stage loaders", async () => { + const projectDir = join("/tmp", "project"); + const atomicAgentDir = join("/home", "user", ".atomic", "agent"); + const inheritedSnapshot: DefaultResourceLoaderInheritanceSnapshot = { + projectTrusted: false, + additionalExtensionPaths: ["/external-package/extensions/index.ts"], + additionalSkillPaths: ["/external-package/.atomic/skills/inherited/SKILL.md"], + additionalPromptTemplatePaths: ["/external-package/.atomic/prompts/review.md"], + additionalThemePaths: ["/external-package/.atomic/themes/theme.json"], + builtinPackagePaths: [ + "/repo/packages/workflows", + { + source: "/repo/packages/subagents", + skills: ["skills/**"], + }, + ], + trustedBorrowedProjectLocalSources: ["/external-package"], + }; + const { sdk, loaderOptions, settingsCalls, reloads } = makeFakeAtomicSdk(atomicAgentDir, [ + "/should/not/use/sdk/builtins", + ]); + + await prepareAtomicStageSessionOptions({ cwd: projectDir }, sdk, { + resourceLoaderInheritanceSnapshot: inheritedSnapshot, + }); + + assert.equal(settingsCalls[0]?.options?.projectTrusted, false); + assert.deepEqual(loaderOptions[0]?.resourceLoaderInheritanceSnapshot, inheritedSnapshot); + assert.deepEqual(loaderOptions[0]?.builtinPackagePaths, [ + { source: "/repo/packages/workflows", extensions: [] }, + { source: "/repo/packages/subagents", skills: ["skills/**"] }, + ]); + assert.equal(reloads.length, 1); + }); + + test("preserves explicit resourceLoader overrides instead of inheriting parent resources", async () => { + const projectDir = join("/tmp", "project"); + const atomicAgentDir = join("/home", "user", ".atomic", "agent"); + const { sdk, loaderOptions, settingsCalls, reloads } = makeFakeAtomicSdk(atomicAgentDir); + const explicitResourceLoader = new DefaultResourceLoader({ + cwd: projectDir, + agentDir: atomicAgentDir, + }); + + const options = await prepareAtomicStageSessionOptions( + { cwd: projectDir, resourceLoader: explicitResourceLoader }, + sdk, + { + resourceLoaderInheritanceSnapshot: { + additionalExtensionPaths: ["/external-package"], + builtinPackagePaths: ["/repo/packages/workflows"], + }, + }, + ); + + assert.equal(options?.resourceLoader, explicitResourceLoader); + assert.equal(loaderOptions.length, 0); + assert.equal(settingsCalls.length, 0); + assert.equal(reloads.length, 0); + }); + + test("serializes workflow stage resource reload env isolation", async () => { + const projectDir = join("/tmp", "project"); + const atomicAgentDir = join("/home", "user", ".atomic", "agent"); + const envKeys = [ + "ATOMIC_SUBAGENT_CHILD", + "ATOMIC_SUBAGENT_FANOUT_CHILD", + "PI_SUBAGENT_CHILD", + "PI_SUBAGENT_FANOUT_CHILD", + ] as const; + const savedEnv = new Map(envKeys.map((key) => [key, process.env[key]])); + const reloadGates: Array<{ + readonly release: ReturnType; + readonly envDuringReload: ReadonlyMap; + }> = []; + + class GatedResourceLoader implements PiSdkResourceLoader { + async reload(): Promise { + const release = deferred(); + reloadGates.push({ + release, + envDuringReload: new Map(envKeys.map((key) => [key, process.env[key]])), + }); + await release.promise; + } + } + + const sdk: PiCodingAgentSdk = { + getAgentDir: () => atomicAgentDir, + getBuiltinPackagePaths: () => [], + SettingsManager: { + create(): PiSdkSettingsManager { + return { + getCodexFastModeSettings: () => ({ + chat: false, + workflow: false, + }), + }; + }, + }, + DefaultResourceLoader: GatedResourceLoader, + async createAgentSession(): Promise<{ session: StageSessionRuntime }> { + return { session: fakeSession() }; + }, + }; + + let first: ReturnType | undefined; + let second: ReturnType | undefined; + try { + process.env.ATOMIC_SUBAGENT_CHILD = "1"; + process.env.ATOMIC_SUBAGENT_FANOUT_CHILD = "0"; + process.env.PI_SUBAGENT_CHILD = "legacy-child"; + delete process.env.PI_SUBAGENT_FANOUT_CHILD; + + first = prepareAtomicStageSessionOptions({ cwd: projectDir }, sdk); + await waitUntil(() => reloadGates.length >= 1, "expected the first resource reload to start"); + second = prepareAtomicStageSessionOptions({ cwd: projectDir }, sdk); + await new Promise((resolve) => setTimeout(resolve, 0)); + + assert.deepEqual(Object.fromEntries(reloadGates[0]!.envDuringReload), { + ATOMIC_SUBAGENT_CHILD: undefined, + ATOMIC_SUBAGENT_FANOUT_CHILD: undefined, + PI_SUBAGENT_CHILD: undefined, + PI_SUBAGENT_FANOUT_CHILD: undefined, + }); + + reloadGates[0]!.release.resolve(); + await waitUntil( + () => reloadGates.length >= 2, + "expected the second resource reload to start after the first completes", + ); + assert.deepEqual(Object.fromEntries(reloadGates[1]!.envDuringReload), { + ATOMIC_SUBAGENT_CHILD: undefined, + ATOMIC_SUBAGENT_FANOUT_CHILD: undefined, + PI_SUBAGENT_CHILD: undefined, + PI_SUBAGENT_FANOUT_CHILD: undefined, + }); + + reloadGates[1]!.release.resolve(); + await Promise.all([first, second]); + + assert.equal(process.env.ATOMIC_SUBAGENT_CHILD, "1"); + assert.equal(process.env.ATOMIC_SUBAGENT_FANOUT_CHILD, "0"); + assert.equal(process.env.PI_SUBAGENT_CHILD, "legacy-child"); + assert.equal(process.env.PI_SUBAGENT_FANOUT_CHILD, undefined); + } finally { + for (const gate of reloadGates) gate.release.resolve(); + const pendingReloads: Array> = []; + if (first !== undefined) pendingReloads.push(first); + if (second !== undefined) pendingReloads.push(second); + await Promise.allSettled(pendingReloads); + for (const key of envKeys) { + const value = savedEnv.get(key); + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + } + }); }); diff --git a/test/unit/wiring-adapters-02-01.test.ts b/test/unit/wiring-adapters-02-01.test.ts index 4be88ec0c..ad536d96a 100644 --- a/test/unit/wiring-adapters-02-01.test.ts +++ b/test/unit/wiring-adapters-02-01.test.ts @@ -7,440 +7,431 @@ * store-backed background adapter (see `background-ui-adapter.test.ts`). */ -import { describe, test } from "bun:test"; import assert from "node:assert/strict"; -import { join } from "node:path"; -import { - buildRuntimeAdapters, - prepareAtomicStageSessionOptions, -} from "../../packages/workflows/src/extension/wiring.js"; -import { StageUiBroker } from "../../packages/workflows/src/shared/stage-ui-broker.js"; -import { createStore } from "../../packages/workflows/src/shared/store.js"; -import { - DefaultResourceLoader, - type CreateAgentSessionOptions, - type DefaultResourceLoaderInheritanceSnapshot, - type PackageSource, +import type { + CreateAgentSessionOptions, + DefaultResourceLoaderInheritanceSnapshot, + PackageSource, } from "@bastani/atomic"; +import { describe, test } from "vitest"; import type { - PiCodingAgentSdk, - PiSdkResourceLoader, - PiSdkSettingsManager, + PiCodingAgentSdk, + PiSdkResourceLoader, + PiSdkSettingsManager, } from "../../packages/workflows/src/extension/wiring.js"; +import { buildRuntimeAdapters } from "../../packages/workflows/src/extension/wiring.js"; import type { StageSessionRuntime } from "../../packages/workflows/src/runs/foreground/stage-runner.js"; -import type { StageExecutionMeta } from "../../packages/workflows/src/shared/types.js"; function fakeSession(): StageSessionRuntime { - let last = ""; - return { - async prompt(text: string): Promise { - last = `reply:${text}`; - return last; - }, - async steer(text: string): Promise { - last = `steer:${text}`; - }, - async followUp(text: string): Promise { - last = `follow:${text}`; - }, - subscribe: () => () => {}, - sessionFile: undefined, - sessionId: "session-1", - async setModel(): Promise {}, - setThinkingLevel(): void {}, - async cycleModel(): Promise { - return undefined; - }, - cycleThinkingLevel(): undefined { - return undefined; - }, - agent: {} as StageSessionRuntime["agent"], - model: undefined, - thinkingLevel: "medium" as StageSessionRuntime["thinkingLevel"], - messages: [], - isStreaming: false, - async navigateTree(): Promise<{ cancelled: boolean }> { - return { cancelled: true }; - }, - async compact(): ReturnType { - return undefined as unknown as Awaited< - ReturnType - >; - }, - abortCompaction(): void {}, - async abort(): Promise {}, - dispose(): void {}, - getLastAssistantText(): string | undefined { - return last; - }, - }; + let last = ""; + return { + async prompt(text: string): Promise { + last = `reply:${text}`; + return last; + }, + async steer(text: string): Promise { + last = `steer:${text}`; + }, + async followUp(text: string): Promise { + last = `follow:${text}`; + }, + subscribe: () => () => {}, + sessionFile: undefined, + sessionId: "session-1", + async setModel(): Promise {}, + setThinkingLevel(): void {}, + async cycleModel(): Promise { + return undefined; + }, + cycleThinkingLevel(): undefined { + return undefined; + }, + agent: {} as StageSessionRuntime["agent"], + model: undefined, + thinkingLevel: "medium" as StageSessionRuntime["thinkingLevel"], + messages: [], + isStreaming: false, + async navigateTree(): Promise<{ cancelled: boolean }> { + return { cancelled: true }; + }, + async compact(): ReturnType { + return undefined as unknown as Awaited>; + }, + abortCompaction(): void {}, + async abort(): Promise {}, + dispose(): void {}, + getLastAssistantText(): string | undefined { + return last; + }, + }; } -function deferred(): { - readonly promise: Promise; - readonly resolve: () => void; - readonly reject: (reason?: unknown) => void; +function _deferred(): { + readonly promise: Promise; + readonly resolve: () => void; + readonly reject: (reason?: unknown) => void; } { - let resolvePromise: (() => void) | undefined; - let rejectPromise: ((reason?: unknown) => void) | undefined; - const promise = new Promise((resolve, reject) => { - resolvePromise = resolve; - rejectPromise = reject; - }); - return { - promise, - resolve: () => resolvePromise?.(), - reject: (reason?: unknown) => rejectPromise?.(reason), - }; + let resolvePromise: (() => void) | undefined; + let rejectPromise: ((reason?: unknown) => void) | undefined; + const promise = new Promise((resolve, reject) => { + resolvePromise = resolve; + rejectPromise = reject; + }); + return { + promise, + resolve: () => resolvePromise?.(), + reject: (reason?: unknown) => rejectPromise?.(reason), + }; } -async function waitUntil(predicate: () => boolean, message: string): Promise { - for (let attempt = 0; attempt < 50; attempt += 1) { - if (predicate()) return; - await new Promise((resolve) => setTimeout(resolve, 0)); - } - assert.fail(message); +async function _waitUntil(predicate: () => boolean, message: string): Promise { + for (let attempt = 0; attempt < 50; attempt += 1) { + if (predicate()) return; + await new Promise((resolve) => setTimeout(resolve, 0)); + } + assert.fail(message); } -function makeFakeAtomicSdk( - defaultAgentDir: string, - builtinPackagePaths: string[] = [], +function _makeFakeAtomicSdk( + defaultAgentDir: string, + builtinPackagePaths: string[] = [], ): { - readonly sdk: PiCodingAgentSdk; - readonly loaderOptions: Array<{ - cwd: string; - agentDir: string; - settingsManager?: PiSdkSettingsManager; - builtinPackagePaths?: PackageSource[]; - resourceLoaderInheritanceSnapshot?: DefaultResourceLoaderInheritanceSnapshot; - }>; - readonly settingsCalls: Array<{ - cwd?: string; - agentDir?: string; - options?: { projectTrusted?: boolean }; - }>; - readonly reloads: PiSdkResourceLoader[]; + readonly sdk: PiCodingAgentSdk; + readonly loaderOptions: Array<{ + cwd: string; + agentDir: string; + settingsManager?: PiSdkSettingsManager; + builtinPackagePaths?: PackageSource[]; + resourceLoaderInheritanceSnapshot?: DefaultResourceLoaderInheritanceSnapshot; + }>; + readonly settingsCalls: Array<{ + cwd?: string; + agentDir?: string; + options?: { projectTrusted?: boolean }; + }>; + readonly reloads: PiSdkResourceLoader[]; } { - const loaderOptions: Array<{ - cwd: string; - agentDir: string; - settingsManager?: PiSdkSettingsManager; - builtinPackagePaths?: PackageSource[]; - resourceLoaderInheritanceSnapshot?: DefaultResourceLoaderInheritanceSnapshot; - }> = []; - const settingsCalls: Array<{ - cwd?: string; - agentDir?: string; - options?: { projectTrusted?: boolean }; - }> = []; - const reloads: PiSdkResourceLoader[] = []; + const loaderOptions: Array<{ + cwd: string; + agentDir: string; + settingsManager?: PiSdkSettingsManager; + builtinPackagePaths?: PackageSource[]; + resourceLoaderInheritanceSnapshot?: DefaultResourceLoaderInheritanceSnapshot; + }> = []; + const settingsCalls: Array<{ + cwd?: string; + agentDir?: string; + options?: { projectTrusted?: boolean }; + }> = []; + const reloads: PiSdkResourceLoader[] = []; - class FakeResourceLoader implements PiSdkResourceLoader { - constructor(options: { - cwd: string; - agentDir: string; - settingsManager?: PiSdkSettingsManager; - builtinPackagePaths?: PackageSource[]; - resourceLoaderInheritanceSnapshot?: DefaultResourceLoaderInheritanceSnapshot; - }) { - loaderOptions.push(options); - } + class FakeResourceLoader implements PiSdkResourceLoader { + constructor(options: { + cwd: string; + agentDir: string; + settingsManager?: PiSdkSettingsManager; + builtinPackagePaths?: PackageSource[]; + resourceLoaderInheritanceSnapshot?: DefaultResourceLoaderInheritanceSnapshot; + }) { + loaderOptions.push(options); + } - async reload(): Promise { - reloads.push(this); - } - } + async reload(): Promise { + reloads.push(this); + } + } - const sdk: PiCodingAgentSdk = { - getAgentDir: () => defaultAgentDir, - getBuiltinPackagePaths: () => builtinPackagePaths, - SettingsManager: { - create( - cwd?: string, - agentDir?: string, - options?: { projectTrusted?: boolean }, - ): PiSdkSettingsManager { - settingsCalls.push({ cwd, agentDir, options }); - return { - getCodexFastModeSettings: () => ({ - chat: false, - workflow: false, - }), - }; - }, - }, - DefaultResourceLoader: FakeResourceLoader, - async createAgentSession(): Promise<{ session: StageSessionRuntime }> { - return { session: fakeSession() }; - }, - }; + const sdk: PiCodingAgentSdk = { + getAgentDir: () => defaultAgentDir, + getBuiltinPackagePaths: () => builtinPackagePaths, + SettingsManager: { + create(cwd?: string, agentDir?: string, options?: { projectTrusted?: boolean }): PiSdkSettingsManager { + settingsCalls.push({ cwd, agentDir, options }); + return { + getCodexFastModeSettings: () => ({ + chat: false, + workflow: false, + }), + }; + }, + }, + DefaultResourceLoader: FakeResourceLoader, + async createAgentSession(): Promise<{ session: StageSessionRuntime }> { + return { session: fakeSession() }; + }, + }; - return { sdk, loaderOptions, settingsCalls, reloads }; + return { sdk, loaderOptions, settingsCalls, reloads }; } describe("buildRuntimeAdapters — SDK AgentSession adapter", () => { - test("provides an agentSession adapter without requiring pi.exec", () => { - const adapters = buildRuntimeAdapters({}); - assert.notEqual(adapters.agentSession, undefined); - assert.equal(adapters.prompt, undefined); - assert.equal(adapters.complete, undefined); - assert.equal( - Object.prototype.hasOwnProperty.call(adapters, "subagent"), - false, - ); - }); - - test("falls back to the pi SDK createAgentSession in production (NODE_ENV unset) — proves pi-coding-agent ≥ 0.74 integration", () => { - // The pi SDK (`@bastani/atomic` ≥ 0.74) exposes - // `createAgentSession` as a top-level package export, NOT on the - // ExtensionAPI surface. The workflow extension MUST resolve a default - // session factory from that package in production (no test context, - // no caller-provided seam). Otherwise stages that rely on the default - // SDK-backed prompt() path crash with "prompt adapter not configured" - // at runtime. - const savedNodeEnv = process.env["NODE_ENV"]; - const savedNodeTestCtx = process.env["NODE_TEST_CONTEXT"]; - delete process.env["NODE_ENV"]; - delete process.env["NODE_TEST_CONTEXT"]; - try { - const adapters = buildRuntimeAdapters({}); - assert.notEqual( - adapters.agentSession, - undefined, - "production buildRuntimeAdapters MUST wire an agentSession adapter via the pi SDK; got undefined.", - ); - } finally { - if (savedNodeEnv === undefined) delete process.env["NODE_ENV"]; - else process.env["NODE_ENV"] = savedNodeEnv; - if (savedNodeTestCtx === undefined) - delete process.env["NODE_TEST_CONTEXT"]; - else process.env["NODE_TEST_CONTEXT"] = savedNodeTestCtx; - } - }); + test("provides an agentSession adapter without requiring pi.exec", () => { + const adapters = buildRuntimeAdapters({}); + assert.notEqual(adapters.agentSession, undefined); + assert.equal(adapters.prompt, undefined); + assert.equal(adapters.complete, undefined); + assert.equal(Object.hasOwn(adapters, "subagent"), false); + }); - test("agentSession.create delegates to createAgentSession seam", async () => { - const calls: Array = []; - const adapters = buildRuntimeAdapters( - {}, - { - createAgentSession: async (options) => { - calls.push(options); - return { session: fakeSession() }; - }, - }, - ); - const result = await adapters.agentSession!.create({ - cwd: "/tmp/project", - }); - assert.equal( - "session" in result ? result.session.sessionId : result.sessionId, - "session-1", - ); - assert.equal(calls[0]?.cwd, "/tmp/project"); - }); + test("falls back to the pi SDK createAgentSession in production (NODE_ENV unset) — proves pi-coding-agent ≥ 0.74 integration", () => { + // The pi SDK (`@bastani/atomic` ≥ 0.74) exposes + // `createAgentSession` as a top-level package export, NOT on the + // ExtensionAPI surface. The workflow extension MUST resolve a default + // session factory from that package in production (no test context, + // no caller-provided seam). Otherwise stages that rely on the default + // SDK-backed prompt() path crash with "prompt adapter not configured" + // at runtime. + const savedNodeEnv = process.env.NODE_ENV; + const savedNodeTestCtx = process.env.NODE_TEST_CONTEXT; + delete process.env.NODE_ENV; + delete process.env.NODE_TEST_CONTEXT; + try { + const adapters = buildRuntimeAdapters({}); + assert.notEqual( + adapters.agentSession, + undefined, + "production buildRuntimeAdapters MUST wire an agentSession adapter via the pi SDK; got undefined.", + ); + } finally { + if (savedNodeEnv === undefined) delete process.env.NODE_ENV; + else process.env.NODE_ENV = savedNodeEnv; + if (savedNodeTestCtx === undefined) delete process.env.NODE_TEST_CONTEXT; + else process.env.NODE_TEST_CONTEXT = savedNodeTestCtx; + } + }); - test("agentSession.create returns the SDK-prepared settings manager for workflow metadata", async () => { - const settingsManager = { - getCodexFastModeSettings: () => ({ chat: false, workflow: true }), - }; - const adapters = buildRuntimeAdapters( - {}, - { - createAgentSession: async () => ({ - session: fakeSession(), - settingsManager, - }), - }, - ); + test("agentSession.create delegates to createAgentSession seam", async () => { + const calls: Array = []; + const adapters = buildRuntimeAdapters( + {}, + { + createAgentSession: async (options) => { + calls.push(options); + return { session: fakeSession() }; + }, + }, + ); + const result = await adapters.agentSession!.create({ + cwd: "/tmp/project", + }); + assert.equal("session" in result ? result.session.sessionId : result.sessionId, "session-1"); + assert.equal(calls[0]?.cwd, "/tmp/project"); + }); - const result = await adapters.agentSession!.create({ - cwd: "/tmp/project", - }); + test("agentSession.create returns the SDK-prepared settings manager for workflow metadata", async () => { + const settingsManager = { + getCodexFastModeSettings: () => ({ chat: false, workflow: true }), + }; + const adapters = buildRuntimeAdapters( + {}, + { + createAgentSession: async () => ({ + session: fakeSession(), + settingsManager, + }), + }, + ); - assert.equal( - "session" in result ? result.settingsManager : undefined, - settingsManager, - ); - }); + const result = await adapters.agentSession!.create({ + cwd: "/tmp/project", + }); - test("agentSession.create marks workflow stages with orchestration constraints and excludes workflow tool", async () => { - const calls: Array = []; - const externallyRouted: string[] = []; - const adapters = buildRuntimeAdapters( - { - sendMessage: (message) => { - if (typeof message.content === "string") externallyRouted.push(message.content); - }, - }, - { - createAgentSession: async (options) => { - calls.push(options); - return { session: fakeSession() }; - }, - }, - ); + assert.equal("session" in result ? result.settingsManager : undefined, settingsManager); + }); - await adapters.agentSession!.create( - { - cwd: "/tmp/project", - excludedTools: ["ask_user_question", "workflow"], - }, - { runId: "run-1", stageId: "stage-1", stageName: "Implement" }, - ); + test("agentSession.create marks workflow stages with orchestration constraints and excludes workflow tool", async () => { + const calls: Array = []; + const externallyRouted: string[] = []; + const adapters = buildRuntimeAdapters( + { + sendMessage: (message) => { + if (typeof message.content === "string") externallyRouted.push(message.content); + }, + }, + { + createAgentSession: async (options) => { + calls.push(options); + return { session: fakeSession() }; + }, + }, + ); - assert.deepEqual(calls[0]?.excludedTools, [ - "ask_user_question", - "workflow", - ]); - const orchestration = calls[0]?.orchestrationContext; - assert.equal(orchestration?.kind, "workflow-stage"); - assert.equal(orchestration?.workflowRunId, "run-1"); - assert.equal(orchestration?.workflowStageId, "stage-1"); - assert.equal(orchestration?.workflowStageName, "Implement"); - assert.deepEqual(orchestration?.constraints, { disableWorkflowTool: true, maxSubagentDepth: 5 }); - assert.equal(typeof orchestration?.lateMessageRouter?.routeMessage, "function"); - await orchestration?.lateMessageRouter?.routeMessage({ customType: "async-job-result", content: "late result", display: true }); - assert.deepEqual(externallyRouted, ["late result"]); - }); + await adapters.agentSession!.create( + { + cwd: "/tmp/project", + excludedTools: ["ask_user_question", "workflow"], + }, + { runId: "run-1", stageId: "stage-1", stageName: "Implement" }, + ); + assert.deepEqual(calls[0]?.excludedTools, ["ask_user_question", "workflow"]); + const orchestration = calls[0]?.orchestrationContext; + assert.equal(orchestration?.kind, "workflow-stage"); + assert.equal(orchestration?.workflowRunId, "run-1"); + assert.equal(orchestration?.workflowStageId, "stage-1"); + assert.equal(orchestration?.workflowStageName, "Implement"); + assert.deepEqual(orchestration?.constraints, { disableWorkflowTool: true, maxSubagentDepth: 5 }); + assert.equal(typeof orchestration?.lateMessageRouter?.routeMessage, "function"); + await orchestration?.lateMessageRouter?.routeMessage({ + customType: "async-job-result", + content: "late result", + display: true, + }); + assert.deepEqual(externallyRouted, ["late result"]); + }); - test("late Intercom traffic is handed to the parent extension event before generic routing", async () => { - let orchestration: CreateAgentSessionOptions["orchestrationContext"]; - const routed: string[] = []; - const targets: Array<{ runId: string; stageId: string; stageName: string }> = []; - const adapters = buildRuntimeAdapters({ - events: { - emit(_channel, payload) { - const event = payload as { - handled: boolean; - messages: Array<{ content?: string }>; - workflowRunId: string; - workflowStageId: string; - workflowStageName: string; - }; - event.handled = true; - targets.push({ runId: event.workflowRunId, stageId: event.workflowStageId, stageName: event.workflowStageName }); - if (typeof event.messages[0]?.content === "string") routed.push(event.messages[0].content); - }, - }, - }, { - createAgentSession: async (options) => { - orchestration = options?.orchestrationContext; - return { session: fakeSession() }; - }, - }); - await adapters.agentSession!.create({}, { runId: "run-1", stageId: "stage-1", stageName: "Implement" }); + test("late Intercom traffic is handed to the parent extension event before generic routing", async () => { + let orchestration: CreateAgentSessionOptions["orchestrationContext"]; + const routed: string[] = []; + const targets: Array<{ runId: string; stageId: string; stageName: string }> = []; + const adapters = buildRuntimeAdapters( + { + events: { + emit(_channel, payload) { + const event = payload as { + handled: boolean; + messages: Array<{ content?: string }>; + workflowRunId: string; + workflowStageId: string; + workflowStageName: string; + }; + event.handled = true; + targets.push({ + runId: event.workflowRunId, + stageId: event.workflowStageId, + stageName: event.workflowStageName, + }); + if (typeof event.messages[0]?.content === "string") routed.push(event.messages[0].content); + }, + }, + }, + { + createAgentSession: async (options) => { + orchestration = options?.orchestrationContext; + return { session: fakeSession() }; + }, + }, + ); + await adapters.agentSession!.create({}, { runId: "run-1", stageId: "stage-1", stageName: "Implement" }); - await orchestration?.lateMessageRouter?.routeMessage({ - customType: "intercom_message", - content: "late reviewer message", - display: true, - }); - assert.deepEqual(routed, ["late reviewer message"]); - assert.deepEqual(targets, [{ runId: "run-1", stageId: "stage-1", stageName: "Implement" }]); - }); + await orchestration?.lateMessageRouter?.routeMessage({ + customType: "intercom_message", + content: "late reviewer message", + display: true, + }); + assert.deepEqual(routed, ["late reviewer message"]); + assert.deepEqual(targets, [{ runId: "run-1", stageId: "stage-1", stageName: "Implement" }]); + }); - test("late stage routing fails when the host has no external message route", async () => { - let orchestration: CreateAgentSessionOptions["orchestrationContext"]; - const adapters = buildRuntimeAdapters({}, { - createAgentSession: async (options) => { - orchestration = options?.orchestrationContext; - return { session: fakeSession() }; - }, - }); - await adapters.agentSession!.create({}, { runId: "run-1", stageId: "stage-1", stageName: "Implement" }); + test("late stage routing fails when the host has no external message route", async () => { + let orchestration: CreateAgentSessionOptions["orchestrationContext"]; + const adapters = buildRuntimeAdapters( + {}, + { + createAgentSession: async (options) => { + orchestration = options?.orchestrationContext; + return { session: fakeSession() }; + }, + }, + ); + await adapters.agentSession!.create({}, { runId: "run-1", stageId: "stage-1", stageName: "Implement" }); - await assert.rejects(async () => { - await orchestration?.lateMessageRouter?.routeMessage({ customType: "async-job-result", content: "late", display: true }); - }, /main-chat late-message route is unavailable/); - }); + await assert.rejects(async () => { + await orchestration?.lateMessageRouter?.routeMessage({ + customType: "async-job-result", + content: "late", + display: true, + }); + }, /main-chat late-message route is unavailable/); + }); - test("late batch fallback routing awaits ordered sends and propagates failures", async () => { - let orchestration: CreateAgentSessionOptions["orchestrationContext"]; - const routed: string[] = []; - const adapters = buildRuntimeAdapters({ - async sendMessage(message) { - if (typeof message.content === "string") routed.push(message.content); - if (message.content === "second") throw new Error("injected route failure"); - }, - }, { - createAgentSession: async (options) => { - orchestration = options?.orchestrationContext; - return { session: fakeSession() }; - }, - }); - await adapters.agentSession!.create({}, { runId: "run-1", stageId: "stage-1", stageName: "Implement" }); + test("late batch fallback routing awaits ordered sends and propagates failures", async () => { + let orchestration: CreateAgentSessionOptions["orchestrationContext"]; + const routed: string[] = []; + const adapters = buildRuntimeAdapters( + { + async sendMessage(message) { + if (typeof message.content === "string") routed.push(message.content); + if (message.content === "second") throw new Error("injected route failure"); + }, + }, + { + createAgentSession: async (options) => { + orchestration = options?.orchestrationContext; + return { session: fakeSession() }; + }, + }, + ); + await adapters.agentSession!.create({}, { runId: "run-1", stageId: "stage-1", stageName: "Implement" }); - await assert.rejects( - () => orchestration!.lateMessageRouter!.routeMessages([ - { customType: "async-job-result", content: "first", display: true }, - { customType: "async-job-result", content: "second", display: true }, - { customType: "async-job-result", content: "third", display: true }, - ]), - /injected route failure/, - ); - assert.deepEqual(routed, ["first", "second"]); - }); - test("interactive stage sessions exclude workflow without blocking opt-in structured_output", async () => { - const calls: Array = []; - const adapters = buildRuntimeAdapters( - {}, - { - createAgentSession: async (options) => { - calls.push(options); - return { session: fakeSession() }; - }, - }, - ); + await assert.rejects( + () => + orchestration!.lateMessageRouter!.routeMessages([ + { customType: "async-job-result", content: "first", display: true }, + { customType: "async-job-result", content: "second", display: true }, + { customType: "async-job-result", content: "third", display: true }, + ]), + /injected route failure/, + ); + assert.deepEqual(routed, ["first", "second"]); + }); + test("interactive stage sessions exclude workflow without blocking opt-in structured_output", async () => { + const calls: Array = []; + const adapters = buildRuntimeAdapters( + {}, + { + createAgentSession: async (options) => { + calls.push(options); + return { session: fakeSession() }; + }, + }, + ); - await adapters.agentSession!.create( - { cwd: "/tmp/project" }, - { - runId: "run-1", - stageId: "stage-1", - stageName: "Implement", - executionMode: "interactive", - }, - ); + await adapters.agentSession!.create( + { cwd: "/tmp/project" }, + { + runId: "run-1", + stageId: "stage-1", + stageName: "Implement", + executionMode: "interactive", + }, + ); - assert.deepEqual(calls[0]?.excludedTools, ["workflow"]); - assert.equal(calls[0]?.excludedTools?.includes("structured_output"), false); - }); + assert.deepEqual(calls[0]?.excludedTools, ["workflow"]); + assert.equal(calls[0]?.excludedTools?.includes("structured_output"), false); + }); - test("non-interactive stage sessions exclude ask_user_question without blocking opt-in structured_output", async () => { - const calls: Array = []; - let bindCalls = 0; - const session = { - ...fakeSession(), - async bindExtensions(): Promise { - bindCalls += 1; - }, - } satisfies StageSessionRuntime & { bindExtensions(): Promise }; - const adapters = buildRuntimeAdapters( - {}, - { - createAgentSession: async (options) => { - calls.push(options); - return { session }; - }, - }, - ); + test("non-interactive stage sessions exclude ask_user_question without blocking opt-in structured_output", async () => { + const calls: Array = []; + let bindCalls = 0; + const session = { + ...fakeSession(), + async bindExtensions(): Promise { + bindCalls += 1; + }, + } satisfies StageSessionRuntime & { bindExtensions(): Promise }; + const adapters = buildRuntimeAdapters( + {}, + { + createAgentSession: async (options) => { + calls.push(options); + return { session }; + }, + }, + ); - await adapters.agentSession!.create( - { cwd: "/tmp/project" }, - { - runId: "run-1", - stageId: "stage-1", - stageName: "Implement", - executionMode: "non_interactive", - }, - ); + await adapters.agentSession!.create( + { cwd: "/tmp/project" }, + { + runId: "run-1", + stageId: "stage-1", + stageName: "Implement", + executionMode: "non_interactive", + }, + ); - assert.deepEqual(calls[0]?.excludedTools, [ - "workflow", - "ask_user_question", - ]); - assert.equal(calls[0]?.excludedTools?.includes("structured_output"), false); - assert.equal(bindCalls, 0); - }); + assert.deepEqual(calls[0]?.excludedTools, ["workflow", "ask_user_question"]); + assert.equal(calls[0]?.excludedTools?.includes("structured_output"), false); + assert.equal(bindCalls, 0); + }); }); diff --git a/test/unit/wiring-adapters-02-02.test.ts b/test/unit/wiring-adapters-02-02.test.ts index d53f4afa4..98cb3eaf6 100644 --- a/test/unit/wiring-adapters-02-02.test.ts +++ b/test/unit/wiring-adapters-02-02.test.ts @@ -7,406 +7,377 @@ * store-backed background adapter (see `background-ui-adapter.test.ts`). */ -import { describe, test } from "bun:test"; import assert from "node:assert/strict"; -import { join } from "node:path"; -import { - buildRuntimeAdapters, - prepareAtomicStageSessionOptions, -} from "../../packages/workflows/src/extension/wiring.js"; -import { StageUiBroker } from "../../packages/workflows/src/shared/stage-ui-broker.js"; -import { createStore } from "../../packages/workflows/src/shared/store.js"; -import { - DefaultResourceLoader, - type CreateAgentSessionOptions, - type DefaultResourceLoaderInheritanceSnapshot, - type PackageSource, +import type { + CreateAgentSessionOptions, + DefaultResourceLoaderInheritanceSnapshot, + PackageSource, } from "@bastani/atomic"; +import { describe, test } from "vitest"; import type { - PiCodingAgentSdk, - PiSdkResourceLoader, - PiSdkSettingsManager, + PiCodingAgentSdk, + PiSdkResourceLoader, + PiSdkSettingsManager, } from "../../packages/workflows/src/extension/wiring.js"; +import { buildRuntimeAdapters } from "../../packages/workflows/src/extension/wiring.js"; import type { StageSessionRuntime } from "../../packages/workflows/src/runs/foreground/stage-runner.js"; +import { StageUiBroker } from "../../packages/workflows/src/shared/stage-ui-broker.js"; +import { createStore } from "../../packages/workflows/src/shared/store.js"; import type { StageExecutionMeta } from "../../packages/workflows/src/shared/types.js"; function fakeSession(): StageSessionRuntime { - let last = ""; - return { - async prompt(text: string): Promise { - last = `reply:${text}`; - return last; - }, - async steer(text: string): Promise { - last = `steer:${text}`; - }, - async followUp(text: string): Promise { - last = `follow:${text}`; - }, - subscribe: () => () => {}, - sessionFile: undefined, - sessionId: "session-1", - async setModel(): Promise {}, - setThinkingLevel(): void {}, - async cycleModel(): Promise { - return undefined; - }, - cycleThinkingLevel(): undefined { - return undefined; - }, - agent: {} as StageSessionRuntime["agent"], - model: undefined, - thinkingLevel: "medium" as StageSessionRuntime["thinkingLevel"], - messages: [], - isStreaming: false, - async navigateTree(): Promise<{ cancelled: boolean }> { - return { cancelled: true }; - }, - async compact(): ReturnType { - return undefined as unknown as Awaited< - ReturnType - >; - }, - abortCompaction(): void {}, - async abort(): Promise {}, - dispose(): void {}, - getLastAssistantText(): string | undefined { - return last; - }, - }; + let last = ""; + return { + async prompt(text: string): Promise { + last = `reply:${text}`; + return last; + }, + async steer(text: string): Promise { + last = `steer:${text}`; + }, + async followUp(text: string): Promise { + last = `follow:${text}`; + }, + subscribe: () => () => {}, + sessionFile: undefined, + sessionId: "session-1", + async setModel(): Promise {}, + setThinkingLevel(): void {}, + async cycleModel(): Promise { + return undefined; + }, + cycleThinkingLevel(): undefined { + return undefined; + }, + agent: {} as StageSessionRuntime["agent"], + model: undefined, + thinkingLevel: "medium" as StageSessionRuntime["thinkingLevel"], + messages: [], + isStreaming: false, + async navigateTree(): Promise<{ cancelled: boolean }> { + return { cancelled: true }; + }, + async compact(): ReturnType { + return undefined as unknown as Awaited>; + }, + abortCompaction(): void {}, + async abort(): Promise {}, + dispose(): void {}, + getLastAssistantText(): string | undefined { + return last; + }, + }; } -function deferred(): { - readonly promise: Promise; - readonly resolve: () => void; - readonly reject: (reason?: unknown) => void; +function _deferred(): { + readonly promise: Promise; + readonly resolve: () => void; + readonly reject: (reason?: unknown) => void; } { - let resolvePromise: (() => void) | undefined; - let rejectPromise: ((reason?: unknown) => void) | undefined; - const promise = new Promise((resolve, reject) => { - resolvePromise = resolve; - rejectPromise = reject; - }); - return { - promise, - resolve: () => resolvePromise?.(), - reject: (reason?: unknown) => rejectPromise?.(reason), - }; + let resolvePromise: (() => void) | undefined; + let rejectPromise: ((reason?: unknown) => void) | undefined; + const promise = new Promise((resolve, reject) => { + resolvePromise = resolve; + rejectPromise = reject; + }); + return { + promise, + resolve: () => resolvePromise?.(), + reject: (reason?: unknown) => rejectPromise?.(reason), + }; } -async function waitUntil(predicate: () => boolean, message: string): Promise { - for (let attempt = 0; attempt < 50; attempt += 1) { - if (predicate()) return; - await new Promise((resolve) => setTimeout(resolve, 0)); - } - assert.fail(message); +async function _waitUntil(predicate: () => boolean, message: string): Promise { + for (let attempt = 0; attempt < 50; attempt += 1) { + if (predicate()) return; + await new Promise((resolve) => setTimeout(resolve, 0)); + } + assert.fail(message); } -function makeFakeAtomicSdk( - defaultAgentDir: string, - builtinPackagePaths: string[] = [], +function _makeFakeAtomicSdk( + defaultAgentDir: string, + builtinPackagePaths: string[] = [], ): { - readonly sdk: PiCodingAgentSdk; - readonly loaderOptions: Array<{ - cwd: string; - agentDir: string; - settingsManager?: PiSdkSettingsManager; - builtinPackagePaths?: PackageSource[]; - resourceLoaderInheritanceSnapshot?: DefaultResourceLoaderInheritanceSnapshot; - }>; - readonly settingsCalls: Array<{ - cwd?: string; - agentDir?: string; - options?: { projectTrusted?: boolean }; - }>; - readonly reloads: PiSdkResourceLoader[]; + readonly sdk: PiCodingAgentSdk; + readonly loaderOptions: Array<{ + cwd: string; + agentDir: string; + settingsManager?: PiSdkSettingsManager; + builtinPackagePaths?: PackageSource[]; + resourceLoaderInheritanceSnapshot?: DefaultResourceLoaderInheritanceSnapshot; + }>; + readonly settingsCalls: Array<{ + cwd?: string; + agentDir?: string; + options?: { projectTrusted?: boolean }; + }>; + readonly reloads: PiSdkResourceLoader[]; } { - const loaderOptions: Array<{ - cwd: string; - agentDir: string; - settingsManager?: PiSdkSettingsManager; - builtinPackagePaths?: PackageSource[]; - resourceLoaderInheritanceSnapshot?: DefaultResourceLoaderInheritanceSnapshot; - }> = []; - const settingsCalls: Array<{ - cwd?: string; - agentDir?: string; - options?: { projectTrusted?: boolean }; - }> = []; - const reloads: PiSdkResourceLoader[] = []; + const loaderOptions: Array<{ + cwd: string; + agentDir: string; + settingsManager?: PiSdkSettingsManager; + builtinPackagePaths?: PackageSource[]; + resourceLoaderInheritanceSnapshot?: DefaultResourceLoaderInheritanceSnapshot; + }> = []; + const settingsCalls: Array<{ + cwd?: string; + agentDir?: string; + options?: { projectTrusted?: boolean }; + }> = []; + const reloads: PiSdkResourceLoader[] = []; - class FakeResourceLoader implements PiSdkResourceLoader { - constructor(options: { - cwd: string; - agentDir: string; - settingsManager?: PiSdkSettingsManager; - builtinPackagePaths?: PackageSource[]; - resourceLoaderInheritanceSnapshot?: DefaultResourceLoaderInheritanceSnapshot; - }) { - loaderOptions.push(options); - } + class FakeResourceLoader implements PiSdkResourceLoader { + constructor(options: { + cwd: string; + agentDir: string; + settingsManager?: PiSdkSettingsManager; + builtinPackagePaths?: PackageSource[]; + resourceLoaderInheritanceSnapshot?: DefaultResourceLoaderInheritanceSnapshot; + }) { + loaderOptions.push(options); + } - async reload(): Promise { - reloads.push(this); - } - } + async reload(): Promise { + reloads.push(this); + } + } - const sdk: PiCodingAgentSdk = { - getAgentDir: () => defaultAgentDir, - getBuiltinPackagePaths: () => builtinPackagePaths, - SettingsManager: { - create( - cwd?: string, - agentDir?: string, - options?: { projectTrusted?: boolean }, - ): PiSdkSettingsManager { - settingsCalls.push({ cwd, agentDir, options }); - return { - getCodexFastModeSettings: () => ({ - chat: false, - workflow: false, - }), - }; - }, - }, - DefaultResourceLoader: FakeResourceLoader, - async createAgentSession(): Promise<{ session: StageSessionRuntime }> { - return { session: fakeSession() }; - }, - }; + const sdk: PiCodingAgentSdk = { + getAgentDir: () => defaultAgentDir, + getBuiltinPackagePaths: () => builtinPackagePaths, + SettingsManager: { + create(cwd?: string, agentDir?: string, options?: { projectTrusted?: boolean }): PiSdkSettingsManager { + settingsCalls.push({ cwd, agentDir, options }); + return { + getCodexFastModeSettings: () => ({ + chat: false, + workflow: false, + }), + }; + }, + }, + DefaultResourceLoader: FakeResourceLoader, + async createAgentSession(): Promise<{ session: StageSessionRuntime }> { + return { session: fakeSession() }; + }, + }; - return { sdk, loaderOptions, settingsCalls, reloads }; + return { sdk, loaderOptions, settingsCalls, reloads }; } describe("buildRuntimeAdapters — SDK AgentSession adapter", () => { + test("agentSession.create forwards stage options unchanged (pi SDK leaves resource isolation to SettingsManager)", async () => { + const calls: Array = []; + const adapters = buildRuntimeAdapters( + {}, + { + createAgentSession: async (options) => { + calls.push(options); + return { session: fakeSession() }; + }, + }, + ); + await adapters.agentSession!.create({ cwd: "/tmp/project" }); + assert.equal(calls[0]?.cwd, "/tmp/project"); + // Per-call isolation knobs (`disableExtensionDiscovery`, `skills`, + // `promptTemplates`, `slashCommands`) are not part of the pi SDK + // surface — resource loading is owned by `SettingsManager` / + // `ResourceLoader`. The SDK intentionally has no equivalent fields. + assert.ok(!("disableExtensionDiscovery" in calls[0]!)); + assert.ok(!("skills" in calls[0]!)); + assert.ok(!("promptTemplates" in calls[0]!)); + assert.ok(!("slashCommands" in calls[0]!)); + }); - test("agentSession.create forwards stage options unchanged (pi SDK leaves resource isolation to SettingsManager)", async () => { - const calls: Array = []; - const adapters = buildRuntimeAdapters( - {}, - { - createAgentSession: async (options) => { - calls.push(options); - return { session: fakeSession() }; - }, - }, - ); - await adapters.agentSession!.create({ cwd: "/tmp/project" }); - assert.equal(calls[0]?.cwd, "/tmp/project"); - // Per-call isolation knobs (`disableExtensionDiscovery`, `skills`, - // `promptTemplates`, `slashCommands`) are not part of the pi SDK - // surface — resource loading is owned by `SettingsManager` / - // `ResourceLoader`. The SDK intentionally has no equivalent fields. - assert.ok(!("disableExtensionDiscovery" in calls[0]!)); - assert.ok(!("skills" in calls[0]!)); - assert.ok(!("promptTemplates" in calls[0]!)); - assert.ok(!("slashCommands" in calls[0]!)); - }); - - test("agentSession.create lets callers override fields the SDK still supports", async () => { - const calls: Array = []; - const adapters = buildRuntimeAdapters( - {}, - { - createAgentSession: async (options) => { - calls.push(options); - return { session: fakeSession() }; - }, - }, - ); - await adapters.agentSession!.create({ - cwd: "/tmp/project", - thinkingLevel: "high", - noTools: "all", - }); - assert.equal(calls[0]?.cwd, "/tmp/project"); - assert.equal(calls[0]?.thinkingLevel, "high"); - assert.equal(calls[0]?.noTools, "all"); - }); + test("agentSession.create lets callers override fields the SDK still supports", async () => { + const calls: Array = []; + const adapters = buildRuntimeAdapters( + {}, + { + createAgentSession: async (options) => { + calls.push(options); + return { session: fakeSession() }; + }, + }, + ); + await adapters.agentSession!.create({ + cwd: "/tmp/project", + thinkingLevel: "high", + noTools: "all", + }); + assert.equal(calls[0]?.cwd, "/tmp/project"); + assert.equal(calls[0]?.thinkingLevel, "high"); + assert.equal(calls[0]?.noTools, "all"); + }); - test("strips workflow-only fallbackModels before calling createAgentSession", async () => { - const calls: Array = []; - const adapters = buildRuntimeAdapters( - {}, - { - createAgentSession: async (options) => { - calls.push(options); - return { session: fakeSession() }; - }, - }, - ); - await adapters.agentSession!.create({ - cwd: "/tmp/project", - fallbackModels: ["openai/fallback"], - }); - assert.equal( - Object.prototype.hasOwnProperty.call(calls[0], "fallbackModels"), - false, - ); - assert.equal(calls[0]?.cwd, "/tmp/project"); - }); + test("strips workflow-only fallbackModels before calling createAgentSession", async () => { + const calls: Array = []; + const adapters = buildRuntimeAdapters( + {}, + { + createAgentSession: async (options) => { + calls.push(options); + return { session: fakeSession() }; + }, + }, + ); + await adapters.agentSession!.create({ + cwd: "/tmp/project", + fallbackModels: ["openai/fallback"], + }); + assert.equal(Object.hasOwn(calls[0], "fallbackModels"), false); + assert.equal(calls[0]?.cwd, "/tmp/project"); + }); - test("strips workflow-only mcp options before calling createAgentSession", async () => { - const calls: Array = []; - const adapters = buildRuntimeAdapters( - {}, - { - createAgentSession: async (options) => { - calls.push(options); - return { session: fakeSession() }; - }, - }, - ); - await adapters.agentSession!.create({ - cwd: "/tmp/project", - mcp: { allow: ["github"] }, - }); - assert.equal( - Object.prototype.hasOwnProperty.call(calls[0], "mcp"), - false, - ); - assert.equal(calls[0]?.cwd, "/tmp/project"); - }); + test("strips workflow-only mcp options before calling createAgentSession", async () => { + const calls: Array = []; + const adapters = buildRuntimeAdapters( + {}, + { + createAgentSession: async (options) => { + calls.push(options); + return { session: fakeSession() }; + }, + }, + ); + await adapters.agentSession!.create({ + cwd: "/tmp/project", + mcp: { allow: ["github"] }, + }); + assert.equal(Object.hasOwn(calls[0], "mcp"), false); + assert.equal(calls[0]?.cwd, "/tmp/project"); + }); - test("binds a broker-backed UI context even when the parent pi surface has no ui", async () => { - const store = createStore(); - store.recordRunStart({ - id: "run-1", - name: "wf", - inputs: {}, - status: "running", - stages: [], - startedAt: Date.now(), - }); - store.recordStageStart("run-1", { - id: "stage-1", - name: "ask", - status: "running", - parentIds: [], - toolEvents: [], - }); - const broker = new StageUiBroker(store); - let capturedUi: - | { - custom( - factory: Parameters[2], - ): Promise; - } - | undefined; - const session = { - ...fakeSession(), - async bindExtensions(bindings: { uiContext?: typeof capturedUi }) { - capturedUi = bindings.uiContext; - }, - } satisfies StageSessionRuntime & { - bindExtensions(bindings: { - uiContext?: typeof capturedUi; - }): Promise; - }; - const adapters = buildRuntimeAdapters( - {}, - { - stageUiBroker: broker, - createAgentSession: async () => ({ session }), - }, - ); - const meta: StageExecutionMeta = { - runId: "run-1", - stageId: "stage-1", - stageName: "ask", - }; + test("binds a broker-backed UI context even when the parent pi surface has no ui", async () => { + const store = createStore(); + store.recordRunStart({ + id: "run-1", + name: "wf", + inputs: {}, + status: "running", + stages: [], + startedAt: Date.now(), + }); + store.recordStageStart("run-1", { + id: "stage-1", + name: "ask", + status: "running", + parentIds: [], + toolEvents: [], + }); + const broker = new StageUiBroker(store); + let capturedUi: + | { + custom(factory: Parameters[2]): Promise; + } + | undefined; + const session = { + ...fakeSession(), + async bindExtensions(bindings: { uiContext?: typeof capturedUi }) { + capturedUi = bindings.uiContext; + }, + } satisfies StageSessionRuntime & { + bindExtensions(bindings: { uiContext?: typeof capturedUi }): Promise; + }; + const adapters = buildRuntimeAdapters( + {}, + { + stageUiBroker: broker, + createAgentSession: async () => ({ session }), + }, + ); + const meta: StageExecutionMeta = { + runId: "run-1", + stageId: "stage-1", + stageName: "ask", + }; - await adapters.agentSession!.create({}, meta); - assert.ok( - capturedUi, - "stage sessions need a non-noop UI context so ask_user_question does not return no_ui", - ); - const pending = capturedUi.custom(() => ({ - render: () => ["question"], - invalidate: () => {}, - })); - assert.equal(store.runs()[0]?.stages[0]?.status, "awaiting_input"); + await adapters.agentSession!.create({}, meta); + assert.ok(capturedUi, "stage sessions need a non-noop UI context so ask_user_question does not return no_ui"); + const pending = capturedUi.custom(() => ({ + render: () => ["question"], + invalidate: () => {}, + })); + assert.equal(store.runs()[0]?.stages[0]?.status, "awaiting_input"); - const unregister = broker.registerHost("run-1", "stage-1", { - showCustomUi(request) { - broker.resolve(request, "answered"); - }, - }); - assert.equal(await pending, "answered"); - unregister(); - }); + const unregister = broker.registerHost("run-1", "stage-1", { + showCustomUi(request) { + broker.resolve(request, "answered"); + }, + }); + assert.equal(await pending, "answered"); + unregister(); + }); - test("binds stage custom UI to the stage UI broker instead of parent overlays", async () => { - const store = createStore(); - store.recordRunStart({ - id: "run-1", - name: "wf", - inputs: {}, - status: "running", - stages: [], - startedAt: Date.now(), - }); - store.recordStageStart("run-1", { - id: "stage-1", - name: "ask", - status: "running", - parentIds: [], - toolEvents: [], - }); - const broker = new StageUiBroker(store); - let capturedUi: - | { - custom( - factory: Parameters[2], - ): Promise; - } - | undefined; - const session = { - ...fakeSession(), - async bindExtensions(bindings: { uiContext?: typeof capturedUi }) { - capturedUi = bindings.uiContext; - }, - } satisfies StageSessionRuntime & { - bindExtensions(bindings: { - uiContext?: typeof capturedUi; - }): Promise; - }; - let parentOverlayCalls = 0; - const adapters = buildRuntimeAdapters( - { - ui: { - theme: {}, - custom() { - parentOverlayCalls += 1; - }, - }, - }, - { - stageUiBroker: broker, - createAgentSession: async () => ({ session }), - }, - ); - const meta: StageExecutionMeta = { - runId: "run-1", - stageId: "stage-1", - stageName: "ask", - }; + test("binds stage custom UI to the stage UI broker instead of parent overlays", async () => { + const store = createStore(); + store.recordRunStart({ + id: "run-1", + name: "wf", + inputs: {}, + status: "running", + stages: [], + startedAt: Date.now(), + }); + store.recordStageStart("run-1", { + id: "stage-1", + name: "ask", + status: "running", + parentIds: [], + toolEvents: [], + }); + const broker = new StageUiBroker(store); + let capturedUi: + | { + custom(factory: Parameters[2]): Promise; + } + | undefined; + const session = { + ...fakeSession(), + async bindExtensions(bindings: { uiContext?: typeof capturedUi }) { + capturedUi = bindings.uiContext; + }, + } satisfies StageSessionRuntime & { + bindExtensions(bindings: { uiContext?: typeof capturedUi }): Promise; + }; + let parentOverlayCalls = 0; + const adapters = buildRuntimeAdapters( + { + ui: { + theme: {}, + custom() { + parentOverlayCalls += 1; + }, + }, + }, + { + stageUiBroker: broker, + createAgentSession: async () => ({ session }), + }, + ); + const meta: StageExecutionMeta = { + runId: "run-1", + stageId: "stage-1", + stageName: "ask", + }; - await adapters.agentSession!.create({}, meta); - assert.ok(capturedUi); - const pending = capturedUi.custom(() => ({ - render: () => ["question"], - invalidate: () => {}, - })); - assert.equal(store.runs()[0]?.stages[0]?.status, "awaiting_input"); + await adapters.agentSession!.create({}, meta); + assert.ok(capturedUi); + const pending = capturedUi.custom(() => ({ + render: () => ["question"], + invalidate: () => {}, + })); + assert.equal(store.runs()[0]?.stages[0]?.status, "awaiting_input"); - const unregister = broker.registerHost("run-1", "stage-1", { - showCustomUi(request) { - broker.resolve(request, "answered"); - }, - }); - assert.equal(await pending, "answered"); - assert.equal(parentOverlayCalls, 0); - unregister(); - }); + const unregister = broker.registerHost("run-1", "stage-1", { + showCustomUi(request) { + broker.resolve(request, "answered"); + }, + }); + assert.equal(await pending, "answered"); + assert.equal(parentOverlayCalls, 0); + unregister(); + }); }); diff --git a/test/unit/wiring.test.ts b/test/unit/wiring.test.ts index 672ecf6c1..baa6fbe86 100644 --- a/test/unit/wiring.test.ts +++ b/test/unit/wiring.test.ts @@ -2,296 +2,260 @@ * Runtime wiring tests for SDK-backed workflow stages. */ -import { describe, test } from "bun:test"; import assert from "node:assert/strict"; -import { buildRuntimeAdapters } from "../../packages/workflows/src/extension/wiring.js"; -import { createStageContext } from "../../packages/workflows/src/runs/foreground/stage-runner.js"; import type { CreateAgentSessionOptions } from "@bastani/atomic"; +import { describe, test } from "vitest"; import type { RuntimeWiringSurface } from "../../packages/workflows/src/extension/wiring.js"; +import { buildRuntimeAdapters } from "../../packages/workflows/src/extension/wiring.js"; import type { StageSessionRuntime } from "../../packages/workflows/src/runs/foreground/stage-runner.js"; +import { createStageContext } from "../../packages/workflows/src/runs/foreground/stage-runner.js"; function fakeSession(): StageSessionRuntime { - let last = ""; - return { - async prompt(text: string): Promise { - last = `sdk:${text}`; - return last; - }, - async steer(text: string): Promise { - last = `steer:${text}`; - }, - async followUp(text: string): Promise { - last = `follow:${text}`; - }, - subscribe: () => () => {}, - sessionFile: undefined, - sessionId: "session-id", - async setModel(): Promise {}, - setThinkingLevel(): void {}, - async cycleModel(): Promise { - return undefined; - }, - cycleThinkingLevel(): undefined { - return undefined; - }, - agent: {} as StageSessionRuntime["agent"], - model: undefined, - thinkingLevel: "medium" as StageSessionRuntime["thinkingLevel"], - messages: [], - isStreaming: false, - async navigateTree(): Promise<{ cancelled: boolean }> { - return { cancelled: true }; - }, - async compact(): ReturnType { - return undefined as unknown as Awaited< - ReturnType - >; - }, - abortCompaction(): void {}, - async abort(): Promise {}, - dispose(): void {}, - getLastAssistantText(): string | undefined { - return last; - }, - }; + let last = ""; + return { + async prompt(text: string): Promise { + last = `sdk:${text}`; + return last; + }, + async steer(text: string): Promise { + last = `steer:${text}`; + }, + async followUp(text: string): Promise { + last = `follow:${text}`; + }, + subscribe: () => () => {}, + sessionFile: undefined, + sessionId: "session-id", + async setModel(): Promise {}, + setThinkingLevel(): void {}, + async cycleModel(): Promise { + return undefined; + }, + cycleThinkingLevel(): undefined { + return undefined; + }, + agent: {} as StageSessionRuntime["agent"], + model: undefined, + thinkingLevel: "medium" as StageSessionRuntime["thinkingLevel"], + messages: [], + isStreaming: false, + async navigateTree(): Promise<{ cancelled: boolean }> { + return { cancelled: true }; + }, + async compact(): ReturnType { + return undefined as unknown as Awaited>; + }, + abortCompaction(): void {}, + async abort(): Promise {}, + dispose(): void {}, + getLastAssistantText(): string | undefined { + return last; + }, + }; } describe("buildRuntimeAdapters — SDK sessions", () => { - test("always configures agentSession without pi.exec", () => { - const adapters = buildRuntimeAdapters({}); - assert.notEqual(adapters.agentSession, undefined); - assert.equal(adapters.prompt, undefined); - assert.equal(adapters.complete, undefined); - assert.equal( - Object.prototype.hasOwnProperty.call(adapters, "subagent"), - false, - ); - }); + test("always configures agentSession without pi.exec", () => { + const adapters = buildRuntimeAdapters({}); + assert.notEqual(adapters.agentSession, undefined); + assert.equal(adapters.prompt, undefined); + assert.equal(adapters.complete, undefined); + assert.equal(Object.hasOwn(adapters, "subagent"), false); + }); - test("forwards createAgentSession options from stage options", async () => { - const calls: Array = []; - const adapters = buildRuntimeAdapters( - {}, - { - createAgentSession: async (options) => { - calls.push(options); - return { session: fakeSession() }; - }, - }, - ); - await adapters.agentSession!.create({ - cwd: "/repo", - tools: ["read"], - mcp: { deny: ["network"] }, - } as unknown as Parameters< - NonNullable["create"] - >[0]); - assert.equal(calls[0]?.cwd, "/repo"); - assert.deepEqual((calls[0] as unknown as { tools?: string[] })?.tools, [ - "read", - ]); - assert.equal( - Object.prototype.hasOwnProperty.call(calls[0], "mcp"), - false, - ); - }); + test("forwards createAgentSession options from stage options", async () => { + const calls: Array = []; + const adapters = buildRuntimeAdapters( + {}, + { + createAgentSession: async (options) => { + calls.push(options); + return { session: fakeSession() }; + }, + }, + ); + await adapters.agentSession!.create({ + cwd: "/repo", + tools: ["read"], + mcp: { deny: ["network"] }, + } as unknown as Parameters["create"]>[0]); + assert.equal(calls[0]?.cwd, "/repo"); + assert.deepEqual((calls[0] as unknown as { tools?: string[] })?.tools, ["read"]); + assert.equal(Object.hasOwn(calls[0], "mcp"), false); + }); - test("stage prompt delegates to the SDK session adapter", async () => { - const adapters = buildRuntimeAdapters( - {}, - { - createAgentSession: async () => ({ session: fakeSession() }), - }, - ); - const stage = createStageContext({ - stageId: "s", - stageName: "Stage", - runId: "r", - adapters, - }); - const result = await stage.prompt("hello"); - assert.equal(result, "sdk:hello"); - assert.equal(stage.getLastAssistantText(), "sdk:hello"); - }); + test("stage prompt delegates to the SDK session adapter", async () => { + const adapters = buildRuntimeAdapters( + {}, + { + createAgentSession: async () => ({ session: fakeSession() }), + }, + ); + const stage = createStageContext({ + stageId: "s", + stageName: "Stage", + runId: "r", + adapters, + }); + const result = await stage.prompt("hello"); + assert.equal(result, "sdk:hello"); + assert.equal(stage.getLastAssistantText(), "sdk:hello"); + }); - test("stage complete falls back to the SDK session adapter", async () => { - const adapters = buildRuntimeAdapters( - {}, - { - createAgentSession: async () => ({ session: fakeSession() }), - }, - ); - const stage = createStageContext({ - stageId: "s", - stageName: "Stage", - runId: "r", - adapters, - }); - const result = await stage.complete("finish"); - assert.equal(result, "sdk:finish"); - assert.equal(stage.getLastAssistantText(), "sdk:finish"); - }); + test("stage complete falls back to the SDK session adapter", async () => { + const adapters = buildRuntimeAdapters( + {}, + { + createAgentSession: async () => ({ session: fakeSession() }), + }, + ); + const stage = createStageContext({ + stageId: "s", + stageName: "Stage", + runId: "r", + adapters, + }); + const result = await stage.complete("finish"); + assert.equal(result, "sdk:finish"); + assert.equal(stage.getLastAssistantText(), "sdk:finish"); + }); - test("stage prompt output options do not override createAgentSession options", async () => { - const calls: Array = []; - const adapters = buildRuntimeAdapters( - {}, - { - createAgentSession: async (options) => { - calls.push(options); - return { session: fakeSession() }; - }, - }, - ); - const stage = createStageContext({ - stageId: "s", - stageName: "Stage", - runId: "r", - adapters, - stageOptions: { cwd: "/stage-cwd" }, - }); + test("stage prompt output options do not override createAgentSession options", async () => { + const calls: Array = []; + const adapters = buildRuntimeAdapters( + {}, + { + createAgentSession: async (options) => { + calls.push(options); + return { session: fakeSession() }; + }, + }, + ); + const stage = createStageContext({ + stageId: "s", + stageName: "Stage", + runId: "r", + adapters, + stageOptions: { cwd: "/stage-cwd" }, + }); - await stage.prompt("hello", { - cwd: "/prompt-cwd", - context: "fork", - sessionDir: "/prompt-sessions", - }); + await stage.prompt("hello", { + cwd: "/prompt-cwd", + context: "fork", + sessionDir: "/prompt-sessions", + }); - assert.equal(calls[0]?.cwd, "/stage-cwd"); - assert.equal( - (calls[0] as { context?: string } | undefined)?.context, - undefined, - ); - assert.equal( - (calls[0] as { sessionDir?: string } | undefined)?.sessionDir, - undefined, - ); - }); + assert.equal(calls[0]?.cwd, "/stage-cwd"); + assert.equal((calls[0] as { context?: string } | undefined)?.context, undefined); + assert.equal((calls[0] as { sessionDir?: string } | undefined)?.sessionDir, undefined); + }); - test("does not inject custom tools per stage", async () => { - const calls: CreateAgentSessionOptions[] = []; - const adapters = buildRuntimeAdapters( - { ui: { custom: () => undefined } }, - { - createAgentSession: async (options) => { - calls.push(options ?? {}); - return { session: fakeSession() }; - }, - }, - ); + test("does not inject custom tools per stage", async () => { + const calls: CreateAgentSessionOptions[] = []; + const adapters = buildRuntimeAdapters( + { ui: { custom: () => undefined } }, + { + createAgentSession: async (options) => { + calls.push(options ?? {}); + return { session: fakeSession() }; + }, + }, + ); - await adapters.agentSession!.create( - {}, - { - runId: "run-1", - stageId: "stage-1", - stageName: "worker-a", - signal: new AbortController().signal, - }, - ); + await adapters.agentSession!.create( + {}, + { + runId: "run-1", + stageId: "stage-1", + stageName: "worker-a", + signal: new AbortController().signal, + }, + ); - assert.equal(calls[0]?.tools, undefined); - assert.equal(calls[0]?.customTools, undefined); - }); + assert.equal(calls[0]?.tools, undefined); + assert.equal(calls[0]?.customTools, undefined); + }); - test("binds pi UI context onto stage sessions when ui.custom is available", async () => { - const bindCalls: Array<{ - uiContext?: Record & { - custom?: ( - factory: unknown, - options?: unknown, - ) => Promise | T | undefined; - }; - }> = []; - const session = { - ...fakeSession(), - async bindExtensions(bindings: { - uiContext?: Record & { - custom?: ( - factory: unknown, - options?: unknown, - ) => Promise | T | undefined; - }; - }): Promise { - bindCalls.push(bindings); - }, - }; - const pi: RuntimeWiringSurface = { - ui: { - custom: async () => undefined, - }, - }; - const adapters = buildRuntimeAdapters(pi, { - createAgentSession: async () => ({ session }), - }); + test("binds pi UI context onto stage sessions when ui.custom is available", async () => { + const bindCalls: Array<{ + uiContext?: Record & { + custom?: (factory: unknown, options?: unknown) => Promise | T | undefined; + }; + }> = []; + const session = { + ...fakeSession(), + async bindExtensions(bindings: { + uiContext?: Record & { + custom?: (factory: unknown, options?: unknown) => Promise | T | undefined; + }; + }): Promise { + bindCalls.push(bindings); + }, + }; + const pi: RuntimeWiringSurface = { + ui: { + custom: async () => undefined, + }, + }; + const adapters = buildRuntimeAdapters(pi, { + createAgentSession: async () => ({ session }), + }); - await adapters.agentSession!.create( - { tools: ["read"] }, - { - runId: "run-1", - stageId: "stage-1", - stageName: "worker-a", - signal: new AbortController().signal, - }, - ); + await adapters.agentSession!.create( + { tools: ["read"] }, + { + runId: "run-1", + stageId: "stage-1", + stageName: "worker-a", + signal: new AbortController().signal, + }, + ); - assert.equal(typeof bindCalls[0]?.uiContext?.custom, "function"); - }); + assert.equal(typeof bindCalls[0]?.uiContext?.custom, "function"); + }); - test("binds inherited theme and UI extension helpers onto stage sessions", async () => { - const bindCalls: Array<{ uiContext?: Record }> = []; - const session = { - ...fakeSession(), - async bindExtensions(bindings: { - uiContext?: Record; - }): Promise { - bindCalls.push(bindings); - }, - }; - const theme = { name: "host-theme" }; - const adapters = buildRuntimeAdapters( - { - ui: { - custom: async () => undefined, - theme, - getAllThemes: () => [ - { name: "host-theme", path: "/themes/host.json" }, - ], - getTheme: (name: string) => - name === "host-theme" ? theme : undefined, - setTheme: () => ({ success: true }), - getToolsExpanded: () => true, - setToolsExpanded: () => undefined, - }, - }, - { createAgentSession: async () => ({ session }) }, - ); + test("binds inherited theme and UI extension helpers onto stage sessions", async () => { + const bindCalls: Array<{ uiContext?: Record }> = []; + const session = { + ...fakeSession(), + async bindExtensions(bindings: { uiContext?: Record }): Promise { + bindCalls.push(bindings); + }, + }; + const theme = { name: "host-theme" }; + const adapters = buildRuntimeAdapters( + { + ui: { + custom: async () => undefined, + theme, + getAllThemes: () => [{ name: "host-theme", path: "/themes/host.json" }], + getTheme: (name: string) => (name === "host-theme" ? theme : undefined), + setTheme: () => ({ success: true }), + getToolsExpanded: () => true, + setToolsExpanded: () => undefined, + }, + }, + { createAgentSession: async () => ({ session }) }, + ); - await adapters.agentSession!.create( - {}, - { - runId: "run-1", - stageId: "stage-1", - stageName: "worker-a", - signal: new AbortController().signal, - }, - ); + await adapters.agentSession!.create( + {}, + { + runId: "run-1", + stageId: "stage-1", + stageName: "worker-a", + signal: new AbortController().signal, + }, + ); - const uiContext = bindCalls[0]?.uiContext; - assert.equal(uiContext?.theme, theme); - assert.deepEqual((uiContext?.getAllThemes as () => unknown)(), [ - { name: "host-theme", path: "/themes/host.json" }, - ]); - assert.equal( - (uiContext?.getTheme as (name: string) => unknown)("host-theme"), - theme, - ); - assert.equal( - (uiContext?.setTheme as (name: string) => { success: boolean })( - "host-theme", - ).success, - true, - ); - assert.equal((uiContext?.getToolsExpanded as () => boolean)(), true); - }); + const uiContext = bindCalls[0]?.uiContext; + assert.ok(uiContext); + assert.equal(uiContext.theme, theme); + assert.deepEqual((uiContext.getAllThemes as () => unknown)(), [ + { name: "host-theme", path: "/themes/host.json" }, + ]); + assert.equal((uiContext.getTheme as (name: string) => unknown)("host-theme"), theme); + assert.equal((uiContext.setTheme as (name: string) => { success: boolean })("host-theme").success, true); + assert.equal((uiContext.getToolsExpanded as () => boolean)(), true); + }); }); diff --git a/test/unit/workflow-active-blocked-claim.test.ts b/test/unit/workflow-active-blocked-claim.test.ts index 0e67e9ecc..d6e5bb4f3 100644 --- a/test/unit/workflow-active-blocked-claim.test.ts +++ b/test/unit/workflow-active-blocked-claim.test.ts @@ -1,11 +1,11 @@ -import { afterEach, describe, test } from "bun:test"; import assert from "node:assert/strict"; +import { afterEach, describe, test } from "vitest"; +import { workflow } from "../../packages/workflows/src/authoring/workflow.js"; import { InMemoryDurableBackend } from "../../packages/workflows/src/durable/backend.js"; import { setDurableBackend } from "../../packages/workflows/src/durable/factory.js"; import { createExtensionRuntime } from "../../packages/workflows/src/extension/runtime.js"; import { createJobTracker } from "../../packages/workflows/src/runs/background/job-tracker.js"; import { createStore } from "../../packages/workflows/src/shared/store.js"; -import { workflow } from "../../packages/workflows/src/authoring/workflow.js"; import { createRegistry } from "../../packages/workflows/src/workflows/registry.js"; const runId = "active-blocked-claim"; @@ -13,174 +13,245 @@ const runId = "active-blocked-claim"; afterEach(() => setDurableBackend(undefined)); function seedBlockedRun() { - const store = createStore(); - store.recordRunStart({ id: runId, name: "claim-flow", inputs: {}, status: "running", stages: [], startedAt: 1 }); - store.recordStageStart(runId, { - id: "only", name: "only", status: "failed", parentIds: [], toolEvents: [], - error: "login", failureKind: "auth", failureRecoverability: "recoverable", - failureDisposition: "active_blocked", failureMessage: "login required", - }); - store.recordRunBlocked(runId, "login", { - failedStageId: "only", failureKind: "auth", failureRecoverability: "recoverable", - failureDisposition: "active_blocked", failureMessage: "login required", resumable: true, - }); - return store; + const store = createStore(); + store.recordRunStart({ id: runId, name: "claim-flow", inputs: {}, status: "running", stages: [], startedAt: 1 }); + store.recordStageStart(runId, { + id: "only", + name: "only", + status: "failed", + parentIds: [], + toolEvents: [], + error: "login", + failureKind: "auth", + failureRecoverability: "recoverable", + failureDisposition: "active_blocked", + failureMessage: "login required", + }); + store.recordRunBlocked(runId, "login", { + failedStageId: "only", + failureKind: "auth", + failureRecoverability: "recoverable", + failureDisposition: "active_blocked", + failureMessage: "login required", + resumable: true, + }); + return store; } function registerBlockedDurable(backend: InMemoryDurableBackend, completedCheckpoints = 1) { - backend.registerWorkflow({ - workflowId: runId, name: "claim-flow", inputs: {}, createdAt: 1, - status: "blocked", completedCheckpoints, resumable: true, - }); + backend.registerWorkflow({ + workflowId: runId, + name: "claim-flow", + inputs: {}, + createdAt: 1, + status: "blocked", + completedCheckpoints, + resumable: true, + }); } class FailingInvocationMetadataBackend extends InMemoryDurableBackend { - override registerWorkflow(handle: Parameters[0]): void { - if (handle.workflowId !== runId && handle.invocationCwd !== undefined) { - throw new Error("invocation metadata persistence failed"); - } - super.registerWorkflow(handle); - } + override registerWorkflow(handle: Parameters[0]): void { + if (handle.workflowId !== runId && handle.invocationCwd !== undefined) { + throw new Error("invocation metadata persistence failed"); + } + super.registerWorkflow(handle); + } } function claimFlow() { - return workflow({ - name: "claim-flow", description: "", inputs: {}, outputs: {}, - run: async (ctx) => { await ctx.stage("only").prompt("go"); return {}; }, - }); + return workflow({ + name: "claim-flow", + description: "", + inputs: {}, + outputs: {}, + run: async (ctx) => { + await ctx.stage("only").prompt("go"); + return {}; + }, + }); } describe("active-blocked resume claim", () => { - test("dispatches a fresh-ID continuation and keeps the durable source blocked/resumable", async () => { - const backend = new InMemoryDurableBackend(); - registerBlockedDurable(backend); - setDurableBackend(backend); - const store = seedBlockedRun(); - const jobs = createJobTracker(); - const runtime = createExtensionRuntime({ - registry: createRegistry([claimFlow()]), store, jobs, - adapters: { prompt: { prompt: async () => "done" } }, - }); - - const result = await runtime.resumeFailedRun(runId); - assert.equal(result.ok, true); - const continuationId = result.ok ? result.runId : ""; - // A fresh-ID continuation is dispatched (its id differs from the source). - assert.notEqual(continuationId, runId); - await jobs.get(continuationId)?.promise; - - // The durable source is left blocked/resumable (not mutated), so the work - // stays recoverable if this process dies. - assert.equal(backend.getWorkflow(runId)?.status, "blocked"); - assert.equal(backend.getWorkflow(runId)?.resumable, true); - // The local source snapshot is killed (same-session routing won't re-resume). - assert.equal(store.runs().find((run) => run.id === runId)?.status, "killed"); - assert.equal(store.runs().find((run) => run.id === continuationId)?.status, "completed"); - }); - - test("keeps a zero-checkpoint block recoverable (durable source unchanged)", () => { - const backend = new InMemoryDurableBackend(); - registerBlockedDurable(backend, 0); - // The source is a zero-progress blocked handle; leaving it untouched (rather - // than claiming `running`) keeps it listed and recoverable. - assert.deepEqual(backend.listResumableWorkflows().map((run) => run.workflowId), [runId]); - }); - - test("returns failure when the continuation's startup (run.start) fails, leaving the source resumable", async () => { - const backend = new InMemoryDurableBackend(); - registerBlockedDurable(backend); - setDurableBackend(backend); - const store = seedBlockedRun(); - const jobs = createJobTracker(); - let callbacks = 0; - const def = workflow({ - name: "claim-flow", description: "", inputs: {}, outputs: {}, - run: async (ctx) => { callbacks += 1; await ctx.stage("only").prompt("go"); return {}; }, - }); - const runtime = createExtensionRuntime({ - registry: createRegistry([def]), store, jobs, - adapters: { prompt: { prompt: async () => "done" } }, - persistence: { - appendEntry(type) { - if (type === "workflow.run.start") throw new Error("run.start persistence failed"); - return "entry"; - }, - }, - }); - - const result = await runtime.resumeFailedRun(runId); - - assert.equal(result.ok, false); - assert.match(result.ok ? "" : result.message, /failed to start: run\.start persistence failed; source left resumable/u); - assert.equal(callbacks, 0); - // No orphan running continuation snapshot. - assert.equal(store.runs().filter((run) => run.id !== runId).length, 0); - // The source stays locally active-blocked/resumable so the same session can retry. - const source = store.runs().find((run) => run.id === runId); - assert.ok(source); - assert.equal(source!.endedAt, undefined); - assert.equal(backend.getWorkflow(runId)?.status, "blocked"); - assert.equal(backend.getWorkflow(runId)?.resumable, true); - }); - - test("leaves the source resumable when durable invocation metadata registration fails", async () => { - const backend = new FailingInvocationMetadataBackend(); - registerBlockedDurable(backend); - setDurableBackend(backend); - const store = seedBlockedRun(); - const jobs = createJobTracker(); - let callbacks = 0; - const def = workflow({ - name: "claim-flow", description: "", inputs: {}, outputs: {}, - run: async () => { callbacks += 1; return {}; }, - }); - const runtime = createExtensionRuntime({ - registry: createRegistry([def]), store, jobs, - adapters: { prompt: { prompt: async () => "done" } }, - }); - - const result = await runtime.resumeFailedRun(runId); - - assert.equal(result.ok, false); - assert.match(result.ok ? "" : result.message, /failed to start: invocation metadata persistence failed; source left resumable/u); - assert.equal(callbacks, 0); - assert.equal(store.runs().filter((run) => run.id !== runId).length, 0); - const source = store.runs().find((run) => run.id === runId); - assert.ok(source); - assert.equal(source.endedAt, undefined); - assert.equal(backend.getWorkflow(runId)?.status, "blocked"); - assert.equal(backend.getWorkflow(runId)?.resumable, true); - assert.deepEqual(backend.listResumableWorkflows().map((run) => run.workflowId), [runId]); - }); - - test("refuses a concurrent second resume (one winner)", async () => { - const backend = new InMemoryDurableBackend(); - registerBlockedDurable(backend); - setDurableBackend(backend); - const store = seedBlockedRun(); - const jobs = createJobTracker(); - let release!: () => void; - const held = new Promise((resolve) => { release = resolve; }); - let calls = 0; - const def = workflow({ - name: "claim-flow", description: "", inputs: {}, outputs: {}, - run: async (ctx) => { calls += 1; await ctx.stage("only").prompt("go"); return {}; }, - }); - const runtime = createExtensionRuntime({ - registry: createRegistry([def]), store, jobs, - adapters: { prompt: { prompt: async () => { await held; return "done"; } } }, - }); - - const first = await runtime.resumeFailedRun(runId); - assert.equal(first.ok, true); - // A second resume while the source is already killed locally is refused - // (it no longer looks like an active-blocked run in this session). - const second = await runtime.resumeFailedRun(runId); - assert.equal(second.ok, false); - - release(); - const continuationId = first.ok ? first.runId : ""; - await jobs.get(continuationId)?.promise; - assert.equal(calls, 1); - }); + test("dispatches a fresh-ID continuation and keeps the durable source blocked/resumable", async () => { + const backend = new InMemoryDurableBackend(); + registerBlockedDurable(backend); + setDurableBackend(backend); + const store = seedBlockedRun(); + const jobs = createJobTracker(); + const runtime = createExtensionRuntime({ + registry: createRegistry([claimFlow()]), + store, + jobs, + adapters: { prompt: { prompt: async () => "done" } }, + }); + + const result = await runtime.resumeFailedRun(runId); + assert.equal(result.ok, true); + const continuationId = result.ok ? result.runId : ""; + // A fresh-ID continuation is dispatched (its id differs from the source). + assert.notEqual(continuationId, runId); + await jobs.get(continuationId)?.promise; + + // The durable source is left blocked/resumable (not mutated), so the work + // stays recoverable if this process dies. + assert.equal(backend.getWorkflow(runId)?.status, "blocked"); + assert.equal(backend.getWorkflow(runId)?.resumable, true); + // The local source snapshot is killed (same-session routing won't re-resume). + assert.equal(store.runs().find((run) => run.id === runId)?.status, "killed"); + assert.equal(store.runs().find((run) => run.id === continuationId)?.status, "completed"); + }); + + test("keeps a zero-checkpoint block recoverable (durable source unchanged)", () => { + const backend = new InMemoryDurableBackend(); + registerBlockedDurable(backend, 0); + // The source is a zero-progress blocked handle; leaving it untouched (rather + // than claiming `running`) keeps it listed and recoverable. + assert.deepEqual( + backend.listResumableWorkflows().map((run) => run.workflowId), + [runId], + ); + }); + + test("returns failure when the continuation's startup (run.start) fails, leaving the source resumable", async () => { + const backend = new InMemoryDurableBackend(); + registerBlockedDurable(backend); + setDurableBackend(backend); + const store = seedBlockedRun(); + const jobs = createJobTracker(); + let callbacks = 0; + const def = workflow({ + name: "claim-flow", + description: "", + inputs: {}, + outputs: {}, + run: async (ctx) => { + callbacks += 1; + await ctx.stage("only").prompt("go"); + return {}; + }, + }); + const runtime = createExtensionRuntime({ + registry: createRegistry([def]), + store, + jobs, + adapters: { prompt: { prompt: async () => "done" } }, + persistence: { + appendEntry(type) { + if (type === "workflow.run.start") throw new Error("run.start persistence failed"); + return "entry"; + }, + }, + }); + + const result = await runtime.resumeFailedRun(runId); + + assert.equal(result.ok, false); + assert.match( + result.ok ? "" : result.message, + /failed to start: run\.start persistence failed; source left resumable/u, + ); + assert.equal(callbacks, 0); + // No orphan running continuation snapshot. + assert.equal(store.runs().filter((run) => run.id !== runId).length, 0); + // The source stays locally active-blocked/resumable so the same session can retry. + const source = store.runs().find((run) => run.id === runId); + assert.ok(source); + assert.equal(source!.endedAt, undefined); + assert.equal(backend.getWorkflow(runId)?.status, "blocked"); + assert.equal(backend.getWorkflow(runId)?.resumable, true); + }); + + test("leaves the source resumable when durable invocation metadata registration fails", async () => { + const backend = new FailingInvocationMetadataBackend(); + registerBlockedDurable(backend); + setDurableBackend(backend); + const store = seedBlockedRun(); + const jobs = createJobTracker(); + let callbacks = 0; + const def = workflow({ + name: "claim-flow", + description: "", + inputs: {}, + outputs: {}, + run: async () => { + callbacks += 1; + return {}; + }, + }); + const runtime = createExtensionRuntime({ + registry: createRegistry([def]), + store, + jobs, + adapters: { prompt: { prompt: async () => "done" } }, + }); + + const result = await runtime.resumeFailedRun(runId); + + assert.equal(result.ok, false); + assert.match( + result.ok ? "" : result.message, + /failed to start: invocation metadata persistence failed; source left resumable/u, + ); + assert.equal(callbacks, 0); + assert.equal(store.runs().filter((run) => run.id !== runId).length, 0); + const source = store.runs().find((run) => run.id === runId); + assert.ok(source); + assert.equal(source.endedAt, undefined); + assert.equal(backend.getWorkflow(runId)?.status, "blocked"); + assert.equal(backend.getWorkflow(runId)?.resumable, true); + assert.deepEqual( + backend.listResumableWorkflows().map((run) => run.workflowId), + [runId], + ); + }); + + test("refuses a concurrent second resume (one winner)", async () => { + const backend = new InMemoryDurableBackend(); + registerBlockedDurable(backend); + setDurableBackend(backend); + const store = seedBlockedRun(); + const jobs = createJobTracker(); + let release!: () => void; + const held = new Promise((resolve) => { + release = resolve; + }); + let calls = 0; + const def = workflow({ + name: "claim-flow", + description: "", + inputs: {}, + outputs: {}, + run: async (ctx) => { + calls += 1; + await ctx.stage("only").prompt("go"); + return {}; + }, + }); + const runtime = createExtensionRuntime({ + registry: createRegistry([def]), + store, + jobs, + adapters: { + prompt: { + prompt: async () => { + await held; + return "done"; + }, + }, + }, + }); + + const first = await runtime.resumeFailedRun(runId); + assert.equal(first.ok, true); + // A second resume while the source is already killed locally is refused + // (it no longer looks like an active-blocked run in this session). + const second = await runtime.resumeFailedRun(runId); + assert.equal(second.ok, false); + + release(); + const continuationId = first.ok ? first.runId : ""; + await jobs.get(continuationId)?.promise; + assert.equal(calls, 1); + }); }); diff --git a/test/unit/workflow-active-blocked-lifecycle.test.ts b/test/unit/workflow-active-blocked-lifecycle.test.ts index fc9c453a9..f7456f89e 100644 --- a/test/unit/workflow-active-blocked-lifecycle.test.ts +++ b/test/unit/workflow-active-blocked-lifecycle.test.ts @@ -1,353 +1,367 @@ -import { describe, test } from "bun:test"; import assert from "node:assert/strict"; +import { describe, test } from "vitest"; import { - createWorkflowLifecycleNotificationState, - installWorkflowLifecycleNotifications, - LIFECYCLE_NOTICE_CUSTOM_TYPE, - resetWorkflowLifecycleNotificationState, - withWorkflowLifecycleNotificationsSuppressed, - type WorkflowLifecycleNoticeDetails, + createWorkflowLifecycleNotificationState, + installWorkflowLifecycleNotifications, + LIFECYCLE_NOTICE_CUSTOM_TYPE, + resetWorkflowLifecycleNotificationState, + type WorkflowLifecycleNoticeDetails, + withWorkflowLifecycleNotificationsSuppressed, } from "../../packages/workflows/src/extension/lifecycle-notifications.js"; +import { effectiveRunStatus } from "../../packages/workflows/src/shared/returned-run-status.js"; import { createStore } from "../../packages/workflows/src/shared/store.js"; import type { RunSnapshot } from "../../packages/workflows/src/shared/store-types.js"; -import { effectiveRunStatus } from "../../packages/workflows/src/shared/returned-run-status.js"; const config = { - enabled: true, - notifyOn: ["completed", "failed", "blocked", "awaiting_input"] as const, + enabled: true, + notifyOn: ["completed", "failed", "blocked", "awaiting_input"] as const, }; interface SentMessage { - readonly customType?: string; - readonly details?: WorkflowLifecycleNoticeDetails; + readonly customType?: string; + readonly details?: WorkflowLifecycleNoticeDetails; } -function startRecoverableRun( - store: ReturnType, - runId: string, - parentRunId?: string, -): number { - const run: RunSnapshot = { - id: runId, - name: "recoverable", - inputs: {}, - status: "running", - stages: [], - startedAt: 1, - ...(parentRunId === undefined ? {} : { parentRunId }), - }; - store.recordRunStart(run); - const blockedAt = Date.now(); - assert.equal(store.recordRunBlocked(runId, "Configure credentials and resume.", { - failureKind: "auth", - failureCode: "missing_api_key", - failureRecoverability: "recoverable", - failureDisposition: "active_blocked", - failureMessage: "No API key for provider: github-copilot", - failedStageId: "reviewer-a", - resumable: true, - blockedAt, - }), true); - return blockedAt; +function startRecoverableRun(store: ReturnType, runId: string, parentRunId?: string): number { + const run: RunSnapshot = { + id: runId, + name: "recoverable", + inputs: {}, + status: "running", + stages: [], + startedAt: 1, + ...(parentRunId === undefined ? {} : { parentRunId }), + }; + store.recordRunStart(run); + const blockedAt = Date.now(); + assert.equal( + store.recordRunBlocked(runId, "Configure credentials and resume.", { + failureKind: "auth", + failureCode: "missing_api_key", + failureRecoverability: "recoverable", + failureDisposition: "active_blocked", + failureMessage: "No API key for provider: github-copilot", + failedStageId: "reviewer-a", + resumable: true, + blockedAt, + }), + true, + ); + return blockedAt; } -function install( - store: ReturnType, - state = createWorkflowLifecycleNotificationState(), -) { - const sent: SentMessage[] = []; - const unsubscribe = installWorkflowLifecycleNotifications({ - store, - state, - config, - sendMessage(message) { - sent.push(message as SentMessage); - }, - }); - return { sent, state, unsubscribe }; +function install(store: ReturnType, state = createWorkflowLifecycleNotificationState()) { + const sent: SentMessage[] = []; + const unsubscribe = installWorkflowLifecycleNotifications({ + store, + state, + config, + sendMessage(message) { + sent.push(message as SentMessage); + }, + }); + return { sent, state, unsubscribe }; } describe("active recoverable blocked lifecycle notices", () => { - test("emits one blocked notice at blockedAt and dedupes later snapshots", () => { - const store = createStore(); - const { sent } = install(store); - - const blockedAt = startRecoverableRun(store, "live-blocked"); - store.recordNotice({ id: "tick", level: "info", message: "tick", createdAt: blockedAt + 1 }); - - assert.equal(sent.length, 1); - assert.equal(sent[0]?.customType, LIFECYCLE_NOTICE_CUSTOM_TYPE); - assert.equal(sent[0]?.details?.kind, "blocked"); - assert.equal(sent[0]?.details?.status, "blocked"); - assert.equal(sent[0]?.details?.active, true); - assert.equal(sent[0]?.details?.createdAt, blockedAt); - assert.equal(sent[0]?.details?.error, "No API key for provider: github-copilot"); - }); - - test("re-notifies when the same runId blocks again at a new blockedAt occurrence", () => { - const store = createStore(); - const { sent } = install(store); - - // First occurrence under a reused id (e.g. after a same-run resume). - const firstAt = startRecoverableRun(store, "reused-id"); - store.recordNotice({ id: "tick1", level: "info", message: "tick", createdAt: firstAt + 1 }); - assert.equal(sent.length, 1); - - // A second blocked occurrence under the same id but a new blockedAt must - // emit a fresh notice (per-occurrence dedupe key), not be suppressed. - assert.equal(store.recordRunBlocked("reused-id", "Configure credentials and resume.", { - failureKind: "auth", - failureCode: "missing_api_key", - failureRecoverability: "recoverable", - failureDisposition: "active_blocked", - failureMessage: "No API key for provider: github-copilot", - failedStageId: "reviewer-a", - resumable: true, - blockedAt: firstAt + 500, - }), true); - store.recordNotice({ id: "tick2", level: "info", message: "tick", createdAt: firstAt + 501 }); - - assert.equal(sent.length, 2); - assert.equal(sent[1]?.details?.kind, "blocked"); - assert.equal(sent[1]?.details?.createdAt, firstAt + 500); - }); - - test("seeds a historical active block without notifying the new chat", () => { - const store = createStore(); - const blockedAt = startRecoverableRun(store, "historical-blocked"); - const { sent } = install(store); - - store.recordNotice({ id: "history-tick", level: "info", message: "tick", createdAt: blockedAt + 1 }); - - assert.deepEqual(sent, []); - }); - - test("consumes an active block created under lifecycle suppression", () => { - const store = createStore(); - const state = createWorkflowLifecycleNotificationState(); - const { sent } = install(store, state); - - withWorkflowLifecycleNotificationsSuppressed(state, () => { - startRecoverableRun(store, "suppressed-blocked"); - }); - store.recordNotice({ id: "after-suppression", level: "info", message: "tick", createdAt: Date.now() }); - - assert.deepEqual(sent, []); - }); - - test("emits a later completion once when the same resumable run completes", () => { - const store = createStore(); - const { sent } = install(store); - - startRecoverableRun(store, "resumed-completion"); - assert.equal(store.recordRunEnd("resumed-completion", "completed", {}), true); - - assert.deepEqual(sent.map((message) => message.details?.kind), ["blocked", "completed"]); - }); - test("authoritative terminal status wins over retained recoverable stage metadata", () => { - const store = createStore(); - startRecoverableRun(store, "terminal-source"); - store.recordStageStart("terminal-source", { - id: "reviewer-a", - name: "reviewer-a", - status: "failed", - parentIds: [], - toolEvents: [], - error: "No API key", - failureKind: "auth", - failureRecoverability: "recoverable", - failureDisposition: "active_blocked", - failureMessage: "No API key", - }); - - assert.equal(store.recordRunEnd("terminal-source", "killed", undefined, "continued elsewhere"), true); - const source = store.runs().find((run) => run.id === "terminal-source"); - - assert.ok(source); - assert.equal(source.status, "killed"); - assert.equal(effectiveRunStatus(source), "killed"); - }); - - - test("retries a rejected chat admission and marks delivery only after acceptance", async () => { - const store = createStore(); - const state = createWorkflowLifecycleNotificationState(); - const sent: SentMessage[] = []; - let attempts = 0; - const unsubscribe = installWorkflowLifecycleNotifications({ - store, - state, - config, - sendMessage(message) { - attempts += 1; - if (attempts === 1) return Promise.reject(new Error("admission rejected")); - sent.push(message as SentMessage); - return Promise.resolve(); - }, - }); - - const retryBlockedAt = startRecoverableRun(store, "retry-blocked"); - assert.equal(state.deliveredTerminalRuns.has(`blocked:retry-blocked:${retryBlockedAt}`), false); - await new Promise((resolve) => setTimeout(resolve, 60)); - - assert.equal(attempts, 2); - assert.equal(sent.length, 1); - assert.equal(state.deliveredTerminalRuns.has(`blocked:retry-blocked:${retryBlockedAt}`), true); - unsubscribe(); - }); - - test("keeps retrying while the invoking chat remains active", async () => { - const store = createStore(); - let attempts = 0; - const sent: SentMessage[] = []; - const unsubscribe = installWorkflowLifecycleNotifications({ - store, - config, - sendMessage(message) { - attempts += 1; - if (attempts < 4) return Promise.reject(new Error("temporary admission outage")); - sent.push(message as SentMessage); - return Promise.resolve(); - }, - }); - - startRecoverableRun(store, "retry-fourth-attempt"); - await new Promise((resolve) => setTimeout(resolve, 180)); - - assert.equal(attempts, 4); - assert.equal(sent.length, 1); - unsubscribe(); - }); - - test("retries the retained blocked payload after the run is consumed", async () => { - const store = createStore(); - const state = createWorkflowLifecycleNotificationState(); - const sent: SentMessage[] = []; - let attempts = 0; - const unsubscribe = installWorkflowLifecycleNotifications({ - store, - state, - config, - sendMessage(message) { - attempts += 1; - if (attempts === 1) return Promise.reject(new Error("admission rejected")); - sent.push(message as SentMessage); - return Promise.resolve(); - }, - }); - startRecoverableRun(store, "consumed-after-rejection"); - assert.equal(store.recordRunEnd("consumed-after-rejection", "killed", undefined, "resumed"), true); - - await new Promise((resolve) => setTimeout(resolve, 60)); - - assert.equal(attempts, 2); - assert.equal(sent[0]?.details?.kind, "blocked"); - assert.equal(sent[0]?.details?.runId, "consumed-after-rejection"); - unsubscribe(); - }); - - test("retries a failed admission across notification reinstallation", async () => { - const store = createStore(); - const state = createWorkflowLifecycleNotificationState(); - const rejected = installWorkflowLifecycleNotifications({ - store, - state, - config, - sendMessage() { return Promise.reject(new Error("session replaced")); }, - }); - const reinstallBlockedAt = startRecoverableRun(store, "reinstall-blocked"); - await new Promise((resolve) => setTimeout(resolve, 40)); - rejected(); - - const sent: SentMessage[] = []; - const installed = installWorkflowLifecycleNotifications({ - store, - state, - config, - sendMessage(message) { sent.push(message as SentMessage); return Promise.resolve(); }, - }); - - await new Promise((resolve) => setTimeout(resolve, 40)); - assert.equal(sent.length, 1); - assert.equal(state.deliveredTerminalRuns.has(`blocked:reinstall-blocked:${reinstallBlockedAt}`), true); - installed(); - }); - test("does not re-send a still-pending admission across a config reinstall", async () => { - const store = createStore(); - const state = createWorkflowLifecycleNotificationState(); - let resolveOld!: () => void; - let oldSends = 0; - const disposed = installWorkflowLifecycleNotifications({ - store, - state, - config, - sendMessage() { - oldSends += 1; - return new Promise((resolve) => { resolveOld = resolve; }); - }, - }); - const pendingBlockedAt = startRecoverableRun(store, "pending-reinstall"); - await Promise.resolve(); - disposed(); - - const newSends: SentMessage[] = []; - const reinstalled = installWorkflowLifecycleNotifications({ - store, - state, - config, - sendMessage(message) { newSends.push(message as SentMessage); return Promise.resolve(); }, - }); - - resolveOld(); - await new Promise((resolve) => setTimeout(resolve, 60)); - - assert.equal(oldSends, 1); - assert.deepEqual(newSends, []); - assert.equal(state.deliveredTerminalRuns.has(`blocked:pending-reinstall:${pendingBlockedAt}`), true); - assert.equal(state.retryableTerminalNotices.has(`blocked:pending-reinstall:${pendingBlockedAt}`), false); - reinstalled(); - }); - - - test("drops a pending admission when its invoking session is replaced", async () => { - const store = createStore(); - const state = createWorkflowLifecycleNotificationState(); - let rejectPending!: (error: Error) => void; - const pending = installWorkflowLifecycleNotifications({ - store, - state, - config, - sendMessage() { - return new Promise((_resolve, reject) => { rejectPending = reject; }); - }, - }); - startRecoverableRun(store, "pending-shutdown"); - pending(); - store.clear(); - resetWorkflowLifecycleNotificationState(state); - - const sent: SentMessage[] = []; - const replacement = installWorkflowLifecycleNotifications({ - store, - state, - config, - sendMessage(message) { sent.push(message as SentMessage); }, - }); - rejectPending(new Error("old session shut down")); - await Promise.resolve(); - - assert.deepEqual(sent, []); - assert.equal(state.retryableTerminalRuns.size, 0); - assert.equal(state.retryableTerminalNotices.size, 0); - replacement(); - }); - - test("does not notify for a nested active blocked child run", () => { - const store = createStore(); - const { sent } = install(store); - - startRecoverableRun(store, "child-blocked", "parent-run"); - - assert.deepEqual(sent, []); - }); + test("emits one blocked notice at blockedAt and dedupes later snapshots", () => { + const store = createStore(); + const { sent } = install(store); + + const blockedAt = startRecoverableRun(store, "live-blocked"); + store.recordNotice({ id: "tick", level: "info", message: "tick", createdAt: blockedAt + 1 }); + + assert.equal(sent.length, 1); + assert.equal(sent[0]?.customType, LIFECYCLE_NOTICE_CUSTOM_TYPE); + assert.equal(sent[0]?.details?.kind, "blocked"); + assert.equal(sent[0]?.details?.status, "blocked"); + assert.equal(sent[0]?.details?.active, true); + assert.equal(sent[0]?.details?.createdAt, blockedAt); + assert.equal(sent[0]?.details?.error, "No API key for provider: github-copilot"); + }); + + test("re-notifies when the same runId blocks again at a new blockedAt occurrence", () => { + const store = createStore(); + const { sent } = install(store); + + // First occurrence under a reused id (e.g. after a same-run resume). + const firstAt = startRecoverableRun(store, "reused-id"); + store.recordNotice({ id: "tick1", level: "info", message: "tick", createdAt: firstAt + 1 }); + assert.equal(sent.length, 1); + + // A second blocked occurrence under the same id but a new blockedAt must + // emit a fresh notice (per-occurrence dedupe key), not be suppressed. + assert.equal( + store.recordRunBlocked("reused-id", "Configure credentials and resume.", { + failureKind: "auth", + failureCode: "missing_api_key", + failureRecoverability: "recoverable", + failureDisposition: "active_blocked", + failureMessage: "No API key for provider: github-copilot", + failedStageId: "reviewer-a", + resumable: true, + blockedAt: firstAt + 500, + }), + true, + ); + store.recordNotice({ id: "tick2", level: "info", message: "tick", createdAt: firstAt + 501 }); + + assert.equal(sent.length, 2); + assert.equal(sent[1]?.details?.kind, "blocked"); + assert.equal(sent[1]?.details?.createdAt, firstAt + 500); + }); + + test("seeds a historical active block without notifying the new chat", () => { + const store = createStore(); + const blockedAt = startRecoverableRun(store, "historical-blocked"); + const { sent } = install(store); + + store.recordNotice({ id: "history-tick", level: "info", message: "tick", createdAt: blockedAt + 1 }); + + assert.deepEqual(sent, []); + }); + + test("consumes an active block created under lifecycle suppression", () => { + const store = createStore(); + const state = createWorkflowLifecycleNotificationState(); + const { sent } = install(store, state); + + withWorkflowLifecycleNotificationsSuppressed(state, () => { + startRecoverableRun(store, "suppressed-blocked"); + }); + store.recordNotice({ id: "after-suppression", level: "info", message: "tick", createdAt: Date.now() }); + + assert.deepEqual(sent, []); + }); + + test("emits a later completion once when the same resumable run completes", () => { + const store = createStore(); + const { sent } = install(store); + + startRecoverableRun(store, "resumed-completion"); + assert.equal(store.recordRunEnd("resumed-completion", "completed", {}), true); + + assert.deepEqual( + sent.map((message) => message.details?.kind), + ["blocked", "completed"], + ); + }); + test("authoritative terminal status wins over retained recoverable stage metadata", () => { + const store = createStore(); + startRecoverableRun(store, "terminal-source"); + store.recordStageStart("terminal-source", { + id: "reviewer-a", + name: "reviewer-a", + status: "failed", + parentIds: [], + toolEvents: [], + error: "No API key", + failureKind: "auth", + failureRecoverability: "recoverable", + failureDisposition: "active_blocked", + failureMessage: "No API key", + }); + + assert.equal(store.recordRunEnd("terminal-source", "killed", undefined, "continued elsewhere"), true); + const source = store.runs().find((run) => run.id === "terminal-source"); + + assert.ok(source); + assert.equal(source.status, "killed"); + assert.equal(effectiveRunStatus(source), "killed"); + }); + + test("retries a rejected chat admission and marks delivery only after acceptance", async () => { + const store = createStore(); + const state = createWorkflowLifecycleNotificationState(); + const sent: SentMessage[] = []; + let attempts = 0; + const unsubscribe = installWorkflowLifecycleNotifications({ + store, + state, + config, + sendMessage(message) { + attempts += 1; + if (attempts === 1) return Promise.reject(new Error("admission rejected")); + sent.push(message as SentMessage); + return Promise.resolve(); + }, + }); + + const retryBlockedAt = startRecoverableRun(store, "retry-blocked"); + assert.equal(state.deliveredTerminalRuns.has(`blocked:retry-blocked:${retryBlockedAt}`), false); + await new Promise((resolve) => setTimeout(resolve, 60)); + + assert.equal(attempts, 2); + assert.equal(sent.length, 1); + assert.equal(state.deliveredTerminalRuns.has(`blocked:retry-blocked:${retryBlockedAt}`), true); + unsubscribe(); + }); + + test("keeps retrying while the invoking chat remains active", async () => { + const store = createStore(); + let attempts = 0; + const sent: SentMessage[] = []; + const unsubscribe = installWorkflowLifecycleNotifications({ + store, + config, + sendMessage(message) { + attempts += 1; + if (attempts < 4) return Promise.reject(new Error("temporary admission outage")); + sent.push(message as SentMessage); + return Promise.resolve(); + }, + }); + + startRecoverableRun(store, "retry-fourth-attempt"); + await new Promise((resolve) => setTimeout(resolve, 180)); + + assert.equal(attempts, 4); + assert.equal(sent.length, 1); + unsubscribe(); + }); + + test("retries the retained blocked payload after the run is consumed", async () => { + const store = createStore(); + const state = createWorkflowLifecycleNotificationState(); + const sent: SentMessage[] = []; + let attempts = 0; + const unsubscribe = installWorkflowLifecycleNotifications({ + store, + state, + config, + sendMessage(message) { + attempts += 1; + if (attempts === 1) return Promise.reject(new Error("admission rejected")); + sent.push(message as SentMessage); + return Promise.resolve(); + }, + }); + startRecoverableRun(store, "consumed-after-rejection"); + assert.equal(store.recordRunEnd("consumed-after-rejection", "killed", undefined, "resumed"), true); + + await new Promise((resolve) => setTimeout(resolve, 60)); + + assert.equal(attempts, 2); + assert.equal(sent[0]?.details?.kind, "blocked"); + assert.equal(sent[0]?.details?.runId, "consumed-after-rejection"); + unsubscribe(); + }); + + test("retries a failed admission across notification reinstallation", async () => { + const store = createStore(); + const state = createWorkflowLifecycleNotificationState(); + const rejected = installWorkflowLifecycleNotifications({ + store, + state, + config, + sendMessage() { + return Promise.reject(new Error("session replaced")); + }, + }); + const reinstallBlockedAt = startRecoverableRun(store, "reinstall-blocked"); + await new Promise((resolve) => setTimeout(resolve, 40)); + rejected(); + + const sent: SentMessage[] = []; + const installed = installWorkflowLifecycleNotifications({ + store, + state, + config, + sendMessage(message) { + sent.push(message as SentMessage); + return Promise.resolve(); + }, + }); + + await new Promise((resolve) => setTimeout(resolve, 40)); + assert.equal(sent.length, 1); + assert.equal(state.deliveredTerminalRuns.has(`blocked:reinstall-blocked:${reinstallBlockedAt}`), true); + installed(); + }); + test("does not re-send a still-pending admission across a config reinstall", async () => { + const store = createStore(); + const state = createWorkflowLifecycleNotificationState(); + let resolveOld!: () => void; + let oldSends = 0; + const disposed = installWorkflowLifecycleNotifications({ + store, + state, + config, + sendMessage() { + oldSends += 1; + return new Promise((resolve) => { + resolveOld = resolve; + }); + }, + }); + const pendingBlockedAt = startRecoverableRun(store, "pending-reinstall"); + await Promise.resolve(); + disposed(); + + const newSends: SentMessage[] = []; + const reinstalled = installWorkflowLifecycleNotifications({ + store, + state, + config, + sendMessage(message) { + newSends.push(message as SentMessage); + return Promise.resolve(); + }, + }); + + resolveOld(); + await new Promise((resolve) => setTimeout(resolve, 60)); + + assert.equal(oldSends, 1); + assert.deepEqual(newSends, []); + assert.equal(state.deliveredTerminalRuns.has(`blocked:pending-reinstall:${pendingBlockedAt}`), true); + assert.equal(state.retryableTerminalNotices.has(`blocked:pending-reinstall:${pendingBlockedAt}`), false); + reinstalled(); + }); + + test("drops a pending admission when its invoking session is replaced", async () => { + const store = createStore(); + const state = createWorkflowLifecycleNotificationState(); + let rejectPending!: (error: Error) => void; + const pending = installWorkflowLifecycleNotifications({ + store, + state, + config, + sendMessage() { + return new Promise((_resolve, reject) => { + rejectPending = reject; + }); + }, + }); + startRecoverableRun(store, "pending-shutdown"); + pending(); + store.clear(); + resetWorkflowLifecycleNotificationState(state); + + const sent: SentMessage[] = []; + const replacement = installWorkflowLifecycleNotifications({ + store, + state, + config, + sendMessage(message) { + sent.push(message as SentMessage); + }, + }); + rejectPending(new Error("old session shut down")); + await Promise.resolve(); + + assert.deepEqual(sent, []); + assert.equal(state.retryableTerminalRuns.size, 0); + assert.equal(state.retryableTerminalNotices.size, 0); + replacement(); + }); + + test("does not notify for a nested active blocked child run", () => { + const store = createStore(); + const { sent } = install(store); + + startRecoverableRun(store, "child-blocked", "parent-run"); + + assert.deepEqual(sent, []); + }); }); diff --git a/test/unit/workflow-activity.test.ts b/test/unit/workflow-activity.test.ts index b12893999..18e1626a8 100644 --- a/test/unit/workflow-activity.test.ts +++ b/test/unit/workflow-activity.test.ts @@ -1,13 +1,16 @@ -import { test } from "bun:test"; import assert from "node:assert/strict"; import { Type } from "typebox"; -import { setCallbackActivityReporter, type CallbackActivity } from "../../packages/coding-agent/src/core/callback-activity.ts"; -import { InMemoryDurableBackend } from "../../packages/workflows/src/durable/backend.ts"; +import { test } from "vitest"; +import { + type CallbackActivity, + setCallbackActivityReporter, +} from "../../packages/coding-agent/src/core/callback-activity.ts"; import { workflow } from "../../packages/workflows/src/authoring/workflow.ts"; +import { InMemoryDurableBackend } from "../../packages/workflows/src/durable/backend.ts"; import { run } from "../../packages/workflows/src/engine/run.ts"; import { createStore } from "../../packages/workflows/src/shared/store.ts"; -test.serial("real workflow author, ctx.tool, and stage adapter callbacks report activity", async () => { +test.sequential("real workflow author, ctx.tool, and stage adapter callbacks report activity", async () => { const started: CallbackActivity[] = []; setCallbackActivityReporter({ started: (activity) => started.push(activity), finished: () => {} }); try { @@ -22,18 +25,43 @@ test.serial("real workflow author, ctx.tool, and stage adapter callbacks report return { value }; }, }); - const result = await run(definition, {}, { - runId: "activity-run", - store: createStore(), - durableBackend: new InMemoryDurableBackend(), - adapters: { complete: { complete: async (text) => text } }, - onStageStart: () => {}, - onStageEnd: () => {}, - }); + const result = await run( + definition, + {}, + { + runId: "activity-run", + store: createStore(), + durableBackend: new InMemoryDurableBackend(), + adapters: { complete: { complete: async (text) => text } }, + onStageStart: () => {}, + onStageEnd: () => {}, + }, + ); assert.equal(result.status, "completed"); - assert.ok(started.some((activity) => activity.kind === "workflow.run" && activity.name === "activity-fixture" && activity.runId === "activity-run")); - assert.ok(started.some((activity) => activity.kind === "workflow.ctx_tool" && activity.name === "author-tool" && activity.runId === "activity-run")); - assert.ok(started.some((activity) => activity.kind === "workflow.stage_adapter" && activity.name === "complete:adapter-stage" && activity.stageId)); + assert.ok( + started.some( + (activity) => + activity.kind === "workflow.run" && + activity.name === "activity-fixture" && + activity.runId === "activity-run", + ), + ); + assert.ok( + started.some( + (activity) => + activity.kind === "workflow.ctx_tool" && + activity.name === "author-tool" && + activity.runId === "activity-run", + ), + ); + assert.ok( + started.some( + (activity) => + activity.kind === "workflow.stage_adapter" && + activity.name === "complete:adapter-stage" && + activity.stageId, + ), + ); assert.ok(started.some((activity) => activity.name === "onStageStart:adapter-stage")); assert.ok(started.some((activity) => activity.name === "onStageEnd:adapter-stage")); } finally { diff --git a/test/unit/workflow-attach-pane-01.test.ts b/test/unit/workflow-attach-pane-01.test.ts index eef05a2ad..9a4c28477 100644 --- a/test/unit/workflow-attach-pane-01.test.ts +++ b/test/unit/workflow-attach-pane-01.test.ts @@ -12,334 +12,271 @@ * cross-ref: src/tui/workflow-attach-pane.ts */ -import { describe, test } from "bun:test"; import assert from "node:assert/strict"; -import { - Key, - type Component, - type EditorComponent, - type TUI, -} from "@earendil-works/pi-tui"; +import type { AgentSession } from "@bastani/atomic"; +import { Key } from "@earendil-works/pi-tui"; +import { describe, test } from "vitest"; +import type { StageControlHandle } from "../../packages/workflows/src/runs/foreground/stage-control-registry.js"; +import { createStageControlRegistry } from "../../packages/workflows/src/runs/foreground/stage-control-registry.js"; import { createStore } from "../../packages/workflows/src/shared/store.js"; -import { WorkflowAttachPane } from "../../packages/workflows/src/tui/workflow-attach-pane.js"; +import type { PendingPrompt, StageInputRequest } from "../../packages/workflows/src/shared/store-types.js"; import { deriveGraphTheme } from "../../packages/workflows/src/tui/graph-theme.js"; -import { createStageControlRegistry } from "../../packages/workflows/src/runs/foreground/stage-control-registry.js"; -import type { StageControlHandle } from "../../packages/workflows/src/runs/foreground/stage-control-registry.js"; -import type { - PendingPrompt, - StageInputRequest, -} from "../../packages/workflows/src/shared/store-types.js"; -import type { AgentSession } from "@bastani/atomic"; -import { StageUiBroker } from "../../packages/workflows/src/shared/stage-ui-broker.js"; +import { WorkflowAttachPane } from "../../packages/workflows/src/tui/workflow-attach-pane.js"; import { makeFakeKeybindings } from "../support/fake-keybindings.js"; type TestStageSeed = { - id: string; - name: string; - status?: "pending" | "running" | "paused" | "completed"; + id: string; + name: string; + status?: "pending" | "running" | "paused" | "completed"; }; -function setupRun( - store: ReturnType, - runId: string, - stages: TestStageSeed[], -) { - store.recordRunStart({ - id: runId, - name: "test-wf", - inputs: {}, - status: "running", - stages: [], - startedAt: Date.now(), - }); - for (const s of stages) { - store.recordStageStart(runId, { - id: s.id, - name: s.name, - status: s.status ?? "running", - parentIds: [], - toolEvents: [], - }); - } -} - -function makePendingPrompt( - overrides: Partial = {}, -): PendingPrompt { - return { - id: "prompt-1", - kind: "input", - message: "What should the workflow use?", - createdAt: Date.now(), - ...overrides, - }; +function setupRun(store: ReturnType, runId: string, stages: TestStageSeed[]) { + store.recordRunStart({ + id: runId, + name: "test-wf", + inputs: {}, + status: "running", + stages: [], + startedAt: Date.now(), + }); + for (const s of stages) { + store.recordStageStart(runId, { + id: s.id, + name: s.name, + status: s.status ?? "running", + parentIds: [], + toolEvents: [], + }); + } } -function makeInputRequest( - overrides: Partial = {}, -): StageInputRequest { - return { - id: "input-request-1", - kind: "ask_user_question", - createdAt: Date.now(), - questions: [ - { - question: "Which option should the workflow use?", - header: "Choice", - options: [{ label: "Use A" }, { label: "Use B" }], - }, - ], - ...overrides, - }; +function makePendingPrompt(overrides: Partial = {}): PendingPrompt { + return { + id: "prompt-1", + kind: "input", + message: "What should the workflow use?", + createdAt: Date.now(), + ...overrides, + }; } -class FakePromptEditor implements EditorComponent { - text = ""; - focused = false; - onSubmit?: (text: string) => void; - onChange?: (text: string) => void; - - render(): string[] { - return [`fake-prompt-editor:${this.text}`]; - } - - handleInput(data: string): void { - if (data === Key.enter || data === "\r" || data === "\n") { - this.onSubmit?.(this.text); - return; - } - this.text += data; - this.onChange?.(this.text); - } - - invalidate(): void {} - - getText(): string { - return this.text; - } - - setText(text: string): void { - this.text = text; - } +function _makeInputRequest(overrides: Partial = {}): StageInputRequest { + return { + id: "input-request-1", + kind: "ask_user_question", + createdAt: Date.now(), + questions: [ + { + question: "Which option should the workflow use?", + header: "Choice", + options: [{ label: "Use A" }, { label: "Use B" }], + }, + ], + ...overrides, + }; } function makeHandle(runId: string, stageId: string): StageControlHandle { - return { - runId, - stageId, - stageName: `stage-${stageId}`, - status: "running", - sessionId: undefined, - sessionFile: undefined, - isStreaming: false, - messages: [] as AgentSession["messages"], - async ensureAttached() {}, - async prompt() {}, - async steer() {}, - async followUp() {}, - async pause() {}, - async resume() {}, - subscribe() { - return () => {}; - }, - }; + return { + runId, + stageId, + stageName: `stage-${stageId}`, + status: "running", + sessionId: undefined, + sessionFile: undefined, + isStreaming: false, + messages: [] as AgentSession["messages"], + async ensureAttached() {}, + async prompt() {}, + async steer() {}, + async followUp() {}, + async pause() {}, + async resume() {}, + subscribe() { + return () => {}; + }, + }; } function makeClock(start = 0): { - now: () => number; - advance: (ms: number) => void; + now: () => number; + advance: (ms: number) => void; } { - let current = start; - return { - now: () => current, - advance: (ms: number) => { - current += ms; - }, - }; + let current = start; + return { + now: () => current, + advance: (ms: number) => { + current += ms; + }, + }; } async function flush(): Promise { - await Promise.resolve(); + await Promise.resolve(); } type AttachedStageChat = { handleInput(data: string): boolean }; function getAttachedStageChat(pane: WorkflowAttachPane): AttachedStageChat { - const chatView = (pane as unknown as { chatView: AttachedStageChat | null }).chatView; - assert.ok(chatView, "expected initialAttachStageId to create a stage chat"); - return chatView; + const chatView = (pane as unknown as { chatView: AttachedStageChat | null }).chatView; + assert.ok(chatView, "expected initialAttachStageId to create a stage chat"); + return chatView; } function submitAttachedStageChatText(chatView: AttachedStageChat, text: string): void { - for (const ch of text) chatView.handleInput(ch); - chatView.handleInput("\r"); + for (const ch of text) chatView.handleInput(ch); + chatView.handleInput("\r"); } -function setupTwoPromptAttachPane( - firstPrompt: PendingPrompt, - opts: { piKeybindings?: unknown; now?: () => number } = {}, +function _setupTwoPromptAttachPane( + firstPrompt: PendingPrompt, + opts: { piKeybindings?: unknown; now?: () => number } = {}, ) { - const store = createStore(); - setupRun(store, "run-1", [ - { id: "stage-a", name: "A" }, - { id: "stage-b", name: "B" }, - ]); - const registry = createStageControlRegistry(); - registry.register(makeHandle("run-1", "stage-a")); - registry.register(makeHandle("run-1", "stage-b")); - const secondPrompt = makePendingPrompt({ id: "prompt-b", createdAt: 2 }); - assert.equal( - store.recordStagePendingPrompt("run-1", "stage-a", firstPrompt), - true, - ); - assert.equal( - store.recordStagePendingPrompt("run-1", "stage-b", secondPrompt), - true, - ); - const pending = store.awaitStagePendingPrompt( - "run-1", - "stage-a", - firstPrompt.id, - ); - const pane = new WorkflowAttachPane({ - store, - graphTheme: deriveGraphTheme({}), - runId: "run-1", - stageControlRegistry: registry, - onClose: () => {}, - initialAttachStageId: "stage-a", - piKeybindings: opts.piKeybindings, - now: opts.now, - }); - return { store, pane, pending, secondPrompt }; + const store = createStore(); + setupRun(store, "run-1", [ + { id: "stage-a", name: "A" }, + { id: "stage-b", name: "B" }, + ]); + const registry = createStageControlRegistry(); + registry.register(makeHandle("run-1", "stage-a")); + registry.register(makeHandle("run-1", "stage-b")); + const secondPrompt = makePendingPrompt({ id: "prompt-b", createdAt: 2 }); + assert.equal(store.recordStagePendingPrompt("run-1", "stage-a", firstPrompt), true); + assert.equal(store.recordStagePendingPrompt("run-1", "stage-b", secondPrompt), true); + const pending = store.awaitStagePendingPrompt("run-1", "stage-a", firstPrompt.id); + const pane = new WorkflowAttachPane({ + store, + graphTheme: deriveGraphTheme({}), + runId: "run-1", + stageControlRegistry: registry, + onClose: () => {}, + initialAttachStageId: "stage-a", + piKeybindings: opts.piKeybindings, + now: opts.now, + }); + return { store, pane, pending, secondPrompt }; } -function assertNextGraphEnterAttaches( - pane: WorkflowAttachPane, - expectedStageId: string, - message: string, -): void { - pane.handleInput(Key.enter); - assert.equal(pane._mode, "stage-chat", message); - assert.equal(pane._lastAttachedStageId, expectedStageId); +function _assertNextGraphEnterAttaches(pane: WorkflowAttachPane, expectedStageId: string, message: string): void { + pane.handleInput(Key.enter); + assert.equal(pane._mode, "stage-chat", message); + assert.equal(pane._lastAttachedStageId, expectedStageId); } describe("WorkflowAttachPane", () => { - test("starts in graph mode", () => { - const store = createStore(); - setupRun(store, "run-1", [{ id: "stage-a", name: "A" }]); - const pane = new WorkflowAttachPane({ - store, - graphTheme: deriveGraphTheme({}), - runId: "run-1", - onClose: () => {}, - }); - assert.equal(pane._mode, "graph"); - assert.equal(pane._hasChatView, false); - pane.dispose(); - }); + test("starts in graph mode", () => { + const store = createStore(); + setupRun(store, "run-1", [{ id: "stage-a", name: "A" }]); + const pane = new WorkflowAttachPane({ + store, + graphTheme: deriveGraphTheme({}), + runId: "run-1", + onClose: () => {}, + }); + assert.equal(pane._mode, "graph"); + assert.equal(pane._hasChatView, false); + pane.dispose(); + }); - test("attached stage chat /exit is not a workflow-local shutdown command", async () => { - for (const input of ["/exit", "/exit now"]) { - const store = createStore(); - setupRun(store, "run-1", [{ id: "stage-a", name: "A" }]); - const registry = createStageControlRegistry(); - const promptCalls: Array = []; - registry.register({ - ...makeHandle("run-1", "stage-a"), - async prompt(text: string) { - promptCalls.push(text); - }, - }); - let closeCalls = 0; - const clock = makeClock(); - const pane = new WorkflowAttachPane({ - store, - graphTheme: deriveGraphTheme({}), - runId: "run-1", - stageControlRegistry: registry, - initialAttachStageId: "stage-a", - onClose: () => { - closeCalls += 1; - }, - now: clock.now, - }); - clock.advance(250); + test("attached stage chat /exit is not a workflow-local shutdown command", async () => { + for (const input of ["/exit", "/exit now"]) { + const store = createStore(); + setupRun(store, "run-1", [{ id: "stage-a", name: "A" }]); + const registry = createStageControlRegistry(); + const promptCalls: Array = []; + registry.register({ + ...makeHandle("run-1", "stage-a"), + async prompt(text: string) { + promptCalls.push(text); + }, + }); + let closeCalls = 0; + const clock = makeClock(); + const pane = new WorkflowAttachPane({ + store, + graphTheme: deriveGraphTheme({}), + runId: "run-1", + stageControlRegistry: registry, + initialAttachStageId: "stage-a", + onClose: () => { + closeCalls += 1; + }, + now: clock.now, + }); + clock.advance(250); - const chatView = getAttachedStageChat(pane); - submitAttachedStageChatText(chatView, input); - await flush(); - await flush(); - await flush(); - await flush(); + const chatView = getAttachedStageChat(pane); + submitAttachedStageChatText(chatView, input); + await flush(); + await flush(); + await flush(); + await flush(); - assert.equal(closeCalls, 0); - assert.equal(promptCalls.length, 1); - assert.equal(promptCalls[0], input); - pane.dispose(); - } - }); + assert.equal(closeCalls, 0); + assert.equal(promptCalls.length, 1); + assert.equal(promptCalls[0], input); + pane.dispose(); + } + }); - test("forwards piKeybindings to GraphView run-level prompt cards", () => { - const store = createStore(); - setupRun(store, "run-1", [{ id: "stage-a", name: "A" }]); - const prompt = makePendingPrompt({ - id: "prompt-select-graph", - kind: "select", - choices: ["alpha", "beta", "gamma"], - }); - assert.equal(store.recordPendingPrompt("run-1", prompt), true); - const resolved: Array<{ - runId: string; - promptId: string; - response: unknown; - }> = []; - const pane = new WorkflowAttachPane({ - store, - graphTheme: deriveGraphTheme({}), - runId: "run-1", - onClose: () => {}, - piKeybindings: makeFakeKeybindings({ - "tui.select.down": ["d"], - "tui.select.confirm": ["s"], - }), - onPromptResolve: (runId, promptId, response) => { - resolved.push({ runId, promptId, response }); - store.resolvePendingPrompt(runId, promptId, response); - }, - }); + test("forwards piKeybindings to GraphView run-level prompt cards", () => { + const store = createStore(); + setupRun(store, "run-1", [{ id: "stage-a", name: "A" }]); + const prompt = makePendingPrompt({ + id: "prompt-select-graph", + kind: "select", + choices: ["alpha", "beta", "gamma"], + }); + assert.equal(store.recordPendingPrompt("run-1", prompt), true); + const resolved: Array<{ + runId: string; + promptId: string; + response: unknown; + }> = []; + const pane = new WorkflowAttachPane({ + store, + graphTheme: deriveGraphTheme({}), + runId: "run-1", + onClose: () => {}, + piKeybindings: makeFakeKeybindings({ + "tui.select.down": ["d"], + "tui.select.confirm": ["s"], + }), + onPromptResolve: (runId, promptId, response) => { + resolved.push({ runId, promptId, response }); + store.resolvePendingPrompt(runId, promptId, response); + }, + }); - assert.equal(pane._mode, "graph"); - assert.equal(pane.handleInput("d"), true); - assert.deepEqual(resolved, []); - assert.equal(store.runs()[0]?.pendingPrompt?.id, prompt.id); + assert.equal(pane._mode, "graph"); + assert.equal(pane.handleInput("d"), true); + assert.deepEqual(resolved, []); + assert.equal(store.runs()[0]?.pendingPrompt?.id, prompt.id); - assert.equal(pane.handleInput("s"), true); - assert.deepEqual(resolved, [ - { runId: "run-1", promptId: prompt.id, response: "beta" }, - ]); - assert.equal(store.runs()[0]?.pendingPrompt, undefined); - assert.equal(pane._mode, "graph"); - pane.dispose(); - }); + assert.equal(pane.handleInput("s"), true); + assert.deepEqual(resolved, [{ runId: "run-1", promptId: prompt.id, response: "beta" }]); + assert.equal(store.runs()[0]?.pendingPrompt, undefined); + assert.equal(pane._mode, "graph"); + pane.dispose(); + }); - test("Enter on a graph node swaps to stage-chat mode", () => { - const store = createStore(); - setupRun(store, "run-1", [{ id: "stage-a", name: "A" }]); - const registry = createStageControlRegistry(); - registry.register(makeHandle("run-1", "stage-a")); - const pane = new WorkflowAttachPane({ - store, - graphTheme: deriveGraphTheme({}), - runId: "run-1", - stageControlRegistry: registry, - onClose: () => {}, - }); - // Enter dispatches through the GraphView's handler. - pane.handleInput(Key.enter); - assert.equal(pane._mode, "stage-chat"); - assert.equal(pane._lastAttachedStageId, "stage-a"); - assert.equal(pane._hasChatView, true); - pane.dispose(); - }); + test("Enter on a graph node swaps to stage-chat mode", () => { + const store = createStore(); + setupRun(store, "run-1", [{ id: "stage-a", name: "A" }]); + const registry = createStageControlRegistry(); + registry.register(makeHandle("run-1", "stage-a")); + const pane = new WorkflowAttachPane({ + store, + graphTheme: deriveGraphTheme({}), + runId: "run-1", + stageControlRegistry: registry, + onClose: () => {}, + }); + // Enter dispatches through the GraphView's handler. + pane.handleInput(Key.enter); + assert.equal(pane._mode, "stage-chat"); + assert.equal(pane._lastAttachedStageId, "stage-a"); + assert.equal(pane._hasChatView, true); + pane.dispose(); + }); }); diff --git a/test/unit/workflow-attach-pane-02.test.ts b/test/unit/workflow-attach-pane-02.test.ts index 75040932a..e1df396d6 100644 --- a/test/unit/workflow-attach-pane-02.test.ts +++ b/test/unit/workflow-attach-pane-02.test.ts @@ -12,351 +12,276 @@ * cross-ref: src/tui/workflow-attach-pane.ts */ -import { describe, test } from "bun:test"; import assert from "node:assert/strict"; -import { - Key, - type Component, - type EditorComponent, - type TUI, -} from "@earendil-works/pi-tui"; +import type { AgentSession } from "@bastani/atomic"; +import { Key } from "@earendil-works/pi-tui"; +import { describe, test } from "vitest"; +import type { StageControlHandle } from "../../packages/workflows/src/runs/foreground/stage-control-registry.js"; +import { createStageControlRegistry } from "../../packages/workflows/src/runs/foreground/stage-control-registry.js"; import { createStore } from "../../packages/workflows/src/shared/store.js"; -import { WorkflowAttachPane } from "../../packages/workflows/src/tui/workflow-attach-pane.js"; +import type { PendingPrompt, StageInputRequest } from "../../packages/workflows/src/shared/store-types.js"; import { deriveGraphTheme } from "../../packages/workflows/src/tui/graph-theme.js"; -import { createStageControlRegistry } from "../../packages/workflows/src/runs/foreground/stage-control-registry.js"; -import type { StageControlHandle } from "../../packages/workflows/src/runs/foreground/stage-control-registry.js"; -import type { - PendingPrompt, - StageInputRequest, -} from "../../packages/workflows/src/shared/store-types.js"; -import type { AgentSession } from "@bastani/atomic"; -import { StageUiBroker } from "../../packages/workflows/src/shared/stage-ui-broker.js"; -import { makeFakeKeybindings } from "../support/fake-keybindings.js"; +import { WorkflowAttachPane } from "../../packages/workflows/src/tui/workflow-attach-pane.js"; type TestStageSeed = { - id: string; - name: string; - status?: "pending" | "running" | "paused" | "completed"; + id: string; + name: string; + status?: "pending" | "running" | "paused" | "completed"; }; -function setupRun( - store: ReturnType, - runId: string, - stages: TestStageSeed[], -) { - store.recordRunStart({ - id: runId, - name: "test-wf", - inputs: {}, - status: "running", - stages: [], - startedAt: Date.now(), - }); - for (const s of stages) { - store.recordStageStart(runId, { - id: s.id, - name: s.name, - status: s.status ?? "running", - parentIds: [], - toolEvents: [], - }); - } -} - -function makePendingPrompt( - overrides: Partial = {}, -): PendingPrompt { - return { - id: "prompt-1", - kind: "input", - message: "What should the workflow use?", - createdAt: Date.now(), - ...overrides, - }; +function setupRun(store: ReturnType, runId: string, stages: TestStageSeed[]) { + store.recordRunStart({ + id: runId, + name: "test-wf", + inputs: {}, + status: "running", + stages: [], + startedAt: Date.now(), + }); + for (const s of stages) { + store.recordStageStart(runId, { + id: s.id, + name: s.name, + status: s.status ?? "running", + parentIds: [], + toolEvents: [], + }); + } } -function makeInputRequest( - overrides: Partial = {}, -): StageInputRequest { - return { - id: "input-request-1", - kind: "ask_user_question", - createdAt: Date.now(), - questions: [ - { - question: "Which option should the workflow use?", - header: "Choice", - options: [{ label: "Use A" }, { label: "Use B" }], - }, - ], - ...overrides, - }; +function makePendingPrompt(overrides: Partial = {}): PendingPrompt { + return { + id: "prompt-1", + kind: "input", + message: "What should the workflow use?", + createdAt: Date.now(), + ...overrides, + }; } -class FakePromptEditor implements EditorComponent { - text = ""; - focused = false; - onSubmit?: (text: string) => void; - onChange?: (text: string) => void; - - render(): string[] { - return [`fake-prompt-editor:${this.text}`]; - } - - handleInput(data: string): void { - if (data === Key.enter || data === "\r" || data === "\n") { - this.onSubmit?.(this.text); - return; - } - this.text += data; - this.onChange?.(this.text); - } - - invalidate(): void {} - - getText(): string { - return this.text; - } - - setText(text: string): void { - this.text = text; - } +function makeInputRequest(overrides: Partial = {}): StageInputRequest { + return { + id: "input-request-1", + kind: "ask_user_question", + createdAt: Date.now(), + questions: [ + { + question: "Which option should the workflow use?", + header: "Choice", + options: [{ label: "Use A" }, { label: "Use B" }], + }, + ], + ...overrides, + }; } function makeHandle(runId: string, stageId: string): StageControlHandle { - return { - runId, - stageId, - stageName: `stage-${stageId}`, - status: "running", - sessionId: undefined, - sessionFile: undefined, - isStreaming: false, - messages: [] as AgentSession["messages"], - async ensureAttached() {}, - async prompt() {}, - async steer() {}, - async followUp() {}, - async pause() {}, - async resume() {}, - subscribe() { - return () => {}; - }, - }; + return { + runId, + stageId, + stageName: `stage-${stageId}`, + status: "running", + sessionId: undefined, + sessionFile: undefined, + isStreaming: false, + messages: [] as AgentSession["messages"], + async ensureAttached() {}, + async prompt() {}, + async steer() {}, + async followUp() {}, + async pause() {}, + async resume() {}, + subscribe() { + return () => {}; + }, + }; } function makeClock(start = 0): { - now: () => number; - advance: (ms: number) => void; + now: () => number; + advance: (ms: number) => void; } { - let current = start; - return { - now: () => current, - advance: (ms: number) => { - current += ms; - }, - }; + let current = start; + return { + now: () => current, + advance: (ms: number) => { + current += ms; + }, + }; } -async function flush(): Promise { - await Promise.resolve(); +async function _flush(): Promise { + await Promise.resolve(); } type AttachedStageChat = { handleInput(data: string): boolean }; -function getAttachedStageChat(pane: WorkflowAttachPane): AttachedStageChat { - const chatView = (pane as unknown as { chatView: AttachedStageChat | null }).chatView; - assert.ok(chatView, "expected initialAttachStageId to create a stage chat"); - return chatView; +function _getAttachedStageChat(pane: WorkflowAttachPane): AttachedStageChat { + const chatView = (pane as unknown as { chatView: AttachedStageChat | null }).chatView; + assert.ok(chatView, "expected initialAttachStageId to create a stage chat"); + return chatView; } -function submitAttachedStageChatText(chatView: AttachedStageChat, text: string): void { - for (const ch of text) chatView.handleInput(ch); - chatView.handleInput("\r"); +function _submitAttachedStageChatText(chatView: AttachedStageChat, text: string): void { + for (const ch of text) chatView.handleInput(ch); + chatView.handleInput("\r"); } -function setupTwoPromptAttachPane( - firstPrompt: PendingPrompt, - opts: { piKeybindings?: unknown; now?: () => number } = {}, +function _setupTwoPromptAttachPane( + firstPrompt: PendingPrompt, + opts: { piKeybindings?: unknown; now?: () => number } = {}, ) { - const store = createStore(); - setupRun(store, "run-1", [ - { id: "stage-a", name: "A" }, - { id: "stage-b", name: "B" }, - ]); - const registry = createStageControlRegistry(); - registry.register(makeHandle("run-1", "stage-a")); - registry.register(makeHandle("run-1", "stage-b")); - const secondPrompt = makePendingPrompt({ id: "prompt-b", createdAt: 2 }); - assert.equal( - store.recordStagePendingPrompt("run-1", "stage-a", firstPrompt), - true, - ); - assert.equal( - store.recordStagePendingPrompt("run-1", "stage-b", secondPrompt), - true, - ); - const pending = store.awaitStagePendingPrompt( - "run-1", - "stage-a", - firstPrompt.id, - ); - const pane = new WorkflowAttachPane({ - store, - graphTheme: deriveGraphTheme({}), - runId: "run-1", - stageControlRegistry: registry, - onClose: () => {}, - initialAttachStageId: "stage-a", - piKeybindings: opts.piKeybindings, - now: opts.now, - }); - return { store, pane, pending, secondPrompt }; + const store = createStore(); + setupRun(store, "run-1", [ + { id: "stage-a", name: "A" }, + { id: "stage-b", name: "B" }, + ]); + const registry = createStageControlRegistry(); + registry.register(makeHandle("run-1", "stage-a")); + registry.register(makeHandle("run-1", "stage-b")); + const secondPrompt = makePendingPrompt({ id: "prompt-b", createdAt: 2 }); + assert.equal(store.recordStagePendingPrompt("run-1", "stage-a", firstPrompt), true); + assert.equal(store.recordStagePendingPrompt("run-1", "stage-b", secondPrompt), true); + const pending = store.awaitStagePendingPrompt("run-1", "stage-a", firstPrompt.id); + const pane = new WorkflowAttachPane({ + store, + graphTheme: deriveGraphTheme({}), + runId: "run-1", + stageControlRegistry: registry, + onClose: () => {}, + initialAttachStageId: "stage-a", + piKeybindings: opts.piKeybindings, + now: opts.now, + }); + return { store, pane, pending, secondPrompt }; } -function assertNextGraphEnterAttaches( - pane: WorkflowAttachPane, - expectedStageId: string, - message: string, -): void { - pane.handleInput(Key.enter); - assert.equal(pane._mode, "stage-chat", message); - assert.equal(pane._lastAttachedStageId, expectedStageId); +function _assertNextGraphEnterAttaches(pane: WorkflowAttachPane, expectedStageId: string, message: string): void { + pane.handleInput(Key.enter); + assert.equal(pane._mode, "stage-chat", message); + assert.equal(pane._lastAttachedStageId, expectedStageId); } describe("WorkflowAttachPane", () => { - test("initial workflow connect Enter does not submit a run-level prompt", async () => { - const clock = makeClock(); - const store = createStore(); - setupRun(store, "run-1", [{ id: "stage-a", name: "A" }]); - const prompt = makePendingPrompt({ - id: "run-select-prompt", - kind: "select", - choices: ["first", "second"], - }); - assert.equal(store.recordPendingPrompt("run-1", prompt), true); - const pending = store.awaitPendingPrompt("run-1", prompt.id); - const pane = new WorkflowAttachPane({ - store, - graphTheme: deriveGraphTheme({}), - runId: "run-1", - onClose: () => {}, - now: clock.now, - }); - - pane.handleInput(Key.enter); - assert.equal(store.runs()[0]?.pendingPrompt?.id, prompt.id); - - clock.advance(201); - pane.handleInput(Key.enter); - assert.equal(await pending, "first"); - pane.dispose(); - }); - - test("retargeted workflow connect Enter does not submit a run-level prompt", async () => { - const clock = makeClock(); - const store = createStore(); - setupRun(store, "run-1", [{ id: "stage-a", name: "A" }]); - setupRun(store, "run-2", [{ id: "stage-b", name: "B" }]); - const prompt = makePendingPrompt({ - id: "retarget-run-select-prompt", - kind: "select", - choices: ["first", "second"], - }); - assert.equal(store.recordPendingPrompt("run-2", prompt), true); - const pending = store.awaitPendingPrompt("run-2", prompt.id); - const pane = new WorkflowAttachPane({ - store, - graphTheme: deriveGraphTheme({}), - runId: "run-1", - onClose: () => {}, - now: clock.now, - }); - - pane.retarget("run-2"); - pane.handleInput(Key.enter); - assert.equal( - store.runs().find((run) => run.id === "run-2")?.pendingPrompt?.id, - prompt.id, - ); - - clock.advance(201); - pane.handleInput(Key.enter); - assert.equal(await pending, "first"); - pane.dispose(); - }); - - test("slash switcher selection swaps directly to selected stage chat", () => { - const store = createStore(); - setupRun(store, "run-1", [ - { id: "stage-a", name: "A" }, - { id: "stage-b", name: "B" }, - ]); - const registry = createStageControlRegistry(); - registry.register(makeHandle("run-1", "stage-a")); - registry.register(makeHandle("run-1", "stage-b")); - const pane = new WorkflowAttachPane({ - store, - graphTheme: deriveGraphTheme({}), - runId: "run-1", - stageControlRegistry: registry, - onClose: () => {}, - }); - - pane.handleInput(Key.slash); - pane.handleInput(Key.down); - pane.handleInput(Key.enter); - - assert.equal(pane._mode, "stage-chat"); - assert.equal(pane._lastAttachedStageId, "stage-b"); - assert.equal(pane._hasChatView, true); - pane.dispose(); - }); - - test("stays in graph mode when a stage becomes awaiting-input until Enter attaches", () => { - const clock = makeClock(); - const store = createStore(); - setupRun(store, "run-1", [ - { id: "stage-a", name: "A", status: "completed" }, - { id: "stage-b", name: "B" }, - ]); - const registry = createStageControlRegistry(); - registry.register(makeHandle("run-1", "stage-a")); - registry.register(makeHandle("run-1", "stage-b")); - const pane = new WorkflowAttachPane({ - store, - graphTheme: deriveGraphTheme({}), - runId: "run-1", - stageControlRegistry: registry, - onClose: () => {}, - now: clock.now, - }); - - assert.equal( - store.recordStageAwaitingInput("run-1", "stage-b", true), - true, - ); - assert.equal( - store.recordStageInputRequest( - "run-1", - "stage-b", - makeInputRequest(), - ), - true, - ); - - assert.equal(pane._mode, "graph"); - assert.equal(pane._hasChatView, false); - - pane.handleInput(Key.enter); - assert.equal(pane._mode, "graph"); - - clock.advance(201); - pane.handleInput(Key.enter); - - assert.equal(pane._mode, "stage-chat"); - assert.equal(pane._lastAttachedStageId, "stage-b"); - assert.equal(pane._hasChatView, true); - pane.dispose(); - }); + test("initial workflow connect Enter does not submit a run-level prompt", async () => { + const clock = makeClock(); + const store = createStore(); + setupRun(store, "run-1", [{ id: "stage-a", name: "A" }]); + const prompt = makePendingPrompt({ + id: "run-select-prompt", + kind: "select", + choices: ["first", "second"], + }); + assert.equal(store.recordPendingPrompt("run-1", prompt), true); + const pending = store.awaitPendingPrompt("run-1", prompt.id); + const pane = new WorkflowAttachPane({ + store, + graphTheme: deriveGraphTheme({}), + runId: "run-1", + onClose: () => {}, + now: clock.now, + }); + + pane.handleInput(Key.enter); + assert.equal(store.runs()[0]?.pendingPrompt?.id, prompt.id); + + clock.advance(201); + pane.handleInput(Key.enter); + assert.equal(await pending, "first"); + pane.dispose(); + }); + + test("retargeted workflow connect Enter does not submit a run-level prompt", async () => { + const clock = makeClock(); + const store = createStore(); + setupRun(store, "run-1", [{ id: "stage-a", name: "A" }]); + setupRun(store, "run-2", [{ id: "stage-b", name: "B" }]); + const prompt = makePendingPrompt({ + id: "retarget-run-select-prompt", + kind: "select", + choices: ["first", "second"], + }); + assert.equal(store.recordPendingPrompt("run-2", prompt), true); + const pending = store.awaitPendingPrompt("run-2", prompt.id); + const pane = new WorkflowAttachPane({ + store, + graphTheme: deriveGraphTheme({}), + runId: "run-1", + onClose: () => {}, + now: clock.now, + }); + + pane.retarget("run-2"); + pane.handleInput(Key.enter); + assert.equal(store.runs().find((run) => run.id === "run-2")?.pendingPrompt?.id, prompt.id); + + clock.advance(201); + pane.handleInput(Key.enter); + assert.equal(await pending, "first"); + pane.dispose(); + }); + + test("slash switcher selection swaps directly to selected stage chat", () => { + const store = createStore(); + setupRun(store, "run-1", [ + { id: "stage-a", name: "A" }, + { id: "stage-b", name: "B" }, + ]); + const registry = createStageControlRegistry(); + registry.register(makeHandle("run-1", "stage-a")); + registry.register(makeHandle("run-1", "stage-b")); + const pane = new WorkflowAttachPane({ + store, + graphTheme: deriveGraphTheme({}), + runId: "run-1", + stageControlRegistry: registry, + onClose: () => {}, + }); + + pane.handleInput(Key.slash); + pane.handleInput(Key.down); + pane.handleInput(Key.enter); + + assert.equal(pane._mode, "stage-chat"); + assert.equal(pane._lastAttachedStageId, "stage-b"); + assert.equal(pane._hasChatView, true); + pane.dispose(); + }); + + test("stays in graph mode when a stage becomes awaiting-input until Enter attaches", () => { + const clock = makeClock(); + const store = createStore(); + setupRun(store, "run-1", [ + { id: "stage-a", name: "A", status: "completed" }, + { id: "stage-b", name: "B" }, + ]); + const registry = createStageControlRegistry(); + registry.register(makeHandle("run-1", "stage-a")); + registry.register(makeHandle("run-1", "stage-b")); + const pane = new WorkflowAttachPane({ + store, + graphTheme: deriveGraphTheme({}), + runId: "run-1", + stageControlRegistry: registry, + onClose: () => {}, + now: clock.now, + }); + + assert.equal(store.recordStageAwaitingInput("run-1", "stage-b", true), true); + assert.equal(store.recordStageInputRequest("run-1", "stage-b", makeInputRequest()), true); + + assert.equal(pane._mode, "graph"); + assert.equal(pane._hasChatView, false); + + pane.handleInput(Key.enter); + assert.equal(pane._mode, "graph"); + + clock.advance(201); + pane.handleInput(Key.enter); + + assert.equal(pane._mode, "stage-chat"); + assert.equal(pane._lastAttachedStageId, "stage-b"); + assert.equal(pane._hasChatView, true); + pane.dispose(); + }); }); diff --git a/test/unit/workflow-attach-pane-03.test.ts b/test/unit/workflow-attach-pane-03.test.ts index b6bd37a94..7798f7d25 100644 --- a/test/unit/workflow-attach-pane-03.test.ts +++ b/test/unit/workflow-attach-pane-03.test.ts @@ -12,411 +12,314 @@ * cross-ref: src/tui/workflow-attach-pane.ts */ -import { describe, test } from "bun:test"; import assert from "node:assert/strict"; -import { - Key, - type Component, - type EditorComponent, - type TUI, -} from "@earendil-works/pi-tui"; +import type { AgentSession } from "@bastani/atomic"; +import { Key } from "@earendil-works/pi-tui"; +import { describe, test } from "vitest"; +import type { StageControlHandle } from "../../packages/workflows/src/runs/foreground/stage-control-registry.js"; +import { createStageControlRegistry } from "../../packages/workflows/src/runs/foreground/stage-control-registry.js"; import { createStore } from "../../packages/workflows/src/shared/store.js"; -import { WorkflowAttachPane } from "../../packages/workflows/src/tui/workflow-attach-pane.js"; +import type { PendingPrompt, StageInputRequest } from "../../packages/workflows/src/shared/store-types.js"; import { deriveGraphTheme } from "../../packages/workflows/src/tui/graph-theme.js"; -import { createStageControlRegistry } from "../../packages/workflows/src/runs/foreground/stage-control-registry.js"; -import type { StageControlHandle } from "../../packages/workflows/src/runs/foreground/stage-control-registry.js"; -import type { - PendingPrompt, - StageInputRequest, -} from "../../packages/workflows/src/shared/store-types.js"; -import type { AgentSession } from "@bastani/atomic"; -import { StageUiBroker } from "../../packages/workflows/src/shared/stage-ui-broker.js"; -import { makeFakeKeybindings } from "../support/fake-keybindings.js"; +import { WorkflowAttachPane } from "../../packages/workflows/src/tui/workflow-attach-pane.js"; type TestStageSeed = { - id: string; - name: string; - status?: "pending" | "running" | "paused" | "completed"; + id: string; + name: string; + status?: "pending" | "running" | "paused" | "completed"; }; -function setupRun( - store: ReturnType, - runId: string, - stages: TestStageSeed[], -) { - store.recordRunStart({ - id: runId, - name: "test-wf", - inputs: {}, - status: "running", - stages: [], - startedAt: Date.now(), - }); - for (const s of stages) { - store.recordStageStart(runId, { - id: s.id, - name: s.name, - status: s.status ?? "running", - parentIds: [], - toolEvents: [], - }); - } -} - -function makePendingPrompt( - overrides: Partial = {}, -): PendingPrompt { - return { - id: "prompt-1", - kind: "input", - message: "What should the workflow use?", - createdAt: Date.now(), - ...overrides, - }; +function setupRun(store: ReturnType, runId: string, stages: TestStageSeed[]) { + store.recordRunStart({ + id: runId, + name: "test-wf", + inputs: {}, + status: "running", + stages: [], + startedAt: Date.now(), + }); + for (const s of stages) { + store.recordStageStart(runId, { + id: s.id, + name: s.name, + status: s.status ?? "running", + parentIds: [], + toolEvents: [], + }); + } } -function makeInputRequest( - overrides: Partial = {}, -): StageInputRequest { - return { - id: "input-request-1", - kind: "ask_user_question", - createdAt: Date.now(), - questions: [ - { - question: "Which option should the workflow use?", - header: "Choice", - options: [{ label: "Use A" }, { label: "Use B" }], - }, - ], - ...overrides, - }; +function makePendingPrompt(overrides: Partial = {}): PendingPrompt { + return { + id: "prompt-1", + kind: "input", + message: "What should the workflow use?", + createdAt: Date.now(), + ...overrides, + }; } -class FakePromptEditor implements EditorComponent { - text = ""; - focused = false; - onSubmit?: (text: string) => void; - onChange?: (text: string) => void; - - render(): string[] { - return [`fake-prompt-editor:${this.text}`]; - } - - handleInput(data: string): void { - if (data === Key.enter || data === "\r" || data === "\n") { - this.onSubmit?.(this.text); - return; - } - this.text += data; - this.onChange?.(this.text); - } - - invalidate(): void {} - - getText(): string { - return this.text; - } - - setText(text: string): void { - this.text = text; - } +function _makeInputRequest(overrides: Partial = {}): StageInputRequest { + return { + id: "input-request-1", + kind: "ask_user_question", + createdAt: Date.now(), + questions: [ + { + question: "Which option should the workflow use?", + header: "Choice", + options: [{ label: "Use A" }, { label: "Use B" }], + }, + ], + ...overrides, + }; } function makeHandle(runId: string, stageId: string): StageControlHandle { - return { - runId, - stageId, - stageName: `stage-${stageId}`, - status: "running", - sessionId: undefined, - sessionFile: undefined, - isStreaming: false, - messages: [] as AgentSession["messages"], - async ensureAttached() {}, - async prompt() {}, - async steer() {}, - async followUp() {}, - async pause() {}, - async resume() {}, - subscribe() { - return () => {}; - }, - }; + return { + runId, + stageId, + stageName: `stage-${stageId}`, + status: "running", + sessionId: undefined, + sessionFile: undefined, + isStreaming: false, + messages: [] as AgentSession["messages"], + async ensureAttached() {}, + async prompt() {}, + async steer() {}, + async followUp() {}, + async pause() {}, + async resume() {}, + subscribe() { + return () => {}; + }, + }; } function makeClock(start = 0): { - now: () => number; - advance: (ms: number) => void; + now: () => number; + advance: (ms: number) => void; } { - let current = start; - return { - now: () => current, - advance: (ms: number) => { - current += ms; - }, - }; + let current = start; + return { + now: () => current, + advance: (ms: number) => { + current += ms; + }, + }; } -async function flush(): Promise { - await Promise.resolve(); +async function _flush(): Promise { + await Promise.resolve(); } type AttachedStageChat = { handleInput(data: string): boolean }; -function getAttachedStageChat(pane: WorkflowAttachPane): AttachedStageChat { - const chatView = (pane as unknown as { chatView: AttachedStageChat | null }).chatView; - assert.ok(chatView, "expected initialAttachStageId to create a stage chat"); - return chatView; +function _getAttachedStageChat(pane: WorkflowAttachPane): AttachedStageChat { + const chatView = (pane as unknown as { chatView: AttachedStageChat | null }).chatView; + assert.ok(chatView, "expected initialAttachStageId to create a stage chat"); + return chatView; } -function submitAttachedStageChatText(chatView: AttachedStageChat, text: string): void { - for (const ch of text) chatView.handleInput(ch); - chatView.handleInput("\r"); +function _submitAttachedStageChatText(chatView: AttachedStageChat, text: string): void { + for (const ch of text) chatView.handleInput(ch); + chatView.handleInput("\r"); } -function setupTwoPromptAttachPane( - firstPrompt: PendingPrompt, - opts: { piKeybindings?: unknown; now?: () => number } = {}, +function _setupTwoPromptAttachPane( + firstPrompt: PendingPrompt, + opts: { piKeybindings?: unknown; now?: () => number } = {}, ) { - const store = createStore(); - setupRun(store, "run-1", [ - { id: "stage-a", name: "A" }, - { id: "stage-b", name: "B" }, - ]); - const registry = createStageControlRegistry(); - registry.register(makeHandle("run-1", "stage-a")); - registry.register(makeHandle("run-1", "stage-b")); - const secondPrompt = makePendingPrompt({ id: "prompt-b", createdAt: 2 }); - assert.equal( - store.recordStagePendingPrompt("run-1", "stage-a", firstPrompt), - true, - ); - assert.equal( - store.recordStagePendingPrompt("run-1", "stage-b", secondPrompt), - true, - ); - const pending = store.awaitStagePendingPrompt( - "run-1", - "stage-a", - firstPrompt.id, - ); - const pane = new WorkflowAttachPane({ - store, - graphTheme: deriveGraphTheme({}), - runId: "run-1", - stageControlRegistry: registry, - onClose: () => {}, - initialAttachStageId: "stage-a", - piKeybindings: opts.piKeybindings, - now: opts.now, - }); - return { store, pane, pending, secondPrompt }; + const store = createStore(); + setupRun(store, "run-1", [ + { id: "stage-a", name: "A" }, + { id: "stage-b", name: "B" }, + ]); + const registry = createStageControlRegistry(); + registry.register(makeHandle("run-1", "stage-a")); + registry.register(makeHandle("run-1", "stage-b")); + const secondPrompt = makePendingPrompt({ id: "prompt-b", createdAt: 2 }); + assert.equal(store.recordStagePendingPrompt("run-1", "stage-a", firstPrompt), true); + assert.equal(store.recordStagePendingPrompt("run-1", "stage-b", secondPrompt), true); + const pending = store.awaitStagePendingPrompt("run-1", "stage-a", firstPrompt.id); + const pane = new WorkflowAttachPane({ + store, + graphTheme: deriveGraphTheme({}), + runId: "run-1", + stageControlRegistry: registry, + onClose: () => {}, + initialAttachStageId: "stage-a", + piKeybindings: opts.piKeybindings, + now: opts.now, + }); + return { store, pane, pending, secondPrompt }; } -function assertNextGraphEnterAttaches( - pane: WorkflowAttachPane, - expectedStageId: string, - message: string, -): void { - pane.handleInput(Key.enter); - assert.equal(pane._mode, "stage-chat", message); - assert.equal(pane._lastAttachedStageId, expectedStageId); +function _assertNextGraphEnterAttaches(pane: WorkflowAttachPane, expectedStageId: string, message: string): void { + pane.handleInput(Key.enter); + assert.equal(pane._mode, "stage-chat", message); + assert.equal(pane._lastAttachedStageId, expectedStageId); } describe("WorkflowAttachPane", () => { - for (const kind of ["input", "confirm", "select", "editor"] as const) { - test(`late-arriving ${kind} stage HIL does not consume stale graph Enter`, () => { - const clock = makeClock(); - const store = createStore(); - setupRun(store, "run-1", [ - { id: "stage-a", name: "A", status: "completed" }, - { id: "stage-b", name: "B" }, - ]); - const registry = createStageControlRegistry(); - registry.register(makeHandle("run-1", "stage-a")); - registry.register(makeHandle("run-1", "stage-b")); - const pane = new WorkflowAttachPane({ - store, - graphTheme: deriveGraphTheme({}), - runId: "run-1", - stageControlRegistry: registry, - onClose: () => {}, - now: clock.now, - }); - - const prompt = makePendingPrompt({ - id: `late-${kind}`, - kind, - choices: kind === "select" ? ["alpha", "beta"] : undefined, - initial: - kind === "input" || kind === "editor" ? "seed" : undefined, - createdAt: clock.now(), - }); - assert.equal( - store.recordStagePendingPrompt("run-1", "stage-b", prompt), - true, - ); - - pane.handleInput(Key.enter); - assert.equal(pane._mode, "graph"); - assert.equal( - store.runs()[0]?.stages[1]?.pendingPrompt?.id, - prompt.id, - ); - - clock.advance(201); - pane.handleInput(Key.enter); - assert.equal(pane._mode, "stage-chat"); - assert.equal(pane._lastAttachedStageId, "stage-b"); - pane.dispose(); - }); - } - - test("graph attach does not re-quarantine prompt after unrelated store updates", async () => { - const clock = makeClock(); - const store = createStore(); - setupRun(store, "run-1", [ - { id: "stage-a", name: "A", status: "completed" }, - { id: "stage-b", name: "B" }, - ]); - const registry = createStageControlRegistry(); - registry.register(makeHandle("run-1", "stage-a")); - registry.register(makeHandle("run-1", "stage-b")); - const pane = new WorkflowAttachPane({ - store, - graphTheme: deriveGraphTheme({}), - runId: "run-1", - stageControlRegistry: registry, - onClose: () => {}, - now: clock.now, - }); - - const prompt = makePendingPrompt({ - id: "graph-attach-prompt", - initial: "seed", - createdAt: clock.now(), - }); - assert.equal( - store.recordStagePendingPrompt("run-1", "stage-b", prompt), - true, - ); - const pending = store.awaitStagePendingPrompt( - "run-1", - "stage-b", - prompt.id, - ); - - clock.advance(201); - pane.handleInput(Key.enter); - assert.equal(pane._mode, "stage-chat"); - - clock.advance(201); - assert.equal( - store.recordStageNotice("run-1", "stage-a", { - id: "unrelated-notice", - ts: clock.now(), - kind: "thinking", - to: "expanded", - }), - true, - ); - pane.handleInput(Key.enter); - - assert.equal(await pending, "seed"); - pane.dispose(); - }); - - for (const kind of ["input", "confirm", "select", "editor"] as const) { - test(`late-arriving ${kind} prompt in attached stage does not consume stale Enter`, async () => { - const clock = makeClock(); - const store = createStore(); - setupRun(store, "run-1", [{ id: "stage-a", name: "A" }]); - const registry = createStageControlRegistry(); - registry.register(makeHandle("run-1", "stage-a")); - const pane = new WorkflowAttachPane({ - store, - graphTheme: deriveGraphTheme({}), - runId: "run-1", - stageControlRegistry: registry, - onClose: () => {}, - initialAttachStageId: "stage-a", - now: clock.now, - }); - assert.equal(pane._mode, "stage-chat"); - - const prompt = makePendingPrompt({ - id: `attached-late-${kind}`, - kind, - choices: kind === "select" ? ["alpha", "beta"] : undefined, - initial: - kind === "input" || kind === "editor" ? "seed" : undefined, - createdAt: clock.now(), - }); - assert.equal( - store.recordStagePendingPrompt("run-1", "stage-a", prompt), - true, - ); - const pending = store.awaitStagePendingPrompt( - "run-1", - "stage-a", - prompt.id, - ); - - pane.handleInput(Key.enter); - assert.equal( - store.runs()[0]?.stages[0]?.pendingPrompt?.id, - prompt.id, - ); - - clock.advance(201); - if (kind === "confirm") pane.handleInput("y"); - else if (kind === "editor") pane.handleInput(Key.ctrl("c")); - else pane.handleInput(Key.enter); - assert.equal( - await pending, - kind === "confirm" - ? true - : kind === "select" - ? "alpha" - : "seed", - ); - pane.dispose(); - }); - } - - test("initial workflow connect Enter does not attach to a stage-local HIL", () => { - const clock = makeClock(); - const store = createStore(); - setupRun(store, "run-1", [{ id: "stage-a", name: "A" }]); - const registry = createStageControlRegistry(); - registry.register(makeHandle("run-1", "stage-a")); - const prompt = makePendingPrompt({ - id: "stage-connect-prompt", - kind: "select", - choices: ["alpha", "beta"], - }); - assert.equal( - store.recordStagePendingPrompt("run-1", "stage-a", prompt), - true, - ); - const pane = new WorkflowAttachPane({ - store, - graphTheme: deriveGraphTheme({}), - runId: "run-1", - stageControlRegistry: registry, - onClose: () => {}, - now: clock.now, - }); - - pane.handleInput(Key.enter); - assert.equal(pane._mode, "graph"); - assert.equal(store.runs()[0]?.stages[0]?.pendingPrompt?.id, prompt.id); - - clock.advance(201); - pane.handleInput(Key.enter); - assert.equal(pane._mode, "stage-chat"); - assert.equal(pane._lastAttachedStageId, "stage-a"); - pane.dispose(); - }); + for (const kind of ["input", "confirm", "select", "editor"] as const) { + test(`late-arriving ${kind} stage HIL does not consume stale graph Enter`, () => { + const clock = makeClock(); + const store = createStore(); + setupRun(store, "run-1", [ + { id: "stage-a", name: "A", status: "completed" }, + { id: "stage-b", name: "B" }, + ]); + const registry = createStageControlRegistry(); + registry.register(makeHandle("run-1", "stage-a")); + registry.register(makeHandle("run-1", "stage-b")); + const pane = new WorkflowAttachPane({ + store, + graphTheme: deriveGraphTheme({}), + runId: "run-1", + stageControlRegistry: registry, + onClose: () => {}, + now: clock.now, + }); + + const prompt = makePendingPrompt({ + id: `late-${kind}`, + kind, + choices: kind === "select" ? ["alpha", "beta"] : undefined, + initial: kind === "input" || kind === "editor" ? "seed" : undefined, + createdAt: clock.now(), + }); + assert.equal(store.recordStagePendingPrompt("run-1", "stage-b", prompt), true); + + pane.handleInput(Key.enter); + assert.equal(pane._mode, "graph"); + assert.equal(store.runs()[0]?.stages[1]?.pendingPrompt?.id, prompt.id); + + clock.advance(201); + pane.handleInput(Key.enter); + assert.equal(pane._mode, "stage-chat"); + assert.equal(pane._lastAttachedStageId, "stage-b"); + pane.dispose(); + }); + } + + test("graph attach does not re-quarantine prompt after unrelated store updates", async () => { + const clock = makeClock(); + const store = createStore(); + setupRun(store, "run-1", [ + { id: "stage-a", name: "A", status: "completed" }, + { id: "stage-b", name: "B" }, + ]); + const registry = createStageControlRegistry(); + registry.register(makeHandle("run-1", "stage-a")); + registry.register(makeHandle("run-1", "stage-b")); + const pane = new WorkflowAttachPane({ + store, + graphTheme: deriveGraphTheme({}), + runId: "run-1", + stageControlRegistry: registry, + onClose: () => {}, + now: clock.now, + }); + + const prompt = makePendingPrompt({ + id: "graph-attach-prompt", + initial: "seed", + createdAt: clock.now(), + }); + assert.equal(store.recordStagePendingPrompt("run-1", "stage-b", prompt), true); + const pending = store.awaitStagePendingPrompt("run-1", "stage-b", prompt.id); + + clock.advance(201); + pane.handleInput(Key.enter); + assert.equal(pane._mode, "stage-chat"); + + clock.advance(201); + assert.equal( + store.recordStageNotice("run-1", "stage-a", { + id: "unrelated-notice", + ts: clock.now(), + kind: "thinking", + to: "expanded", + }), + true, + ); + pane.handleInput(Key.enter); + + assert.equal(await pending, "seed"); + pane.dispose(); + }); + + for (const kind of ["input", "confirm", "select", "editor"] as const) { + test(`late-arriving ${kind} prompt in attached stage does not consume stale Enter`, async () => { + const clock = makeClock(); + const store = createStore(); + setupRun(store, "run-1", [{ id: "stage-a", name: "A" }]); + const registry = createStageControlRegistry(); + registry.register(makeHandle("run-1", "stage-a")); + const pane = new WorkflowAttachPane({ + store, + graphTheme: deriveGraphTheme({}), + runId: "run-1", + stageControlRegistry: registry, + onClose: () => {}, + initialAttachStageId: "stage-a", + now: clock.now, + }); + assert.equal(pane._mode, "stage-chat"); + + const prompt = makePendingPrompt({ + id: `attached-late-${kind}`, + kind, + choices: kind === "select" ? ["alpha", "beta"] : undefined, + initial: kind === "input" || kind === "editor" ? "seed" : undefined, + createdAt: clock.now(), + }); + assert.equal(store.recordStagePendingPrompt("run-1", "stage-a", prompt), true); + const pending = store.awaitStagePendingPrompt("run-1", "stage-a", prompt.id); + + pane.handleInput(Key.enter); + assert.equal(store.runs()[0]?.stages[0]?.pendingPrompt?.id, prompt.id); + + clock.advance(201); + if (kind === "confirm") pane.handleInput("y"); + else if (kind === "editor") pane.handleInput(Key.ctrl("c")); + else pane.handleInput(Key.enter); + assert.equal(await pending, kind === "confirm" ? true : kind === "select" ? "alpha" : "seed"); + pane.dispose(); + }); + } + + test("initial workflow connect Enter does not attach to a stage-local HIL", () => { + const clock = makeClock(); + const store = createStore(); + setupRun(store, "run-1", [{ id: "stage-a", name: "A" }]); + const registry = createStageControlRegistry(); + registry.register(makeHandle("run-1", "stage-a")); + const prompt = makePendingPrompt({ + id: "stage-connect-prompt", + kind: "select", + choices: ["alpha", "beta"], + }); + assert.equal(store.recordStagePendingPrompt("run-1", "stage-a", prompt), true); + const pane = new WorkflowAttachPane({ + store, + graphTheme: deriveGraphTheme({}), + runId: "run-1", + stageControlRegistry: registry, + onClose: () => {}, + now: clock.now, + }); + + pane.handleInput(Key.enter); + assert.equal(pane._mode, "graph"); + assert.equal(store.runs()[0]?.stages[0]?.pendingPrompt?.id, prompt.id); + + clock.advance(201); + pane.handleInput(Key.enter); + assert.equal(pane._mode, "stage-chat"); + assert.equal(pane._lastAttachedStageId, "stage-a"); + pane.dispose(); + }); }); diff --git a/test/unit/workflow-attach-pane-04.test.ts b/test/unit/workflow-attach-pane-04.test.ts index 65e2a97de..9050a0ce5 100644 --- a/test/unit/workflow-attach-pane-04.test.ts +++ b/test/unit/workflow-attach-pane-04.test.ts @@ -12,418 +12,317 @@ * cross-ref: src/tui/workflow-attach-pane.ts */ -import { describe, test } from "bun:test"; import assert from "node:assert/strict"; -import { - Key, - type Component, - type EditorComponent, - type TUI, -} from "@earendil-works/pi-tui"; +import type { AgentSession } from "@bastani/atomic"; +import { Key } from "@earendil-works/pi-tui"; +import { describe, test } from "vitest"; +import type { StageControlHandle } from "../../packages/workflows/src/runs/foreground/stage-control-registry.js"; +import { createStageControlRegistry } from "../../packages/workflows/src/runs/foreground/stage-control-registry.js"; import { createStore } from "../../packages/workflows/src/shared/store.js"; -import { WorkflowAttachPane } from "../../packages/workflows/src/tui/workflow-attach-pane.js"; +import type { PendingPrompt, StageInputRequest } from "../../packages/workflows/src/shared/store-types.js"; import { deriveGraphTheme } from "../../packages/workflows/src/tui/graph-theme.js"; -import { createStageControlRegistry } from "../../packages/workflows/src/runs/foreground/stage-control-registry.js"; -import type { StageControlHandle } from "../../packages/workflows/src/runs/foreground/stage-control-registry.js"; -import type { - PendingPrompt, - StageInputRequest, -} from "../../packages/workflows/src/shared/store-types.js"; -import type { AgentSession } from "@bastani/atomic"; -import { StageUiBroker } from "../../packages/workflows/src/shared/stage-ui-broker.js"; -import { makeFakeKeybindings } from "../support/fake-keybindings.js"; +import { WorkflowAttachPane } from "../../packages/workflows/src/tui/workflow-attach-pane.js"; type TestStageSeed = { - id: string; - name: string; - status?: "pending" | "running" | "paused" | "completed"; + id: string; + name: string; + status?: "pending" | "running" | "paused" | "completed"; }; -function setupRun( - store: ReturnType, - runId: string, - stages: TestStageSeed[], -) { - store.recordRunStart({ - id: runId, - name: "test-wf", - inputs: {}, - status: "running", - stages: [], - startedAt: Date.now(), - }); - for (const s of stages) { - store.recordStageStart(runId, { - id: s.id, - name: s.name, - status: s.status ?? "running", - parentIds: [], - toolEvents: [], - }); - } -} - -function makePendingPrompt( - overrides: Partial = {}, -): PendingPrompt { - return { - id: "prompt-1", - kind: "input", - message: "What should the workflow use?", - createdAt: Date.now(), - ...overrides, - }; +function setupRun(store: ReturnType, runId: string, stages: TestStageSeed[]) { + store.recordRunStart({ + id: runId, + name: "test-wf", + inputs: {}, + status: "running", + stages: [], + startedAt: Date.now(), + }); + for (const s of stages) { + store.recordStageStart(runId, { + id: s.id, + name: s.name, + status: s.status ?? "running", + parentIds: [], + toolEvents: [], + }); + } } -function makeInputRequest( - overrides: Partial = {}, -): StageInputRequest { - return { - id: "input-request-1", - kind: "ask_user_question", - createdAt: Date.now(), - questions: [ - { - question: "Which option should the workflow use?", - header: "Choice", - options: [{ label: "Use A" }, { label: "Use B" }], - }, - ], - ...overrides, - }; +function makePendingPrompt(overrides: Partial = {}): PendingPrompt { + return { + id: "prompt-1", + kind: "input", + message: "What should the workflow use?", + createdAt: Date.now(), + ...overrides, + }; } -class FakePromptEditor implements EditorComponent { - text = ""; - focused = false; - onSubmit?: (text: string) => void; - onChange?: (text: string) => void; - - render(): string[] { - return [`fake-prompt-editor:${this.text}`]; - } - - handleInput(data: string): void { - if (data === Key.enter || data === "\r" || data === "\n") { - this.onSubmit?.(this.text); - return; - } - this.text += data; - this.onChange?.(this.text); - } - - invalidate(): void {} - - getText(): string { - return this.text; - } - - setText(text: string): void { - this.text = text; - } +function _makeInputRequest(overrides: Partial = {}): StageInputRequest { + return { + id: "input-request-1", + kind: "ask_user_question", + createdAt: Date.now(), + questions: [ + { + question: "Which option should the workflow use?", + header: "Choice", + options: [{ label: "Use A" }, { label: "Use B" }], + }, + ], + ...overrides, + }; } function makeHandle(runId: string, stageId: string): StageControlHandle { - return { - runId, - stageId, - stageName: `stage-${stageId}`, - status: "running", - sessionId: undefined, - sessionFile: undefined, - isStreaming: false, - messages: [] as AgentSession["messages"], - async ensureAttached() {}, - async prompt() {}, - async steer() {}, - async followUp() {}, - async pause() {}, - async resume() {}, - subscribe() { - return () => {}; - }, - }; + return { + runId, + stageId, + stageName: `stage-${stageId}`, + status: "running", + sessionId: undefined, + sessionFile: undefined, + isStreaming: false, + messages: [] as AgentSession["messages"], + async ensureAttached() {}, + async prompt() {}, + async steer() {}, + async followUp() {}, + async pause() {}, + async resume() {}, + subscribe() { + return () => {}; + }, + }; } function makeClock(start = 0): { - now: () => number; - advance: (ms: number) => void; + now: () => number; + advance: (ms: number) => void; } { - let current = start; - return { - now: () => current, - advance: (ms: number) => { - current += ms; - }, - }; + let current = start; + return { + now: () => current, + advance: (ms: number) => { + current += ms; + }, + }; } -async function flush(): Promise { - await Promise.resolve(); +async function _flush(): Promise { + await Promise.resolve(); } type AttachedStageChat = { handleInput(data: string): boolean }; -function getAttachedStageChat(pane: WorkflowAttachPane): AttachedStageChat { - const chatView = (pane as unknown as { chatView: AttachedStageChat | null }).chatView; - assert.ok(chatView, "expected initialAttachStageId to create a stage chat"); - return chatView; +function _getAttachedStageChat(pane: WorkflowAttachPane): AttachedStageChat { + const chatView = (pane as unknown as { chatView: AttachedStageChat | null }).chatView; + assert.ok(chatView, "expected initialAttachStageId to create a stage chat"); + return chatView; } -function submitAttachedStageChatText(chatView: AttachedStageChat, text: string): void { - for (const ch of text) chatView.handleInput(ch); - chatView.handleInput("\r"); +function _submitAttachedStageChatText(chatView: AttachedStageChat, text: string): void { + for (const ch of text) chatView.handleInput(ch); + chatView.handleInput("\r"); } -function setupTwoPromptAttachPane( - firstPrompt: PendingPrompt, - opts: { piKeybindings?: unknown; now?: () => number } = {}, +function _setupTwoPromptAttachPane( + firstPrompt: PendingPrompt, + opts: { piKeybindings?: unknown; now?: () => number } = {}, ) { - const store = createStore(); - setupRun(store, "run-1", [ - { id: "stage-a", name: "A" }, - { id: "stage-b", name: "B" }, - ]); - const registry = createStageControlRegistry(); - registry.register(makeHandle("run-1", "stage-a")); - registry.register(makeHandle("run-1", "stage-b")); - const secondPrompt = makePendingPrompt({ id: "prompt-b", createdAt: 2 }); - assert.equal( - store.recordStagePendingPrompt("run-1", "stage-a", firstPrompt), - true, - ); - assert.equal( - store.recordStagePendingPrompt("run-1", "stage-b", secondPrompt), - true, - ); - const pending = store.awaitStagePendingPrompt( - "run-1", - "stage-a", - firstPrompt.id, - ); - const pane = new WorkflowAttachPane({ - store, - graphTheme: deriveGraphTheme({}), - runId: "run-1", - stageControlRegistry: registry, - onClose: () => {}, - initialAttachStageId: "stage-a", - piKeybindings: opts.piKeybindings, - now: opts.now, - }); - return { store, pane, pending, secondPrompt }; + const store = createStore(); + setupRun(store, "run-1", [ + { id: "stage-a", name: "A" }, + { id: "stage-b", name: "B" }, + ]); + const registry = createStageControlRegistry(); + registry.register(makeHandle("run-1", "stage-a")); + registry.register(makeHandle("run-1", "stage-b")); + const secondPrompt = makePendingPrompt({ id: "prompt-b", createdAt: 2 }); + assert.equal(store.recordStagePendingPrompt("run-1", "stage-a", firstPrompt), true); + assert.equal(store.recordStagePendingPrompt("run-1", "stage-b", secondPrompt), true); + const pending = store.awaitStagePendingPrompt("run-1", "stage-a", firstPrompt.id); + const pane = new WorkflowAttachPane({ + store, + graphTheme: deriveGraphTheme({}), + runId: "run-1", + stageControlRegistry: registry, + onClose: () => {}, + initialAttachStageId: "stage-a", + piKeybindings: opts.piKeybindings, + now: opts.now, + }); + return { store, pane, pending, secondPrompt }; } -function assertNextGraphEnterAttaches( - pane: WorkflowAttachPane, - expectedStageId: string, - message: string, -): void { - pane.handleInput(Key.enter); - assert.equal(pane._mode, "stage-chat", message); - assert.equal(pane._lastAttachedStageId, expectedStageId); +function _assertNextGraphEnterAttaches(pane: WorkflowAttachPane, expectedStageId: string, message: string): void { + pane.handleInput(Key.enter); + assert.equal(pane._mode, "stage-chat", message); + assert.equal(pane._lastAttachedStageId, expectedStageId); } describe("WorkflowAttachPane", () => { - test("entering a non-HIL graph node while another stage has HIL does not submit it", () => { - const clock = makeClock(); - const store = createStore(); - setupRun(store, "run-1", [ - { id: "stage-a", name: "Inspect", status: "completed" }, - { id: "stage-b", name: "Needs input" }, - ]); - const registry = createStageControlRegistry(); - registry.register(makeHandle("run-1", "stage-a")); - registry.register(makeHandle("run-1", "stage-b")); - const prompt = makePendingPrompt({ - id: "other-stage-prompt", - kind: "select", - choices: ["alpha", "beta"], - }); - assert.equal( - store.recordStagePendingPrompt("run-1", "stage-b", prompt), - true, - ); - const pane = new WorkflowAttachPane({ - store, - graphTheme: deriveGraphTheme({}), - runId: "run-1", - stageControlRegistry: registry, - onClose: () => {}, - now: clock.now, - }); - - pane.handleInput("k"); - pane.handleInput(Key.enter); - - assert.equal(pane._mode, "stage-chat"); - assert.equal(pane._lastAttachedStageId, "stage-a"); - assert.equal(store.runs()[0]?.stages[1]?.pendingPrompt?.id, prompt.id); - - clock.advance(201); - pane.handleInput(Key.enter); - assert.equal(store.runs()[0]?.stages[1]?.pendingPrompt?.id, prompt.id); - pane.dispose(); - }); - - test("multiple stage HIL prompts stay isolated to the attached node", async () => { - const clock = makeClock(); - const store = createStore(); - setupRun(store, "run-1", [ - { id: "stage-a", name: "A" }, - { id: "stage-b", name: "B" }, - ]); - const registry = createStageControlRegistry(); - registry.register(makeHandle("run-1", "stage-a")); - registry.register(makeHandle("run-1", "stage-b")); - const firstPrompt = makePendingPrompt({ - id: "prompt-a", - kind: "select", - choices: ["a1", "a2"], - createdAt: 1, - }); - const secondPrompt = makePendingPrompt({ - id: "prompt-b", - kind: "select", - choices: ["b1", "b2"], - createdAt: 2, - }); - assert.equal( - store.recordStagePendingPrompt("run-1", "stage-a", firstPrompt), - true, - ); - assert.equal( - store.recordStagePendingPrompt("run-1", "stage-b", secondPrompt), - true, - ); - const firstPending = store.awaitStagePendingPrompt( - "run-1", - "stage-a", - firstPrompt.id, - ); - const pane = new WorkflowAttachPane({ - store, - graphTheme: deriveGraphTheme({}), - runId: "run-1", - stageControlRegistry: registry, - onClose: () => {}, - now: clock.now, - }); - - pane.handleInput("k"); - pane.handleInput(Key.enter); - assert.equal(pane._mode, "stage-chat"); - assert.equal(pane._lastAttachedStageId, "stage-a"); - - pane.handleInput(Key.enter); - assert.equal( - store.runs()[0]?.stages[0]?.pendingPrompt?.id, - firstPrompt.id, - ); - assert.equal( - store.runs()[0]?.stages[1]?.pendingPrompt?.id, - secondPrompt.id, - ); - - clock.advance(201); - pane.handleInput(Key.enter); - assert.equal(await firstPending, "a1"); - assert.equal(store.runs()[0]?.stages[0]?.pendingPrompt, undefined); - assert.equal( - store.runs()[0]?.stages[1]?.pendingPrompt?.id, - secondPrompt.id, - ); - pane.dispose(); - }); - - test("graph Enter attach does not immediately submit a stage select prompt", async () => { - const clock = makeClock(); - const store = createStore(); - setupRun(store, "run-1", [{ id: "stage-a", name: "A" }]); - const registry = createStageControlRegistry(); - registry.register(makeHandle("run-1", "stage-a")); - const prompt = makePendingPrompt({ - id: "prompt-select", - kind: "select", - choices: ["alpha", "beta"], - }); - assert.equal( - store.recordStagePendingPrompt("run-1", "stage-a", prompt), - true, - ); - const pending = store.awaitStagePendingPrompt( - "run-1", - "stage-a", - prompt.id, - ); - const pane = new WorkflowAttachPane({ - store, - graphTheme: deriveGraphTheme({}), - runId: "run-1", - stageControlRegistry: registry, - onClose: () => {}, - now: clock.now, - }); - - clock.advance(201); - pane.handleInput(Key.enter); - assert.equal(pane._mode, "stage-chat"); - - pane.handleInput(Key.enter); - assert.equal(store.runs()[0]?.stages[0]?.pendingPrompt?.id, prompt.id); - - clock.advance(201); - pane.handleInput(Key.enter); - assert.equal(await pending, "alpha"); - assert.equal(pane._mode, "graph"); - pane.dispose(); - }); - - test("held Enter after graph attach must stop repeating before it can submit a prompt", async () => { - const clock = makeClock(); - const store = createStore(); - setupRun(store, "run-1", [{ id: "stage-a", name: "A" }]); - const registry = createStageControlRegistry(); - registry.register(makeHandle("run-1", "stage-a")); - const prompt = makePendingPrompt({ - id: "repeat-prompt-select", - kind: "select", - choices: ["alpha", "beta"], - }); - assert.equal( - store.recordStagePendingPrompt("run-1", "stage-a", prompt), - true, - ); - const pending = store.awaitStagePendingPrompt( - "run-1", - "stage-a", - prompt.id, - ); - const pane = new WorkflowAttachPane({ - store, - graphTheme: deriveGraphTheme({}), - runId: "run-1", - stageControlRegistry: registry, - onClose: () => {}, - now: clock.now, - }); - - clock.advance(201); - pane.handleInput(Key.enter); - assert.equal(pane._mode, "stage-chat"); - - for (let i = 0; i < 8; i++) { - clock.advance(50); - pane.handleInput(Key.enter); - assert.equal( - store.runs()[0]?.stages[0]?.pendingPrompt?.id, - prompt.id, - ); - } - - clock.advance(201); - pane.handleInput(Key.enter); - assert.equal(await pending, "alpha"); - pane.dispose(); - }); + test("entering a non-HIL graph node while another stage has HIL does not submit it", () => { + const clock = makeClock(); + const store = createStore(); + setupRun(store, "run-1", [ + { id: "stage-a", name: "Inspect", status: "completed" }, + { id: "stage-b", name: "Needs input" }, + ]); + const registry = createStageControlRegistry(); + registry.register(makeHandle("run-1", "stage-a")); + registry.register(makeHandle("run-1", "stage-b")); + const prompt = makePendingPrompt({ + id: "other-stage-prompt", + kind: "select", + choices: ["alpha", "beta"], + }); + assert.equal(store.recordStagePendingPrompt("run-1", "stage-b", prompt), true); + const pane = new WorkflowAttachPane({ + store, + graphTheme: deriveGraphTheme({}), + runId: "run-1", + stageControlRegistry: registry, + onClose: () => {}, + now: clock.now, + }); + + pane.handleInput("k"); + pane.handleInput(Key.enter); + + assert.equal(pane._mode, "stage-chat"); + assert.equal(pane._lastAttachedStageId, "stage-a"); + assert.equal(store.runs()[0]?.stages[1]?.pendingPrompt?.id, prompt.id); + + clock.advance(201); + pane.handleInput(Key.enter); + assert.equal(store.runs()[0]?.stages[1]?.pendingPrompt?.id, prompt.id); + pane.dispose(); + }); + + test("multiple stage HIL prompts stay isolated to the attached node", async () => { + const clock = makeClock(); + const store = createStore(); + setupRun(store, "run-1", [ + { id: "stage-a", name: "A" }, + { id: "stage-b", name: "B" }, + ]); + const registry = createStageControlRegistry(); + registry.register(makeHandle("run-1", "stage-a")); + registry.register(makeHandle("run-1", "stage-b")); + const firstPrompt = makePendingPrompt({ + id: "prompt-a", + kind: "select", + choices: ["a1", "a2"], + createdAt: 1, + }); + const secondPrompt = makePendingPrompt({ + id: "prompt-b", + kind: "select", + choices: ["b1", "b2"], + createdAt: 2, + }); + assert.equal(store.recordStagePendingPrompt("run-1", "stage-a", firstPrompt), true); + assert.equal(store.recordStagePendingPrompt("run-1", "stage-b", secondPrompt), true); + const firstPending = store.awaitStagePendingPrompt("run-1", "stage-a", firstPrompt.id); + const pane = new WorkflowAttachPane({ + store, + graphTheme: deriveGraphTheme({}), + runId: "run-1", + stageControlRegistry: registry, + onClose: () => {}, + now: clock.now, + }); + + pane.handleInput("k"); + pane.handleInput(Key.enter); + assert.equal(pane._mode, "stage-chat"); + assert.equal(pane._lastAttachedStageId, "stage-a"); + + pane.handleInput(Key.enter); + assert.equal(store.runs()[0]?.stages[0]?.pendingPrompt?.id, firstPrompt.id); + assert.equal(store.runs()[0]?.stages[1]?.pendingPrompt?.id, secondPrompt.id); + + clock.advance(201); + pane.handleInput(Key.enter); + assert.equal(await firstPending, "a1"); + assert.equal(store.runs()[0]?.stages[0]?.pendingPrompt, undefined); + assert.equal(store.runs()[0]?.stages[1]?.pendingPrompt?.id, secondPrompt.id); + pane.dispose(); + }); + + test("graph Enter attach does not immediately submit a stage select prompt", async () => { + const clock = makeClock(); + const store = createStore(); + setupRun(store, "run-1", [{ id: "stage-a", name: "A" }]); + const registry = createStageControlRegistry(); + registry.register(makeHandle("run-1", "stage-a")); + const prompt = makePendingPrompt({ + id: "prompt-select", + kind: "select", + choices: ["alpha", "beta"], + }); + assert.equal(store.recordStagePendingPrompt("run-1", "stage-a", prompt), true); + const pending = store.awaitStagePendingPrompt("run-1", "stage-a", prompt.id); + const pane = new WorkflowAttachPane({ + store, + graphTheme: deriveGraphTheme({}), + runId: "run-1", + stageControlRegistry: registry, + onClose: () => {}, + now: clock.now, + }); + + clock.advance(201); + pane.handleInput(Key.enter); + assert.equal(pane._mode, "stage-chat"); + + pane.handleInput(Key.enter); + assert.equal(store.runs()[0]?.stages[0]?.pendingPrompt?.id, prompt.id); + + clock.advance(201); + pane.handleInput(Key.enter); + assert.equal(await pending, "alpha"); + assert.equal(pane._mode, "graph"); + pane.dispose(); + }); + + test("held Enter after graph attach must stop repeating before it can submit a prompt", async () => { + const clock = makeClock(); + const store = createStore(); + setupRun(store, "run-1", [{ id: "stage-a", name: "A" }]); + const registry = createStageControlRegistry(); + registry.register(makeHandle("run-1", "stage-a")); + const prompt = makePendingPrompt({ + id: "repeat-prompt-select", + kind: "select", + choices: ["alpha", "beta"], + }); + assert.equal(store.recordStagePendingPrompt("run-1", "stage-a", prompt), true); + const pending = store.awaitStagePendingPrompt("run-1", "stage-a", prompt.id); + const pane = new WorkflowAttachPane({ + store, + graphTheme: deriveGraphTheme({}), + runId: "run-1", + stageControlRegistry: registry, + onClose: () => {}, + now: clock.now, + }); + + clock.advance(201); + pane.handleInput(Key.enter); + assert.equal(pane._mode, "stage-chat"); + + for (let i = 0; i < 8; i++) { + clock.advance(50); + pane.handleInput(Key.enter); + assert.equal(store.runs()[0]?.stages[0]?.pendingPrompt?.id, prompt.id); + } + + clock.advance(201); + pane.handleInput(Key.enter); + assert.equal(await pending, "alpha"); + pane.dispose(); + }); }); diff --git a/test/unit/workflow-attach-pane-05.test.ts b/test/unit/workflow-attach-pane-05.test.ts index 85c22a205..96cfcd60c 100644 --- a/test/unit/workflow-attach-pane-05.test.ts +++ b/test/unit/workflow-attach-pane-05.test.ts @@ -12,381 +12,300 @@ * cross-ref: src/tui/workflow-attach-pane.ts */ -import { describe, test } from "bun:test"; import assert from "node:assert/strict"; -import { - Key, - type Component, - type EditorComponent, - type TUI, -} from "@earendil-works/pi-tui"; -import { createStore } from "../../packages/workflows/src/shared/store.js"; -import { WorkflowAttachPane } from "../../packages/workflows/src/tui/workflow-attach-pane.js"; -import { deriveGraphTheme } from "../../packages/workflows/src/tui/graph-theme.js"; -import { createStageControlRegistry } from "../../packages/workflows/src/runs/foreground/stage-control-registry.js"; -import type { StageControlHandle } from "../../packages/workflows/src/runs/foreground/stage-control-registry.js"; -import type { - PendingPrompt, - StageInputRequest, -} from "../../packages/workflows/src/shared/store-types.js"; import type { AgentSession } from "@bastani/atomic"; +import { type Component, Key, type TUI } from "@earendil-works/pi-tui"; +import { describe, test } from "vitest"; +import type { StageControlHandle } from "../../packages/workflows/src/runs/foreground/stage-control-registry.js"; +import { createStageControlRegistry } from "../../packages/workflows/src/runs/foreground/stage-control-registry.js"; import { StageUiBroker } from "../../packages/workflows/src/shared/stage-ui-broker.js"; -import { makeFakeKeybindings } from "../support/fake-keybindings.js"; +import { createStore } from "../../packages/workflows/src/shared/store.js"; +import type { PendingPrompt, StageInputRequest } from "../../packages/workflows/src/shared/store-types.js"; +import { deriveGraphTheme } from "../../packages/workflows/src/tui/graph-theme.js"; +import { WorkflowAttachPane } from "../../packages/workflows/src/tui/workflow-attach-pane.js"; type TestStageSeed = { - id: string; - name: string; - status?: "pending" | "running" | "paused" | "completed"; + id: string; + name: string; + status?: "pending" | "running" | "paused" | "completed"; }; -function setupRun( - store: ReturnType, - runId: string, - stages: TestStageSeed[], -) { - store.recordRunStart({ - id: runId, - name: "test-wf", - inputs: {}, - status: "running", - stages: [], - startedAt: Date.now(), - }); - for (const s of stages) { - store.recordStageStart(runId, { - id: s.id, - name: s.name, - status: s.status ?? "running", - parentIds: [], - toolEvents: [], - }); - } +function setupRun(store: ReturnType, runId: string, stages: TestStageSeed[]) { + store.recordRunStart({ + id: runId, + name: "test-wf", + inputs: {}, + status: "running", + stages: [], + startedAt: Date.now(), + }); + for (const s of stages) { + store.recordStageStart(runId, { + id: s.id, + name: s.name, + status: s.status ?? "running", + parentIds: [], + toolEvents: [], + }); + } } -function makePendingPrompt( - overrides: Partial = {}, -): PendingPrompt { - return { - id: "prompt-1", - kind: "input", - message: "What should the workflow use?", - createdAt: Date.now(), - ...overrides, - }; +function makePendingPrompt(overrides: Partial = {}): PendingPrompt { + return { + id: "prompt-1", + kind: "input", + message: "What should the workflow use?", + createdAt: Date.now(), + ...overrides, + }; } -function makeInputRequest( - overrides: Partial = {}, -): StageInputRequest { - return { - id: "input-request-1", - kind: "ask_user_question", - createdAt: Date.now(), - questions: [ - { - question: "Which option should the workflow use?", - header: "Choice", - options: [{ label: "Use A" }, { label: "Use B" }], - }, - ], - ...overrides, - }; -} - -class FakePromptEditor implements EditorComponent { - text = ""; - focused = false; - onSubmit?: (text: string) => void; - onChange?: (text: string) => void; - - render(): string[] { - return [`fake-prompt-editor:${this.text}`]; - } - - handleInput(data: string): void { - if (data === Key.enter || data === "\r" || data === "\n") { - this.onSubmit?.(this.text); - return; - } - this.text += data; - this.onChange?.(this.text); - } - - invalidate(): void {} - - getText(): string { - return this.text; - } - - setText(text: string): void { - this.text = text; - } +function _makeInputRequest(overrides: Partial = {}): StageInputRequest { + return { + id: "input-request-1", + kind: "ask_user_question", + createdAt: Date.now(), + questions: [ + { + question: "Which option should the workflow use?", + header: "Choice", + options: [{ label: "Use A" }, { label: "Use B" }], + }, + ], + ...overrides, + }; } function makeHandle(runId: string, stageId: string): StageControlHandle { - return { - runId, - stageId, - stageName: `stage-${stageId}`, - status: "running", - sessionId: undefined, - sessionFile: undefined, - isStreaming: false, - messages: [] as AgentSession["messages"], - async ensureAttached() {}, - async prompt() {}, - async steer() {}, - async followUp() {}, - async pause() {}, - async resume() {}, - subscribe() { - return () => {}; - }, - }; + return { + runId, + stageId, + stageName: `stage-${stageId}`, + status: "running", + sessionId: undefined, + sessionFile: undefined, + isStreaming: false, + messages: [] as AgentSession["messages"], + async ensureAttached() {}, + async prompt() {}, + async steer() {}, + async followUp() {}, + async pause() {}, + async resume() {}, + subscribe() { + return () => {}; + }, + }; } function makeClock(start = 0): { - now: () => number; - advance: (ms: number) => void; + now: () => number; + advance: (ms: number) => void; } { - let current = start; - return { - now: () => current, - advance: (ms: number) => { - current += ms; - }, - }; + let current = start; + return { + now: () => current, + advance: (ms: number) => { + current += ms; + }, + }; } async function flush(): Promise { - await Promise.resolve(); + await Promise.resolve(); } type AttachedStageChat = { handleInput(data: string): boolean }; -function getAttachedStageChat(pane: WorkflowAttachPane): AttachedStageChat { - const chatView = (pane as unknown as { chatView: AttachedStageChat | null }).chatView; - assert.ok(chatView, "expected initialAttachStageId to create a stage chat"); - return chatView; +function _getAttachedStageChat(pane: WorkflowAttachPane): AttachedStageChat { + const chatView = (pane as unknown as { chatView: AttachedStageChat | null }).chatView; + assert.ok(chatView, "expected initialAttachStageId to create a stage chat"); + return chatView; } -function submitAttachedStageChatText(chatView: AttachedStageChat, text: string): void { - for (const ch of text) chatView.handleInput(ch); - chatView.handleInput("\r"); +function _submitAttachedStageChatText(chatView: AttachedStageChat, text: string): void { + for (const ch of text) chatView.handleInput(ch); + chatView.handleInput("\r"); } -function setupTwoPromptAttachPane( - firstPrompt: PendingPrompt, - opts: { piKeybindings?: unknown; now?: () => number } = {}, +function _setupTwoPromptAttachPane( + firstPrompt: PendingPrompt, + opts: { piKeybindings?: unknown; now?: () => number } = {}, ) { - const store = createStore(); - setupRun(store, "run-1", [ - { id: "stage-a", name: "A" }, - { id: "stage-b", name: "B" }, - ]); - const registry = createStageControlRegistry(); - registry.register(makeHandle("run-1", "stage-a")); - registry.register(makeHandle("run-1", "stage-b")); - const secondPrompt = makePendingPrompt({ id: "prompt-b", createdAt: 2 }); - assert.equal( - store.recordStagePendingPrompt("run-1", "stage-a", firstPrompt), - true, - ); - assert.equal( - store.recordStagePendingPrompt("run-1", "stage-b", secondPrompt), - true, - ); - const pending = store.awaitStagePendingPrompt( - "run-1", - "stage-a", - firstPrompt.id, - ); - const pane = new WorkflowAttachPane({ - store, - graphTheme: deriveGraphTheme({}), - runId: "run-1", - stageControlRegistry: registry, - onClose: () => {}, - initialAttachStageId: "stage-a", - piKeybindings: opts.piKeybindings, - now: opts.now, - }); - return { store, pane, pending, secondPrompt }; + const store = createStore(); + setupRun(store, "run-1", [ + { id: "stage-a", name: "A" }, + { id: "stage-b", name: "B" }, + ]); + const registry = createStageControlRegistry(); + registry.register(makeHandle("run-1", "stage-a")); + registry.register(makeHandle("run-1", "stage-b")); + const secondPrompt = makePendingPrompt({ id: "prompt-b", createdAt: 2 }); + assert.equal(store.recordStagePendingPrompt("run-1", "stage-a", firstPrompt), true); + assert.equal(store.recordStagePendingPrompt("run-1", "stage-b", secondPrompt), true); + const pending = store.awaitStagePendingPrompt("run-1", "stage-a", firstPrompt.id); + const pane = new WorkflowAttachPane({ + store, + graphTheme: deriveGraphTheme({}), + runId: "run-1", + stageControlRegistry: registry, + onClose: () => {}, + initialAttachStageId: "stage-a", + piKeybindings: opts.piKeybindings, + now: opts.now, + }); + return { store, pane, pending, secondPrompt }; } -function assertNextGraphEnterAttaches( - pane: WorkflowAttachPane, - expectedStageId: string, - message: string, -): void { - pane.handleInput(Key.enter); - assert.equal(pane._mode, "stage-chat", message); - assert.equal(pane._lastAttachedStageId, expectedStageId); +function _assertNextGraphEnterAttaches(pane: WorkflowAttachPane, expectedStageId: string, message: string): void { + pane.handleInput(Key.enter); + assert.equal(pane._mode, "stage-chat", message); + assert.equal(pane._lastAttachedStageId, expectedStageId); } describe("WorkflowAttachPane", () => { - test("direct stage attach does not immediately submit a stage select prompt", async () => { - const clock = makeClock(); - const store = createStore(); - setupRun(store, "run-1", [{ id: "stage-a", name: "A" }]); - const registry = createStageControlRegistry(); - registry.register(makeHandle("run-1", "stage-a")); - const prompt = makePendingPrompt({ - id: "direct-prompt-select", - kind: "select", - choices: ["first", "second"], - }); - assert.equal( - store.recordStagePendingPrompt("run-1", "stage-a", prompt), - true, - ); - const pending = store.awaitStagePendingPrompt( - "run-1", - "stage-a", - prompt.id, - ); - const pane = new WorkflowAttachPane({ - store, - graphTheme: deriveGraphTheme({}), - runId: "run-1", - stageControlRegistry: registry, - onClose: () => {}, - initialAttachStageId: "stage-a", - now: clock.now, - }); + test("direct stage attach does not immediately submit a stage select prompt", async () => { + const clock = makeClock(); + const store = createStore(); + setupRun(store, "run-1", [{ id: "stage-a", name: "A" }]); + const registry = createStageControlRegistry(); + registry.register(makeHandle("run-1", "stage-a")); + const prompt = makePendingPrompt({ + id: "direct-prompt-select", + kind: "select", + choices: ["first", "second"], + }); + assert.equal(store.recordStagePendingPrompt("run-1", "stage-a", prompt), true); + const pending = store.awaitStagePendingPrompt("run-1", "stage-a", prompt.id); + const pane = new WorkflowAttachPane({ + store, + graphTheme: deriveGraphTheme({}), + runId: "run-1", + stageControlRegistry: registry, + onClose: () => {}, + initialAttachStageId: "stage-a", + now: clock.now, + }); - assert.equal(pane._mode, "stage-chat"); - pane.handleInput(Key.enter); - assert.equal(store.runs()[0]?.stages[0]?.pendingPrompt?.id, prompt.id); + assert.equal(pane._mode, "stage-chat"); + pane.handleInput(Key.enter); + assert.equal(store.runs()[0]?.stages[0]?.pendingPrompt?.id, prompt.id); - clock.advance(201); - pane.handleInput(Key.enter); - assert.equal(await pending, "first"); - assert.equal(pane._mode, "graph"); - pane.dispose(); - }); + clock.advance(201); + pane.handleInput(Key.enter); + assert.equal(await pending, "first"); + assert.equal(pane._mode, "graph"); + pane.dispose(); + }); - test("retargeted stage attach does not immediately submit a stage select prompt", async () => { - const clock = makeClock(); - const store = createStore(); - setupRun(store, "run-1", [{ id: "stage-a", name: "A" }]); - setupRun(store, "run-2", [{ id: "stage-b", name: "B" }]); - const registry = createStageControlRegistry(); - registry.register(makeHandle("run-2", "stage-b")); - const prompt = makePendingPrompt({ - id: "retarget-stage-prompt-select", - kind: "select", - choices: ["first", "second"], - }); - assert.equal( - store.recordStagePendingPrompt("run-2", "stage-b", prompt), - true, - ); - const pending = store.awaitStagePendingPrompt( - "run-2", - "stage-b", - prompt.id, - ); - const pane = new WorkflowAttachPane({ - store, - graphTheme: deriveGraphTheme({}), - runId: "run-1", - stageControlRegistry: registry, - onClose: () => {}, - now: clock.now, - }); + test("retargeted stage attach does not immediately submit a stage select prompt", async () => { + const clock = makeClock(); + const store = createStore(); + setupRun(store, "run-1", [{ id: "stage-a", name: "A" }]); + setupRun(store, "run-2", [{ id: "stage-b", name: "B" }]); + const registry = createStageControlRegistry(); + registry.register(makeHandle("run-2", "stage-b")); + const prompt = makePendingPrompt({ + id: "retarget-stage-prompt-select", + kind: "select", + choices: ["first", "second"], + }); + assert.equal(store.recordStagePendingPrompt("run-2", "stage-b", prompt), true); + const pending = store.awaitStagePendingPrompt("run-2", "stage-b", prompt.id); + const pane = new WorkflowAttachPane({ + store, + graphTheme: deriveGraphTheme({}), + runId: "run-1", + stageControlRegistry: registry, + onClose: () => {}, + now: clock.now, + }); - pane.retarget("run-2", "stage-b"); - assert.equal(pane._mode, "stage-chat"); - pane.handleInput(Key.enter); - const run = store.runs().find((candidate) => candidate.id === "run-2"); - assert.equal(run?.stages[0]?.pendingPrompt?.id, prompt.id); + pane.retarget("run-2", "stage-b"); + assert.equal(pane._mode, "stage-chat"); + pane.handleInput(Key.enter); + const run = store.runs().find((candidate) => candidate.id === "run-2"); + assert.equal(run?.stages[0]?.pendingPrompt?.id, prompt.id); - clock.advance(201); - pane.handleInput(Key.enter); - assert.equal(await pending, "first"); - assert.equal(pane._mode, "graph"); - pane.dispose(); - }); + clock.advance(201); + pane.handleInput(Key.enter); + assert.equal(await pending, "first"); + assert.equal(pane._mode, "graph"); + pane.dispose(); + }); - test("direct stage attach does not immediately submit brokered custom UI", async () => { - const clock = makeClock(); - const store = createStore(); - setupRun(store, "run-1", [{ id: "stage-a", name: "A" }]); - const registry = createStageControlRegistry(); - registry.register(makeHandle("run-1", "stage-a")); - const broker = new StageUiBroker(store); - let resolved = false; - const pending = broker - .requestCustomUi("run-1", "stage-a", (_tui, _theme, _kb, done) => { - const component: Component = { - render: () => ["custom question"], - handleInput: () => done("custom answer"), - invalidate: () => {}, - }; - return component; - }) - .then((value) => { - resolved = true; - return value; - }); - const pane = new WorkflowAttachPane({ - store, - graphTheme: deriveGraphTheme({}), - runId: "run-1", - stageControlRegistry: registry, - stageUiBroker: broker, - onClose: () => {}, - initialAttachStageId: "stage-a", - piTui: { - requestRender: () => {}, - terminal: { rows: 32, columns: 80 }, - } as unknown as TUI, - piTheme: {}, - piKeybindings: {}, - now: clock.now, - }); - await flush(); + test("direct stage attach does not immediately submit brokered custom UI", async () => { + const clock = makeClock(); + const store = createStore(); + setupRun(store, "run-1", [{ id: "stage-a", name: "A" }]); + const registry = createStageControlRegistry(); + registry.register(makeHandle("run-1", "stage-a")); + const broker = new StageUiBroker(store); + let resolved = false; + const pending = broker + .requestCustomUi("run-1", "stage-a", (_tui, _theme, _kb, done) => { + const component: Component = { + render: () => ["custom question"], + handleInput: () => done("custom answer"), + invalidate: () => {}, + }; + return component; + }) + .then((value) => { + resolved = true; + return value; + }); + const pane = new WorkflowAttachPane({ + store, + graphTheme: deriveGraphTheme({}), + runId: "run-1", + stageControlRegistry: registry, + stageUiBroker: broker, + onClose: () => {}, + initialAttachStageId: "stage-a", + piTui: { + requestRender: () => {}, + terminal: { rows: 32, columns: 80 }, + } as unknown as TUI, + piTheme: {}, + piKeybindings: {}, + now: clock.now, + }); + await flush(); - assert.equal(pane._mode, "stage-chat"); - pane.handleInput(Key.enter); - await flush(); - assert.equal(resolved, false); + assert.equal(pane._mode, "stage-chat"); + pane.handleInput(Key.enter); + await flush(); + assert.equal(resolved, false); - clock.advance(201); - pane.handleInput(Key.enter); - assert.equal(await pending, "custom answer"); - pane.dispose(); - }); + clock.advance(201); + pane.handleInput(Key.enter); + assert.equal(await pending, "custom answer"); + pane.dispose(); + }); - test("Ctrl+X in graph mode hides without resolving a pending stage prompt", () => { - const store = createStore(); - setupRun(store, "run-1", [{ id: "stage-a", name: "A" }]); - const prompt = makePendingPrompt(); - assert.equal( - store.recordStagePendingPrompt("run-1", "stage-a", prompt), - true, - ); - let hidden = 0; - const pane = new WorkflowAttachPane({ - store, - graphTheme: deriveGraphTheme({}), - runId: "run-1", - onClose: () => {}, - onHide: () => { - hidden += 1; - }, - }); + test("Ctrl+X in graph mode hides without resolving a pending stage prompt", () => { + const store = createStore(); + setupRun(store, "run-1", [{ id: "stage-a", name: "A" }]); + const prompt = makePendingPrompt(); + assert.equal(store.recordStagePendingPrompt("run-1", "stage-a", prompt), true); + let hidden = 0; + const pane = new WorkflowAttachPane({ + store, + graphTheme: deriveGraphTheme({}), + runId: "run-1", + onClose: () => {}, + onHide: () => { + hidden += 1; + }, + }); - pane.handleInput(Key.ctrl("x")); + pane.handleInput(Key.ctrl("x")); - assert.equal(hidden, 1); - assert.equal(pane._mode, "graph"); - assert.equal(pane._hasChatView, false); - assert.equal( - store.snapshot().runs[0]!.stages[0]!.pendingPrompt?.id, - prompt.id, - ); - pane.dispose(); - }); + assert.equal(hidden, 1); + assert.equal(pane._mode, "graph"); + assert.equal(pane._hasChatView, false); + assert.equal(store.snapshot().runs[0]!.stages[0]!.pendingPrompt?.id, prompt.id); + pane.dispose(); + }); }); diff --git a/test/unit/workflow-attach-pane-06.test.ts b/test/unit/workflow-attach-pane-06.test.ts index 94e416d5c..7373fbae2 100644 --- a/test/unit/workflow-attach-pane-06.test.ts +++ b/test/unit/workflow-attach-pane-06.test.ts @@ -12,394 +12,334 @@ * cross-ref: src/tui/workflow-attach-pane.ts */ -import { describe, test } from "bun:test"; import assert from "node:assert/strict"; -import { - Key, - type Component, - type EditorComponent, - type TUI, -} from "@earendil-works/pi-tui"; +import type { AgentSession } from "@bastani/atomic"; +import { type EditorComponent, Key, type TUI } from "@earendil-works/pi-tui"; +import { describe, test } from "vitest"; +import type { StageControlHandle } from "../../packages/workflows/src/runs/foreground/stage-control-registry.js"; +import { createStageControlRegistry } from "../../packages/workflows/src/runs/foreground/stage-control-registry.js"; import { createStore } from "../../packages/workflows/src/shared/store.js"; -import { WorkflowAttachPane } from "../../packages/workflows/src/tui/workflow-attach-pane.js"; +import type { PendingPrompt, StageInputRequest } from "../../packages/workflows/src/shared/store-types.js"; import { deriveGraphTheme } from "../../packages/workflows/src/tui/graph-theme.js"; -import { createStageControlRegistry } from "../../packages/workflows/src/runs/foreground/stage-control-registry.js"; -import type { StageControlHandle } from "../../packages/workflows/src/runs/foreground/stage-control-registry.js"; -import type { - PendingPrompt, - StageInputRequest, -} from "../../packages/workflows/src/shared/store-types.js"; -import type { AgentSession } from "@bastani/atomic"; -import { StageUiBroker } from "../../packages/workflows/src/shared/stage-ui-broker.js"; -import { makeFakeKeybindings } from "../support/fake-keybindings.js"; +import { WorkflowAttachPane } from "../../packages/workflows/src/tui/workflow-attach-pane.js"; type TestStageSeed = { - id: string; - name: string; - status?: "pending" | "running" | "paused" | "completed"; + id: string; + name: string; + status?: "pending" | "running" | "paused" | "completed"; }; -function setupRun( - store: ReturnType, - runId: string, - stages: TestStageSeed[], -) { - store.recordRunStart({ - id: runId, - name: "test-wf", - inputs: {}, - status: "running", - stages: [], - startedAt: Date.now(), - }); - for (const s of stages) { - store.recordStageStart(runId, { - id: s.id, - name: s.name, - status: s.status ?? "running", - parentIds: [], - toolEvents: [], - }); - } +function setupRun(store: ReturnType, runId: string, stages: TestStageSeed[]) { + store.recordRunStart({ + id: runId, + name: "test-wf", + inputs: {}, + status: "running", + stages: [], + startedAt: Date.now(), + }); + for (const s of stages) { + store.recordStageStart(runId, { + id: s.id, + name: s.name, + status: s.status ?? "running", + parentIds: [], + toolEvents: [], + }); + } } -function makePendingPrompt( - overrides: Partial = {}, -): PendingPrompt { - return { - id: "prompt-1", - kind: "input", - message: "What should the workflow use?", - createdAt: Date.now(), - ...overrides, - }; +function makePendingPrompt(overrides: Partial = {}): PendingPrompt { + return { + id: "prompt-1", + kind: "input", + message: "What should the workflow use?", + createdAt: Date.now(), + ...overrides, + }; } -function makeInputRequest( - overrides: Partial = {}, -): StageInputRequest { - return { - id: "input-request-1", - kind: "ask_user_question", - createdAt: Date.now(), - questions: [ - { - question: "Which option should the workflow use?", - header: "Choice", - options: [{ label: "Use A" }, { label: "Use B" }], - }, - ], - ...overrides, - }; +function _makeInputRequest(overrides: Partial = {}): StageInputRequest { + return { + id: "input-request-1", + kind: "ask_user_question", + createdAt: Date.now(), + questions: [ + { + question: "Which option should the workflow use?", + header: "Choice", + options: [{ label: "Use A" }, { label: "Use B" }], + }, + ], + ...overrides, + }; } class FakePromptEditor implements EditorComponent { - text = ""; - focused = false; - onSubmit?: (text: string) => void; - onChange?: (text: string) => void; - - render(): string[] { - return [`fake-prompt-editor:${this.text}`]; - } - - handleInput(data: string): void { - if (data === Key.enter || data === "\r" || data === "\n") { - this.onSubmit?.(this.text); - return; - } - this.text += data; - this.onChange?.(this.text); - } - - invalidate(): void {} - - getText(): string { - return this.text; - } - - setText(text: string): void { - this.text = text; - } + text = ""; + focused = false; + onSubmit?: (text: string) => void; + onChange?: (text: string) => void; + + render(): string[] { + return [`fake-prompt-editor:${this.text}`]; + } + + handleInput(data: string): void { + if (data === Key.enter || data === "\r" || data === "\n") { + this.onSubmit?.(this.text); + return; + } + this.text += data; + this.onChange?.(this.text); + } + + invalidate(): void {} + + getText(): string { + return this.text; + } + + setText(text: string): void { + this.text = text; + } } function makeHandle(runId: string, stageId: string): StageControlHandle { - return { - runId, - stageId, - stageName: `stage-${stageId}`, - status: "running", - sessionId: undefined, - sessionFile: undefined, - isStreaming: false, - messages: [] as AgentSession["messages"], - async ensureAttached() {}, - async prompt() {}, - async steer() {}, - async followUp() {}, - async pause() {}, - async resume() {}, - subscribe() { - return () => {}; - }, - }; + return { + runId, + stageId, + stageName: `stage-${stageId}`, + status: "running", + sessionId: undefined, + sessionFile: undefined, + isStreaming: false, + messages: [] as AgentSession["messages"], + async ensureAttached() {}, + async prompt() {}, + async steer() {}, + async followUp() {}, + async pause() {}, + async resume() {}, + subscribe() { + return () => {}; + }, + }; } -function makeClock(start = 0): { - now: () => number; - advance: (ms: number) => void; +function _makeClock(start = 0): { + now: () => number; + advance: (ms: number) => void; } { - let current = start; - return { - now: () => current, - advance: (ms: number) => { - current += ms; - }, - }; + let current = start; + return { + now: () => current, + advance: (ms: number) => { + current += ms; + }, + }; } -async function flush(): Promise { - await Promise.resolve(); +async function _flush(): Promise { + await Promise.resolve(); } type AttachedStageChat = { handleInput(data: string): boolean }; -function getAttachedStageChat(pane: WorkflowAttachPane): AttachedStageChat { - const chatView = (pane as unknown as { chatView: AttachedStageChat | null }).chatView; - assert.ok(chatView, "expected initialAttachStageId to create a stage chat"); - return chatView; +function _getAttachedStageChat(pane: WorkflowAttachPane): AttachedStageChat { + const chatView = (pane as unknown as { chatView: AttachedStageChat | null }).chatView; + assert.ok(chatView, "expected initialAttachStageId to create a stage chat"); + return chatView; } -function submitAttachedStageChatText(chatView: AttachedStageChat, text: string): void { - for (const ch of text) chatView.handleInput(ch); - chatView.handleInput("\r"); +function _submitAttachedStageChatText(chatView: AttachedStageChat, text: string): void { + for (const ch of text) chatView.handleInput(ch); + chatView.handleInput("\r"); } -function setupTwoPromptAttachPane( - firstPrompt: PendingPrompt, - opts: { piKeybindings?: unknown; now?: () => number } = {}, +function _setupTwoPromptAttachPane( + firstPrompt: PendingPrompt, + opts: { piKeybindings?: unknown; now?: () => number } = {}, ) { - const store = createStore(); - setupRun(store, "run-1", [ - { id: "stage-a", name: "A" }, - { id: "stage-b", name: "B" }, - ]); - const registry = createStageControlRegistry(); - registry.register(makeHandle("run-1", "stage-a")); - registry.register(makeHandle("run-1", "stage-b")); - const secondPrompt = makePendingPrompt({ id: "prompt-b", createdAt: 2 }); - assert.equal( - store.recordStagePendingPrompt("run-1", "stage-a", firstPrompt), - true, - ); - assert.equal( - store.recordStagePendingPrompt("run-1", "stage-b", secondPrompt), - true, - ); - const pending = store.awaitStagePendingPrompt( - "run-1", - "stage-a", - firstPrompt.id, - ); - const pane = new WorkflowAttachPane({ - store, - graphTheme: deriveGraphTheme({}), - runId: "run-1", - stageControlRegistry: registry, - onClose: () => {}, - initialAttachStageId: "stage-a", - piKeybindings: opts.piKeybindings, - now: opts.now, - }); - return { store, pane, pending, secondPrompt }; + const store = createStore(); + setupRun(store, "run-1", [ + { id: "stage-a", name: "A" }, + { id: "stage-b", name: "B" }, + ]); + const registry = createStageControlRegistry(); + registry.register(makeHandle("run-1", "stage-a")); + registry.register(makeHandle("run-1", "stage-b")); + const secondPrompt = makePendingPrompt({ id: "prompt-b", createdAt: 2 }); + assert.equal(store.recordStagePendingPrompt("run-1", "stage-a", firstPrompt), true); + assert.equal(store.recordStagePendingPrompt("run-1", "stage-b", secondPrompt), true); + const pending = store.awaitStagePendingPrompt("run-1", "stage-a", firstPrompt.id); + const pane = new WorkflowAttachPane({ + store, + graphTheme: deriveGraphTheme({}), + runId: "run-1", + stageControlRegistry: registry, + onClose: () => {}, + initialAttachStageId: "stage-a", + piKeybindings: opts.piKeybindings, + now: opts.now, + }); + return { store, pane, pending, secondPrompt }; } -function assertNextGraphEnterAttaches( - pane: WorkflowAttachPane, - expectedStageId: string, - message: string, -): void { - pane.handleInput(Key.enter); - assert.equal(pane._mode, "stage-chat", message); - assert.equal(pane._lastAttachedStageId, expectedStageId); +function _assertNextGraphEnterAttaches(pane: WorkflowAttachPane, expectedStageId: string, message: string): void { + pane.handleInput(Key.enter); + assert.equal(pane._mode, "stage-chat", message); + assert.equal(pane._lastAttachedStageId, expectedStageId); } describe("WorkflowAttachPane", () => { - test("answering a stage prompt returns to the graph", async () => { - const store = createStore(); - setupRun(store, "run-1", [{ id: "stage-a", name: "A" }]); - const registry = createStageControlRegistry(); - registry.register(makeHandle("run-1", "stage-a")); - const prompt = makePendingPrompt(); - assert.equal( - store.recordStagePendingPrompt("run-1", "stage-a", prompt), - true, - ); - const pending = store.awaitStagePendingPrompt( - "run-1", - "stage-a", - prompt.id, - ); - const pane = new WorkflowAttachPane({ - store, - graphTheme: deriveGraphTheme({}), - runId: "run-1", - stageControlRegistry: registry, - onClose: () => {}, - initialAttachStageId: "stage-a", - }); - - assert.equal(pane._mode, "stage-chat"); - for (const ch of "answer") pane.handleInput(ch); - pane.handleInput(Key.enter); - - assert.equal(await pending, "answer"); - assert.equal(pane._mode, "graph"); - assert.equal(pane._hasChatView, false); - assert.equal(pane._lastAttachedStageId, "stage-a"); - pane.dispose(); - }); - - test("hidden attached stage pane cannot resolve a prompt if stale input is routed", async () => { - const store = createStore(); - setupRun(store, "run-1", [{ id: "stage-a", name: "A" }]); - const registry = createStageControlRegistry(); - registry.register(makeHandle("run-1", "stage-a")); - const prompt = makePendingPrompt(); - assert.equal( - store.recordStagePendingPrompt("run-1", "stage-a", prompt), - true, - ); - const pending = store.awaitStagePendingPrompt( - "run-1", - "stage-a", - prompt.id, - ); - const pane = new WorkflowAttachPane({ - store, - graphTheme: deriveGraphTheme({}), - runId: "run-1", - stageControlRegistry: registry, - onClose: () => {}, - initialAttachStageId: "stage-a", - }); - - assert.equal(pane._mode, "stage-chat"); - pane.setVisible(false); - for (const ch of "stale") pane.handleInput(ch); - pane.handleInput(Key.enter); - assert.equal(store.runs()[0]?.stages[0]?.pendingPrompt?.id, prompt.id); - - pane.setVisible(true); - for (const ch of "answer") pane.handleInput(ch); - pane.handleInput(Key.enter); - - assert.equal(await pending, "answer"); - pane.dispose(); - }); - - test("answering a stage prompt through the host editor returns to the graph", async () => { - const store = createStore(); - setupRun(store, "run-1", [{ id: "stage-a", name: "A" }]); - const registry = createStageControlRegistry(); - registry.register(makeHandle("run-1", "stage-a")); - const prompt = makePendingPrompt({ initial: "seed" }); - assert.equal( - store.recordStagePendingPrompt("run-1", "stage-a", prompt), - true, - ); - const pending = store.awaitStagePendingPrompt( - "run-1", - "stage-a", - prompt.id, - ); - let createdEditor: FakePromptEditor | undefined; - const pane = new WorkflowAttachPane({ - store, - graphTheme: deriveGraphTheme({}), - runId: "run-1", - stageControlRegistry: registry, - onClose: () => {}, - initialAttachStageId: "stage-a", - piTui: { - requestRender: () => {}, - terminal: { rows: 32, columns: 80 }, - } as unknown as TUI, - piTheme: {}, - piKeybindings: {}, - piEditorFactory: () => { - createdEditor = new FakePromptEditor(); - return createdEditor; - }, - }); - - assert.equal(pane._mode, "stage-chat"); - assert.equal(createdEditor?.getText(), "seed"); - pane.handleInput("!"); - pane.handleInput(Key.enter); - - assert.equal(await pending, "seed!"); - assert.equal(pane._mode, "graph"); - assert.equal(pane._hasChatView, false); - assert.equal(pane._lastAttachedStageId, "stage-a"); - pane.dispose(); - }); - - test("editor prompt drafts survive Ctrl+X detach and reattach", async () => { - const store = createStore(); - setupRun(store, "run-1", [{ id: "stage-a", name: "A" }]); - const registry = createStageControlRegistry(); - registry.register(makeHandle("run-1", "stage-a")); - const prompt = makePendingPrompt({ kind: "editor", initial: "seed" }); - assert.equal( - store.recordStagePendingPrompt("run-1", "stage-a", prompt), - true, - ); - const pending = store.awaitStagePendingPrompt( - "run-1", - "stage-a", - prompt.id, - ); - const editors: FakePromptEditor[] = []; - const pane = new WorkflowAttachPane({ - store, - graphTheme: deriveGraphTheme({}), - runId: "run-1", - stageControlRegistry: registry, - onClose: () => {}, - initialAttachStageId: "stage-a", - piTui: { - requestRender: () => {}, - terminal: { rows: 32, columns: 80 }, - } as unknown as TUI, - piTheme: {}, - piKeybindings: {}, - piEditorFactory: () => { - const editor = new FakePromptEditor(); - editors.push(editor); - return editor; - }, - }); - - assert.equal(pane._mode, "stage-chat"); - assert.equal(editors.at(-1)?.getText(), "seed"); - for (const ch of "-draft") pane.handleInput(ch); - pane.handleInput(Key.ctrl("x")); - - assert.equal(pane._mode, "graph"); - assert.equal(store.getStagePromptDraft("run-1", "stage-a", prompt.id), "seed-draft"); - assert.equal(store.runs()[0]?.stages[0]?.pendingPrompt?.id, prompt.id); - - pane.handleInput(Key.enter); - assert.equal(pane._mode, "stage-chat"); - assert.equal(editors.at(-1)?.getText(), "seed-draft"); - pane.handleInput("!"); - pane.handleInput(Key.enter); - - assert.equal(await pending, "seed-draft!"); - assert.equal(store.getStagePromptDraft("run-1", "stage-a", prompt.id), undefined); - assert.equal(store.runs()[0]?.stages[0]?.pendingPrompt, undefined); - assert.equal(pane._mode, "graph"); - pane.dispose(); - }); + test("answering a stage prompt returns to the graph", async () => { + const store = createStore(); + setupRun(store, "run-1", [{ id: "stage-a", name: "A" }]); + const registry = createStageControlRegistry(); + registry.register(makeHandle("run-1", "stage-a")); + const prompt = makePendingPrompt(); + assert.equal(store.recordStagePendingPrompt("run-1", "stage-a", prompt), true); + const pending = store.awaitStagePendingPrompt("run-1", "stage-a", prompt.id); + const pane = new WorkflowAttachPane({ + store, + graphTheme: deriveGraphTheme({}), + runId: "run-1", + stageControlRegistry: registry, + onClose: () => {}, + initialAttachStageId: "stage-a", + }); + + assert.equal(pane._mode, "stage-chat"); + for (const ch of "answer") pane.handleInput(ch); + pane.handleInput(Key.enter); + + assert.equal(await pending, "answer"); + assert.equal(pane._mode, "graph"); + assert.equal(pane._hasChatView, false); + assert.equal(pane._lastAttachedStageId, "stage-a"); + pane.dispose(); + }); + + test("hidden attached stage pane cannot resolve a prompt if stale input is routed", async () => { + const store = createStore(); + setupRun(store, "run-1", [{ id: "stage-a", name: "A" }]); + const registry = createStageControlRegistry(); + registry.register(makeHandle("run-1", "stage-a")); + const prompt = makePendingPrompt(); + assert.equal(store.recordStagePendingPrompt("run-1", "stage-a", prompt), true); + const pending = store.awaitStagePendingPrompt("run-1", "stage-a", prompt.id); + const pane = new WorkflowAttachPane({ + store, + graphTheme: deriveGraphTheme({}), + runId: "run-1", + stageControlRegistry: registry, + onClose: () => {}, + initialAttachStageId: "stage-a", + }); + + assert.equal(pane._mode, "stage-chat"); + pane.setVisible(false); + for (const ch of "stale") pane.handleInput(ch); + pane.handleInput(Key.enter); + assert.equal(store.runs()[0]?.stages[0]?.pendingPrompt?.id, prompt.id); + + pane.setVisible(true); + for (const ch of "answer") pane.handleInput(ch); + pane.handleInput(Key.enter); + + assert.equal(await pending, "answer"); + pane.dispose(); + }); + + test("answering a stage prompt through the host editor returns to the graph", async () => { + const store = createStore(); + setupRun(store, "run-1", [{ id: "stage-a", name: "A" }]); + const registry = createStageControlRegistry(); + registry.register(makeHandle("run-1", "stage-a")); + const prompt = makePendingPrompt({ initial: "seed" }); + assert.equal(store.recordStagePendingPrompt("run-1", "stage-a", prompt), true); + const pending = store.awaitStagePendingPrompt("run-1", "stage-a", prompt.id); + let createdEditor: FakePromptEditor | undefined; + const pane = new WorkflowAttachPane({ + store, + graphTheme: deriveGraphTheme({}), + runId: "run-1", + stageControlRegistry: registry, + onClose: () => {}, + initialAttachStageId: "stage-a", + piTui: { + requestRender: () => {}, + terminal: { rows: 32, columns: 80 }, + } as unknown as TUI, + piTheme: {}, + piKeybindings: {}, + piEditorFactory: () => { + createdEditor = new FakePromptEditor(); + return createdEditor; + }, + }); + + assert.equal(pane._mode, "stage-chat"); + assert.equal(createdEditor?.getText(), "seed"); + pane.handleInput("!"); + pane.handleInput(Key.enter); + + assert.equal(await pending, "seed!"); + assert.equal(pane._mode, "graph"); + assert.equal(pane._hasChatView, false); + assert.equal(pane._lastAttachedStageId, "stage-a"); + pane.dispose(); + }); + + test("editor prompt drafts survive Ctrl+X detach and reattach", async () => { + const store = createStore(); + setupRun(store, "run-1", [{ id: "stage-a", name: "A" }]); + const registry = createStageControlRegistry(); + registry.register(makeHandle("run-1", "stage-a")); + const prompt = makePendingPrompt({ kind: "editor", initial: "seed" }); + assert.equal(store.recordStagePendingPrompt("run-1", "stage-a", prompt), true); + const pending = store.awaitStagePendingPrompt("run-1", "stage-a", prompt.id); + const editors: FakePromptEditor[] = []; + const pane = new WorkflowAttachPane({ + store, + graphTheme: deriveGraphTheme({}), + runId: "run-1", + stageControlRegistry: registry, + onClose: () => {}, + initialAttachStageId: "stage-a", + piTui: { + requestRender: () => {}, + terminal: { rows: 32, columns: 80 }, + } as unknown as TUI, + piTheme: {}, + piKeybindings: {}, + piEditorFactory: () => { + const editor = new FakePromptEditor(); + editors.push(editor); + return editor; + }, + }); + + assert.equal(pane._mode, "stage-chat"); + assert.equal(editors.at(-1)?.getText(), "seed"); + for (const ch of "-draft") pane.handleInput(ch); + pane.handleInput(Key.ctrl("x")); + + assert.equal(pane._mode, "graph"); + assert.equal(store.getStagePromptDraft("run-1", "stage-a", prompt.id), "seed-draft"); + assert.equal(store.runs()[0]?.stages[0]?.pendingPrompt?.id, prompt.id); + + pane.handleInput(Key.enter); + assert.equal(pane._mode, "stage-chat"); + assert.equal(editors.at(-1)?.getText(), "seed-draft"); + pane.handleInput("!"); + pane.handleInput(Key.enter); + + assert.equal(await pending, "seed-draft!"); + assert.equal(store.getStagePromptDraft("run-1", "stage-a", prompt.id), undefined); + assert.equal(store.runs()[0]?.stages[0]?.pendingPrompt, undefined); + assert.equal(pane._mode, "graph"); + pane.dispose(); + }); }); diff --git a/test/unit/workflow-attach-pane-07.test.ts b/test/unit/workflow-attach-pane-07.test.ts index 3254378f5..3370aacbb 100644 --- a/test/unit/workflow-attach-pane-07.test.ts +++ b/test/unit/workflow-attach-pane-07.test.ts @@ -12,361 +12,258 @@ * cross-ref: src/tui/workflow-attach-pane.ts */ -import { describe, test } from "bun:test"; import assert from "node:assert/strict"; -import { - Key, - type Component, - type EditorComponent, - type TUI, -} from "@earendil-works/pi-tui"; +import type { AgentSession } from "@bastani/atomic"; +import { Key } from "@earendil-works/pi-tui"; +import { describe, test } from "vitest"; +import type { StageControlHandle } from "../../packages/workflows/src/runs/foreground/stage-control-registry.js"; +import { createStageControlRegistry } from "../../packages/workflows/src/runs/foreground/stage-control-registry.js"; import { createStore } from "../../packages/workflows/src/shared/store.js"; -import { WorkflowAttachPane } from "../../packages/workflows/src/tui/workflow-attach-pane.js"; +import type { PendingPrompt, StageInputRequest } from "../../packages/workflows/src/shared/store-types.js"; import { deriveGraphTheme } from "../../packages/workflows/src/tui/graph-theme.js"; -import { createStageControlRegistry } from "../../packages/workflows/src/runs/foreground/stage-control-registry.js"; -import type { StageControlHandle } from "../../packages/workflows/src/runs/foreground/stage-control-registry.js"; -import type { - PendingPrompt, - StageInputRequest, -} from "../../packages/workflows/src/shared/store-types.js"; -import type { AgentSession } from "@bastani/atomic"; -import { StageUiBroker } from "../../packages/workflows/src/shared/stage-ui-broker.js"; -import { makeFakeKeybindings } from "../support/fake-keybindings.js"; +import { WorkflowAttachPane } from "../../packages/workflows/src/tui/workflow-attach-pane.js"; type TestStageSeed = { - id: string; - name: string; - status?: "pending" | "running" | "paused" | "completed"; + id: string; + name: string; + status?: "pending" | "running" | "paused" | "completed"; }; -function setupRun( - store: ReturnType, - runId: string, - stages: TestStageSeed[], -) { - store.recordRunStart({ - id: runId, - name: "test-wf", - inputs: {}, - status: "running", - stages: [], - startedAt: Date.now(), - }); - for (const s of stages) { - store.recordStageStart(runId, { - id: s.id, - name: s.name, - status: s.status ?? "running", - parentIds: [], - toolEvents: [], - }); - } -} - -function makePendingPrompt( - overrides: Partial = {}, -): PendingPrompt { - return { - id: "prompt-1", - kind: "input", - message: "What should the workflow use?", - createdAt: Date.now(), - ...overrides, - }; +function setupRun(store: ReturnType, runId: string, stages: TestStageSeed[]) { + store.recordRunStart({ + id: runId, + name: "test-wf", + inputs: {}, + status: "running", + stages: [], + startedAt: Date.now(), + }); + for (const s of stages) { + store.recordStageStart(runId, { + id: s.id, + name: s.name, + status: s.status ?? "running", + parentIds: [], + toolEvents: [], + }); + } } -function makeInputRequest( - overrides: Partial = {}, -): StageInputRequest { - return { - id: "input-request-1", - kind: "ask_user_question", - createdAt: Date.now(), - questions: [ - { - question: "Which option should the workflow use?", - header: "Choice", - options: [{ label: "Use A" }, { label: "Use B" }], - }, - ], - ...overrides, - }; +function makePendingPrompt(overrides: Partial = {}): PendingPrompt { + return { + id: "prompt-1", + kind: "input", + message: "What should the workflow use?", + createdAt: Date.now(), + ...overrides, + }; } -class FakePromptEditor implements EditorComponent { - text = ""; - focused = false; - onSubmit?: (text: string) => void; - onChange?: (text: string) => void; - - render(): string[] { - return [`fake-prompt-editor:${this.text}`]; - } - - handleInput(data: string): void { - if (data === Key.enter || data === "\r" || data === "\n") { - this.onSubmit?.(this.text); - return; - } - this.text += data; - this.onChange?.(this.text); - } - - invalidate(): void {} - - getText(): string { - return this.text; - } - - setText(text: string): void { - this.text = text; - } +function _makeInputRequest(overrides: Partial = {}): StageInputRequest { + return { + id: "input-request-1", + kind: "ask_user_question", + createdAt: Date.now(), + questions: [ + { + question: "Which option should the workflow use?", + header: "Choice", + options: [{ label: "Use A" }, { label: "Use B" }], + }, + ], + ...overrides, + }; } function makeHandle(runId: string, stageId: string): StageControlHandle { - return { - runId, - stageId, - stageName: `stage-${stageId}`, - status: "running", - sessionId: undefined, - sessionFile: undefined, - isStreaming: false, - messages: [] as AgentSession["messages"], - async ensureAttached() {}, - async prompt() {}, - async steer() {}, - async followUp() {}, - async pause() {}, - async resume() {}, - subscribe() { - return () => {}; - }, - }; + return { + runId, + stageId, + stageName: `stage-${stageId}`, + status: "running", + sessionId: undefined, + sessionFile: undefined, + isStreaming: false, + messages: [] as AgentSession["messages"], + async ensureAttached() {}, + async prompt() {}, + async steer() {}, + async followUp() {}, + async pause() {}, + async resume() {}, + subscribe() { + return () => {}; + }, + }; } function makeClock(start = 0): { - now: () => number; - advance: (ms: number) => void; + now: () => number; + advance: (ms: number) => void; } { - let current = start; - return { - now: () => current, - advance: (ms: number) => { - current += ms; - }, - }; + let current = start; + return { + now: () => current, + advance: (ms: number) => { + current += ms; + }, + }; } -async function flush(): Promise { - await Promise.resolve(); +async function _flush(): Promise { + await Promise.resolve(); } type AttachedStageChat = { handleInput(data: string): boolean }; -function getAttachedStageChat(pane: WorkflowAttachPane): AttachedStageChat { - const chatView = (pane as unknown as { chatView: AttachedStageChat | null }).chatView; - assert.ok(chatView, "expected initialAttachStageId to create a stage chat"); - return chatView; +function _getAttachedStageChat(pane: WorkflowAttachPane): AttachedStageChat { + const chatView = (pane as unknown as { chatView: AttachedStageChat | null }).chatView; + assert.ok(chatView, "expected initialAttachStageId to create a stage chat"); + return chatView; } -function submitAttachedStageChatText(chatView: AttachedStageChat, text: string): void { - for (const ch of text) chatView.handleInput(ch); - chatView.handleInput("\r"); +function _submitAttachedStageChatText(chatView: AttachedStageChat, text: string): void { + for (const ch of text) chatView.handleInput(ch); + chatView.handleInput("\r"); } function setupTwoPromptAttachPane( - firstPrompt: PendingPrompt, - opts: { piKeybindings?: unknown; now?: () => number } = {}, + firstPrompt: PendingPrompt, + opts: { piKeybindings?: unknown; now?: () => number } = {}, ) { - const store = createStore(); - setupRun(store, "run-1", [ - { id: "stage-a", name: "A" }, - { id: "stage-b", name: "B" }, - ]); - const registry = createStageControlRegistry(); - registry.register(makeHandle("run-1", "stage-a")); - registry.register(makeHandle("run-1", "stage-b")); - const secondPrompt = makePendingPrompt({ id: "prompt-b", createdAt: 2 }); - assert.equal( - store.recordStagePendingPrompt("run-1", "stage-a", firstPrompt), - true, - ); - assert.equal( - store.recordStagePendingPrompt("run-1", "stage-b", secondPrompt), - true, - ); - const pending = store.awaitStagePendingPrompt( - "run-1", - "stage-a", - firstPrompt.id, - ); - const pane = new WorkflowAttachPane({ - store, - graphTheme: deriveGraphTheme({}), - runId: "run-1", - stageControlRegistry: registry, - onClose: () => {}, - initialAttachStageId: "stage-a", - piKeybindings: opts.piKeybindings, - now: opts.now, - }); - return { store, pane, pending, secondPrompt }; + const store = createStore(); + setupRun(store, "run-1", [ + { id: "stage-a", name: "A" }, + { id: "stage-b", name: "B" }, + ]); + const registry = createStageControlRegistry(); + registry.register(makeHandle("run-1", "stage-a")); + registry.register(makeHandle("run-1", "stage-b")); + const secondPrompt = makePendingPrompt({ id: "prompt-b", createdAt: 2 }); + assert.equal(store.recordStagePendingPrompt("run-1", "stage-a", firstPrompt), true); + assert.equal(store.recordStagePendingPrompt("run-1", "stage-b", secondPrompt), true); + const pending = store.awaitStagePendingPrompt("run-1", "stage-a", firstPrompt.id); + const pane = new WorkflowAttachPane({ + store, + graphTheme: deriveGraphTheme({}), + runId: "run-1", + stageControlRegistry: registry, + onClose: () => {}, + initialAttachStageId: "stage-a", + piKeybindings: opts.piKeybindings, + now: opts.now, + }); + return { store, pane, pending, secondPrompt }; } -function assertNextGraphEnterAttaches( - pane: WorkflowAttachPane, - expectedStageId: string, - message: string, -): void { - pane.handleInput(Key.enter); - assert.equal(pane._mode, "stage-chat", message); - assert.equal(pane._lastAttachedStageId, expectedStageId); +function assertNextGraphEnterAttaches(pane: WorkflowAttachPane, expectedStageId: string, message: string): void { + pane.handleInput(Key.enter); + assert.equal(pane._mode, "stage-chat", message); + assert.equal(pane._lastAttachedStageId, expectedStageId); } describe("WorkflowAttachPane", () => { - test("explicitly declining a stage prompt returns to the graph", async () => { - const store = createStore(); - setupRun(store, "run-1", [{ id: "stage-a", name: "A" }]); - const registry = createStageControlRegistry(); - registry.register(makeHandle("run-1", "stage-a")); - const prompt = makePendingPrompt({ initial: "default" }); - assert.equal( - store.recordStagePendingPrompt("run-1", "stage-a", prompt), - true, - ); - const pending = store.awaitStagePendingPrompt( - "run-1", - "stage-a", - prompt.id, - ); - const pane = new WorkflowAttachPane({ - store, - graphTheme: deriveGraphTheme({}), - runId: "run-1", - stageControlRegistry: registry, - onClose: () => {}, - initialAttachStageId: "stage-a", - }); - - assert.equal(pane._mode, "stage-chat"); - pane.handleInput(Key.ctrl("c")); - - assert.equal(await pending, "default"); - assert.equal(pane._mode, "graph"); - assert.equal(pane._hasChatView, false); - assert.equal(pane._lastAttachedStageId, "stage-a"); - pane.dispose(); - }); - - test("repeated Enter after a prompt answer does not attach the next prompt", async () => { - const clock = makeClock(); - const firstPrompt = makePendingPrompt({ id: "prompt-a", createdAt: 1 }); - const { store, pane, pending, secondPrompt } = setupTwoPromptAttachPane( - firstPrompt, - { - now: clock.now, - }, - ); - - assert.equal(pane._mode, "stage-chat"); - for (const ch of "answer") pane.handleInput(ch); - pane.handleInput(Key.enter); - - assert.equal(await pending, "answer"); - assert.equal(pane._mode, "graph"); - assert.equal(pane._hasChatView, false); - assert.equal( - store.runs()[0]?.stages[1]?.pendingPrompt?.id, - secondPrompt.id, - ); - - pane.handleInput(Key.enter); - assert.equal( - pane._mode, - "graph", - "immediate graph-mode Enter after answer is consumed", - ); - assert.equal(pane._hasChatView, false); - - for (let i = 0; i < 8; i++) { - clock.advance(50); - pane.handleInput(Key.enter); - assert.equal( - pane._mode, - "graph", - "held graph-mode Enter repeats are still consumed", - ); - assert.equal(pane._hasChatView, false); - } - - clock.advance(201); - pane.handleInput(Key.enter); - assert.equal( - pane._mode, - "stage-chat", - "Enter after the transition window attaches normally", - ); - assert.equal(pane._lastAttachedStageId, "stage-b"); - pane.dispose(); - }); - - test("Ctrl+C prompt skip does not consume the next graph Enter", async () => { - const firstPrompt = makePendingPrompt({ - id: "prompt-a", - initial: "default", - createdAt: 1, - }); - const { store, pane, pending, secondPrompt } = - setupTwoPromptAttachPane(firstPrompt); - - assert.equal(pane._mode, "stage-chat"); - pane.handleInput(Key.ctrl("c")); - - assert.equal(await pending, "default"); - assert.equal(pane._mode, "graph"); - assert.equal( - store.runs()[0]?.stages[1]?.pendingPrompt?.id, - secondPrompt.id, - ); - - assertNextGraphEnterAttaches( - pane, - "stage-b", - "first graph-mode Enter after Ctrl+C attaches", - ); - pane.dispose(); - }); - - for (const [key, expected] of [ - ["y", true], - ["n", false], - ] as const) { - test(`confirm ${key} prompt answer does not consume the next graph Enter`, async () => { - const firstPrompt = makePendingPrompt({ - id: "prompt-a", - kind: "confirm", - createdAt: 1, - }); - const { store, pane, pending, secondPrompt } = - setupTwoPromptAttachPane(firstPrompt); - - assert.equal(pane._mode, "stage-chat"); - pane.handleInput(key); - - assert.equal(await pending, expected); - assert.equal(pane._mode, "graph"); - assert.equal( - store.runs()[0]?.stages[1]?.pendingPrompt?.id, - secondPrompt.id, - ); - - assertNextGraphEnterAttaches( - pane, - "stage-b", - `first graph-mode Enter after ${key} attaches`, - ); - pane.dispose(); - }); - } + test("explicitly declining a stage prompt returns to the graph", async () => { + const store = createStore(); + setupRun(store, "run-1", [{ id: "stage-a", name: "A" }]); + const registry = createStageControlRegistry(); + registry.register(makeHandle("run-1", "stage-a")); + const prompt = makePendingPrompt({ initial: "default" }); + assert.equal(store.recordStagePendingPrompt("run-1", "stage-a", prompt), true); + const pending = store.awaitStagePendingPrompt("run-1", "stage-a", prompt.id); + const pane = new WorkflowAttachPane({ + store, + graphTheme: deriveGraphTheme({}), + runId: "run-1", + stageControlRegistry: registry, + onClose: () => {}, + initialAttachStageId: "stage-a", + }); + + assert.equal(pane._mode, "stage-chat"); + pane.handleInput(Key.ctrl("c")); + + assert.equal(await pending, "default"); + assert.equal(pane._mode, "graph"); + assert.equal(pane._hasChatView, false); + assert.equal(pane._lastAttachedStageId, "stage-a"); + pane.dispose(); + }); + + test("repeated Enter after a prompt answer does not attach the next prompt", async () => { + const clock = makeClock(); + const firstPrompt = makePendingPrompt({ id: "prompt-a", createdAt: 1 }); + const { store, pane, pending, secondPrompt } = setupTwoPromptAttachPane(firstPrompt, { + now: clock.now, + }); + + assert.equal(pane._mode, "stage-chat"); + for (const ch of "answer") pane.handleInput(ch); + pane.handleInput(Key.enter); + + assert.equal(await pending, "answer"); + assert.equal(pane._mode, "graph"); + assert.equal(pane._hasChatView, false); + assert.equal(store.runs()[0]?.stages[1]?.pendingPrompt?.id, secondPrompt.id); + + pane.handleInput(Key.enter); + assert.equal(pane._mode, "graph", "immediate graph-mode Enter after answer is consumed"); + assert.equal(pane._hasChatView, false); + + for (let i = 0; i < 8; i++) { + clock.advance(50); + pane.handleInput(Key.enter); + assert.equal(pane._mode, "graph", "held graph-mode Enter repeats are still consumed"); + assert.equal(pane._hasChatView, false); + } + + clock.advance(201); + pane.handleInput(Key.enter); + assert.equal(pane._mode, "stage-chat", "Enter after the transition window attaches normally"); + assert.equal(pane._lastAttachedStageId, "stage-b"); + pane.dispose(); + }); + + test("Ctrl+C prompt skip does not consume the next graph Enter", async () => { + const firstPrompt = makePendingPrompt({ + id: "prompt-a", + initial: "default", + createdAt: 1, + }); + const { store, pane, pending, secondPrompt } = setupTwoPromptAttachPane(firstPrompt); + + assert.equal(pane._mode, "stage-chat"); + pane.handleInput(Key.ctrl("c")); + + assert.equal(await pending, "default"); + assert.equal(pane._mode, "graph"); + assert.equal(store.runs()[0]?.stages[1]?.pendingPrompt?.id, secondPrompt.id); + + assertNextGraphEnterAttaches(pane, "stage-b", "first graph-mode Enter after Ctrl+C attaches"); + pane.dispose(); + }); + + for (const [key, expected] of [ + ["y", true], + ["n", false], + ] as const) { + test(`confirm ${key} prompt answer does not consume the next graph Enter`, async () => { + const firstPrompt = makePendingPrompt({ + id: "prompt-a", + kind: "confirm", + createdAt: 1, + }); + const { store, pane, pending, secondPrompt } = setupTwoPromptAttachPane(firstPrompt); + + assert.equal(pane._mode, "stage-chat"); + pane.handleInput(key); + + assert.equal(await pending, expected); + assert.equal(pane._mode, "graph"); + assert.equal(store.runs()[0]?.stages[1]?.pendingPrompt?.id, secondPrompt.id); + + assertNextGraphEnterAttaches(pane, "stage-b", `first graph-mode Enter after ${key} attaches`); + pane.dispose(); + }); + } }); diff --git a/test/unit/workflow-attach-pane-08.test.ts b/test/unit/workflow-attach-pane-08.test.ts index 1da8c70b6..851daa579 100644 --- a/test/unit/workflow-attach-pane-08.test.ts +++ b/test/unit/workflow-attach-pane-08.test.ts @@ -12,318 +12,245 @@ * cross-ref: src/tui/workflow-attach-pane.ts */ -import { describe, test } from "bun:test"; import assert from "node:assert/strict"; -import { - Key, - type Component, - type EditorComponent, - type TUI, -} from "@earendil-works/pi-tui"; +import type { AgentSession } from "@bastani/atomic"; +import { Key } from "@earendil-works/pi-tui"; +import { describe, test } from "vitest"; +import type { StageControlHandle } from "../../packages/workflows/src/runs/foreground/stage-control-registry.js"; +import { createStageControlRegistry } from "../../packages/workflows/src/runs/foreground/stage-control-registry.js"; import { createStore } from "../../packages/workflows/src/shared/store.js"; -import { WorkflowAttachPane } from "../../packages/workflows/src/tui/workflow-attach-pane.js"; +import type { PendingPrompt, StageInputRequest } from "../../packages/workflows/src/shared/store-types.js"; import { deriveGraphTheme } from "../../packages/workflows/src/tui/graph-theme.js"; -import { createStageControlRegistry } from "../../packages/workflows/src/runs/foreground/stage-control-registry.js"; -import type { StageControlHandle } from "../../packages/workflows/src/runs/foreground/stage-control-registry.js"; -import type { - PendingPrompt, - StageInputRequest, -} from "../../packages/workflows/src/shared/store-types.js"; -import type { AgentSession } from "@bastani/atomic"; -import { StageUiBroker } from "../../packages/workflows/src/shared/stage-ui-broker.js"; +import { WorkflowAttachPane } from "../../packages/workflows/src/tui/workflow-attach-pane.js"; import { makeFakeKeybindings } from "../support/fake-keybindings.js"; type TestStageSeed = { - id: string; - name: string; - status?: "pending" | "running" | "paused" | "completed"; + id: string; + name: string; + status?: "pending" | "running" | "paused" | "completed"; }; -function setupRun( - store: ReturnType, - runId: string, - stages: TestStageSeed[], -) { - store.recordRunStart({ - id: runId, - name: "test-wf", - inputs: {}, - status: "running", - stages: [], - startedAt: Date.now(), - }); - for (const s of stages) { - store.recordStageStart(runId, { - id: s.id, - name: s.name, - status: s.status ?? "running", - parentIds: [], - toolEvents: [], - }); - } -} - -function makePendingPrompt( - overrides: Partial = {}, -): PendingPrompt { - return { - id: "prompt-1", - kind: "input", - message: "What should the workflow use?", - createdAt: Date.now(), - ...overrides, - }; +function setupRun(store: ReturnType, runId: string, stages: TestStageSeed[]) { + store.recordRunStart({ + id: runId, + name: "test-wf", + inputs: {}, + status: "running", + stages: [], + startedAt: Date.now(), + }); + for (const s of stages) { + store.recordStageStart(runId, { + id: s.id, + name: s.name, + status: s.status ?? "running", + parentIds: [], + toolEvents: [], + }); + } } -function makeInputRequest( - overrides: Partial = {}, -): StageInputRequest { - return { - id: "input-request-1", - kind: "ask_user_question", - createdAt: Date.now(), - questions: [ - { - question: "Which option should the workflow use?", - header: "Choice", - options: [{ label: "Use A" }, { label: "Use B" }], - }, - ], - ...overrides, - }; +function makePendingPrompt(overrides: Partial = {}): PendingPrompt { + return { + id: "prompt-1", + kind: "input", + message: "What should the workflow use?", + createdAt: Date.now(), + ...overrides, + }; } -class FakePromptEditor implements EditorComponent { - text = ""; - focused = false; - onSubmit?: (text: string) => void; - onChange?: (text: string) => void; - - render(): string[] { - return [`fake-prompt-editor:${this.text}`]; - } - - handleInput(data: string): void { - if (data === Key.enter || data === "\r" || data === "\n") { - this.onSubmit?.(this.text); - return; - } - this.text += data; - this.onChange?.(this.text); - } - - invalidate(): void {} - - getText(): string { - return this.text; - } - - setText(text: string): void { - this.text = text; - } +function _makeInputRequest(overrides: Partial = {}): StageInputRequest { + return { + id: "input-request-1", + kind: "ask_user_question", + createdAt: Date.now(), + questions: [ + { + question: "Which option should the workflow use?", + header: "Choice", + options: [{ label: "Use A" }, { label: "Use B" }], + }, + ], + ...overrides, + }; } function makeHandle(runId: string, stageId: string): StageControlHandle { - return { - runId, - stageId, - stageName: `stage-${stageId}`, - status: "running", - sessionId: undefined, - sessionFile: undefined, - isStreaming: false, - messages: [] as AgentSession["messages"], - async ensureAttached() {}, - async prompt() {}, - async steer() {}, - async followUp() {}, - async pause() {}, - async resume() {}, - subscribe() { - return () => {}; - }, - }; + return { + runId, + stageId, + stageName: `stage-${stageId}`, + status: "running", + sessionId: undefined, + sessionFile: undefined, + isStreaming: false, + messages: [] as AgentSession["messages"], + async ensureAttached() {}, + async prompt() {}, + async steer() {}, + async followUp() {}, + async pause() {}, + async resume() {}, + subscribe() { + return () => {}; + }, + }; } -function makeClock(start = 0): { - now: () => number; - advance: (ms: number) => void; +function _makeClock(start = 0): { + now: () => number; + advance: (ms: number) => void; } { - let current = start; - return { - now: () => current, - advance: (ms: number) => { - current += ms; - }, - }; + let current = start; + return { + now: () => current, + advance: (ms: number) => { + current += ms; + }, + }; } -async function flush(): Promise { - await Promise.resolve(); +async function _flush(): Promise { + await Promise.resolve(); } type AttachedStageChat = { handleInput(data: string): boolean }; -function getAttachedStageChat(pane: WorkflowAttachPane): AttachedStageChat { - const chatView = (pane as unknown as { chatView: AttachedStageChat | null }).chatView; - assert.ok(chatView, "expected initialAttachStageId to create a stage chat"); - return chatView; +function _getAttachedStageChat(pane: WorkflowAttachPane): AttachedStageChat { + const chatView = (pane as unknown as { chatView: AttachedStageChat | null }).chatView; + assert.ok(chatView, "expected initialAttachStageId to create a stage chat"); + return chatView; } -function submitAttachedStageChatText(chatView: AttachedStageChat, text: string): void { - for (const ch of text) chatView.handleInput(ch); - chatView.handleInput("\r"); +function _submitAttachedStageChatText(chatView: AttachedStageChat, text: string): void { + for (const ch of text) chatView.handleInput(ch); + chatView.handleInput("\r"); } function setupTwoPromptAttachPane( - firstPrompt: PendingPrompt, - opts: { piKeybindings?: unknown; now?: () => number } = {}, + firstPrompt: PendingPrompt, + opts: { piKeybindings?: unknown; now?: () => number } = {}, ) { - const store = createStore(); - setupRun(store, "run-1", [ - { id: "stage-a", name: "A" }, - { id: "stage-b", name: "B" }, - ]); - const registry = createStageControlRegistry(); - registry.register(makeHandle("run-1", "stage-a")); - registry.register(makeHandle("run-1", "stage-b")); - const secondPrompt = makePendingPrompt({ id: "prompt-b", createdAt: 2 }); - assert.equal( - store.recordStagePendingPrompt("run-1", "stage-a", firstPrompt), - true, - ); - assert.equal( - store.recordStagePendingPrompt("run-1", "stage-b", secondPrompt), - true, - ); - const pending = store.awaitStagePendingPrompt( - "run-1", - "stage-a", - firstPrompt.id, - ); - const pane = new WorkflowAttachPane({ - store, - graphTheme: deriveGraphTheme({}), - runId: "run-1", - stageControlRegistry: registry, - onClose: () => {}, - initialAttachStageId: "stage-a", - piKeybindings: opts.piKeybindings, - now: opts.now, - }); - return { store, pane, pending, secondPrompt }; + const store = createStore(); + setupRun(store, "run-1", [ + { id: "stage-a", name: "A" }, + { id: "stage-b", name: "B" }, + ]); + const registry = createStageControlRegistry(); + registry.register(makeHandle("run-1", "stage-a")); + registry.register(makeHandle("run-1", "stage-b")); + const secondPrompt = makePendingPrompt({ id: "prompt-b", createdAt: 2 }); + assert.equal(store.recordStagePendingPrompt("run-1", "stage-a", firstPrompt), true); + assert.equal(store.recordStagePendingPrompt("run-1", "stage-b", secondPrompt), true); + const pending = store.awaitStagePendingPrompt("run-1", "stage-a", firstPrompt.id); + const pane = new WorkflowAttachPane({ + store, + graphTheme: deriveGraphTheme({}), + runId: "run-1", + stageControlRegistry: registry, + onClose: () => {}, + initialAttachStageId: "stage-a", + piKeybindings: opts.piKeybindings, + now: opts.now, + }); + return { store, pane, pending, secondPrompt }; } -function assertNextGraphEnterAttaches( - pane: WorkflowAttachPane, - expectedStageId: string, - message: string, -): void { - pane.handleInput(Key.enter); - assert.equal(pane._mode, "stage-chat", message); - assert.equal(pane._lastAttachedStageId, expectedStageId); +function assertNextGraphEnterAttaches(pane: WorkflowAttachPane, expectedStageId: string, message: string): void { + pane.handleInput(Key.enter); + assert.equal(pane._mode, "stage-chat", message); + assert.equal(pane._lastAttachedStageId, expectedStageId); } describe("WorkflowAttachPane", () => { - test("remapped select-confirm does not consume the next graph Enter", async () => { - const firstPrompt = makePendingPrompt({ - id: "prompt-a", - kind: "select", - choices: ["alpha", "beta"], - createdAt: 1, - }); - const { store, pane, pending, secondPrompt } = setupTwoPromptAttachPane( - firstPrompt, - { - piKeybindings: makeFakeKeybindings({ - "tui.select.down": ["d"], - "tui.select.confirm": ["s"], - }), - }, - ); + test("remapped select-confirm does not consume the next graph Enter", async () => { + const firstPrompt = makePendingPrompt({ + id: "prompt-a", + kind: "select", + choices: ["alpha", "beta"], + createdAt: 1, + }); + const { store, pane, pending, secondPrompt } = setupTwoPromptAttachPane(firstPrompt, { + piKeybindings: makeFakeKeybindings({ + "tui.select.down": ["d"], + "tui.select.confirm": ["s"], + }), + }); - assert.equal(pane._mode, "stage-chat"); - pane.handleInput("d"); - assert.equal(pane._mode, "stage-chat"); - pane.handleInput("s"); + assert.equal(pane._mode, "stage-chat"); + pane.handleInput("d"); + assert.equal(pane._mode, "stage-chat"); + pane.handleInput("s"); - assert.equal(await pending, "beta"); - assert.equal(pane._mode, "graph"); - assert.equal( - store.runs()[0]?.stages[1]?.pendingPrompt?.id, - secondPrompt.id, - ); + assert.equal(await pending, "beta"); + assert.equal(pane._mode, "graph"); + assert.equal(store.runs()[0]?.stages[1]?.pendingPrompt?.id, secondPrompt.id); - assertNextGraphEnterAttaches( - pane, - "stage-b", - "first graph-mode Enter after remapped select attaches", - ); - pane.dispose(); - }); + assertNextGraphEnterAttaches(pane, "stage-b", "first graph-mode Enter after remapped select attaches"); + pane.dispose(); + }); - test("Ctrl+X in stage-chat mode swaps back to graph with same stage focused", () => { - const store = createStore(); - setupRun(store, "run-1", [ - { id: "stage-a", name: "A" }, - { id: "stage-b", name: "B" }, - ]); - const registry = createStageControlRegistry(); - registry.register(makeHandle("run-1", "stage-a")); - const pane = new WorkflowAttachPane({ - store, - graphTheme: deriveGraphTheme({}), - runId: "run-1", - stageControlRegistry: registry, - onClose: () => {}, - }); - pane.handleInput(Key.enter); - assert.equal(pane._mode, "stage-chat"); - for (const ch of "unsent draft") pane.handleInput(ch); - // Ctrl+X returns to graph. - pane.handleInput(Key.ctrl("x")); - assert.equal(pane._mode, "graph"); - assert.equal(pane._hasChatView, false); - // Stage id and unsent composer draft are preserved for re-attach. - assert.equal(pane._lastAttachedStageId, "stage-a"); - pane.handleInput(Key.enter); - assert.equal(pane._mode, "stage-chat"); - assert.equal(pane._chatInputBuffer, "unsent draft"); - pane.dispose(); - }); + test("Ctrl+X in stage-chat mode swaps back to graph with same stage focused", () => { + const store = createStore(); + setupRun(store, "run-1", [ + { id: "stage-a", name: "A" }, + { id: "stage-b", name: "B" }, + ]); + const registry = createStageControlRegistry(); + registry.register(makeHandle("run-1", "stage-a")); + const pane = new WorkflowAttachPane({ + store, + graphTheme: deriveGraphTheme({}), + runId: "run-1", + stageControlRegistry: registry, + onClose: () => {}, + }); + pane.handleInput(Key.enter); + assert.equal(pane._mode, "stage-chat"); + for (const ch of "unsent draft") pane.handleInput(ch); + // Ctrl+X returns to graph. + pane.handleInput(Key.ctrl("x")); + assert.equal(pane._mode, "graph"); + assert.equal(pane._hasChatView, false); + // Stage id and unsent composer draft are preserved for re-attach. + assert.equal(pane._lastAttachedStageId, "stage-a"); + pane.handleInput(Key.enter); + assert.equal(pane._mode, "stage-chat"); + assert.equal(pane._chatInputBuffer, "unsent draft"); + pane.dispose(); + }); - test("Ctrl+X in paused stage-chat mode returns to graph", () => { - const store = createStore(); - setupRun(store, "run-1", [ - { id: "stage-a", name: "A", status: "paused" }, - ]); - const registry = createStageControlRegistry(); - registry.register({ - ...makeHandle("run-1", "stage-a"), - status: "paused", - }); - let closed = 0; - let hidden = 0; - const pane = new WorkflowAttachPane({ - store, - graphTheme: deriveGraphTheme({}), - runId: "run-1", - stageControlRegistry: registry, - onClose: () => { - closed += 1; - }, - onHide: () => { - hidden += 1; - }, - initialAttachStageId: "stage-a", - }); - assert.equal(pane._mode, "stage-chat"); - pane.handleInput(Key.ctrl("x")); - assert.equal(closed, 0); - assert.equal(hidden, 0); - assert.equal(pane._mode, "graph"); - assert.equal(pane._hasChatView, false); - assert.equal(pane._lastAttachedStageId, "stage-a"); - pane.dispose(); - }); + test("Ctrl+X in paused stage-chat mode returns to graph", () => { + const store = createStore(); + setupRun(store, "run-1", [{ id: "stage-a", name: "A", status: "paused" }]); + const registry = createStageControlRegistry(); + registry.register({ + ...makeHandle("run-1", "stage-a"), + status: "paused", + }); + let closed = 0; + let hidden = 0; + const pane = new WorkflowAttachPane({ + store, + graphTheme: deriveGraphTheme({}), + runId: "run-1", + stageControlRegistry: registry, + onClose: () => { + closed += 1; + }, + onHide: () => { + hidden += 1; + }, + initialAttachStageId: "stage-a", + }); + assert.equal(pane._mode, "stage-chat"); + pane.handleInput(Key.ctrl("x")); + assert.equal(closed, 0); + assert.equal(hidden, 0); + assert.equal(pane._mode, "graph"); + assert.equal(pane._hasChatView, false); + assert.equal(pane._lastAttachedStageId, "stage-a"); + pane.dispose(); + }); }); diff --git a/test/unit/workflow-attach-pane-09.test.ts b/test/unit/workflow-attach-pane-09.test.ts index 85b769051..438294090 100644 --- a/test/unit/workflow-attach-pane-09.test.ts +++ b/test/unit/workflow-attach-pane-09.test.ts @@ -12,355 +12,282 @@ * cross-ref: src/tui/workflow-attach-pane.ts */ -import { describe, test } from "bun:test"; import assert from "node:assert/strict"; -import { - Key, - type Component, - type EditorComponent, - type TUI, -} from "@earendil-works/pi-tui"; +import type { AgentSession } from "@bastani/atomic"; +import { Key } from "@earendil-works/pi-tui"; +import { describe, test } from "vitest"; +import type { StageControlHandle } from "../../packages/workflows/src/runs/foreground/stage-control-registry.js"; +import { createStageControlRegistry } from "../../packages/workflows/src/runs/foreground/stage-control-registry.js"; import { createStore } from "../../packages/workflows/src/shared/store.js"; -import { WorkflowAttachPane } from "../../packages/workflows/src/tui/workflow-attach-pane.js"; +import type { PendingPrompt, StageInputRequest } from "../../packages/workflows/src/shared/store-types.js"; import { deriveGraphTheme } from "../../packages/workflows/src/tui/graph-theme.js"; -import { createStageControlRegistry } from "../../packages/workflows/src/runs/foreground/stage-control-registry.js"; -import type { StageControlHandle } from "../../packages/workflows/src/runs/foreground/stage-control-registry.js"; -import type { - PendingPrompt, - StageInputRequest, -} from "../../packages/workflows/src/shared/store-types.js"; -import type { AgentSession } from "@bastani/atomic"; -import { StageUiBroker } from "../../packages/workflows/src/shared/stage-ui-broker.js"; -import { makeFakeKeybindings } from "../support/fake-keybindings.js"; +import { WorkflowAttachPane } from "../../packages/workflows/src/tui/workflow-attach-pane.js"; type TestStageSeed = { - id: string; - name: string; - status?: "pending" | "running" | "paused" | "completed"; + id: string; + name: string; + status?: "pending" | "running" | "paused" | "completed"; }; -function setupRun( - store: ReturnType, - runId: string, - stages: TestStageSeed[], -) { - store.recordRunStart({ - id: runId, - name: "test-wf", - inputs: {}, - status: "running", - stages: [], - startedAt: Date.now(), - }); - for (const s of stages) { - store.recordStageStart(runId, { - id: s.id, - name: s.name, - status: s.status ?? "running", - parentIds: [], - toolEvents: [], - }); - } -} - -function makePendingPrompt( - overrides: Partial = {}, -): PendingPrompt { - return { - id: "prompt-1", - kind: "input", - message: "What should the workflow use?", - createdAt: Date.now(), - ...overrides, - }; +function setupRun(store: ReturnType, runId: string, stages: TestStageSeed[]) { + store.recordRunStart({ + id: runId, + name: "test-wf", + inputs: {}, + status: "running", + stages: [], + startedAt: Date.now(), + }); + for (const s of stages) { + store.recordStageStart(runId, { + id: s.id, + name: s.name, + status: s.status ?? "running", + parentIds: [], + toolEvents: [], + }); + } } -function makeInputRequest( - overrides: Partial = {}, -): StageInputRequest { - return { - id: "input-request-1", - kind: "ask_user_question", - createdAt: Date.now(), - questions: [ - { - question: "Which option should the workflow use?", - header: "Choice", - options: [{ label: "Use A" }, { label: "Use B" }], - }, - ], - ...overrides, - }; +function makePendingPrompt(overrides: Partial = {}): PendingPrompt { + return { + id: "prompt-1", + kind: "input", + message: "What should the workflow use?", + createdAt: Date.now(), + ...overrides, + }; } -class FakePromptEditor implements EditorComponent { - text = ""; - focused = false; - onSubmit?: (text: string) => void; - onChange?: (text: string) => void; - - render(): string[] { - return [`fake-prompt-editor:${this.text}`]; - } - - handleInput(data: string): void { - if (data === Key.enter || data === "\r" || data === "\n") { - this.onSubmit?.(this.text); - return; - } - this.text += data; - this.onChange?.(this.text); - } - - invalidate(): void {} - - getText(): string { - return this.text; - } - - setText(text: string): void { - this.text = text; - } +function _makeInputRequest(overrides: Partial = {}): StageInputRequest { + return { + id: "input-request-1", + kind: "ask_user_question", + createdAt: Date.now(), + questions: [ + { + question: "Which option should the workflow use?", + header: "Choice", + options: [{ label: "Use A" }, { label: "Use B" }], + }, + ], + ...overrides, + }; } function makeHandle(runId: string, stageId: string): StageControlHandle { - return { - runId, - stageId, - stageName: `stage-${stageId}`, - status: "running", - sessionId: undefined, - sessionFile: undefined, - isStreaming: false, - messages: [] as AgentSession["messages"], - async ensureAttached() {}, - async prompt() {}, - async steer() {}, - async followUp() {}, - async pause() {}, - async resume() {}, - subscribe() { - return () => {}; - }, - }; + return { + runId, + stageId, + stageName: `stage-${stageId}`, + status: "running", + sessionId: undefined, + sessionFile: undefined, + isStreaming: false, + messages: [] as AgentSession["messages"], + async ensureAttached() {}, + async prompt() {}, + async steer() {}, + async followUp() {}, + async pause() {}, + async resume() {}, + subscribe() { + return () => {}; + }, + }; } -function makeClock(start = 0): { - now: () => number; - advance: (ms: number) => void; +function _makeClock(start = 0): { + now: () => number; + advance: (ms: number) => void; } { - let current = start; - return { - now: () => current, - advance: (ms: number) => { - current += ms; - }, - }; + let current = start; + return { + now: () => current, + advance: (ms: number) => { + current += ms; + }, + }; } -async function flush(): Promise { - await Promise.resolve(); +async function _flush(): Promise { + await Promise.resolve(); } type AttachedStageChat = { handleInput(data: string): boolean }; -function getAttachedStageChat(pane: WorkflowAttachPane): AttachedStageChat { - const chatView = (pane as unknown as { chatView: AttachedStageChat | null }).chatView; - assert.ok(chatView, "expected initialAttachStageId to create a stage chat"); - return chatView; +function _getAttachedStageChat(pane: WorkflowAttachPane): AttachedStageChat { + const chatView = (pane as unknown as { chatView: AttachedStageChat | null }).chatView; + assert.ok(chatView, "expected initialAttachStageId to create a stage chat"); + return chatView; } -function submitAttachedStageChatText(chatView: AttachedStageChat, text: string): void { - for (const ch of text) chatView.handleInput(ch); - chatView.handleInput("\r"); +function _submitAttachedStageChatText(chatView: AttachedStageChat, text: string): void { + for (const ch of text) chatView.handleInput(ch); + chatView.handleInput("\r"); } -function setupTwoPromptAttachPane( - firstPrompt: PendingPrompt, - opts: { piKeybindings?: unknown; now?: () => number } = {}, +function _setupTwoPromptAttachPane( + firstPrompt: PendingPrompt, + opts: { piKeybindings?: unknown; now?: () => number } = {}, ) { - const store = createStore(); - setupRun(store, "run-1", [ - { id: "stage-a", name: "A" }, - { id: "stage-b", name: "B" }, - ]); - const registry = createStageControlRegistry(); - registry.register(makeHandle("run-1", "stage-a")); - registry.register(makeHandle("run-1", "stage-b")); - const secondPrompt = makePendingPrompt({ id: "prompt-b", createdAt: 2 }); - assert.equal( - store.recordStagePendingPrompt("run-1", "stage-a", firstPrompt), - true, - ); - assert.equal( - store.recordStagePendingPrompt("run-1", "stage-b", secondPrompt), - true, - ); - const pending = store.awaitStagePendingPrompt( - "run-1", - "stage-a", - firstPrompt.id, - ); - const pane = new WorkflowAttachPane({ - store, - graphTheme: deriveGraphTheme({}), - runId: "run-1", - stageControlRegistry: registry, - onClose: () => {}, - initialAttachStageId: "stage-a", - piKeybindings: opts.piKeybindings, - now: opts.now, - }); - return { store, pane, pending, secondPrompt }; + const store = createStore(); + setupRun(store, "run-1", [ + { id: "stage-a", name: "A" }, + { id: "stage-b", name: "B" }, + ]); + const registry = createStageControlRegistry(); + registry.register(makeHandle("run-1", "stage-a")); + registry.register(makeHandle("run-1", "stage-b")); + const secondPrompt = makePendingPrompt({ id: "prompt-b", createdAt: 2 }); + assert.equal(store.recordStagePendingPrompt("run-1", "stage-a", firstPrompt), true); + assert.equal(store.recordStagePendingPrompt("run-1", "stage-b", secondPrompt), true); + const pending = store.awaitStagePendingPrompt("run-1", "stage-a", firstPrompt.id); + const pane = new WorkflowAttachPane({ + store, + graphTheme: deriveGraphTheme({}), + runId: "run-1", + stageControlRegistry: registry, + onClose: () => {}, + initialAttachStageId: "stage-a", + piKeybindings: opts.piKeybindings, + now: opts.now, + }); + return { store, pane, pending, secondPrompt }; } -function assertNextGraphEnterAttaches( - pane: WorkflowAttachPane, - expectedStageId: string, - message: string, -): void { - pane.handleInput(Key.enter); - assert.equal(pane._mode, "stage-chat", message); - assert.equal(pane._lastAttachedStageId, expectedStageId); +function _assertNextGraphEnterAttaches(pane: WorkflowAttachPane, expectedStageId: string, message: string): void { + pane.handleInput(Key.enter); + assert.equal(pane._mode, "stage-chat", message); + assert.equal(pane._lastAttachedStageId, expectedStageId); } describe("WorkflowAttachPane", () => { - test("Ctrl+X returns the graph pane to main chat without a workflow-control callback", () => { - const store = createStore(); - setupRun(store, "run-1", [{ id: "stage-a", name: "A" }]); - let hidden = 0; - const before = structuredClone(store.runs().find((run) => run.id === "run-1")); - const pane = new WorkflowAttachPane({ - store, - graphTheme: deriveGraphTheme({}), - runId: "run-1", - onHide: () => { - hidden += 1; - }, - onClose: () => {}, - getViewportRows: () => 36, - }); - - pane.handleInput(Key.ctrl("x")); - - assert.equal(hidden, 1); - assert.deepEqual(store.runs().find((run) => run.id === "run-1"), before); - assert.equal(pane._mode, "graph"); - pane.dispose(); - }); - test("q does not navigate or quit from the graph pane", () => { - const store = createStore(); - setupRun(store, "run-1", [{ id: "stage-a", name: "A" }]); - let hidden = 0; - const before = structuredClone(store.runs()); - const pane = new WorkflowAttachPane({ - store, - graphTheme: deriveGraphTheme({}), - runId: "run-1", - onHide: () => { hidden += 1; }, - onClose: () => {}, - }); - - assert.equal(pane.handleInput("q"), false); - assert.equal(hidden, 0); - assert.deepEqual(store.runs(), before); - pane.dispose(); - }); - - - test("initialAttachStageId opens directly on stage-chat", () => { - const store = createStore(); - setupRun(store, "run-1", [{ id: "stage-a", name: "A" }]); - const registry = createStageControlRegistry(); - registry.register(makeHandle("run-1", "stage-a")); - const pane = new WorkflowAttachPane({ - store, - graphTheme: deriveGraphTheme({}), - runId: "run-1", - stageControlRegistry: registry, - onClose: () => {}, - initialAttachStageId: "stage-a", - }); - assert.equal(pane._mode, "stage-chat"); - assert.equal(pane._lastAttachedStageId, "stage-a"); - pane.dispose(); - }); - - test("focus requests are limited to the visible attached node that owns input", () => { - const store = createStore(); - setupRun(store, "run-1", [ - { id: "stage-a", name: "A" }, - { id: "stage-b", name: "B" }, - ]); - const registry = createStageControlRegistry(); - registry.register(makeHandle("run-1", "stage-a")); - registry.register(makeHandle("run-1", "stage-b")); - const pane = new WorkflowAttachPane({ - store, - graphTheme: deriveGraphTheme({}), - runId: "run-1", - stageControlRegistry: registry, - onClose: () => {}, - }); - const prompt = makePendingPrompt({ id: "focus-prompt" }); - - assert.equal( - store.recordStagePendingPrompt("run-1", "stage-b", prompt), - true, - ); - assert.equal( - pane.wantsFocusForAwaitingInput(store.snapshot()), - true, - "visible graph should reclaim focus so the user can attach to the prompt", - ); - - pane.handleInput("k"); - pane.handleInput(Key.enter); - assert.equal(pane._lastAttachedStageId, "stage-a"); - assert.equal( - pane.wantsFocusForAwaitingInput(store.snapshot()), - false, - "sibling node cannot answer the prompt", - ); - - pane.handleInput(Key.ctrl("x")); - pane.handleInput(Key.enter); - assert.equal(pane._lastAttachedStageId, "stage-b"); - assert.equal( - pane.wantsFocusForAwaitingInput(store.snapshot()), - true, - "attached prompted node owns input", - ); - - pane.setVisible(false); - assert.equal( - pane.wantsFocusForAwaitingInput(store.snapshot()), - false, - "hidden node cannot own input", - ); - pane.dispose(); - }); - - test("visibility controls whether stage is marked attached", () => { - const store = createStore(); - setupRun(store, "run-1", [{ id: "stage-a", name: "A" }]); - const registry = createStageControlRegistry(); - registry.register(makeHandle("run-1", "stage-a")); - const pane = new WorkflowAttachPane({ - store, - graphTheme: deriveGraphTheme({}), - runId: "run-1", - stageControlRegistry: registry, - onClose: () => {}, - initialAttachStageId: "stage-a", - }); - - const stage = () => store.snapshot().runs[0]!.stages[0]!; - assert.equal(stage().attached, true); - pane.setVisible(false); - assert.equal(stage().attached, undefined); - pane.setVisible(true); - assert.equal(stage().attached, true); - pane.dispose(); - }); + test("Ctrl+X returns the graph pane to main chat without a workflow-control callback", () => { + const store = createStore(); + setupRun(store, "run-1", [{ id: "stage-a", name: "A" }]); + let hidden = 0; + const before = structuredClone(store.runs().find((run) => run.id === "run-1")); + const pane = new WorkflowAttachPane({ + store, + graphTheme: deriveGraphTheme({}), + runId: "run-1", + onHide: () => { + hidden += 1; + }, + onClose: () => {}, + getViewportRows: () => 36, + }); + + pane.handleInput(Key.ctrl("x")); + + assert.equal(hidden, 1); + assert.deepEqual( + store.runs().find((run) => run.id === "run-1"), + before, + ); + assert.equal(pane._mode, "graph"); + pane.dispose(); + }); + test("q does not navigate or quit from the graph pane", () => { + const store = createStore(); + setupRun(store, "run-1", [{ id: "stage-a", name: "A" }]); + let hidden = 0; + const before = structuredClone(store.runs()); + const pane = new WorkflowAttachPane({ + store, + graphTheme: deriveGraphTheme({}), + runId: "run-1", + onHide: () => { + hidden += 1; + }, + onClose: () => {}, + }); + + assert.equal(pane.handleInput("q"), false); + assert.equal(hidden, 0); + assert.deepEqual(store.runs(), before); + pane.dispose(); + }); + + test("initialAttachStageId opens directly on stage-chat", () => { + const store = createStore(); + setupRun(store, "run-1", [{ id: "stage-a", name: "A" }]); + const registry = createStageControlRegistry(); + registry.register(makeHandle("run-1", "stage-a")); + const pane = new WorkflowAttachPane({ + store, + graphTheme: deriveGraphTheme({}), + runId: "run-1", + stageControlRegistry: registry, + onClose: () => {}, + initialAttachStageId: "stage-a", + }); + assert.equal(pane._mode, "stage-chat"); + assert.equal(pane._lastAttachedStageId, "stage-a"); + pane.dispose(); + }); + + test("focus requests are limited to the visible attached node that owns input", () => { + const store = createStore(); + setupRun(store, "run-1", [ + { id: "stage-a", name: "A" }, + { id: "stage-b", name: "B" }, + ]); + const registry = createStageControlRegistry(); + registry.register(makeHandle("run-1", "stage-a")); + registry.register(makeHandle("run-1", "stage-b")); + const pane = new WorkflowAttachPane({ + store, + graphTheme: deriveGraphTheme({}), + runId: "run-1", + stageControlRegistry: registry, + onClose: () => {}, + }); + const prompt = makePendingPrompt({ id: "focus-prompt" }); + + assert.equal(store.recordStagePendingPrompt("run-1", "stage-b", prompt), true); + assert.equal( + pane.wantsFocusForAwaitingInput(store.snapshot()), + true, + "visible graph should reclaim focus so the user can attach to the prompt", + ); + + pane.handleInput("k"); + pane.handleInput(Key.enter); + assert.equal(pane._lastAttachedStageId, "stage-a"); + assert.equal(pane.wantsFocusForAwaitingInput(store.snapshot()), false, "sibling node cannot answer the prompt"); + + pane.handleInput(Key.ctrl("x")); + pane.handleInput(Key.enter); + assert.equal(pane._lastAttachedStageId, "stage-b"); + assert.equal(pane.wantsFocusForAwaitingInput(store.snapshot()), true, "attached prompted node owns input"); + + pane.setVisible(false); + assert.equal(pane.wantsFocusForAwaitingInput(store.snapshot()), false, "hidden node cannot own input"); + pane.dispose(); + }); + + test("visibility controls whether stage is marked attached", () => { + const store = createStore(); + setupRun(store, "run-1", [{ id: "stage-a", name: "A" }]); + const registry = createStageControlRegistry(); + registry.register(makeHandle("run-1", "stage-a")); + const pane = new WorkflowAttachPane({ + store, + graphTheme: deriveGraphTheme({}), + runId: "run-1", + stageControlRegistry: registry, + onClose: () => {}, + initialAttachStageId: "stage-a", + }); + + const stage = () => store.snapshot().runs[0]!.stages[0]!; + assert.equal(stage().attached, true); + pane.setVisible(false); + assert.equal(stage().attached, undefined); + pane.setVisible(true); + assert.equal(stage().attached, true); + pane.dispose(); + }); }); diff --git a/test/unit/workflow-attach-pane-10.test.ts b/test/unit/workflow-attach-pane-10.test.ts index dfe840713..5dea8fbf5 100644 --- a/test/unit/workflow-attach-pane-10.test.ts +++ b/test/unit/workflow-attach-pane-10.test.ts @@ -12,357 +12,295 @@ * cross-ref: src/tui/workflow-attach-pane.ts */ -import { describe, test } from "bun:test"; import assert from "node:assert/strict"; -import { - Key, - type Component, - type EditorComponent, - type TUI, -} from "@earendil-works/pi-tui"; +import type { AgentSession } from "@bastani/atomic"; +import { Key } from "@earendil-works/pi-tui"; +import { describe, test } from "vitest"; +import type { StageControlHandle } from "../../packages/workflows/src/runs/foreground/stage-control-registry.js"; +import { createStageControlRegistry } from "../../packages/workflows/src/runs/foreground/stage-control-registry.js"; import { createStore } from "../../packages/workflows/src/shared/store.js"; -import { WorkflowAttachPane } from "../../packages/workflows/src/tui/workflow-attach-pane.js"; +import type { PendingPrompt, StageInputRequest } from "../../packages/workflows/src/shared/store-types.js"; import { deriveGraphTheme } from "../../packages/workflows/src/tui/graph-theme.js"; -import { createStageControlRegistry } from "../../packages/workflows/src/runs/foreground/stage-control-registry.js"; -import type { StageControlHandle } from "../../packages/workflows/src/runs/foreground/stage-control-registry.js"; -import type { - PendingPrompt, - StageInputRequest, -} from "../../packages/workflows/src/shared/store-types.js"; -import type { AgentSession } from "@bastani/atomic"; -import { StageUiBroker } from "../../packages/workflows/src/shared/stage-ui-broker.js"; -import { makeFakeKeybindings } from "../support/fake-keybindings.js"; +import { WorkflowAttachPane } from "../../packages/workflows/src/tui/workflow-attach-pane.js"; type TestStageSeed = { - id: string; - name: string; - status?: "pending" | "running" | "paused" | "completed"; + id: string; + name: string; + status?: "pending" | "running" | "paused" | "completed"; }; -function setupRun( - store: ReturnType, - runId: string, - stages: TestStageSeed[], -) { - store.recordRunStart({ - id: runId, - name: "test-wf", - inputs: {}, - status: "running", - stages: [], - startedAt: Date.now(), - }); - for (const s of stages) { - store.recordStageStart(runId, { - id: s.id, - name: s.name, - status: s.status ?? "running", - parentIds: [], - toolEvents: [], - }); - } -} - -function makePendingPrompt( - overrides: Partial = {}, -): PendingPrompt { - return { - id: "prompt-1", - kind: "input", - message: "What should the workflow use?", - createdAt: Date.now(), - ...overrides, - }; +function setupRun(store: ReturnType, runId: string, stages: TestStageSeed[]) { + store.recordRunStart({ + id: runId, + name: "test-wf", + inputs: {}, + status: "running", + stages: [], + startedAt: Date.now(), + }); + for (const s of stages) { + store.recordStageStart(runId, { + id: s.id, + name: s.name, + status: s.status ?? "running", + parentIds: [], + toolEvents: [], + }); + } } -function makeInputRequest( - overrides: Partial = {}, -): StageInputRequest { - return { - id: "input-request-1", - kind: "ask_user_question", - createdAt: Date.now(), - questions: [ - { - question: "Which option should the workflow use?", - header: "Choice", - options: [{ label: "Use A" }, { label: "Use B" }], - }, - ], - ...overrides, - }; +function makePendingPrompt(overrides: Partial = {}): PendingPrompt { + return { + id: "prompt-1", + kind: "input", + message: "What should the workflow use?", + createdAt: Date.now(), + ...overrides, + }; } -class FakePromptEditor implements EditorComponent { - text = ""; - focused = false; - onSubmit?: (text: string) => void; - onChange?: (text: string) => void; - - render(): string[] { - return [`fake-prompt-editor:${this.text}`]; - } - - handleInput(data: string): void { - if (data === Key.enter || data === "\r" || data === "\n") { - this.onSubmit?.(this.text); - return; - } - this.text += data; - this.onChange?.(this.text); - } - - invalidate(): void {} - - getText(): string { - return this.text; - } - - setText(text: string): void { - this.text = text; - } +function _makeInputRequest(overrides: Partial = {}): StageInputRequest { + return { + id: "input-request-1", + kind: "ask_user_question", + createdAt: Date.now(), + questions: [ + { + question: "Which option should the workflow use?", + header: "Choice", + options: [{ label: "Use A" }, { label: "Use B" }], + }, + ], + ...overrides, + }; } function makeHandle(runId: string, stageId: string): StageControlHandle { - return { - runId, - stageId, - stageName: `stage-${stageId}`, - status: "running", - sessionId: undefined, - sessionFile: undefined, - isStreaming: false, - messages: [] as AgentSession["messages"], - async ensureAttached() {}, - async prompt() {}, - async steer() {}, - async followUp() {}, - async pause() {}, - async resume() {}, - subscribe() { - return () => {}; - }, - }; + return { + runId, + stageId, + stageName: `stage-${stageId}`, + status: "running", + sessionId: undefined, + sessionFile: undefined, + isStreaming: false, + messages: [] as AgentSession["messages"], + async ensureAttached() {}, + async prompt() {}, + async steer() {}, + async followUp() {}, + async pause() {}, + async resume() {}, + subscribe() { + return () => {}; + }, + }; } -function makeClock(start = 0): { - now: () => number; - advance: (ms: number) => void; +function _makeClock(start = 0): { + now: () => number; + advance: (ms: number) => void; } { - let current = start; - return { - now: () => current, - advance: (ms: number) => { - current += ms; - }, - }; + let current = start; + return { + now: () => current, + advance: (ms: number) => { + current += ms; + }, + }; } -async function flush(): Promise { - await Promise.resolve(); +async function _flush(): Promise { + await Promise.resolve(); } type AttachedStageChat = { handleInput(data: string): boolean }; -function getAttachedStageChat(pane: WorkflowAttachPane): AttachedStageChat { - const chatView = (pane as unknown as { chatView: AttachedStageChat | null }).chatView; - assert.ok(chatView, "expected initialAttachStageId to create a stage chat"); - return chatView; +function _getAttachedStageChat(pane: WorkflowAttachPane): AttachedStageChat { + const chatView = (pane as unknown as { chatView: AttachedStageChat | null }).chatView; + assert.ok(chatView, "expected initialAttachStageId to create a stage chat"); + return chatView; } -function submitAttachedStageChatText(chatView: AttachedStageChat, text: string): void { - for (const ch of text) chatView.handleInput(ch); - chatView.handleInput("\r"); +function _submitAttachedStageChatText(chatView: AttachedStageChat, text: string): void { + for (const ch of text) chatView.handleInput(ch); + chatView.handleInput("\r"); } -function setupTwoPromptAttachPane( - firstPrompt: PendingPrompt, - opts: { piKeybindings?: unknown; now?: () => number } = {}, +function _setupTwoPromptAttachPane( + firstPrompt: PendingPrompt, + opts: { piKeybindings?: unknown; now?: () => number } = {}, ) { - const store = createStore(); - setupRun(store, "run-1", [ - { id: "stage-a", name: "A" }, - { id: "stage-b", name: "B" }, - ]); - const registry = createStageControlRegistry(); - registry.register(makeHandle("run-1", "stage-a")); - registry.register(makeHandle("run-1", "stage-b")); - const secondPrompt = makePendingPrompt({ id: "prompt-b", createdAt: 2 }); - assert.equal( - store.recordStagePendingPrompt("run-1", "stage-a", firstPrompt), - true, - ); - assert.equal( - store.recordStagePendingPrompt("run-1", "stage-b", secondPrompt), - true, - ); - const pending = store.awaitStagePendingPrompt( - "run-1", - "stage-a", - firstPrompt.id, - ); - const pane = new WorkflowAttachPane({ - store, - graphTheme: deriveGraphTheme({}), - runId: "run-1", - stageControlRegistry: registry, - onClose: () => {}, - initialAttachStageId: "stage-a", - piKeybindings: opts.piKeybindings, - now: opts.now, - }); - return { store, pane, pending, secondPrompt }; + const store = createStore(); + setupRun(store, "run-1", [ + { id: "stage-a", name: "A" }, + { id: "stage-b", name: "B" }, + ]); + const registry = createStageControlRegistry(); + registry.register(makeHandle("run-1", "stage-a")); + registry.register(makeHandle("run-1", "stage-b")); + const secondPrompt = makePendingPrompt({ id: "prompt-b", createdAt: 2 }); + assert.equal(store.recordStagePendingPrompt("run-1", "stage-a", firstPrompt), true); + assert.equal(store.recordStagePendingPrompt("run-1", "stage-b", secondPrompt), true); + const pending = store.awaitStagePendingPrompt("run-1", "stage-a", firstPrompt.id); + const pane = new WorkflowAttachPane({ + store, + graphTheme: deriveGraphTheme({}), + runId: "run-1", + stageControlRegistry: registry, + onClose: () => {}, + initialAttachStageId: "stage-a", + piKeybindings: opts.piKeybindings, + now: opts.now, + }); + return { store, pane, pending, secondPrompt }; } -function assertNextGraphEnterAttaches( - pane: WorkflowAttachPane, - expectedStageId: string, - message: string, -): void { - pane.handleInput(Key.enter); - assert.equal(pane._mode, "stage-chat", message); - assert.equal(pane._lastAttachedStageId, expectedStageId); +function _assertNextGraphEnterAttaches(pane: WorkflowAttachPane, expectedStageId: string, message: string): void { + pane.handleInput(Key.enter); + assert.equal(pane._mode, "stage-chat", message); + assert.equal(pane._lastAttachedStageId, expectedStageId); } describe("WorkflowAttachPane", () => { - test("retarget replaces the current run and optional attached stage", () => { - const store = createStore(); - setupRun(store, "run-1", [{ id: "stage-a", name: "A" }]); - setupRun(store, "run-2", [{ id: "stage-b", name: "B" }]); - const registry = createStageControlRegistry(); - registry.register(makeHandle("run-1", "stage-a")); - registry.register(makeHandle("run-2", "stage-b")); - const pane = new WorkflowAttachPane({ - store, - graphTheme: deriveGraphTheme({}), - runId: "run-1", - stageControlRegistry: registry, - onClose: () => {}, - }); - - pane.handleInput(Key.enter); - assert.equal(pane._mode, "stage-chat"); - assert.equal(pane._runId, "run-1"); - assert.equal(pane._lastAttachedStageId, "stage-a"); - - pane.retarget("run-2"); - assert.equal(pane._mode, "graph"); - assert.equal(pane._runId, "run-2"); - assert.equal(pane._lastAttachedStageId, null); - assert.equal(pane._hasChatView, false); - - pane.retarget("run-2", "stage-b"); - assert.equal(pane._mode, "stage-chat"); - assert.equal(pane._runId, "run-2"); - assert.equal(pane._lastAttachedStageId, "stage-b"); - assert.equal(pane._hasChatView, true); - pane.dispose(); - }); - - test("stage chat captures mouse tracking by default and ctrl+t toggles copy mode", () => { - const store = createStore(); - setupRun(store, "run-1", [{ id: "stage-a", name: "A" }]); - const registry = createStageControlRegistry(); - registry.register(makeHandle("run-1", "stage-a")); - const mouseTracking: boolean[] = []; - const pane = new WorkflowAttachPane({ - store, - graphTheme: deriveGraphTheme({}), - runId: "run-1", - stageControlRegistry: registry, - onClose: () => {}, - setMouseScrollTracking: (enabled) => mouseTracking.push(enabled), - }); - - assert.equal(pane.wantsMouseScrollTracking(), true); - assert.deepEqual(mouseTracking, [true]); - pane.handleInput(Key.enter); - assert.equal(pane._mode, "stage-chat"); - assert.equal(pane.wantsMouseScrollTracking(), true); - assert.deepEqual(mouseTracking, [true, true]); - - pane.handleInput("\x1b[27;5;116~"); - assert.equal(pane.wantsMouseScrollTracking(), false); - assert.deepEqual(mouseTracking, [true, true, false]); - pane.handleInput("\x1b[116;5u"); - assert.equal(pane.wantsMouseScrollTracking(), true); - assert.deepEqual(mouseTracking, [true, true, false, true]); - - pane.handleInput(Key.ctrl("x")); - assert.equal(pane._mode, "graph"); - assert.equal(pane.wantsMouseScrollTracking(), true); - assert.deepEqual(mouseTracking, [true, true, false, true, true]); - pane.dispose(); - assert.deepEqual(mouseTracking, [true, true, false, true, true, false]); - }); - test("read-only stage copy mode releases terminal mouse tracking and ctrl+x returns to graph", () => { - const store = createStore(); - setupRun(store, "run-1", [{ id: "stage-a", name: "A", status: "completed" }]); - const mouseTracking: boolean[] = []; - const pane = new WorkflowAttachPane({ - store, - graphTheme: deriveGraphTheme({}), - runId: "run-1", - stageControlRegistry: createStageControlRegistry(), - onClose: () => {}, - setMouseScrollTracking: (enabled) => mouseTracking.push(enabled), - }); - - pane.handleInput(Key.enter); - assert.equal(pane._mode, "stage-chat"); - assert.match(pane.render(96).join("\n"), /copy mode off/); - - pane.handleInput("\x1b[116;5u"); - assert.equal(pane.wantsMouseScrollTracking(), false); - assert.match(pane.render(96).join("\n"), /copy mode on/); - assert.equal(mouseTracking.at(-1), false); - - pane.handleInput(Key.ctrl("x")); - assert.equal(pane._mode, "graph"); - assert.equal(pane.wantsMouseScrollTracking(), true); - assert.equal(mouseTracking.at(-1), true); - pane.dispose(); - }); - - test("forwards getViewportRows to graph mode", () => { - // The host provides terminal.rows through `getViewportRows`; the - // attach pane must thread that through to GraphView so the - // overlay frame fills the terminal in graph mode. - const store = createStore(); - setupRun(store, "run-1", [{ id: "stage-a", name: "A" }]); - const pane = new WorkflowAttachPane({ - store, - graphTheme: deriveGraphTheme({}), - runId: "run-1", - onClose: () => {}, - getViewportRows: () => 50, - }); - const lines = pane.render(120); - assert.equal(pane._mode, "graph"); - assert.equal(lines.length, 50); - pane.dispose(); - }); - - test("forwards getViewportRows to stage-chat mode after attach", () => { - // After Enter on a graph node the attach pane swaps the interior - // to StageChatView. The viewport accessor must continue to apply - // so the chat surface keeps filling the terminal. - const store = createStore(); - setupRun(store, "run-1", [{ id: "stage-a", name: "A" }]); - const registry = createStageControlRegistry(); - registry.register(makeHandle("run-1", "stage-a")); - const pane = new WorkflowAttachPane({ - store, - graphTheme: deriveGraphTheme({}), - runId: "run-1", - stageControlRegistry: registry, - onClose: () => {}, - getViewportRows: () => 44, - }); - pane.handleInput(Key.enter); - assert.equal(pane._mode, "stage-chat"); - const lines = pane.render(120); - assert.equal(lines.length, 44); - pane.dispose(); - }); + test("retarget replaces the current run and optional attached stage", () => { + const store = createStore(); + setupRun(store, "run-1", [{ id: "stage-a", name: "A" }]); + setupRun(store, "run-2", [{ id: "stage-b", name: "B" }]); + const registry = createStageControlRegistry(); + registry.register(makeHandle("run-1", "stage-a")); + registry.register(makeHandle("run-2", "stage-b")); + const pane = new WorkflowAttachPane({ + store, + graphTheme: deriveGraphTheme({}), + runId: "run-1", + stageControlRegistry: registry, + onClose: () => {}, + }); + + pane.handleInput(Key.enter); + assert.equal(pane._mode, "stage-chat"); + assert.equal(pane._runId, "run-1"); + assert.equal(pane._lastAttachedStageId, "stage-a"); + + pane.retarget("run-2"); + assert.equal(pane._mode, "graph"); + assert.equal(pane._runId, "run-2"); + assert.equal(pane._lastAttachedStageId, null); + assert.equal(pane._hasChatView, false); + + pane.retarget("run-2", "stage-b"); + assert.equal(pane._mode, "stage-chat"); + assert.equal(pane._runId, "run-2"); + assert.equal(pane._lastAttachedStageId, "stage-b"); + assert.equal(pane._hasChatView, true); + pane.dispose(); + }); + + test("stage chat captures mouse tracking by default and ctrl+t toggles copy mode", () => { + const store = createStore(); + setupRun(store, "run-1", [{ id: "stage-a", name: "A" }]); + const registry = createStageControlRegistry(); + registry.register(makeHandle("run-1", "stage-a")); + const mouseTracking: boolean[] = []; + const pane = new WorkflowAttachPane({ + store, + graphTheme: deriveGraphTheme({}), + runId: "run-1", + stageControlRegistry: registry, + onClose: () => {}, + setMouseScrollTracking: (enabled) => mouseTracking.push(enabled), + }); + + assert.equal(pane.wantsMouseScrollTracking(), true); + assert.deepEqual(mouseTracking, [true]); + pane.handleInput(Key.enter); + assert.equal(pane._mode, "stage-chat"); + assert.equal(pane.wantsMouseScrollTracking(), true); + assert.deepEqual(mouseTracking, [true, true]); + + pane.handleInput("\x1b[27;5;116~"); + assert.equal(pane.wantsMouseScrollTracking(), false); + assert.deepEqual(mouseTracking, [true, true, false]); + pane.handleInput("\x1b[116;5u"); + assert.equal(pane.wantsMouseScrollTracking(), true); + assert.deepEqual(mouseTracking, [true, true, false, true]); + + pane.handleInput(Key.ctrl("x")); + assert.equal(pane._mode, "graph"); + assert.equal(pane.wantsMouseScrollTracking(), true); + assert.deepEqual(mouseTracking, [true, true, false, true, true]); + pane.dispose(); + assert.deepEqual(mouseTracking, [true, true, false, true, true, false]); + }); + test("read-only stage copy mode releases terminal mouse tracking and ctrl+x returns to graph", () => { + const store = createStore(); + setupRun(store, "run-1", [{ id: "stage-a", name: "A", status: "completed" }]); + const mouseTracking: boolean[] = []; + const pane = new WorkflowAttachPane({ + store, + graphTheme: deriveGraphTheme({}), + runId: "run-1", + stageControlRegistry: createStageControlRegistry(), + onClose: () => {}, + setMouseScrollTracking: (enabled) => mouseTracking.push(enabled), + }); + + pane.handleInput(Key.enter); + assert.equal(pane._mode, "stage-chat"); + assert.match(pane.render(96).join("\n"), /copy mode off/); + + pane.handleInput("\x1b[116;5u"); + assert.equal(pane.wantsMouseScrollTracking(), false); + assert.match(pane.render(96).join("\n"), /copy mode on/); + assert.equal(mouseTracking.at(-1), false); + + pane.handleInput(Key.ctrl("x")); + assert.equal(pane._mode, "graph"); + assert.equal(pane.wantsMouseScrollTracking(), true); + assert.equal(mouseTracking.at(-1), true); + pane.dispose(); + }); + + test("forwards getViewportRows to graph mode", () => { + // The host provides terminal.rows through `getViewportRows`; the + // attach pane must thread that through to GraphView so the + // overlay frame fills the terminal in graph mode. + const store = createStore(); + setupRun(store, "run-1", [{ id: "stage-a", name: "A" }]); + const pane = new WorkflowAttachPane({ + store, + graphTheme: deriveGraphTheme({}), + runId: "run-1", + onClose: () => {}, + getViewportRows: () => 50, + }); + const lines = pane.render(120); + assert.equal(pane._mode, "graph"); + assert.equal(lines.length, 50); + pane.dispose(); + }); + + test("forwards getViewportRows to stage-chat mode after attach", () => { + // After Enter on a graph node the attach pane swaps the interior + // to StageChatView. The viewport accessor must continue to apply + // so the chat surface keeps filling the terminal. + const store = createStore(); + setupRun(store, "run-1", [{ id: "stage-a", name: "A" }]); + const registry = createStageControlRegistry(); + registry.register(makeHandle("run-1", "stage-a")); + const pane = new WorkflowAttachPane({ + store, + graphTheme: deriveGraphTheme({}), + runId: "run-1", + stageControlRegistry: registry, + onClose: () => {}, + getViewportRows: () => 44, + }); + pane.handleInput(Key.enter); + assert.equal(pane._mode, "stage-chat"); + const lines = pane.render(120); + assert.equal(lines.length, 44); + pane.dispose(); + }); }); diff --git a/test/unit/workflow-attach-pane-11.test.ts b/test/unit/workflow-attach-pane-11.test.ts index 869eeec7c..413f5d724 100644 --- a/test/unit/workflow-attach-pane-11.test.ts +++ b/test/unit/workflow-attach-pane-11.test.ts @@ -11,161 +11,175 @@ * cross-ref: src/tui/workflow-attach-pane.ts, src/runs/foreground/postmortem-stage-chat.ts */ -import { describe, test } from "bun:test"; import assert from "node:assert/strict"; +import type { AgentSession } from "@bastani/atomic"; +import { describe, test } from "vitest"; +import { createPostMortemHandleResolver } from "../../packages/workflows/src/extension/postmortem-deps.js"; +import type { StageControlHandle } from "../../packages/workflows/src/runs/foreground/stage-control-registry.js"; +import { createStageControlRegistry } from "../../packages/workflows/src/runs/foreground/stage-control-registry.js"; import { createStore, store as globalStore } from "../../packages/workflows/src/shared/store.js"; -import { WorkflowAttachPane } from "../../packages/workflows/src/tui/workflow-attach-pane.js"; import { deriveGraphTheme } from "../../packages/workflows/src/tui/graph-theme.js"; -import { createStageControlRegistry } from "../../packages/workflows/src/runs/foreground/stage-control-registry.js"; -import type { StageControlHandle } from "../../packages/workflows/src/runs/foreground/stage-control-registry.js"; -import { createPostMortemHandleResolver } from "../../packages/workflows/src/extension/postmortem-deps.js"; -import type { AgentSession } from "@bastani/atomic"; +import { WorkflowAttachPane } from "../../packages/workflows/src/tui/workflow-attach-pane.js"; + function setupCompletedRun(store: ReturnType, runId: string) { - store.recordRunStart({ id: runId, name: "test-wf", inputs: {}, status: "completed", stages: [], startedAt: 1 }); - store.recordStageStart(runId, { - id: "stage-a", - name: "A", - status: "completed", - parentIds: [], - toolEvents: [], - result: "done", - sessionFile: "/tmp/a.jsonl", - attachable: false, - }); + store.recordRunStart({ id: runId, name: "test-wf", inputs: {}, status: "completed", stages: [], startedAt: 1 }); + store.recordStageStart(runId, { + id: "stage-a", + name: "A", + status: "completed", + parentIds: [], + toolEvents: [], + result: "done", + sessionFile: "/tmp/a.jsonl", + attachable: false, + }); } function makeHandle(runId: string, stageId: string, promptCalls: string[]): StageControlHandle { - return { - runId, - stageId, - stageName: `stage-${stageId}`, - status: "completed", - sessionId: undefined, - sessionFile: "/tmp/a.jsonl", - isStreaming: false, - messages: [] as AgentSession["messages"], - async ensureAttached() {}, - async prompt(text: string) { promptCalls.push(text); }, - async steer() {}, - async followUp() {}, - async pause() {}, - async resume() {}, - subscribe() { return () => {}; }, - }; + return { + runId, + stageId, + stageName: `stage-${stageId}`, + status: "completed", + sessionId: undefined, + sessionFile: "/tmp/a.jsonl", + isStreaming: false, + messages: [] as AgentSession["messages"], + async ensureAttached() {}, + async prompt(text: string) { + promptCalls.push(text); + }, + async steer() {}, + async followUp() {}, + async pause() {}, + async resume() {}, + subscribe() { + return () => {}; + }, + }; } async function flush(): Promise { - for (let i = 0; i < 6; i += 1) await Promise.resolve(); + for (let i = 0; i < 6; i += 1) await Promise.resolve(); } function submit(chatView: { handleInput(data: string): boolean }, text: string): void { - for (const ch of text) chatView.handleInput(ch); - chatView.handleInput("\r"); + for (const ch of text) chatView.handleInput(ch); + chatView.handleInput("\r"); } describe("WorkflowAttachPane post-mortem revival", () => { - test("revives a post-mortem handle when the registry misses", async () => { - const store = createStore(); - setupCompletedRun(store, "run-1"); - const registry = createStageControlRegistry(); - const promptCalls: string[] = []; - const resolverCalls: Array<[string, string]> = []; - const pane = new WorkflowAttachPane({ - store, - graphTheme: deriveGraphTheme({}), - runId: "run-1", - stageControlRegistry: registry, - resolvePostMortemHandle: (runId, stageId) => { - resolverCalls.push([runId, stageId]); - return { ok: true, handle: makeHandle(runId, stageId, promptCalls) }; - }, - onClose: () => {}, - initialAttachStageId: "stage-a", - }); - assert.deepEqual(resolverCalls, [["run-1", "stage-a"]]); - const chatView = (pane as unknown as { chatView: { handleInput(data: string): boolean } | null }).chatView; - assert.ok(chatView, "expected an interactive stage chat"); - submit(chatView, "follow up question"); - await flush(); - assert.deepEqual(promptCalls, ["follow up question"]); - pane.dispose(); - }); + test("revives a post-mortem handle when the registry misses", async () => { + const store = createStore(); + setupCompletedRun(store, "run-1"); + const registry = createStageControlRegistry(); + const promptCalls: string[] = []; + const resolverCalls: Array<[string, string]> = []; + const pane = new WorkflowAttachPane({ + store, + graphTheme: deriveGraphTheme({}), + runId: "run-1", + stageControlRegistry: registry, + resolvePostMortemHandle: (runId, stageId) => { + resolverCalls.push([runId, stageId]); + return { ok: true, handle: makeHandle(runId, stageId, promptCalls) }; + }, + onClose: () => {}, + initialAttachStageId: "stage-a", + }); + assert.deepEqual(resolverCalls, [["run-1", "stage-a"]]); + const chatView = (pane as unknown as { chatView: { handleInput(data: string): boolean } | null }).chatView; + assert.ok(chatView, "expected an interactive stage chat"); + submit(chatView, "follow up question"); + await flush(); + assert.deepEqual(promptCalls, ["follow up question"]); + pane.dispose(); + }); - test("uses the live registry handle and never consults the resolver", () => { - const store = createStore(); - setupCompletedRun(store, "run-1"); - const registry = createStageControlRegistry(); - const promptCalls: string[] = []; - registry.register(makeHandle("run-1", "stage-a", promptCalls)); - let resolverCalls = 0; - const pane = new WorkflowAttachPane({ - store, - graphTheme: deriveGraphTheme({}), - runId: "run-1", - stageControlRegistry: registry, - resolvePostMortemHandle: () => { resolverCalls += 1; return undefined; }, - onClose: () => {}, - initialAttachStageId: "stage-a", - }); - assert.equal(resolverCalls, 0); - assert.equal(pane._mode, "stage-chat"); - pane.dispose(); - }); + test("uses the live registry handle and never consults the resolver", () => { + const store = createStore(); + setupCompletedRun(store, "run-1"); + const registry = createStageControlRegistry(); + const promptCalls: string[] = []; + registry.register(makeHandle("run-1", "stage-a", promptCalls)); + let resolverCalls = 0; + const pane = new WorkflowAttachPane({ + store, + graphTheme: deriveGraphTheme({}), + runId: "run-1", + stageControlRegistry: registry, + resolvePostMortemHandle: () => { + resolverCalls += 1; + return undefined; + }, + onClose: () => {}, + initialAttachStageId: "stage-a", + }); + assert.equal(resolverCalls, 0); + assert.equal(pane._mode, "stage-chat"); + pane.dispose(); + }); - test("keeps a read-only archive when the stage is not revivable", () => { - const store = createStore(); - setupCompletedRun(store, "run-1"); - const registry = createStageControlRegistry(); - const pane = new WorkflowAttachPane({ - store, - graphTheme: deriveGraphTheme({}), - runId: "run-1", - stageControlRegistry: registry, - resolvePostMortemHandle: () => undefined, - onClose: () => {}, - initialAttachStageId: "stage-a", - }); - assert.equal(pane._mode, "stage-chat"); - assert.equal(pane._hasChatView, true); - pane.dispose(); - }); + test("keeps a read-only archive when the stage is not revivable", () => { + const store = createStore(); + setupCompletedRun(store, "run-1"); + const registry = createStageControlRegistry(); + const pane = new WorkflowAttachPane({ + store, + graphTheme: deriveGraphTheme({}), + runId: "run-1", + stageControlRegistry: registry, + resolvePostMortemHandle: () => undefined, + onClose: () => {}, + initialAttachStageId: "stage-a", + }); + assert.equal(pane._mode, "stage-chat"); + assert.equal(pane._hasChatView, true); + pane.dispose(); + }); - test("renders the post-mortem unavailability reason instead of a generic archive", () => { - const store = createStore(); - setupCompletedRun(store, "run-1"); - const pane = new WorkflowAttachPane({ - store, - graphTheme: deriveGraphTheme({}), - runId: "run-1", - stageControlRegistry: createStageControlRegistry(), - resolvePostMortemHandle: () => ({ ok: false, reason: "invalid_session" }), - onClose: () => {}, - initialAttachStageId: "stage-a", - }); + test("renders the post-mortem unavailability reason instead of a generic archive", () => { + const store = createStore(); + setupCompletedRun(store, "run-1"); + const pane = new WorkflowAttachPane({ + store, + graphTheme: deriveGraphTheme({}), + runId: "run-1", + stageControlRegistry: createStageControlRegistry(), + resolvePostMortemHandle: () => ({ ok: false, reason: "invalid_session" }), + onClose: () => {}, + initialAttachStageId: "stage-a", + }); - const rendered = pane.render(40).join("\n"); - const visible = rendered.replace(/\x1b\[[0-9;]*m/g, "").replace(/\s+/g, " "); - assert.match(visible, /SESSION UNAVAILABLE/); - assert.match(visible, /The retained session is missing, unreadable, or invalid\. Check that the session file still exists and is readable\./); - assert.doesNotMatch(visible, /archived transcript/); - pane.dispose(); - }); + const rendered = pane.render(40).join("\n"); + const visible = rendered.replace(/\x1b\[[0-9;]*m/g, "").replace(/\s+/g, " "); + assert.match(visible, /SESSION UNAVAILABLE/); + assert.match( + visible, + /The retained session is missing, unreadable, or invalid\. Check that the session file still exists and is readable\./, + ); + assert.doesNotMatch(visible, /archived transcript/); + pane.dispose(); + }); - test.serial("the extension resolver preserves invalid-session reasons", () => { - globalStore.clear(); - try { - setupCompletedRun(globalStore, "resolver-run"); - const resolver = createPostMortemHandleResolver({ - adapters: { agentSession: { async create() { throw new Error("must not create"); } } }, - resolveDefaultStageSessionDir: () => undefined, - }); + test.sequential("the extension resolver preserves invalid-session reasons", () => { + globalStore.clear(); + try { + setupCompletedRun(globalStore, "resolver-run"); + const resolver = createPostMortemHandleResolver({ + adapters: { + agentSession: { + async create() { + throw new Error("must not create"); + }, + }, + }, + resolveDefaultStageSessionDir: () => undefined, + }); - assert.deepEqual( - resolver("resolver-run", "stage-a"), - { ok: false, reason: "invalid_session" }, - ); - } finally { - globalStore.clear(); - } - }); + assert.deepEqual(resolver("resolver-run", "stage-a"), { ok: false, reason: "invalid_session" }); + } finally { + globalStore.clear(); + } + }); }); diff --git a/test/unit/workflow-authoring-folder-disclosure.test.ts b/test/unit/workflow-authoring-folder-disclosure.test.ts index 13479d8db..53e4a8706 100644 --- a/test/unit/workflow-authoring-folder-disclosure.test.ts +++ b/test/unit/workflow-authoring-folder-disclosure.test.ts @@ -1,35 +1,33 @@ import { resolve } from "node:path"; -import { describe, expect, test } from "bun:test"; +import { describe, expect, test } from "vitest"; import { - DEFAULT_PROMPT_GUIDANCE, - WORKFLOW_TOOL_DESCRIPTION, + DEFAULT_PROMPT_GUIDANCE, + WORKFLOW_TOOL_DESCRIPTION, } from "../../packages/workflows/src/extension/workflow-prompts.js"; +import { moduleDir, readText } from "../helpers/runtime.js"; -const repositoryRoot = resolve(import.meta.dir, "../.."); +const repositoryRoot = resolve(moduleDir(import.meta.url), "../.."); const disclosureMessage = "Custom workflow created. You can inspect its code at: "; const newCustomWorkflowScope = "only for newly created custom workflows"; async function readRepositoryFile(path: string): Promise { - return (await Bun.file(resolve(repositoryRoot, path)).text()).replaceAll("\r\n", "\n"); + return (await readText(resolve(repositoryRoot, path))).replaceAll("\r\n", "\n"); } function expectFolderDisclosure(content: string, source: string): void { - expect(content, source).toContain(disclosureMessage); - expect(content, source).toContain(newCustomWorkflowScope); + expect(content, source).toContain(disclosureMessage); + expect(content, source).toContain(newCustomWorkflowScope); } describe("custom workflow folder disclosure", () => { - test("keeps the tool description and prompt guidance explicit and scoped", () => { - expectFolderDisclosure(WORKFLOW_TOOL_DESCRIPTION, "WORKFLOW_TOOL_DESCRIPTION"); - expectFolderDisclosure(DEFAULT_PROMPT_GUIDANCE.join("\n"), "DEFAULT_PROMPT_GUIDANCE"); - }); + test("keeps the tool description and prompt guidance explicit and scoped", () => { + expectFolderDisclosure(WORKFLOW_TOOL_DESCRIPTION, "WORKFLOW_TOOL_DESCRIPTION"); + expectFolderDisclosure(DEFAULT_PROMPT_GUIDANCE.join("\n"), "DEFAULT_PROMPT_GUIDANCE"); + }); - test("keeps workflow creation docs explicit and scoped", async () => { - for (const path of [ - "packages/coding-agent/docs/workflows.md", - "packages/coding-agent/docs/quickstart.md", - ]) { - expectFolderDisclosure(await readRepositoryFile(path), path); - } - }); + test("keeps workflow creation docs explicit and scoped", async () => { + for (const path of ["packages/coding-agent/docs/workflows.md", "packages/coding-agent/docs/quickstart.md"]) { + expectFolderDisclosure(await readRepositoryFile(path), path); + } + }); }); diff --git a/test/unit/workflow-auto-restore-quit-resume.test.ts b/test/unit/workflow-auto-restore-quit-resume.test.ts index f113664b1..a959a2ab8 100644 --- a/test/unit/workflow-auto-restore-quit-resume.test.ts +++ b/test/unit/workflow-auto-restore-quit-resume.test.ts @@ -1,485 +1,545 @@ -import { afterEach, beforeEach, describe, test } from "bun:test"; import assert from "node:assert/strict"; -import { WORKFLOW_STAGE_SUBAGENT_GUARD_ENV, type AgentSession } from "@bastani/atomic"; +import { type AgentSession, WORKFLOW_STAGE_SUBAGENT_GUARD_ENV } from "@bastani/atomic"; import { Type } from "typebox"; +import { afterEach, beforeEach, describe, test } from "vitest"; import { workflow } from "../../packages/workflows/src/authoring/workflow.js"; import { InMemoryDurableBackend } from "../../packages/workflows/src/durable/backend.js"; import { setDurableBackend } from "../../packages/workflows/src/durable/factory.js"; -import { handleRunControlCommand } from "../../packages/workflows/src/extension/workflow-run-control-command.js"; -import { reconcileDurableResumeShadow } from "../../packages/workflows/src/extension/workflow-resume-shadow.js"; +import type { DurableWorkflowStatus } from "../../packages/workflows/src/durable/types.js"; import { createExtensionRuntime } from "../../packages/workflows/src/extension/runtime.js"; +import { reconcileDurableResumeShadow } from "../../packages/workflows/src/extension/workflow-resume-shadow.js"; +import { handleRunControlCommand } from "../../packages/workflows/src/extension/workflow-run-control-command.js"; import { makeExecuteWorkflowTool } from "../../packages/workflows/src/extension/workflow-tool.js"; import { createJobTracker, jobTracker } from "../../packages/workflows/src/runs/background/job-tracker.js"; import { - createStageControlRegistry, - stageControlRegistry, - type StageControlHandle, + createStageControlRegistry, + type StageControlHandle, + stageControlRegistry, } from "../../packages/workflows/src/runs/foreground/stage-control-registry.js"; import { restoreOnSessionStart } from "../../packages/workflows/src/shared/persistence-restore.js"; import { createStore, store } from "../../packages/workflows/src/shared/store.js"; -import type { DurableWorkflowStatus } from "../../packages/workflows/src/durable/types.js"; function seedRestoredShadow( - backend: InMemoryDurableBackend, - workflowId: string, - status: Extract, + backend: InMemoryDurableBackend, + workflowId: string, + status: Extract, ): void { - backend.registerWorkflow({ - workflowId, - name: "restored-workflow", - inputs: {}, - createdAt: 1, - status, - resumable: true, - }); - backend.recordCheckpoint({ - kind: "tool", - workflowId, - checkpointId: `tool:${workflowId}`, - name: "completed-side-effect", - argsHash: `hash:${workflowId}`, - output: "done", - completedAt: 2, - }); - restoreOnSessionStart( - { - getEntries: () => [{ - id: `run-start:${workflowId}`, - type: "workflow.run.start", - payload: { runId: workflowId, name: "restored-workflow", inputs: {}, ts: 1 }, - }], - }, - { resumeInFlight: "auto", persistRuns: true }, - store, - ); + backend.registerWorkflow({ + workflowId, + name: "restored-workflow", + inputs: {}, + createdAt: 1, + status, + resumable: true, + }); + backend.recordCheckpoint({ + kind: "tool", + workflowId, + checkpointId: `tool:${workflowId}`, + name: "completed-side-effect", + argsHash: `hash:${workflowId}`, + output: "done", + completedAt: 2, + }); + restoreOnSessionStart( + { + getEntries: () => [ + { + id: `run-start:${workflowId}`, + type: "workflow.run.start", + payload: { runId: workflowId, name: "restored-workflow", inputs: {}, ts: 1 }, + }, + ], + }, + { resumeInFlight: "auto", persistRuns: true }, + store, + ); } -function seedDurableOnly( - backend: InMemoryDurableBackend, - workflowId: string, - withProgress = true, -): void { - backend.registerWorkflow({ - workflowId, - name: "restored-workflow", - inputs: {}, - createdAt: 1, - status: "paused", - resumable: true, - }); - if (!withProgress) return; - backend.recordCheckpoint({ - kind: "tool", - workflowId, - checkpointId: `tool:${workflowId}`, - name: "completed-side-effect", - argsHash: `hash:${workflowId}`, - output: "done", - completedAt: 2, - }); +function seedDurableOnly(backend: InMemoryDurableBackend, workflowId: string, withProgress = true): void { + backend.registerWorkflow({ + workflowId, + name: "restored-workflow", + inputs: {}, + createdAt: 1, + status: "paused", + resumable: true, + }); + if (!withProgress) return; + backend.recordCheckpoint({ + kind: "tool", + workflowId, + checkpointId: `tool:${workflowId}`, + name: "completed-side-effect", + argsHash: `hash:${workflowId}`, + output: "done", + completedAt: 2, + }); } - function seedZeroProgressRestoredOrphan( - backend: InMemoryDurableBackend, - workflowId: string, - status: Extract, + backend: InMemoryDurableBackend, + workflowId: string, + status: Extract, ): void { - backend.registerWorkflow({ - workflowId, - name: "restored-workflow", - inputs: {}, - createdAt: 1, - status, - resumable: true, - }); - restoreOnSessionStart( - { - getEntries: () => [{ - id: `run-start:${workflowId}`, - type: "workflow.run.start", - payload: { runId: workflowId, name: "restored-workflow", inputs: {}, ts: 1 }, - }], - }, - { resumeInFlight: "auto", persistRuns: true }, - store, - ); + backend.registerWorkflow({ + workflowId, + name: "restored-workflow", + inputs: {}, + createdAt: 1, + status, + resumable: true, + }); + restoreOnSessionStart( + { + getEntries: () => [ + { + id: `run-start:${workflowId}`, + type: "workflow.run.start", + payload: { runId: workflowId, name: "restored-workflow", inputs: {}, ts: 1 }, + }, + ], + }, + { resumeInFlight: "auto", persistRuns: true }, + store, + ); } function resumableRuntime(releaseResumedStage: Promise) { - const definition = workflow({ - name: "restored-workflow", - description: "", - inputs: {}, - outputs: { done: Type.Boolean() }, - run: async (ctx) => { - await ctx.stage("resumed-stage").prompt("resume"); - return { done: true }; - }, - }); - return createExtensionRuntime({ - definitions: [definition], - store, - adapters: { - prompt: { - prompt: async () => { - await releaseResumedStage; - return "resumed"; - }, - }, - }, - }); + const definition = workflow({ + name: "restored-workflow", + description: "", + inputs: {}, + outputs: { done: Type.Boolean() }, + run: async (ctx) => { + await ctx.stage("resumed-stage").prompt("resume"); + return { done: true }; + }, + }); + return createExtensionRuntime({ + definitions: [definition], + store, + adapters: { + prompt: { + prompt: async () => { + await releaseResumedStage; + return "resumed"; + }, + }, + }, + }); } function liveControl(runId: string, initialStatus: "running" | "paused" = "running"): StageControlHandle { - let status = initialStatus; - return { - runId, - stageId: "live-stage", - stageName: "live-stage", - get status() { return status; }, - sessionId: undefined, - sessionFile: undefined, - isStreaming: false, - messages: [] as AgentSession["messages"], - async ensureAttached() {}, - async prompt() {}, - async steer() {}, - async followUp() {}, - async pause() { status = "paused"; }, - async resume() { status = "running"; }, - subscribe: () => () => {}, - }; + let status = initialStatus; + return { + runId, + stageId: "live-stage", + stageName: "live-stage", + get status() { + return status; + }, + sessionId: undefined, + sessionFile: undefined, + isStreaming: false, + messages: [] as AgentSession["messages"], + async ensureAttached() {}, + async prompt() {}, + async steer() {}, + async followUp() {}, + async pause() { + status = "paused"; + }, + async resume() { + status = "running"; + }, + subscribe: () => () => {}, + }; } beforeEach(() => { - delete process.env[WORKFLOW_STAGE_SUBAGENT_GUARD_ENV]; + delete process.env[WORKFLOW_STAGE_SUBAGENT_GUARD_ENV]; }); afterEach(async () => { - delete process.env[WORKFLOW_STAGE_SUBAGENT_GUARD_ENV]; - stageControlRegistry.clear(); - for (const runId of jobTracker.runIds()) { - const entry = jobTracker.get(runId); - entry?.controller.abort(); - await entry?.promise; - jobTracker.unregister(runId); - } - store.clear(); - setDurableBackend(undefined); + delete process.env[WORKFLOW_STAGE_SUBAGENT_GUARD_ENV]; + stageControlRegistry.clear(); + for (const runId of jobTracker.runIds()) { + const entry = jobTracker.get(runId); + entry?.controller.abort(); + await entry?.promise; + jobTracker.unregister(runId); + } + store.clear(); + setDurableBackend(undefined); }); describe("gracefully quit durable workflow session restore", () => { - test.serial.each(["paused", "running"] as const)( - "slash resume recovers authoritative durable %s shadow with the original workflow id", - async (durableStatus) => { - const workflowId = `slash-restored-${durableStatus}`; - const backend = new InMemoryDurableBackend(); - setDurableBackend(backend); - seedRestoredShadow(backend, workflowId, durableStatus); - assert.equal(store.runs().find((run) => run.id === workflowId)?.status, "running"); - assert.equal(jobTracker.has(workflowId), false); - assert.equal(stageControlRegistry.run(workflowId).stages().length, 0); - - const releaseResumedStage = Promise.withResolvers(); - const runtime = resumableRuntime(releaseResumedStage.promise); - const info: string[] = []; - const errors: string[] = []; - - await handleRunControlCommand( - "resume", - [workflowId.slice(0, 12)], - { hasUI: false, ui: { notify: () => undefined } }, - { info: (message) => info.push(message), error: (message) => errors.push(message) }, - { - pi: {}, - overlay: { open: () => undefined, toggle: () => undefined, close: () => undefined }, - runtimeForContext: () => runtime, - ensureWorkflowResourcesLoaded: () => undefined, - }, - ); - - assert.deepEqual(errors, []); - assert.match(info.join("\n"), /Resuming durable workflow/); - const resumedJob = jobTracker.get(workflowId); - assert.ok(resumedJob, "durable resume must dispatch a live job with the original workflow id"); - assert.equal(backend.getWorkflow(workflowId)?.status, "running"); - assert.equal(store.runs().filter((run) => run.id === workflowId).length, 1); - - releaseResumedStage.resolve(); - await resumedJob.promise; - assert.equal(store.runs().find((run) => run.id === workflowId)?.status, "completed"); - }, - ); - - test.serial.each(["paused", "running"] as const)( - "workflow tool resume recovers authoritative durable %s shadow with the original workflow id", - async (durableStatus) => { - const workflowId = `tool-restored-${durableStatus}`; - const backend = new InMemoryDurableBackend(); - setDurableBackend(backend); - seedRestoredShadow(backend, workflowId, durableStatus); - const releaseResumedStage = Promise.withResolvers(); - const runtime = resumableRuntime(releaseResumedStage.promise); - const execute = makeExecuteWorkflowTool(runtime, () => undefined, () => undefined); - - const result = await execute({ action: "resume", runId: workflowId }, {} as never); - - assert.equal(result.action, "resume"); - assert.equal(result.runId, workflowId); - assert.equal(result.status, "running"); - assert.match(result.message, /Resuming durable workflow/); - const resumedJob = jobTracker.get(workflowId); - assert.ok(resumedJob, "tool resume must dispatch a live job with the original workflow id"); - assert.equal(backend.getWorkflow(workflowId)?.status, "running"); - assert.equal(store.runs().filter((run) => run.id === workflowId).length, 1); - - releaseResumedStage.resolve(); - await resumedJob.promise; - }, - ); - - test.serial.each([ - ["exact id", (workflowId: string) => workflowId], - ["unique prefix", (workflowId: string) => workflowId.slice(0, 18)], - ] as const)( - "workflow tool resume discovers a durable-only target by %s", - async (_label, selectTarget) => { - const workflowId = `tool-durable-only-${Date.now()}`; - const backend = new InMemoryDurableBackend(); - setDurableBackend(backend); - seedDurableOnly(backend, workflowId); - assert.deepEqual(store.runs(), []); - const release = Promise.withResolvers(); - const execute = makeExecuteWorkflowTool(resumableRuntime(release.promise), () => undefined, () => undefined); - - const result = await execute({ action: "resume", runId: selectTarget(workflowId) }, {} as never); - - assert.equal(result.action, "resume"); - assert.equal(result.runId, workflowId); - assert.equal(result.status, "running"); - assert.match(result.message, /Resuming durable workflow/); - const resumedJob = jobTracker.get(workflowId); - assert.ok(resumedJob, "durable-only resume must dispatch under the original workflow id"); - assert.equal(store.runs().filter((run) => run.id === workflowId).length, 1); - release.resolve(); - await resumedJob.promise; - }, - ); - - test.serial("workflow tool reports every ambiguous durable-only prefix match", async () => { - const prefix = `tool-durable-ambiguous-${Date.now()}`; - const workflowIds = [`${prefix}-alpha`, `${prefix}-beta`]; - const backend = new InMemoryDurableBackend(); - setDurableBackend(backend); - for (const workflowId of workflowIds) seedDurableOnly(backend, workflowId); - const release = Promise.withResolvers(); - const execute = makeExecuteWorkflowTool(resumableRuntime(release.promise), () => undefined, () => undefined); - - const result = await execute({ action: "resume", runId: prefix }, {} as never); - - assert.equal(result.action, "resume"); - assert.equal(result.status, "noop"); - assert.match(result.message, /Ambiguous run prefix/); - for (const workflowId of workflowIds) { - assert.ok(result.message.includes(workflowId)); - assert.equal(backend.getWorkflow(workflowId)?.status, "paused"); - assert.equal(jobTracker.has(workflowId), false); - } - }); - - test.serial("workflow tool ambiguity includes local and durable-only prefix matches", async () => { - const prefix = `tool-mixed-ambiguous-${Date.now()}`; - const localIds = [`${prefix}-local`]; - const durableId = `${prefix}-durable`; - for (const id of localIds) { - store.recordRunStart({ - id, - name: "restored-workflow", - inputs: {}, - status: "paused", - stages: [], - startedAt: 1, - resumable: true, - }); - } - const backend = new InMemoryDurableBackend(); - setDurableBackend(backend); - seedDurableOnly(backend, durableId); - const release = Promise.withResolvers(); - const execute = makeExecuteWorkflowTool(resumableRuntime(release.promise), () => undefined, () => undefined); - - const result = await execute({ action: "resume", runId: prefix }, {} as never); - - assert.equal(result.action, "resume"); - assert.equal(result.status, "noop"); - assert.match(result.message, /Ambiguous run prefix/); - for (const id of [...localIds, durableId]) assert.ok(result.message.includes(id)); - assert.equal(jobTracker.has(durableId), false); - }); - - test.serial("combined resolution keeps a sole eligible live prefix target", async () => { - const prefix = `tool-live-filter-${Date.now()}`; - const liveId = `${prefix}-paused`; - const terminalId = `${prefix}-terminal`; - store.recordRunStart({ id: liveId, name: "restored-workflow", inputs: {}, status: "paused", stages: [], startedAt: 1, resumable: true }); - store.recordStageStart(liveId, { id: "live-stage", name: "live-stage", status: "paused", parentIds: [], toolEvents: [] }); - store.recordRunStart({ id: terminalId, name: "old", inputs: {}, status: "killed", stages: [], startedAt: 1, endedAt: 2, resumable: false }); - const backend = new InMemoryDurableBackend(); - setDurableBackend(backend); - seedDurableOnly(backend, liveId); - stageControlRegistry.register(liveControl(liveId, "paused")); - const release = Promise.withResolvers(); - const execute = makeExecuteWorkflowTool(resumableRuntime(release.promise), () => undefined, () => undefined); - - const result = await execute({ action: "resume", runId: prefix }, {} as never); - - assert.equal(result.action, "resume"); - assert.equal(result.runId, liveId); - assert.equal(result.status, "ok"); - assert.equal(store.runs().find((run) => run.id === liveId)?.status, "running"); - assert.equal(store.runs().find((run) => run.id === terminalId)?.status, "killed"); - }); - - - test.serial("workflow tool refuses a durable-only zero-progress target without local synthesis", async () => { - const workflowId = `tool-durable-ineligible-${Date.now()}`; - const backend = new InMemoryDurableBackend(); - setDurableBackend(backend); - seedDurableOnly(backend, workflowId, false); - const release = Promise.withResolvers(); - const execute = makeExecuteWorkflowTool(resumableRuntime(release.promise), () => undefined, () => undefined); - - const result = await execute({ action: "resume", runId: workflowId }, {} as never); - - assert.equal(result.action, "resume"); - assert.equal(result.status, "noop"); - assert.match(result.message, /not resumable|no durable progress/i); - assert.deepEqual(store.runs(), []); - assert.equal(backend.getWorkflow(workflowId)?.status, "paused"); - }); - - test.serial("workflow tool surfaces resource loading failure before durable-only lookup", async () => { - const workflowId = `tool-durable-loader-failure-${Date.now()}`; - const backend = new InMemoryDurableBackend(); - setDurableBackend(backend); - seedDurableOnly(backend, workflowId); - const release = Promise.withResolvers(); - const execute = makeExecuteWorkflowTool( - resumableRuntime(release.promise), - () => undefined, - () => { throw new Error("resource loading exploded"); }, - ); - - const result = await execute({ action: "resume", runId: workflowId }, {} as never); - - assert.equal(result.action, "resume"); - assert.equal(result.status, "noop"); - assert.match(result.message, /resource loading exploded/); - assert.doesNotMatch(result.message, /Run not found/); - assert.equal(jobTracker.has(workflowId), false); - }); - - test.serial.each([ - ["paused", "job"], - ["running", "job"], - ["paused", "control"], - ["running", "control"], - ] as const)( - "durable %s snapshot is not a resume shadow while a live %s exists", - (durableStatus, liveKind) => { - const workflowId = `live-${durableStatus}-${liveKind}`; - const backend = new InMemoryDurableBackend(); - backend.registerWorkflow({ - workflowId, - name: "restored-workflow", - inputs: {}, - createdAt: 1, - status: durableStatus, - }); - const localStore = createStore(); - localStore.recordRunStart({ - id: workflowId, - name: "restored-workflow", - inputs: {}, - status: "running", - stages: [], - startedAt: 1, - }); - const jobs = createJobTracker(); - const controls = createStageControlRegistry(); - if (liveKind === "job") { - jobs.register({ runId: workflowId, controller: new AbortController(), promise: Promise.resolve() }); - } else { - controls.register(liveControl(workflowId)); - } - const run = localStore.runs().find((candidate) => candidate.id === workflowId)!; - - assert.equal(reconcileDurableResumeShadow(run, localStore, { - backend, - jobs, - stageControls: controls, - }), false); - assert.equal(localStore.runs().find((candidate) => candidate.id === workflowId)?.status, "running"); - }, - ); - - test.serial.each(["paused", "running"] as const)( - "zero-progress durable %s orphan stays unmodified and tool resume is a noop", - async (durableStatus) => { - const workflowId = `zero-tool-${durableStatus}`; - const backend = new InMemoryDurableBackend(); - setDurableBackend(backend); - seedZeroProgressRestoredOrphan(backend, workflowId, durableStatus); - const before = structuredClone(store.runs().find((run) => run.id === workflowId)); - assert.ok(before); - assert.equal(reconcileDurableResumeShadow(before, store, { backend }), false); - - const release = Promise.withResolvers(); - const execute = makeExecuteWorkflowTool(resumableRuntime(release.promise), () => undefined, () => undefined); - const result = await execute({ action: "resume", runId: workflowId.slice(0, 12) }, {} as never); - - assert.equal(result.action, "resume"); - assert.equal(result.runId, workflowId); - assert.equal(result.status, "noop"); - assert.match(result.message, /not resumable|no durable progress/i); - assert.deepEqual(store.runs().find((run) => run.id === workflowId), before); - assert.equal(backend.getWorkflow(workflowId)?.status, durableStatus); - assert.equal(jobTracker.has(workflowId), false); - }, - ); - - test.serial.each(["paused", "running"] as const)( - "zero-progress durable %s orphan stays unmodified through slash resume", - async (durableStatus) => { - const workflowId = `zero-slash-${durableStatus}`; - const backend = new InMemoryDurableBackend(); - setDurableBackend(backend); - seedZeroProgressRestoredOrphan(backend, workflowId, durableStatus); - const before = structuredClone(store.runs().find((run) => run.id === workflowId)); - assert.ok(before); - const release = Promise.withResolvers(); - const info: string[] = []; - const errors: string[] = []; - - await handleRunControlCommand( - "resume", - [workflowId.slice(0, 12)], - { hasUI: false, ui: { notify: () => undefined } }, - { info: (message) => info.push(message), error: (message) => errors.push(message) }, - { - pi: {}, - overlay: { open: () => undefined, toggle: () => undefined, close: () => undefined }, - runtimeForContext: () => resumableRuntime(release.promise), - ensureWorkflowResourcesLoaded: () => undefined, - }, - ); - - assert.deepEqual(info, []); - assert.match(errors.join("\n"), /not resumable|no durable progress/i); - assert.deepEqual(store.runs().find((run) => run.id === workflowId), before); - assert.equal(backend.getWorkflow(workflowId)?.status, durableStatus); - assert.equal(jobTracker.has(workflowId), false); - }, - ); + test.sequential.each(["paused", "running"] as const)( + "slash resume recovers authoritative durable %s shadow with the original workflow id", + async (durableStatus) => { + const workflowId = `slash-restored-${durableStatus}`; + const backend = new InMemoryDurableBackend(); + setDurableBackend(backend); + seedRestoredShadow(backend, workflowId, durableStatus); + assert.equal(store.runs().find((run) => run.id === workflowId)?.status, "running"); + assert.equal(jobTracker.has(workflowId), false); + assert.equal(stageControlRegistry.run(workflowId).stages().length, 0); + + const releaseResumedStage = Promise.withResolvers(); + const runtime = resumableRuntime(releaseResumedStage.promise); + const info: string[] = []; + const errors: string[] = []; + + await handleRunControlCommand( + "resume", + [workflowId.slice(0, 12)], + { hasUI: false, ui: { notify: () => undefined } }, + { info: (message) => info.push(message), error: (message) => errors.push(message) }, + { + pi: {}, + overlay: { open: () => undefined, toggle: () => undefined, close: () => undefined }, + runtimeForContext: () => runtime, + ensureWorkflowResourcesLoaded: () => undefined, + }, + ); + + assert.deepEqual(errors, []); + assert.match(info.join("\n"), /Resuming durable workflow/); + const resumedJob = jobTracker.get(workflowId); + assert.ok(resumedJob, "durable resume must dispatch a live job with the original workflow id"); + assert.equal(backend.getWorkflow(workflowId)?.status, "running"); + assert.equal(store.runs().filter((run) => run.id === workflowId).length, 1); + + releaseResumedStage.resolve(); + await resumedJob.promise; + assert.equal(store.runs().find((run) => run.id === workflowId)?.status, "completed"); + }, + ); + + test.sequential.each(["paused", "running"] as const)( + "workflow tool resume recovers authoritative durable %s shadow with the original workflow id", + async (durableStatus) => { + const workflowId = `tool-restored-${durableStatus}`; + const backend = new InMemoryDurableBackend(); + setDurableBackend(backend); + seedRestoredShadow(backend, workflowId, durableStatus); + const releaseResumedStage = Promise.withResolvers(); + const runtime = resumableRuntime(releaseResumedStage.promise); + const execute = makeExecuteWorkflowTool( + runtime, + () => undefined, + () => undefined, + ); + + const result = await execute({ action: "resume", runId: workflowId }, {} as never); + + assert.equal(result.action, "resume"); + assert.equal(result.runId, workflowId); + assert.equal(result.status, "running"); + assert.match(result.message, /Resuming durable workflow/); + const resumedJob = jobTracker.get(workflowId); + assert.ok(resumedJob, "tool resume must dispatch a live job with the original workflow id"); + assert.equal(backend.getWorkflow(workflowId)?.status, "running"); + assert.equal(store.runs().filter((run) => run.id === workflowId).length, 1); + + releaseResumedStage.resolve(); + await resumedJob.promise; + }, + ); + + test.sequential.each([ + ["exact id", (workflowId: string) => workflowId], + ["unique prefix", (workflowId: string) => workflowId.slice(0, 18)], + ] as const)("workflow tool resume discovers a durable-only target by %s", async (_label, selectTarget) => { + const workflowId = `tool-durable-only-${Date.now()}`; + const backend = new InMemoryDurableBackend(); + setDurableBackend(backend); + seedDurableOnly(backend, workflowId); + assert.deepEqual(store.runs(), []); + const release = Promise.withResolvers(); + const execute = makeExecuteWorkflowTool( + resumableRuntime(release.promise), + () => undefined, + () => undefined, + ); + + const result = await execute({ action: "resume", runId: selectTarget(workflowId) }, {} as never); + + assert.equal(result.action, "resume"); + assert.equal(result.runId, workflowId); + assert.equal(result.status, "running"); + assert.match(result.message, /Resuming durable workflow/); + const resumedJob = jobTracker.get(workflowId); + assert.ok(resumedJob, "durable-only resume must dispatch under the original workflow id"); + assert.equal(store.runs().filter((run) => run.id === workflowId).length, 1); + release.resolve(); + await resumedJob.promise; + }); + + test.sequential("workflow tool reports every ambiguous durable-only prefix match", async () => { + const prefix = `tool-durable-ambiguous-${Date.now()}`; + const workflowIds = [`${prefix}-alpha`, `${prefix}-beta`]; + const backend = new InMemoryDurableBackend(); + setDurableBackend(backend); + for (const workflowId of workflowIds) seedDurableOnly(backend, workflowId); + const release = Promise.withResolvers(); + const execute = makeExecuteWorkflowTool( + resumableRuntime(release.promise), + () => undefined, + () => undefined, + ); + + const result = await execute({ action: "resume", runId: prefix }, {} as never); + + assert.equal(result.action, "resume"); + assert.equal(result.status, "noop"); + assert.match(result.message, /Ambiguous run prefix/); + for (const workflowId of workflowIds) { + assert.ok(result.message.includes(workflowId)); + assert.equal(backend.getWorkflow(workflowId)?.status, "paused"); + assert.equal(jobTracker.has(workflowId), false); + } + }); + + test.sequential("workflow tool ambiguity includes local and durable-only prefix matches", async () => { + const prefix = `tool-mixed-ambiguous-${Date.now()}`; + const localIds = [`${prefix}-local`]; + const durableId = `${prefix}-durable`; + for (const id of localIds) { + store.recordRunStart({ + id, + name: "restored-workflow", + inputs: {}, + status: "paused", + stages: [], + startedAt: 1, + resumable: true, + }); + } + const backend = new InMemoryDurableBackend(); + setDurableBackend(backend); + seedDurableOnly(backend, durableId); + const release = Promise.withResolvers(); + const execute = makeExecuteWorkflowTool( + resumableRuntime(release.promise), + () => undefined, + () => undefined, + ); + + const result = await execute({ action: "resume", runId: prefix }, {} as never); + + assert.equal(result.action, "resume"); + assert.equal(result.status, "noop"); + assert.match(result.message, /Ambiguous run prefix/); + for (const id of [...localIds, durableId]) assert.ok(result.message.includes(id)); + assert.equal(jobTracker.has(durableId), false); + }); + + test.sequential("combined resolution keeps a sole eligible live prefix target", async () => { + const prefix = `tool-live-filter-${Date.now()}`; + const liveId = `${prefix}-paused`; + const terminalId = `${prefix}-terminal`; + store.recordRunStart({ + id: liveId, + name: "restored-workflow", + inputs: {}, + status: "paused", + stages: [], + startedAt: 1, + resumable: true, + }); + store.recordStageStart(liveId, { + id: "live-stage", + name: "live-stage", + status: "paused", + parentIds: [], + toolEvents: [], + }); + store.recordRunStart({ + id: terminalId, + name: "old", + inputs: {}, + status: "killed", + stages: [], + startedAt: 1, + endedAt: 2, + resumable: false, + }); + const backend = new InMemoryDurableBackend(); + setDurableBackend(backend); + seedDurableOnly(backend, liveId); + stageControlRegistry.register(liveControl(liveId, "paused")); + const release = Promise.withResolvers(); + const execute = makeExecuteWorkflowTool( + resumableRuntime(release.promise), + () => undefined, + () => undefined, + ); + + const result = await execute({ action: "resume", runId: prefix }, {} as never); + + assert.equal(result.action, "resume"); + assert.equal(result.runId, liveId); + assert.equal(result.status, "ok"); + assert.equal(store.runs().find((run) => run.id === liveId)?.status, "running"); + assert.equal(store.runs().find((run) => run.id === terminalId)?.status, "killed"); + }); + + test.sequential("workflow tool refuses a durable-only zero-progress target without local synthesis", async () => { + const workflowId = `tool-durable-ineligible-${Date.now()}`; + const backend = new InMemoryDurableBackend(); + setDurableBackend(backend); + seedDurableOnly(backend, workflowId, false); + const release = Promise.withResolvers(); + const execute = makeExecuteWorkflowTool( + resumableRuntime(release.promise), + () => undefined, + () => undefined, + ); + + const result = await execute({ action: "resume", runId: workflowId }, {} as never); + + assert.equal(result.action, "resume"); + assert.equal(result.status, "noop"); + assert.match(result.message, /not resumable|no durable progress/i); + assert.deepEqual(store.runs(), []); + assert.equal(backend.getWorkflow(workflowId)?.status, "paused"); + }); + + test.sequential("workflow tool surfaces resource loading failure before durable-only lookup", async () => { + const workflowId = `tool-durable-loader-failure-${Date.now()}`; + const backend = new InMemoryDurableBackend(); + setDurableBackend(backend); + seedDurableOnly(backend, workflowId); + const release = Promise.withResolvers(); + const execute = makeExecuteWorkflowTool( + resumableRuntime(release.promise), + () => undefined, + () => { + throw new Error("resource loading exploded"); + }, + ); + + const result = await execute({ action: "resume", runId: workflowId }, {} as never); + + assert.equal(result.action, "resume"); + assert.equal(result.status, "noop"); + assert.match(result.message, /resource loading exploded/); + assert.doesNotMatch(result.message, /Run not found/); + assert.equal(jobTracker.has(workflowId), false); + }); + + test.sequential.each([ + ["paused", "job"], + ["running", "job"], + ["paused", "control"], + ["running", "control"], + ] as const)("durable %s snapshot is not a resume shadow while a live %s exists", (durableStatus, liveKind) => { + const workflowId = `live-${durableStatus}-${liveKind}`; + const backend = new InMemoryDurableBackend(); + backend.registerWorkflow({ + workflowId, + name: "restored-workflow", + inputs: {}, + createdAt: 1, + status: durableStatus, + }); + const localStore = createStore(); + localStore.recordRunStart({ + id: workflowId, + name: "restored-workflow", + inputs: {}, + status: "running", + stages: [], + startedAt: 1, + }); + const jobs = createJobTracker(); + const controls = createStageControlRegistry(); + if (liveKind === "job") { + jobs.register({ runId: workflowId, controller: new AbortController(), promise: Promise.resolve() }); + } else { + controls.register(liveControl(workflowId)); + } + const run = localStore.runs().find((candidate) => candidate.id === workflowId)!; + + assert.equal( + reconcileDurableResumeShadow(run, localStore, { + backend, + jobs, + stageControls: controls, + }), + false, + ); + assert.equal(localStore.runs().find((candidate) => candidate.id === workflowId)?.status, "running"); + }); + + test.sequential.each(["paused", "running"] as const)( + "zero-progress durable %s orphan stays unmodified and tool resume is a noop", + async (durableStatus) => { + const workflowId = `zero-tool-${durableStatus}`; + const backend = new InMemoryDurableBackend(); + setDurableBackend(backend); + seedZeroProgressRestoredOrphan(backend, workflowId, durableStatus); + const before = structuredClone(store.runs().find((run) => run.id === workflowId)); + assert.ok(before); + assert.equal(reconcileDurableResumeShadow(before, store, { backend }), false); + + const release = Promise.withResolvers(); + const execute = makeExecuteWorkflowTool( + resumableRuntime(release.promise), + () => undefined, + () => undefined, + ); + const result = await execute({ action: "resume", runId: workflowId.slice(0, 12) }, {} as never); + + assert.equal(result.action, "resume"); + assert.equal(result.runId, workflowId); + assert.equal(result.status, "noop"); + assert.match(result.message, /not resumable|no durable progress/i); + assert.deepEqual( + store.runs().find((run) => run.id === workflowId), + before, + ); + assert.equal(backend.getWorkflow(workflowId)?.status, durableStatus); + assert.equal(jobTracker.has(workflowId), false); + }, + ); + + test.sequential.each(["paused", "running"] as const)( + "zero-progress durable %s orphan stays unmodified through slash resume", + async (durableStatus) => { + const workflowId = `zero-slash-${durableStatus}`; + const backend = new InMemoryDurableBackend(); + setDurableBackend(backend); + seedZeroProgressRestoredOrphan(backend, workflowId, durableStatus); + const before = structuredClone(store.runs().find((run) => run.id === workflowId)); + assert.ok(before); + const release = Promise.withResolvers(); + const info: string[] = []; + const errors: string[] = []; + + await handleRunControlCommand( + "resume", + [workflowId.slice(0, 12)], + { hasUI: false, ui: { notify: () => undefined } }, + { info: (message) => info.push(message), error: (message) => errors.push(message) }, + { + pi: {}, + overlay: { open: () => undefined, toggle: () => undefined, close: () => undefined }, + runtimeForContext: () => resumableRuntime(release.promise), + ensureWorkflowResourcesLoaded: () => undefined, + }, + ); + + assert.deepEqual(info, []); + assert.match(errors.join("\n"), /not resumable|no durable progress/i); + assert.deepEqual( + store.runs().find((run) => run.id === workflowId), + before, + ); + assert.equal(backend.getWorkflow(workflowId)?.status, durableStatus); + assert.equal(jobTracker.has(workflowId), false); + }, + ); }); diff --git a/test/unit/workflow-command-alias.test.ts b/test/unit/workflow-command-alias.test.ts index b8de5abb7..dd2536935 100644 --- a/test/unit/workflow-command-alias.test.ts +++ b/test/unit/workflow-command-alias.test.ts @@ -1,47 +1,52 @@ -import { test } from "bun:test"; import assert from "node:assert/strict"; +import { test } from "vitest"; import { InMemoryDurableBackend } from "../../packages/workflows/src/durable/backend.js"; import { setDurableBackend } from "../../packages/workflows/src/durable/factory.js"; -import { registerWorkflowSlashCommand } from "../../packages/workflows/src/extension/workflow-command-registration.js"; import type { ExtensionAPI } from "../../packages/workflows/src/extension/public-types.js"; import { createExtensionRuntime } from "../../packages/workflows/src/extension/runtime.js"; +import { registerWorkflowSlashCommand } from "../../packages/workflows/src/extension/workflow-command-registration.js"; import type { WorkflowCommandHandler } from "../../packages/workflows/src/extension/workflow-command-utils.js"; import type { GraphOverlayPort } from "../../packages/workflows/src/tui/overlay-adapter.js"; const overlay: GraphOverlayPort = { - open: () => {}, - toggle: () => {}, - close: () => {}, + open: () => {}, + toggle: () => {}, + close: () => {}, }; test("registers /workflows as the durable run-history alias", () => { - setDurableBackend(new InMemoryDurableBackend()); - try { - const handlers = new Map(); - const registered: string[] = []; - const pi: ExtensionAPI = { - registerCommand(name) { registered.push(name); }, - }; - const runtime = createExtensionRuntime(); - registerWorkflowSlashCommand(pi, handlers, { - runtimeProxy: runtime, - runtimeForContext: () => runtime, - overlay, - reloadWorkflowResources: () => undefined, - ensureWorkflowResourcesLoaded: () => undefined, - runWithLifecycleSuppressedForPolicy: (_policy, run) => run(), - runControl: { - pi, - overlay, - runtimeForContext: () => runtime, - ensureWorkflowResourcesLoaded: () => undefined, - }, - }); + setDurableBackend(new InMemoryDurableBackend()); + try { + const handlers = new Map(); + const registered: string[] = []; + const pi: ExtensionAPI = { + registerCommand(name) { + registered.push(name); + }, + }; + const runtime = createExtensionRuntime(); + registerWorkflowSlashCommand(pi, handlers, { + runtimeProxy: runtime, + runtimeForContext: () => runtime, + overlay, + reloadWorkflowResources: () => undefined, + ensureWorkflowResourcesLoaded: () => undefined, + runWithLifecycleSuppressedForPolicy: (_policy, run) => run(), + runControl: { + pi, + overlay, + runtimeForContext: () => runtime, + ensureWorkflowResourcesLoaded: () => undefined, + }, + }); - assert.equal(handlers.has("workflow"), true); - assert.equal(handlers.has("workflows"), true); - assert.deepEqual(registered.filter((name) => name.startsWith("workflow")), ["workflow", "workflows"]); - } finally { - setDurableBackend(undefined); - } + assert.equal(handlers.has("workflow"), true); + assert.equal(handlers.has("workflows"), true); + assert.deepEqual( + registered.filter((name) => name.startsWith("workflow")), + ["workflow", "workflows"], + ); + } finally { + setDurableBackend(undefined); + } }); diff --git a/test/unit/workflow-completed-inspection-lifecycle-state.test.ts b/test/unit/workflow-completed-inspection-lifecycle-state.test.ts index a81004afd..2390d3fdd 100644 --- a/test/unit/workflow-completed-inspection-lifecycle-state.test.ts +++ b/test/unit/workflow-completed-inspection-lifecycle-state.test.ts @@ -1,149 +1,178 @@ -import { describe, test } from "bun:test"; import assert from "node:assert/strict"; +import { describe, test } from "vitest"; import { InMemoryDurableBackend } from "../../packages/workflows/src/durable/backend.js"; import { openCompletedDurableWorkflow } from "../../packages/workflows/src/durable/completed-inspection.js"; import { - createWorkflowLifecycleNotificationState, - installWorkflowLifecycleNotifications, - seedWorkflowLifecycleNotificationState, - type WorkflowLifecycleNoticeDetails, + createWorkflowLifecycleNotificationState, + installWorkflowLifecycleNotifications, + seedWorkflowLifecycleNotificationState, + type WorkflowLifecycleNoticeDetails, } from "../../packages/workflows/src/extension/lifecycle-notifications.js"; import { createStore } from "../../packages/workflows/src/shared/store.js"; interface Admission { - readonly content?: string; - readonly details?: WorkflowLifecycleNoticeDetails; + readonly content?: string; + readonly details?: WorkflowLifecycleNoticeDetails; } interface TimerRecord { - readonly callback: () => void; - active: boolean; + readonly callback: () => void; + active: boolean; } function seedCompletedTool(backend: InMemoryDurableBackend, runId: string, name = "historical workflow"): void { - backend.registerWorkflow({ workflowId: runId, name, inputs: {}, createdAt: 1, updatedAt: 3, status: "completed" }); - backend.recordCheckpoint({ - kind: "tool", workflowId: runId, checkpointId: "tool:done", name: "done", - argsHash: "done-hash", output: true, completedAt: 2, - }); + backend.registerWorkflow({ workflowId: runId, name, inputs: {}, createdAt: 1, updatedAt: 3, status: "completed" }); + backend.recordCheckpoint({ + kind: "tool", + workflowId: runId, + checkpointId: "tool:done", + name: "done", + argsHash: "done-hash", + output: true, + completedAt: 2, + }); } function flushMicrotasks(): Promise { - return Promise.resolve().then(() => undefined).then(() => undefined).then(() => undefined); + return Promise.resolve() + .then(() => undefined) + .then(() => undefined) + .then(() => undefined); } function installTimerHarness(): { runNext(): void; activeCount(): number; restore(): void } { - const originalSetTimeout = globalThis.setTimeout; - const originalClearTimeout = globalThis.clearTimeout; - const timers: TimerRecord[] = []; - globalThis.setTimeout = ((callback: () => void) => { - const timer: TimerRecord = { callback, active: true }; - timers.push(timer); - return timer as never; - }) as unknown as typeof setTimeout; - globalThis.clearTimeout = ((timer: ReturnType) => { - (timer as unknown as TimerRecord).active = false; - }) as typeof clearTimeout; - return { - runNext() { - const timer = timers.find((candidate) => candidate.active); - assert.ok(timer); - timer.active = false; - timer.callback(); - }, - activeCount: () => timers.filter((timer) => timer.active).length, - restore() { - globalThis.setTimeout = originalSetTimeout; - globalThis.clearTimeout = originalClearTimeout; - }, - }; + const originalSetTimeout = globalThis.setTimeout; + const originalClearTimeout = globalThis.clearTimeout; + const timers: TimerRecord[] = []; + globalThis.setTimeout = ((callback: () => void) => { + const timer: TimerRecord = { callback, active: true }; + timers.push(timer); + return timer as never; + }) as unknown as typeof setTimeout; + globalThis.clearTimeout = ((timer: ReturnType) => { + (timer as unknown as TimerRecord).active = false; + }) as typeof clearTimeout; + return { + runNext() { + const timer = timers.find((candidate) => candidate.active); + assert.ok(timer); + timer.active = false; + timer.callback(); + }, + activeCount: () => timers.filter((timer) => timer.active).length, + restore() { + globalThis.setTimeout = originalSetTimeout; + globalThis.clearTimeout = originalClearTimeout; + }, + }; } describe("completed inspection lifecycle delivery state", () => { - test("preserves an in-flight live completion admission until its original send resolves", async () => { - const backend = new InMemoryDurableBackend(); - const store = createStore(); - const state = createWorkflowLifecycleNotificationState(); - const send = Promise.withResolvers(); - const admissions: Admission[] = []; - seedCompletedTool(backend, "pending-live", "historical replacement"); - const unsubscribe = installWorkflowLifecycleNotifications({ - store, state, seedExisting: false, - config: { enabled: true, notifyOn: ["completed"] }, - sendMessage(message) { admissions.push(message as Admission); return send.promise; }, - }); - store.recordRunStart({ id: "pending-live", name: "original live", inputs: {}, status: "running", stages: [], startedAt: 1 }); - store.recordRunEnd("pending-live", "completed", {}); - assert.equal(admissions.length, 1); + test("preserves an in-flight live completion admission until its original send resolves", async () => { + const backend = new InMemoryDurableBackend(); + const store = createStore(); + const state = createWorkflowLifecycleNotificationState(); + const send = Promise.withResolvers(); + const admissions: Admission[] = []; + seedCompletedTool(backend, "pending-live", "historical replacement"); + const unsubscribe = installWorkflowLifecycleNotifications({ + store, + state, + seedExisting: false, + config: { enabled: true, notifyOn: ["completed"] }, + sendMessage(message) { + admissions.push(message as Admission); + return send.promise; + }, + }); + store.recordRunStart({ + id: "pending-live", + name: "original live", + inputs: {}, + status: "running", + stages: [], + startedAt: 1, + }); + store.recordRunEnd("pending-live", "completed", {}); + assert.equal(admissions.length, 1); - const opened = openCompletedDurableWorkflow("pending-live", { - durableBackend: backend, - store, - beforeRestore(snapshots) { - seedWorkflowLifecycleNotificationState(state, { ...store.snapshot(), runs: snapshots }); - }, - }); - assert.equal(opened.ok, true); - assert.equal(admissions.length, 1, "historical insertion must not duplicate a pending live admission"); - assert.equal(state.pendingTerminalRuns.size, 1); + const opened = openCompletedDurableWorkflow("pending-live", { + durableBackend: backend, + store, + beforeRestore(snapshots) { + seedWorkflowLifecycleNotificationState(state, { ...store.snapshot(), runs: snapshots }); + }, + }); + assert.equal(opened.ok, true); + assert.equal(admissions.length, 1, "historical insertion must not duplicate a pending live admission"); + assert.equal(state.pendingTerminalRuns.size, 1); - send.resolve(); - await flushMicrotasks(); - assert.equal(state.pendingTerminalRuns.size, 0); - assert.equal(state.deliveredTerminalRuns.size, 1); - assert.equal(admissions.length, 1); - unsubscribe(); - }); + send.resolve(); + await flushMicrotasks(); + assert.equal(state.pendingTerminalRuns.size, 0); + assert.equal(state.deliveredTerminalRuns.size, 1); + assert.equal(admissions.length, 1); + unsubscribe(); + }); - test("preserves the retained retry envelope until its scheduled retry", async () => { - const timers = installTimerHarness(); - const backend = new InMemoryDurableBackend(); - const store = createStore(); - const state = createWorkflowLifecycleNotificationState(); - const admissions: Admission[] = []; - let attempt = 0; - seedCompletedTool(backend, "retry-live", "historical replacement"); - const unsubscribe = installWorkflowLifecycleNotifications({ - store, state, seedExisting: false, - config: { enabled: true, notifyOn: ["completed"] }, - sendMessage(message) { - admissions.push(message as Admission); - attempt += 1; - if (attempt === 1) return Promise.reject(new Error("first admission rejected")); - }, - }); - try { - store.recordRunStart({ id: "retry-live", name: "original live", inputs: {}, status: "running", stages: [], startedAt: 1 }); - store.recordRunEnd("retry-live", "completed", {}); - await flushMicrotasks(); - assert.equal(admissions.length, 1); - assert.equal(timers.activeCount(), 1); - const originalDetails = admissions[0]?.details; - const originalContent = admissions[0]?.content; - assert.equal(originalDetails?.workflowName, "original live"); + test("preserves the retained retry envelope until its scheduled retry", async () => { + const timers = installTimerHarness(); + const backend = new InMemoryDurableBackend(); + const store = createStore(); + const state = createWorkflowLifecycleNotificationState(); + const admissions: Admission[] = []; + let attempt = 0; + seedCompletedTool(backend, "retry-live", "historical replacement"); + const unsubscribe = installWorkflowLifecycleNotifications({ + store, + state, + seedExisting: false, + config: { enabled: true, notifyOn: ["completed"] }, + sendMessage(message) { + admissions.push(message as Admission); + attempt += 1; + if (attempt === 1) return Promise.reject(new Error("first admission rejected")); + }, + }); + try { + store.recordRunStart({ + id: "retry-live", + name: "original live", + inputs: {}, + status: "running", + stages: [], + startedAt: 1, + }); + store.recordRunEnd("retry-live", "completed", {}); + await flushMicrotasks(); + assert.equal(admissions.length, 1); + assert.equal(timers.activeCount(), 1); + const originalDetails = admissions[0]?.details; + const originalContent = admissions[0]?.content; + assert.equal(originalDetails?.workflowName, "original live"); - const opened = openCompletedDurableWorkflow("retry-live", { - durableBackend: backend, - store, - beforeRestore(snapshots) { - seedWorkflowLifecycleNotificationState(state, { ...store.snapshot(), runs: snapshots }); - }, - }); - assert.equal(opened.ok, true); - assert.equal(admissions.length, 1, "restoration must not immediately replace a retryable live envelope"); - assert.equal(state.retryableTerminalNotices.values().next().value, originalDetails); - assert.equal(timers.activeCount(), 1); + const opened = openCompletedDurableWorkflow("retry-live", { + durableBackend: backend, + store, + beforeRestore(snapshots) { + seedWorkflowLifecycleNotificationState(state, { ...store.snapshot(), runs: snapshots }); + }, + }); + assert.equal(opened.ok, true); + assert.equal(admissions.length, 1, "restoration must not immediately replace a retryable live envelope"); + assert.equal(state.retryableTerminalNotices.values().next().value, originalDetails); + assert.equal(timers.activeCount(), 1); - timers.runNext(); - await flushMicrotasks(); - assert.equal(admissions.length, 2); - assert.equal(admissions[1]?.details, originalDetails); - assert.equal(admissions[1]?.content, originalContent); - assert.equal(state.retryableTerminalNotices.size, 0); - assert.equal(state.deliveredTerminalRuns.size, 1); - } finally { - unsubscribe(); - timers.restore(); - } - }); + timers.runNext(); + await flushMicrotasks(); + assert.equal(admissions.length, 2); + assert.equal(admissions[1]?.details, originalDetails); + assert.equal(admissions[1]?.content, originalContent); + assert.equal(state.retryableTerminalNotices.size, 0); + assert.equal(state.deliveredTerminalRuns.size, 1); + } finally { + unsubscribe(); + timers.restore(); + } + }); }); diff --git a/test/unit/workflow-completed-inspection.test.ts b/test/unit/workflow-completed-inspection.test.ts index 32c372823..f0d09e0cc 100644 --- a/test/unit/workflow-completed-inspection.test.ts +++ b/test/unit/workflow-completed-inspection.test.ts @@ -1,15 +1,15 @@ -import { afterEach, beforeEach, describe, test } from "bun:test"; import assert from "node:assert/strict"; import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { afterEach, beforeEach, describe, test } from "vitest"; import { SessionManager } from "../../packages/coding-agent/src/core/session-manager.js"; import { InMemoryDurableBackend } from "../../packages/workflows/src/durable/backend.js"; import { openCompletedDurableWorkflow } from "../../packages/workflows/src/durable/completed-inspection.js"; import { - createWorkflowLifecycleNotificationState, - installWorkflowLifecycleNotifications, - seedWorkflowLifecycleNotificationState, + createWorkflowLifecycleNotificationState, + installWorkflowLifecycleNotifications, + seedWorkflowLifecycleNotificationState, } from "../../packages/workflows/src/extension/lifecycle-notifications.js"; import { createStageControlRegistry } from "../../packages/workflows/src/runs/foreground/stage-control-registry.js"; import { expandWorkflowGraph } from "../../packages/workflows/src/shared/expanded-workflow-graph.js"; @@ -22,413 +22,608 @@ import { defaultTheme, visibleText } from "./overlay-graph-helpers.js"; let tempDir = ""; -beforeEach(() => { tempDir = mkdtempSync(join(tmpdir(), "atomic-completed-inspection-")); }); -afterEach(() => { rmSync(tempDir, { recursive: true, force: true }); }); +beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), "atomic-completed-inspection-")); +}); +afterEach(() => { + rmSync(tempDir, { recursive: true, force: true }); +}); function retainedSession(name: string, internal = false): string { - const path = join(tempDir, `${name}.jsonl`); - writeFileSync(path, [ - JSON.stringify({ - type: "session", - version: 3, - id: `${name}-session`, - timestamp: new Date().toISOString(), - cwd: tempDir, - ...(internal ? { internal: true, workflow: { runId: name, stageId: "final", stageName: "final" } } : {}), - }), - JSON.stringify({ type: "message", id: `${name}-message`, parentId: null, timestamp: new Date().toISOString(), message: { role: "user", content: "Original workflow request", timestamp: Date.now() } }), - ].join("\n") + "\n"); - return path; + const path = join(tempDir, `${name}.jsonl`); + writeFileSync( + path, + `${[ + JSON.stringify({ + type: "session", + version: 3, + id: `${name}-session`, + timestamp: new Date().toISOString(), + cwd: tempDir, + ...(internal ? { internal: true, workflow: { runId: name, stageId: "final", stageName: "final" } } : {}), + }), + JSON.stringify({ + type: "message", + id: `${name}-message`, + parentId: null, + timestamp: new Date().toISOString(), + message: { role: "user", content: "Original workflow request", timestamp: Date.now() }, + }), + ].join("\n")}\n`, + ); + return path; } function completedTopology(stageId: string, sourceOrder = 0) { - return { version: 1 as const, stageId, parentIds: [] as readonly string[], sourceOrder, status: "completed" as const }; + return { + version: 1 as const, + stageId, + parentIds: [] as readonly string[], + sourceOrder, + status: "completed" as const, + }; } function clickForSingleNode(stage: RunSnapshot["stages"][number], width = 96, rows = 32): string { - const [node] = computeLayout([stage], { orientation: "vertical" }); - const bodyRows = rows - 2 - 6; - const totalGraphRows = node.y + NODE_H; - const topPad = totalGraphRows <= bodyRows ? Math.min(3, Math.max(0, Math.floor((bodyRows - totalGraphRows) / 2))) : 0; - const graphInner = Math.max(1, Math.max(40, width) - 4); - const leftMargin = Math.max(2, node.x + NODE_W <= graphInner ? Math.floor((graphInner - node.x - NODE_W) / 2) : 2); - return `\x1b[<0;${leftMargin + node.x + 3};${1 + 3 + topPad + node.y + 3}M`; + const [node] = computeLayout([stage], { orientation: "vertical" }); + const bodyRows = rows - 2 - 6; + const totalGraphRows = node.y + NODE_H; + const topPad = + totalGraphRows <= bodyRows ? Math.min(3, Math.max(0, Math.floor((bodyRows - totalGraphRows) / 2))) : 0; + const graphInner = Math.max(1, Math.max(40, width) - 4); + const leftMargin = Math.max(2, node.x + NODE_W <= graphInner ? Math.floor((graphInner - node.x - NODE_W) / 2) : 2); + return `\x1b[<0;${leftMargin + node.x + 3};${1 + 3 + topPad + node.y + 3}M`; } function lifecycleRestoration(store: ReturnType) { - const state = createWorkflowLifecycleNotificationState(); - const sent: Array<{ readonly options?: { readonly deliverAs?: string } }> = []; - const unsubscribe = installWorkflowLifecycleNotifications({ - store, - state, - config: { enabled: true, notifyOn: ["completed", "failed"] }, - sendMessage(_message, options) { sent.push({ options }); }, - }); - return { - sent, - unsubscribe, - beforeRestore(snapshots: readonly RunSnapshot[]) { - seedWorkflowLifecycleNotificationState(state, { ...store.snapshot(), runs: snapshots }); - }, - }; + const state = createWorkflowLifecycleNotificationState(); + const sent: Array<{ readonly options?: { readonly deliverAs?: string } }> = []; + const unsubscribe = installWorkflowLifecycleNotifications({ + store, + state, + config: { enabled: true, notifyOn: ["completed", "failed"] }, + sendMessage(_message, options) { + sent.push({ options }); + }, + }); + return { + sent, + unsubscribe, + beforeRestore(snapshots: readonly RunSnapshot[]) { + seedWorkflowLifecycleNotificationState(state, { ...store.snapshot(), runs: snapshots }); + }, + }; } - describe("completed workflow inspection", () => { - test("opens immutable detail and appends follow-up chat without durable re-dispatch", async () => { - const backend = new InMemoryDurableBackend(); - const store = createStore(); - const lifecycle = lifecycleRestoration(store); - const registry = createStageControlRegistry(); - const sessionFile = retainedSession("completed-inspection"); - const promptCalls: string[] = []; - const session: StageSessionRuntime = { - ...mockSession(), - sessionFile, - async prompt(text: string) { promptCalls.push(text); }, - }; - backend.registerWorkflow({ - workflowId: "completed-inspection", - name: "completed-flow", - inputs: { topic: "done" }, - createdAt: 1, - updatedAt: 3, - status: "completed", - }); - backend.recordCheckpoint({ - kind: "stage", - workflowId: "completed-inspection", - checkpointId: "stage:1", - name: "final", - replayKey: "stage:final:1", - output: "done", - sessionFile, - completedAt: 2, - topology: completedTopology("final-source"), - }); - - let sessionCreates = 0; - let restoredMessageCount = 0; - const opened = openCompletedDurableWorkflow("completed-ins", { - durableBackend: backend, - store, - beforeRestore: lifecycle.beforeRestore, - stageControlRegistry: registry, - adapters: { - agentSession: { - async create(options) { - restoredMessageCount = options.sessionManager?.getEntries().length ?? 0; - sessionCreates += 1; - return session; - }, - }, - }, - cwd: tempDir, - }); - - assert.equal(opened.ok, true); - assert.equal(store.runs()[0]?.status, "completed"); - assert.equal(store.runs()[0]?.stages[0]?.attachable, false); - assert.equal(backend.getWorkflow("completed-inspection")?.status, "completed"); - const handle = registry.get("completed-inspection", "final-source"); - assert.ok(handle); - assert.deepEqual(registry.run("completed-inspection").stages(), []); - - const attached: string[] = []; - const graph = expandWorkflowGraph(store.snapshot(), "completed-inspection"); - const view = new GraphView({ - mode: "overlay", runId: "completed-inspection", store, graphTheme: defaultTheme, - getViewportRows: () => 32, - onStageAttach: (runId, stageId) => { attached.push(`${runId}/${stageId}`); }, - }); - assert.match(visibleText(view.render(96)), /↵ open stage chat/); - view.handleInput("\r"); - view.handleInput(clickForSingleNode(graph.renderStages[0]!)); - view.handleInput("/"); - assert.match(visibleText(view.render(96)), /↵ open stage chat/); - for (const char of "final") view.handleInput(char); - view.handleInput("\r"); - assert.deepEqual(attached, [ - "completed-inspection/final-source", - "completed-inspection/final-source", - "completed-inspection/final-source", - ]); - view.dispose(); - await handle.prompt("What should I do next?"); - assert.equal(sessionCreates, 1); - assert.equal(restoredMessageCount, 1); - assert.deepEqual(promptCalls, ["What should I do next?"]); - assert.equal(store.runs()[0]?.status, "completed"); - assert.equal(backend.getWorkflow("completed-inspection")?.status, "completed"); - assert.deepEqual(lifecycle.sent, []); - lifecycle.unsubscribe(); - }); - - test("refuses to replace an active run with the same id", () => { - const backend = new InMemoryDurableBackend(); - const store = createStore(); - const sessionFile = retainedSession("same-id"); - backend.registerWorkflow({ workflowId: "same-id", name: "completed-flow", inputs: {}, createdAt: 1, status: "completed" }); - backend.recordCheckpoint({ - kind: "stage", workflowId: "same-id", checkpointId: "stage:1", name: "final", - replayKey: "stage:final:1", sessionFile, completedAt: 2, - topology: completedTopology("final-source"), - }); - store.recordRunStart({ id: "same-id", name: "active", inputs: {}, status: "running", stages: [], startedAt: 1 }); - - const opened = openCompletedDurableWorkflow("same-id", { durableBackend: backend, store }); - assert.equal(opened.ok, false); - if (!opened.ok) assert.equal(opened.reason, "active"); - assert.equal(store.runs()[0]?.status, "running"); - }); - - test("replaces a retained completed snapshot with authoritative durable detail", () => { - const backend = new InMemoryDurableBackend(); - const store = createStore(); - const sessionFile = retainedSession("authoritative"); - backend.registerWorkflow({ workflowId: "authoritative", name: "durable-name", inputs: {}, createdAt: 1, status: "completed" }); - backend.recordCheckpoint({ - kind: "stage", workflowId: "authoritative", checkpointId: "stage:1", name: "durable-stage", - replayKey: "stage:durable:1", output: "durable result", sessionFile, completedAt: 2, - topology: completedTopology("durable-source"), - }); - store.recordRunStart({ - id: "authoritative", name: "stale-local-name", inputs: {}, status: "completed", - stages: [], startedAt: 1, endedAt: 2, resumable: false, - }); - - const opened = openCompletedDurableWorkflow("authoritative", { durableBackend: backend, store }); - - assert.equal(opened.ok, true); - assert.equal(store.runs()[0]?.name, "durable-name"); - assert.equal(store.runs()[0]?.stages[0]?.name, "durable-stage"); - assert.equal(store.runs()[0]?.stages[0]?.sessionFile, sessionFile); - }); - - test("refreshes a retained chat handle when authoritative transcript detail changes", () => { - const backend = new InMemoryDurableBackend(); - const store = createStore(); - const lifecycle = lifecycleRestoration(store); - const registry = createStageControlRegistry(); - const firstSessionFile = retainedSession("first-authoritative"); - const secondSessionFile = retainedSession("second-authoritative"); - backend.registerWorkflow({ - workflowId: "refresh-chat", name: "completed-flow", inputs: {}, createdAt: 1, status: "completed", - }); - backend.recordCheckpoint({ - kind: "stage", workflowId: "refresh-chat", checkpointId: "stage:1", name: "final", - replayKey: "stage:final:1", sessionFile: firstSessionFile, completedAt: 2, - topology: completedTopology("final-source"), - }); - const deps = { - durableBackend: backend, - store, - stageControlRegistry: registry, - beforeRestore: lifecycle.beforeRestore, - adapters: { agentSession: { async create() { return mockSession(); } } }, - }; - - assert.equal(openCompletedDurableWorkflow("refresh-chat", deps).ok, true); - const firstHandle = registry.get("refresh-chat", "final-source"); - assert.equal(firstHandle?.sessionFile, firstSessionFile); - backend.recordCheckpoint({ - kind: "stage", workflowId: "refresh-chat", checkpointId: "stage:2", name: "final", - replayKey: "stage:final:1", sessionFile: secondSessionFile, completedAt: 3, - topology: completedTopology("final-source"), - }); - - assert.equal(openCompletedDurableWorkflow("refresh-chat", deps).ok, true); - assert.equal(firstHandle?.isDisposed, true); - assert.equal(registry.get("refresh-chat", "final-source")?.sessionFile, secondSessionFile); - assert.deepEqual(lifecycle.sent, []); - lifecycle.unsubscribe(); - }); - - test("removes a retained chat handle when its transcript becomes invalid", () => { - const backend = new InMemoryDurableBackend(); - const store = createStore(); - const registry = createStageControlRegistry(); - const invalidatedSessionFile = retainedSession("invalidated-stage"); - const retainedSessionFile = retainedSession("still-retained-stage"); - backend.registerWorkflow({ - workflowId: "invalidate-chat", name: "completed-flow", inputs: {}, createdAt: 1, status: "completed", - }); - backend.recordCheckpoint({ - kind: "stage", workflowId: "invalidate-chat", checkpointId: "stage:1", name: "first", - replayKey: "stage:first:1", sessionFile: invalidatedSessionFile, completedAt: 2, - topology: completedTopology("first-source", 0), - }); - backend.recordCheckpoint({ - kind: "stage", workflowId: "invalidate-chat", checkpointId: "stage:2", name: "second", - replayKey: "stage:second:1", sessionFile: retainedSessionFile, completedAt: 3, - topology: completedTopology("second-source", 1), - }); - const deps = { - durableBackend: backend, - store, - stageControlRegistry: registry, - adapters: { agentSession: { async create() { return mockSession(); } } }, - }; - - assert.equal(openCompletedDurableWorkflow("invalidate-chat", deps).ok, true); - const invalidatedHandle = registry.get("invalidate-chat", "first-source"); - assert.ok(invalidatedHandle); - rmSync(invalidatedSessionFile); - - assert.equal(openCompletedDurableWorkflow("invalidate-chat", deps).ok, true); - assert.equal(invalidatedHandle.isDisposed, true); - assert.equal(registry.get("invalidate-chat", "first-source"), undefined); - assert.equal(registry.get("invalidate-chat", "second-source")?.sessionFile, retainedSessionFile); - }); - - test("opens a retained internal stage transcript without exposing it in ordinary history", async () => { - const backend = new InMemoryDurableBackend(); - const store = createStore(); - const internalSessionFile = retainedSession("internal-completed", true); - retainedSession("regular-history"); - backend.registerWorkflow({ - workflowId: "internal-completed", name: "completed-flow", inputs: {}, createdAt: 1, status: "completed", - }); - backend.recordCheckpoint({ - kind: "stage", workflowId: "internal-completed", checkpointId: "stage:1", name: "final", - replayKey: "stage:final:1", sessionFile: internalSessionFile, completedAt: 2, - topology: completedTopology("final-source"), - }); - - assert.equal(openCompletedDurableWorkflow("internal-completed", { durableBackend: backend, store }).ok, true); - assert.equal(store.runs()[0]?.stages[0]?.sessionFile, internalSessionFile); - assert.deepEqual((await SessionManager.list(tempDir, tempDir)).map((session) => session.id), ["regular-history-session"]); - }); - - test("opens a tool-only run as a read-only graph without promising chat", () => { - const backend = new InMemoryDurableBackend(); - const store = createStore(); - const registry = createStageControlRegistry(); - backend.registerWorkflow({ - workflowId: "completed-tool-only", name: "tool-only", inputs: {}, createdAt: 1, status: "completed", - }); - backend.recordCheckpoint({ - kind: "tool", workflowId: "completed-tool-only", checkpointId: "tool:publish", name: "publish", - argsHash: "publish-hash", output: "done", completedAt: 2, - }); - - const opened = openCompletedDurableWorkflow("completed-tool", { - durableBackend: backend, - store, - stageControlRegistry: registry, - adapters: { agentSession: { async create() { return mockSession(); } } }, - }); - - assert.equal(opened.ok, true); - if (!opened.ok) return; - assert.match(opened.message, /read-only inspection/); - assert.doesNotMatch(opened.message, /follow-up chat/); - assert.deepEqual(registry.forRun("completed-tool-only"), []); - assert.deepEqual(store.runs()[0]?.toolNodes?.map((tool) => tool.name), ["publish"]); - }); - - - test("restores nested completed snapshots without lifecycle delivery", () => { - const backend = new InMemoryDurableBackend(); - const store = createStore(); - const lifecycle = lifecycleRestoration(store); - const runId = "silent-nested-root"; - const childRunId = "silent-nested-child"; - backend.registerWorkflow({ - workflowId: runId, name: "nested root", inputs: {}, createdAt: 1, updatedAt: 4, status: "completed", - }); - backend.recordCheckpoint({ - kind: "stage", workflowId: runId, checkpointId: "boundary", name: "workflow:nested child", replayKey: "boundary", - output: { workflow: "nested child", runId: childRunId, status: "completed", exited: false, outputs: {} }, - completedAt: 3, - topology: { - version: 1, stageId: "boundary", parentIds: [], - run: { runId, runName: "nested root" }, - }, - }); - backend.recordCheckpoint({ - kind: "tool", workflowId: runId, checkpointId: "child-tool", name: "nested publish", argsHash: "nested-publish", - output: "done", completedAt: 2, - topology: { - version: 1, nodeId: "nested-tool-node", ordinal: 1, order: 1, parentIds: [], endedAt: 2, - run: { runId: childRunId, runName: "nested child", parentRunId: runId, parentStageId: "boundary", rootRunId: runId }, - }, - }); - - const opened = openCompletedDurableWorkflow("silent-nested", { - durableBackend: backend, store, beforeRestore: lifecycle.beforeRestore, - }); - lifecycle.unsubscribe(); - - assert.equal(opened.ok, true); - assert.deepEqual(store.runs().map((run) => run.id).sort(), [childRunId, runId].sort()); - assert.equal(store.runs().find((run) => run.id === childRunId)?.toolNodes?.[0]?.name, "nested publish"); - assert.deepEqual(lifecycle.sent, []); - }); - test("historical tool-only restoration is lifecycle-silent", () => { - const backend = new InMemoryDurableBackend(); - const store = createStore(); - const state = createWorkflowLifecycleNotificationState(); - const sent: unknown[] = []; - const unsubscribe = installWorkflowLifecycleNotifications({ - store, - state, - config: { enabled: true, notifyOn: ["completed", "failed"] }, - sendMessage(message, options) { sent.push({ message, options }); }, - }); - backend.registerWorkflow({ - workflowId: "silent-tool-only", name: "silent tool", inputs: {}, createdAt: 1, updatedAt: 3, status: "completed", - }); - backend.recordCheckpoint({ - kind: "tool", workflowId: "silent-tool-only", checkpointId: "tool:publish", name: "publish", - argsHash: "publish-hash", output: "done", completedAt: 2, - }); - - const opened = openCompletedDurableWorkflow("silent-tool", { - durableBackend: backend, - store, - beforeRestore(snapshots) { - seedWorkflowLifecycleNotificationState(state, { ...store.snapshot(), runs: snapshots }); - }, - }); - unsubscribe(); - - assert.equal(opened.ok, true); - assert.deepEqual(store.runs()[0]?.toolNodes?.map((node) => node.name), ["publish"]); - assert.deepEqual(sent, []); - }); - - test("completed inspection does not duplicate an already delivered live notice", () => { - const backend = new InMemoryDurableBackend(); - const store = createStore(); - const state = createWorkflowLifecycleNotificationState(); - const sent: unknown[] = []; - const unsubscribe = installWorkflowLifecycleNotifications({ - store, - state, - seedExisting: false, - config: { enabled: true, notifyOn: ["completed"] }, - sendMessage(message, options) { sent.push({ message, options }); }, - }); - store.recordRunStart({ id: "live-then-inspect", name: "live tool", inputs: {}, status: "running", stages: [], startedAt: 1 }); - store.recordRunEnd("live-then-inspect", "completed", {}); - assert.equal(sent.length, 1); - backend.registerWorkflow({ - workflowId: "live-then-inspect", name: "live tool", inputs: {}, createdAt: 1, updatedAt: 3, status: "completed", - }); - backend.recordCheckpoint({ - kind: "tool", workflowId: "live-then-inspect", checkpointId: "tool:done", name: "done", - argsHash: "done-hash", output: true, completedAt: 2, - }); - - const opened = openCompletedDurableWorkflow("live-then", { - durableBackend: backend, - store, - beforeRestore(snapshots) { - seedWorkflowLifecycleNotificationState(state, { ...store.snapshot(), runs: snapshots }); - }, - }); - unsubscribe(); - - assert.equal(opened.ok, true); - assert.equal(sent.length, 1); - }); + test("opens immutable detail and appends follow-up chat without durable re-dispatch", async () => { + const backend = new InMemoryDurableBackend(); + const store = createStore(); + const lifecycle = lifecycleRestoration(store); + const registry = createStageControlRegistry(); + const sessionFile = retainedSession("completed-inspection"); + const promptCalls: string[] = []; + const session: StageSessionRuntime = { + ...mockSession(), + sessionFile, + async prompt(text: string) { + promptCalls.push(text); + }, + }; + backend.registerWorkflow({ + workflowId: "completed-inspection", + name: "completed-flow", + inputs: { topic: "done" }, + createdAt: 1, + updatedAt: 3, + status: "completed", + }); + backend.recordCheckpoint({ + kind: "stage", + workflowId: "completed-inspection", + checkpointId: "stage:1", + name: "final", + replayKey: "stage:final:1", + output: "done", + sessionFile, + completedAt: 2, + topology: completedTopology("final-source"), + }); + + let sessionCreates = 0; + let restoredMessageCount = 0; + const opened = openCompletedDurableWorkflow("completed-ins", { + durableBackend: backend, + store, + beforeRestore: lifecycle.beforeRestore, + stageControlRegistry: registry, + adapters: { + agentSession: { + async create(options) { + restoredMessageCount = options.sessionManager?.getEntries().length ?? 0; + sessionCreates += 1; + return session; + }, + }, + }, + cwd: tempDir, + }); + + assert.equal(opened.ok, true); + assert.equal(store.runs()[0]?.status, "completed"); + assert.equal(store.runs()[0]?.stages[0]?.attachable, false); + assert.equal(backend.getWorkflow("completed-inspection")?.status, "completed"); + const handle = registry.get("completed-inspection", "final-source"); + assert.ok(handle); + assert.deepEqual(registry.run("completed-inspection").stages(), []); + + const attached: string[] = []; + const graph = expandWorkflowGraph(store.snapshot(), "completed-inspection"); + const view = new GraphView({ + mode: "overlay", + runId: "completed-inspection", + store, + graphTheme: defaultTheme, + getViewportRows: () => 32, + onStageAttach: (runId, stageId) => { + attached.push(`${runId}/${stageId}`); + }, + }); + assert.match(visibleText(view.render(96)), /↵ open stage chat/); + view.handleInput("\r"); + view.handleInput(clickForSingleNode(graph.renderStages[0]!)); + view.handleInput("/"); + assert.match(visibleText(view.render(96)), /↵ open stage chat/); + for (const char of "final") view.handleInput(char); + view.handleInput("\r"); + assert.deepEqual(attached, [ + "completed-inspection/final-source", + "completed-inspection/final-source", + "completed-inspection/final-source", + ]); + view.dispose(); + await handle.prompt("What should I do next?"); + assert.equal(sessionCreates, 1); + assert.equal(restoredMessageCount, 1); + assert.deepEqual(promptCalls, ["What should I do next?"]); + assert.equal(store.runs()[0]?.status, "completed"); + assert.equal(backend.getWorkflow("completed-inspection")?.status, "completed"); + assert.deepEqual(lifecycle.sent, []); + lifecycle.unsubscribe(); + }); + + test("refuses to replace an active run with the same id", () => { + const backend = new InMemoryDurableBackend(); + const store = createStore(); + const sessionFile = retainedSession("same-id"); + backend.registerWorkflow({ + workflowId: "same-id", + name: "completed-flow", + inputs: {}, + createdAt: 1, + status: "completed", + }); + backend.recordCheckpoint({ + kind: "stage", + workflowId: "same-id", + checkpointId: "stage:1", + name: "final", + replayKey: "stage:final:1", + sessionFile, + completedAt: 2, + topology: completedTopology("final-source"), + }); + store.recordRunStart({ id: "same-id", name: "active", inputs: {}, status: "running", stages: [], startedAt: 1 }); + + const opened = openCompletedDurableWorkflow("same-id", { durableBackend: backend, store }); + assert.equal(opened.ok, false); + if (!opened.ok) assert.equal(opened.reason, "active"); + assert.equal(store.runs()[0]?.status, "running"); + }); + + test("replaces a retained completed snapshot with authoritative durable detail", () => { + const backend = new InMemoryDurableBackend(); + const store = createStore(); + const sessionFile = retainedSession("authoritative"); + backend.registerWorkflow({ + workflowId: "authoritative", + name: "durable-name", + inputs: {}, + createdAt: 1, + status: "completed", + }); + backend.recordCheckpoint({ + kind: "stage", + workflowId: "authoritative", + checkpointId: "stage:1", + name: "durable-stage", + replayKey: "stage:durable:1", + output: "durable result", + sessionFile, + completedAt: 2, + topology: completedTopology("durable-source"), + }); + store.recordRunStart({ + id: "authoritative", + name: "stale-local-name", + inputs: {}, + status: "completed", + stages: [], + startedAt: 1, + endedAt: 2, + resumable: false, + }); + + const opened = openCompletedDurableWorkflow("authoritative", { durableBackend: backend, store }); + + assert.equal(opened.ok, true); + assert.equal(store.runs()[0]?.name, "durable-name"); + assert.equal(store.runs()[0]?.stages[0]?.name, "durable-stage"); + assert.equal(store.runs()[0]?.stages[0]?.sessionFile, sessionFile); + }); + + test("refreshes a retained chat handle when authoritative transcript detail changes", () => { + const backend = new InMemoryDurableBackend(); + const store = createStore(); + const lifecycle = lifecycleRestoration(store); + const registry = createStageControlRegistry(); + const firstSessionFile = retainedSession("first-authoritative"); + const secondSessionFile = retainedSession("second-authoritative"); + backend.registerWorkflow({ + workflowId: "refresh-chat", + name: "completed-flow", + inputs: {}, + createdAt: 1, + status: "completed", + }); + backend.recordCheckpoint({ + kind: "stage", + workflowId: "refresh-chat", + checkpointId: "stage:1", + name: "final", + replayKey: "stage:final:1", + sessionFile: firstSessionFile, + completedAt: 2, + topology: completedTopology("final-source"), + }); + const deps = { + durableBackend: backend, + store, + stageControlRegistry: registry, + beforeRestore: lifecycle.beforeRestore, + adapters: { + agentSession: { + async create() { + return mockSession(); + }, + }, + }, + }; + + assert.equal(openCompletedDurableWorkflow("refresh-chat", deps).ok, true); + const firstHandle = registry.get("refresh-chat", "final-source"); + assert.equal(firstHandle?.sessionFile, firstSessionFile); + backend.recordCheckpoint({ + kind: "stage", + workflowId: "refresh-chat", + checkpointId: "stage:2", + name: "final", + replayKey: "stage:final:1", + sessionFile: secondSessionFile, + completedAt: 3, + topology: completedTopology("final-source"), + }); + + assert.equal(openCompletedDurableWorkflow("refresh-chat", deps).ok, true); + assert.equal(firstHandle?.isDisposed, true); + assert.equal(registry.get("refresh-chat", "final-source")?.sessionFile, secondSessionFile); + assert.deepEqual(lifecycle.sent, []); + lifecycle.unsubscribe(); + }); + + test("removes a retained chat handle when its transcript becomes invalid", () => { + const backend = new InMemoryDurableBackend(); + const store = createStore(); + const registry = createStageControlRegistry(); + const invalidatedSessionFile = retainedSession("invalidated-stage"); + const retainedSessionFile = retainedSession("still-retained-stage"); + backend.registerWorkflow({ + workflowId: "invalidate-chat", + name: "completed-flow", + inputs: {}, + createdAt: 1, + status: "completed", + }); + backend.recordCheckpoint({ + kind: "stage", + workflowId: "invalidate-chat", + checkpointId: "stage:1", + name: "first", + replayKey: "stage:first:1", + sessionFile: invalidatedSessionFile, + completedAt: 2, + topology: completedTopology("first-source", 0), + }); + backend.recordCheckpoint({ + kind: "stage", + workflowId: "invalidate-chat", + checkpointId: "stage:2", + name: "second", + replayKey: "stage:second:1", + sessionFile: retainedSessionFile, + completedAt: 3, + topology: completedTopology("second-source", 1), + }); + const deps = { + durableBackend: backend, + store, + stageControlRegistry: registry, + adapters: { + agentSession: { + async create() { + return mockSession(); + }, + }, + }, + }; + + assert.equal(openCompletedDurableWorkflow("invalidate-chat", deps).ok, true); + const invalidatedHandle = registry.get("invalidate-chat", "first-source"); + assert.ok(invalidatedHandle); + rmSync(invalidatedSessionFile); + + assert.equal(openCompletedDurableWorkflow("invalidate-chat", deps).ok, true); + assert.equal(invalidatedHandle.isDisposed, true); + assert.equal(registry.get("invalidate-chat", "first-source"), undefined); + assert.equal(registry.get("invalidate-chat", "second-source")?.sessionFile, retainedSessionFile); + }); + + test("opens a retained internal stage transcript without exposing it in ordinary history", async () => { + const backend = new InMemoryDurableBackend(); + const store = createStore(); + const internalSessionFile = retainedSession("internal-completed", true); + retainedSession("regular-history"); + backend.registerWorkflow({ + workflowId: "internal-completed", + name: "completed-flow", + inputs: {}, + createdAt: 1, + status: "completed", + }); + backend.recordCheckpoint({ + kind: "stage", + workflowId: "internal-completed", + checkpointId: "stage:1", + name: "final", + replayKey: "stage:final:1", + sessionFile: internalSessionFile, + completedAt: 2, + topology: completedTopology("final-source"), + }); + + assert.equal(openCompletedDurableWorkflow("internal-completed", { durableBackend: backend, store }).ok, true); + assert.equal(store.runs()[0]?.stages[0]?.sessionFile, internalSessionFile); + assert.deepEqual( + (await SessionManager.list(tempDir, tempDir)).map((session) => session.id), + ["regular-history-session"], + ); + }); + + test("opens a tool-only run as a read-only graph without promising chat", () => { + const backend = new InMemoryDurableBackend(); + const store = createStore(); + const registry = createStageControlRegistry(); + backend.registerWorkflow({ + workflowId: "completed-tool-only", + name: "tool-only", + inputs: {}, + createdAt: 1, + status: "completed", + }); + backend.recordCheckpoint({ + kind: "tool", + workflowId: "completed-tool-only", + checkpointId: "tool:publish", + name: "publish", + argsHash: "publish-hash", + output: "done", + completedAt: 2, + }); + + const opened = openCompletedDurableWorkflow("completed-tool", { + durableBackend: backend, + store, + stageControlRegistry: registry, + adapters: { + agentSession: { + async create() { + return mockSession(); + }, + }, + }, + }); + + assert.equal(opened.ok, true); + if (!opened.ok) return; + assert.match(opened.message, /read-only inspection/); + assert.doesNotMatch(opened.message, /follow-up chat/); + assert.deepEqual(registry.forRun("completed-tool-only"), []); + assert.deepEqual( + store.runs()[0]?.toolNodes?.map((tool) => tool.name), + ["publish"], + ); + }); + + test("restores nested completed snapshots without lifecycle delivery", () => { + const backend = new InMemoryDurableBackend(); + const store = createStore(); + const lifecycle = lifecycleRestoration(store); + const runId = "silent-nested-root"; + const childRunId = "silent-nested-child"; + backend.registerWorkflow({ + workflowId: runId, + name: "nested root", + inputs: {}, + createdAt: 1, + updatedAt: 4, + status: "completed", + }); + backend.recordCheckpoint({ + kind: "stage", + workflowId: runId, + checkpointId: "boundary", + name: "workflow:nested child", + replayKey: "boundary", + output: { workflow: "nested child", runId: childRunId, status: "completed", exited: false, outputs: {} }, + completedAt: 3, + topology: { + version: 1, + stageId: "boundary", + parentIds: [], + run: { runId, runName: "nested root" }, + }, + }); + backend.recordCheckpoint({ + kind: "tool", + workflowId: runId, + checkpointId: "child-tool", + name: "nested publish", + argsHash: "nested-publish", + output: "done", + completedAt: 2, + topology: { + version: 1, + nodeId: "nested-tool-node", + ordinal: 1, + order: 1, + parentIds: [], + endedAt: 2, + run: { + runId: childRunId, + runName: "nested child", + parentRunId: runId, + parentStageId: "boundary", + rootRunId: runId, + }, + }, + }); + + const opened = openCompletedDurableWorkflow("silent-nested", { + durableBackend: backend, + store, + beforeRestore: lifecycle.beforeRestore, + }); + lifecycle.unsubscribe(); + + assert.equal(opened.ok, true); + assert.deepEqual( + store + .runs() + .map((run) => run.id) + .sort(), + [childRunId, runId].sort(), + ); + assert.equal(store.runs().find((run) => run.id === childRunId)?.toolNodes?.[0]?.name, "nested publish"); + assert.deepEqual(lifecycle.sent, []); + }); + test("historical tool-only restoration is lifecycle-silent", () => { + const backend = new InMemoryDurableBackend(); + const store = createStore(); + const state = createWorkflowLifecycleNotificationState(); + const sent: unknown[] = []; + const unsubscribe = installWorkflowLifecycleNotifications({ + store, + state, + config: { enabled: true, notifyOn: ["completed", "failed"] }, + sendMessage(message, options) { + sent.push({ message, options }); + }, + }); + backend.registerWorkflow({ + workflowId: "silent-tool-only", + name: "silent tool", + inputs: {}, + createdAt: 1, + updatedAt: 3, + status: "completed", + }); + backend.recordCheckpoint({ + kind: "tool", + workflowId: "silent-tool-only", + checkpointId: "tool:publish", + name: "publish", + argsHash: "publish-hash", + output: "done", + completedAt: 2, + }); + + const opened = openCompletedDurableWorkflow("silent-tool", { + durableBackend: backend, + store, + beforeRestore(snapshots) { + seedWorkflowLifecycleNotificationState(state, { ...store.snapshot(), runs: snapshots }); + }, + }); + unsubscribe(); + + assert.equal(opened.ok, true); + assert.deepEqual( + store.runs()[0]?.toolNodes?.map((node) => node.name), + ["publish"], + ); + assert.deepEqual(sent, []); + }); + + test("completed inspection does not duplicate an already delivered live notice", () => { + const backend = new InMemoryDurableBackend(); + const store = createStore(); + const state = createWorkflowLifecycleNotificationState(); + const sent: unknown[] = []; + const unsubscribe = installWorkflowLifecycleNotifications({ + store, + state, + seedExisting: false, + config: { enabled: true, notifyOn: ["completed"] }, + sendMessage(message, options) { + sent.push({ message, options }); + }, + }); + store.recordRunStart({ + id: "live-then-inspect", + name: "live tool", + inputs: {}, + status: "running", + stages: [], + startedAt: 1, + }); + store.recordRunEnd("live-then-inspect", "completed", {}); + assert.equal(sent.length, 1); + backend.registerWorkflow({ + workflowId: "live-then-inspect", + name: "live tool", + inputs: {}, + createdAt: 1, + updatedAt: 3, + status: "completed", + }); + backend.recordCheckpoint({ + kind: "tool", + workflowId: "live-then-inspect", + checkpointId: "tool:done", + name: "done", + argsHash: "done-hash", + output: true, + completedAt: 2, + }); + + const opened = openCompletedDurableWorkflow("live-then", { + durableBackend: backend, + store, + beforeRestore(snapshots) { + seedWorkflowLifecycleNotificationState(state, { ...store.snapshot(), runs: snapshots }); + }, + }); + unsubscribe(); + + assert.equal(opened.ok, true); + assert.equal(sent.length, 1); + }); }); diff --git a/test/unit/workflow-completed-stage-intercom-ask.test.ts b/test/unit/workflow-completed-stage-intercom-ask.test.ts index 028d6c602..170d8fdf5 100644 --- a/test/unit/workflow-completed-stage-intercom-ask.test.ts +++ b/test/unit/workflow-completed-stage-intercom-ask.test.ts @@ -1,149 +1,176 @@ -import { test } from "bun:test"; import assert from "node:assert/strict"; +import { test } from "vitest"; import { registerCompletedStageIntercomAskRouter } from "../../packages/workflows/src/extension/completed-stage-intercom-ask.js"; import type { StageControlHandle } from "../../packages/workflows/src/runs/foreground/stage-control-registry.js"; +import { sleep } from "../helpers/runtime.js"; interface LateAskEvent { - handled: boolean; - completion?: Promise; - batch: boolean; - workflowRunId: string; - workflowStageId: string; - messages: Array<{ - customType: "intercom_message"; - content: string; - details: { - from: { id: string }; - message: { id: string; expectsReply: true; content: { text: string } }; - }; - }>; + handled: boolean; + completion?: Promise; + batch: boolean; + workflowRunId: string; + workflowStageId: string; + messages: Array<{ + customType: "intercom_message"; + content: string; + details: { + from: { id: string }; + message: { id: string; expectsReply: true; content: { text: string } }; + }; + }>; } function askEvent(overrides: Partial = {}): LateAskEvent { - return { - handled: false, - batch: false, - workflowRunId: "run-1", - workflowStageId: "stage-a", - messages: [{ - customType: "intercom_message", - content: "**From B**\n\nReturn the exact summary", - details: { - from: { id: "stage-b-session" }, - message: { id: "ask-1", expectsReply: true, content: { text: "Return the exact summary" } }, - }, - }], - ...overrides, - }; + return { + handled: false, + batch: false, + workflowRunId: "run-1", + workflowStageId: "stage-a", + messages: [ + { + customType: "intercom_message", + content: "**From B**\n\nReturn the exact summary", + details: { + from: { id: "stage-b-session" }, + message: { id: "ask-1", expectsReply: true, content: { text: "Return the exact summary" } }, + }, + }, + ], + ...overrides, + }; } function eventHarness() { - let listener: ((event: unknown) => void) | undefined; - return { - pi: { - events: { - on(_name: string, callback: (event: unknown) => void) { - listener = callback; - return () => { listener = undefined; }; - }, - }, - }, - emit(event: LateAskEvent) { - listener?.(event); - return event; - }, - }; + let listener: ((event: unknown) => void) | undefined; + return { + pi: { + events: { + on(_name: string, callback: (event: unknown) => void) { + listener = callback; + return () => { + listener = undefined; + }; + }, + }, + }, + emit(event: LateAskEvent) { + listener?.(event); + return event; + }, + }; } function completedHandle( - prompt: (text: string) => Promise, - ensureAttached: () => Promise = async () => {}, + prompt: (text: string) => Promise, + ensureAttached: () => Promise = async () => {}, ): StageControlHandle { - return { - runId: "run-1", - stageId: "stage-a", - stageName: "A", - status: "completed", - sessionId: "stage-a-session", - sessionFile: "/tmp/stage-a.jsonl", - isStreaming: false, - messages: [], - ensureAttached, - prompt, - steer: async () => {}, - followUp: async () => {}, - pause: async () => {}, - resume: async () => {}, - subscribe: () => () => {}, - }; + return { + runId: "run-1", + stageId: "stage-a", + stageName: "A", + status: "completed", + sessionId: "stage-a-session", + sessionFile: "/tmp/stage-a.jsonl", + isStreaming: false, + messages: [], + ensureAttached, + prompt, + steer: async () => {}, + followUp: async () => {}, + pause: async () => {}, + resume: async () => {}, + subscribe: () => () => {}, + }; } test("completed-stage ask schedules one post-mortem turn with the exact inbound text", async () => { - const harness = eventHarness(); - const prompts: string[] = []; - registerCompletedStageIntercomAskRouter(harness.pi as never, () => ({ - ok: true, - handle: completedHandle(async (text) => { prompts.push(text); }), - })); + const harness = eventHarness(); + const prompts: string[] = []; + registerCompletedStageIntercomAskRouter(harness.pi as never, () => ({ + ok: true, + handle: completedHandle(async (text) => { + prompts.push(text); + }), + })); - const event = harness.emit(askEvent()); - assert.equal(event.handled, true); - assert.ok(event.completion); - await event.completion; - assert.deepEqual(prompts, ["**From B**\n\nReturn the exact summary"]); + const event = harness.emit(askEvent()); + assert.equal(event.handled, true); + assert.ok(event.completion); + await event.completion; + assert.deepEqual(prompts, ["**From B**\n\nReturn the exact summary"]); }); test("concurrent duplicate wakeups serialize onto the retained completed conversation", async () => { - const harness = eventHarness(); - const first = Promise.withResolvers(); - const order: string[] = []; - registerCompletedStageIntercomAskRouter(harness.pi as never, () => ({ - ok: true, - handle: completedHandle(async (text) => { - order.push(`start:${text}`); - if (text === "first") await first.promise; - order.push(`end:${text}`); - }), - })); + const harness = eventHarness(); + const first = Promise.withResolvers(); + const order: string[] = []; + registerCompletedStageIntercomAskRouter(harness.pi as never, () => ({ + ok: true, + handle: completedHandle(async (text) => { + order.push(`start:${text}`); + if (text === "first") await first.promise; + order.push(`end:${text}`); + }), + })); - const firstEvent = harness.emit(askEvent({ messages: [{ ...askEvent().messages[0]!, content: "first" }] })); - const secondEvent = harness.emit(askEvent({ messages: [{ ...askEvent().messages[0]!, content: "second", details: { ...askEvent().messages[0]!.details, message: { ...askEvent().messages[0]!.details.message, id: "ask-2" } } }] })); - await Bun.sleep(0); - assert.deepEqual(order, ["start:first"]); - first.resolve(); - await Promise.all([firstEvent.completion, secondEvent.completion]); - assert.deepEqual(order, ["start:first", "end:first", "start:second", "end:second"]); + const firstEvent = harness.emit(askEvent({ messages: [{ ...askEvent().messages[0]!, content: "first" }] })); + const secondEvent = harness.emit( + askEvent({ + messages: [ + { + ...askEvent().messages[0]!, + content: "second", + details: { + ...askEvent().messages[0]!.details, + message: { ...askEvent().messages[0]!.details.message, id: "ask-2" }, + }, + }, + ], + }), + ); + await sleep(0); + assert.deepEqual(order, ["start:first"]); + first.resolve(); + await Promise.all([firstEvent.completion, secondEvent.completion]); + assert.deepEqual(order, ["start:first", "end:first", "start:second", "end:second"]); }); test("deleted, invalid, non-resumable, and failed-to-attach targets reject promptly and actionably", async () => { - const failedAttach = completedHandle(async () => {}, async () => { throw new Error("session reopen failed"); }); - const disposed = { ...completedHandle(async () => {}), isDisposed: true }; - const cases = [ - { result: undefined, expected: /deleted or is no longer retained/ }, - { result: { ok: false as const, reason: "not_terminal" as const }, expected: /not resumable/ }, - { result: { ok: false as const, reason: "no_session" as const }, expected: /has no retained conversation/ }, - { result: { ok: false as const, reason: "invalid_session" as const }, expected: /missing, deleted, or invalid/ }, - { result: { ok: false as const, reason: "no_adapter" as const }, expected: /not resumable/ }, - { result: { ok: true as const, handle: disposed }, expected: /not resumable/ }, - { result: { ok: true as const, handle: failedAttach }, expected: /session reopen failed/ }, - ]; - for (const { result, expected } of cases) { - const harness = eventHarness(); - registerCompletedStageIntercomAskRouter(harness.pi as never, () => result); - const started = performance.now(); - const event = harness.emit(askEvent()); - assert.equal(event.handled, true); - await assert.rejects(event.completion!, expected); - assert.ok(performance.now() - started < 1_000, "failure must be bounded rather than waiting for ask timeout"); - } + const failedAttach = completedHandle( + async () => {}, + async () => { + throw new Error("session reopen failed"); + }, + ); + const disposed = { ...completedHandle(async () => {}), isDisposed: true }; + const cases = [ + { result: undefined, expected: /deleted or is no longer retained/ }, + { result: { ok: false as const, reason: "not_terminal" as const }, expected: /not resumable/ }, + { result: { ok: false as const, reason: "no_session" as const }, expected: /has no retained conversation/ }, + { result: { ok: false as const, reason: "invalid_session" as const }, expected: /missing, deleted, or invalid/ }, + { result: { ok: false as const, reason: "no_adapter" as const }, expected: /not resumable/ }, + { result: { ok: true as const, handle: disposed }, expected: /not resumable/ }, + { result: { ok: true as const, handle: failedAttach }, expected: /session reopen failed/ }, + ]; + for (const { result, expected } of cases) { + const harness = eventHarness(); + registerCompletedStageIntercomAskRouter(harness.pi as never, () => result); + const started = performance.now(); + const event = harness.emit(askEvent()); + assert.equal(event.handled, true); + await assert.rejects(event.completion!, expected); + assert.ok(performance.now() - started < 1_000, "failure must be bounded rather than waiting for ask timeout"); + } }); test("ordinary completed-stage sends retain the existing late-message route", () => { - const harness = eventHarness(); - registerCompletedStageIntercomAskRouter(harness.pi as never, () => { throw new Error("must not resolve"); }); - const event = askEvent(); - event.messages[0]!.details.message.expectsReply = false as never; - harness.emit(event); - assert.equal(event.handled, false); - assert.equal(event.completion, undefined); + const harness = eventHarness(); + registerCompletedStageIntercomAskRouter(harness.pi as never, () => { + throw new Error("must not resolve"); + }); + const event = askEvent(); + event.messages[0]!.details.message.expectsReply = false as never; + harness.emit(event); + assert.equal(event.handled, false); + assert.equal(event.completion, undefined); }); diff --git a/test/unit/workflow-contract-scope-discipline.test.ts b/test/unit/workflow-contract-scope-discipline.test.ts index a6a34e2be..177a59ade 100644 --- a/test/unit/workflow-contract-scope-discipline.test.ts +++ b/test/unit/workflow-contract-scope-discipline.test.ts @@ -1,14 +1,15 @@ // @ts-nocheck -import { describe, test } from "bun:test"; + import assert from "node:assert/strict"; -import { - LITERAL_OBJECTIVE_CONTRACT, - SCOPE_DISCIPLINE_CONTRACT, - STEERING_PROPAGATION_CONTRACT, - withSteeringPropagation, -} from "../../packages/workflows/builtin/shared-prompts.js"; +import { describe, test } from "vitest"; import { renderGoalContinuationPrompt } from "../../packages/workflows/builtin/goal-prompts.js"; import { renderForkedOrchestratorPrompt } from "../../packages/workflows/builtin/ralph-forked-prompts.js"; +import { + LITERAL_OBJECTIVE_CONTRACT, + SCOPE_DISCIPLINE_CONTRACT, + STEERING_PROPAGATION_CONTRACT, + withSteeringPropagation, +} from "../../packages/workflows/builtin/shared-prompts.js"; /** * The user may amend a run's contract mid-flight; agents may not. An amendment @@ -16,66 +17,64 @@ import { renderForkedOrchestratorPrompt } from "../../packages/workflows/builtin * carries the steering propagation contract and hands amendments forward. */ describe("workflow contract discipline", () => { - test("only the user may change the contract", () => { - assert.match(LITERAL_OBJECTIVE_CONTRACT, /Only the user may change the contract/); - assert.match(LITERAL_OBJECTIVE_CONTRACT, /is authoritative: adopt it as required behavior/); - assert.match(LITERAL_OBJECTIVE_CONTRACT, /You may never widen the contract yourself/); - assert.match(LITERAL_OBJECTIVE_CONTRACT, /deferred work, not a new criterion/); - }); + test("only the user may change the contract", () => { + assert.match(LITERAL_OBJECTIVE_CONTRACT, /Only the user may change the contract/); + assert.match(LITERAL_OBJECTIVE_CONTRACT, /is authoritative: adopt it as required behavior/); + assert.match(LITERAL_OBJECTIVE_CONTRACT, /You may never widen the contract yourself/); + assert.match(LITERAL_OBJECTIVE_CONTRACT, /deferred work, not a new criterion/); + }); - test("amendments must reach the next stage", () => { - assert.match(STEERING_PROPAGATION_CONTRACT, /An amendment that stays in your session is lost/); - assert.match(STEERING_PROPAGATION_CONTRACT, /Contract amendments received/); - assert.match(STEERING_PROPAGATION_CONTRACT, /Treat amendments inherited from an upstream stage as contract clauses/); - assert.match(STEERING_PROPAGATION_CONTRACT, /never classify inherited user amendments as beyond_objective/); - assert.match(STEERING_PROPAGATION_CONTRACT, /ask through `intercom`/); - assert.match(STEERING_PROPAGATION_CONTRACT, /Propagate nothing else this way/); - }); + test("amendments must reach the next stage", () => { + assert.match(STEERING_PROPAGATION_CONTRACT, /An amendment that stays in your session is lost/); + assert.match(STEERING_PROPAGATION_CONTRACT, /Contract amendments received/); + assert.match( + STEERING_PROPAGATION_CONTRACT, + /Treat amendments inherited from an upstream stage as contract clauses/, + ); + assert.match(STEERING_PROPAGATION_CONTRACT, /never classify inherited user amendments as beyond_objective/); + assert.match(STEERING_PROPAGATION_CONTRACT, /ask through `intercom`/); + assert.match(STEERING_PROPAGATION_CONTRACT, /Propagate nothing else this way/); + }); - test("the contract lands before a stage's closing instruction", () => { - const withInstruction = "\nc\n\n\n\ndo the thing\n"; - const wrapped = withSteeringPropagation(withInstruction); - assert.equal(wrapped.trimEnd().endsWith(""), true); - assert.ok(wrapped.indexOf("") < wrapped.indexOf("")); + test("the contract lands before a stage's closing instruction", () => { + const withInstruction = "\nc\n\n\n\ndo the thing\n"; + const wrapped = withSteeringPropagation(withInstruction); + assert.equal(wrapped.trimEnd().endsWith(""), true); + assert.ok(wrapped.indexOf("") < wrapped.indexOf("")); - assert.match(withSteeringPropagation("bare prompt"), /bare prompt\n\n/); - assert.equal(withSteeringPropagation(wrapped), wrapped, "wrapping twice must not duplicate the contract"); - }); + assert.match(withSteeringPropagation("bare prompt"), /bare prompt\n\n/); + assert.equal(withSteeringPropagation(wrapped), wrapped, "wrapping twice must not duplicate the contract"); + }); - test("the scope discipline contract states the frozen-contract rules", () => { - for (const rule of [ - /That list is the contract\. Freeze it\./, - /Done means the contract, not "good\."/, - /Every addition must trace to a criterion/, - /Keep a deferred list, not a growing diff/, - /Distinguish blockers from improvements/, - /Watch for the tells/, - /Prefer the smallest diff that satisfies the contract/, - /Report three things at the end/, - /Scope changes belong in the report, never in the diff/, - ]) { - assert.match(SCOPE_DISCIPLINE_CONTRACT, rule); - } - }); + test("the scope discipline contract states the frozen-contract rules", () => { + for (const rule of [ + /That list is the contract\. Freeze it\./, + /Done means the contract, not "good\."/, + /Every addition must trace to a criterion/, + /Keep a deferred list, not a growing diff/, + /Distinguish blockers from improvements/, + /Watch for the tells/, + /Prefer the smallest diff that satisfies the contract/, + /Report three things at the end/, + /Scope changes belong in the report, never in the diff/, + ]) { + assert.match(SCOPE_DISCIPLINE_CONTRACT, rule); + } + }); - test("goal implementation stages inherit both contracts", () => { - const prompt = renderGoalContinuationPrompt( - { receipts: [] }, - "/tmp/goal-ledger.json", - 3, - [], - ); - assert.match(prompt, //); - assert.match(prompt, /Prefer the smallest diff that satisfies the contract/); - assert.match(prompt, /Only the user may change the contract/); - }); + test("goal implementation stages inherit both contracts", () => { + const prompt = renderGoalContinuationPrompt({ receipts: [] }, "/tmp/goal-ledger.json", 3, []); + assert.match(prompt, //); + assert.match(prompt, /Prefer the smallest diff that satisfies the contract/); + assert.match(prompt, /Only the user may change the contract/); + }); - test("ralph continuation iterations keep scope discipline bound", () => { - const prompt = renderForkedOrchestratorPrompt({ - researchPath: "/tmp/research.md", - implementationNotesPath: "/tmp/notes.md", - }); - assert.match(prompt, /scope discipline/); - assert.match(prompt, /record anything outside it on the deferred list instead of implementing it/); - }); + test("ralph continuation iterations keep scope discipline bound", () => { + const prompt = renderForkedOrchestratorPrompt({ + researchPath: "/tmp/research.md", + implementationNotesPath: "/tmp/notes.md", + }); + assert.match(prompt, /scope discipline/); + assert.match(prompt, /record anything outside it on the deferred list instead of implementing it/); + }); }); diff --git a/test/unit/workflow-durable-tool-failure-notice.test.ts b/test/unit/workflow-durable-tool-failure-notice.test.ts index ba55876e6..d92c963da 100644 --- a/test/unit/workflow-durable-tool-failure-notice.test.ts +++ b/test/unit/workflow-durable-tool-failure-notice.test.ts @@ -1,348 +1,399 @@ -import { afterEach, describe, test } from "bun:test"; import assert from "node:assert/strict"; import type { AgentTool } from "@earendil-works/pi-agent-core"; -import { fauxAssistantMessage, fauxToolCall, type Context } from "@earendil-works/pi-ai/compat"; -import { createHarness, getMessageText, type Harness } from "../../packages/coding-agent/test/suite/harness.js"; +import { type Context, fauxAssistantMessage, fauxToolCall } from "@earendil-works/pi-ai/compat"; +import { afterEach, describe, test } from "vitest"; import { PROTECTED_RECONCILIATION_CUSTOM_TYPE } from "../../packages/coding-agent/src/core/agent-session-persistent-custom-messages.js"; +import { createHarness, getMessageText, type Harness } from "../../packages/coding-agent/test/suite/harness.js"; import { workflow } from "../../packages/workflows/src/authoring/workflow.js"; import { InMemoryDurableBackend } from "../../packages/workflows/src/durable/backend.js"; import { setDurableBackend } from "../../packages/workflows/src/durable/factory.js"; -import { createExtensionRuntime } from "../../packages/workflows/src/extension/runtime.js"; import { createWorkflowExtensionRuntimeState } from "../../packages/workflows/src/extension/extension-runtime-state.js"; import { - createWorkflowLifecycleNotificationState, - installWorkflowLifecycleNotifications, - LIFECYCLE_NOTICE_CUSTOM_TYPE, - type WorkflowLifecycleNoticeDetails, + createWorkflowLifecycleNotificationState, + installWorkflowLifecycleNotifications, + LIFECYCLE_NOTICE_CUSTOM_TYPE, + type WorkflowLifecycleNoticeDetails, } from "../../packages/workflows/src/extension/lifecycle-notifications.js"; +import type { + ExtensionAPI, + PiExecuteContext, + PiToolOpts, + WorkflowToolArgs, +} from "../../packages/workflows/src/extension/public-types.js"; import type { WorkflowToolResult } from "../../packages/workflows/src/extension/render-result.js"; +import { createExtensionRuntime } from "../../packages/workflows/src/extension/runtime.js"; import { makeExecuteWorkflowTool } from "../../packages/workflows/src/extension/workflow-tool.js"; import { registerWorkflowTool } from "../../packages/workflows/src/extension/workflow-tool-registration.js"; -import type { ExtensionAPI, PiExecuteContext, PiToolOpts, WorkflowToolArgs } from "../../packages/workflows/src/extension/public-types.js"; import { createCancellationRegistry } from "../../packages/workflows/src/runs/background/cancellation-registry.js"; import { createJobTracker } from "../../packages/workflows/src/runs/background/job-tracker.js"; import { restoreOnSessionStart, type SessionEntry } from "../../packages/workflows/src/shared/persistence-restore.js"; import { createStore, store as workflowStore } from "../../packages/workflows/src/shared/store.js"; import type { WorkflowSerializableValue } from "../../packages/workflows/src/shared/types.js"; import { classifyWorkflowFailure } from "../../packages/workflows/src/shared/workflow-failures.js"; +import { sleep } from "../helpers/runtime.js"; import { lifecycleConfig } from "./workflow-lifecycle-parent-reconciliation-support.js"; interface PersistedEntry { - readonly id: string; - readonly type: string; - readonly payload: Record; + readonly id: string; + readonly type: string; + readonly payload: Record; } describe("interactive durable tool failure lifecycle", () => { - let harness: Harness | undefined; - let unsubscribeLifecycle: (() => void) | undefined; + let harness: Harness | undefined; + let unsubscribeLifecycle: (() => void) | undefined; - afterEach(() => { - unsubscribeLifecycle?.(); - unsubscribeLifecycle = undefined; - harness?.cleanup(); - harness = undefined; - workflowStore.clear(); - setDurableBackend(undefined); - }); + afterEach(() => { + unsubscribeLifecycle?.(); + unsubscribeLifecycle = undefined; + harness?.cleanup(); + harness = undefined; + workflowStore.clear(); + setDurableBackend(undefined); + }); - test("reports one failed notice and reconciliation when a post-admission durable callback throws", async () => { - const backend = new InMemoryDurableBackend(); - setDurableBackend(backend); - const store = workflowStore; - store.clear(); - const jobs = createJobTracker(); - const cancellation = createCancellationRegistry(); - const callbackEntered = Promise.withResolvers(); - const releaseFailure = Promise.withResolvers(); - const persisted: PersistedEntry[] = []; - const lifecycleState = createWorkflowLifecycleNotificationState(); - const definition = workflow({ - name: "post-admission-tool-failure", - description: "Fail a durable tool after named-run startup admission.", - inputs: {}, - outputs: {}, - run: async (ctx) => { - await ctx.tool("commit-docs", {}, async () => { - callbackEntered.resolve(); - await releaseFailure.promise; - throw Object.assign(new Error("outer callback wrapper"), { - code: "CANCELLED", - cause: Object.assign(new Error("commit hook rejected docs"), { - exitCode: 1, - stderr: "docs check failed", - }), - }); - }); - return {}; - }, - }); - const runtime = createExtensionRuntime({ - definitions: [definition], - store, - jobs, - cancellation, - persistence: { - appendEntry(type, payload) { - const id = `entry-${persisted.length + 1}`; - persisted.push({ id, type, payload: { ...payload } as Record }); - return id; - }, - setLabel() {}, - }, - }); - const executeWorkflow = makeExecuteWorkflowTool(runtime, () => undefined, () => undefined); - let registeredTool: PiToolOpts | undefined; - const extensionApi = { - registerTool(options: PiToolOpts) { - registeredTool = options; - }, - } as ExtensionAPI; - let launchPolicyMode: string | undefined; - registerWorkflowTool(extensionApi, executeWorkflow, async (policy, execute) => { - launchPolicyMode = policy.mode; - return await execute(); - }); - assert.ok(registeredTool); - const interactiveTool = registeredTool; - const interactiveContext: PiExecuteContext = { hasUI: true }; - let runId = ""; - let admissionContext: Context | undefined; - let providerContext: Context | undefined; - let failureReleased = false; - const workflowTool: AgentTool = { - name: interactiveTool.name, - label: interactiveTool.label, - description: interactiveTool.description, - parameters: interactiveTool.parameters as AgentTool["parameters"], - execute: async (toolCallId, params, signal) => { - const result = await interactiveTool.execute( - toolCallId, - params as WorkflowToolArgs, - signal, - undefined, - interactiveContext, - ); - runId = result.details.action === "run" ? result.details.runId : ""; - return { - content: result.content.map((part) => { - if (part.type !== "text") throw new Error("unexpected workflow tool image result"); - return part; - }), - details: result.details, - }; - }, - }; - harness = await createHarness({ tools: [workflowTool] }); - unsubscribeLifecycle = installWorkflowLifecycleNotifications({ - store, - state: lifecycleState, - config: lifecycleConfig, - seedExisting: false, - sendMessage: (message, options) => harness!.session.sendCustomMessage(message, options), - }); - harness.session.subscribe((event) => { - if ( - !failureReleased && - harness!.faux.state.callCount === 2 && - event.type === "message_update" && - event.assistantMessageEvent.type === "text_delta" - ) { - failureReleased = true; - releaseFailure.resolve(); - } - }); - harness.setResponses([ - fauxAssistantMessage(fauxToolCall("workflow", { - action: "run", - workflow: "post-admission-tool-failure", - }, { id: "workflow-call-post-admission" }), { stopReason: "toolUse" }), - (context) => { - admissionContext = context; - return fauxAssistantMessage("The admitted workflow is still running."); - }, - (context) => { - providerContext = context; - return fauxAssistantMessage("I inspected the failed workflow notice."); - }, - ]); + test("reports one failed notice and reconciliation when a post-admission durable callback throws", async () => { + const backend = new InMemoryDurableBackend(); + setDurableBackend(backend); + const store = workflowStore; + store.clear(); + const jobs = createJobTracker(); + const cancellation = createCancellationRegistry(); + const callbackEntered = Promise.withResolvers(); + const releaseFailure = Promise.withResolvers(); + const persisted: PersistedEntry[] = []; + const lifecycleState = createWorkflowLifecycleNotificationState(); + const definition = workflow({ + name: "post-admission-tool-failure", + description: "Fail a durable tool after named-run startup admission.", + inputs: {}, + outputs: {}, + run: async (ctx) => { + await ctx.tool("commit-docs", {}, async () => { + callbackEntered.resolve(); + await releaseFailure.promise; + throw Object.assign(new Error("outer callback wrapper"), { + code: "CANCELLED", + cause: Object.assign(new Error("commit hook rejected docs"), { + exitCode: 1, + stderr: "docs check failed", + }), + }); + }); + return {}; + }, + }); + const runtime = createExtensionRuntime({ + definitions: [definition], + store, + jobs, + cancellation, + persistence: { + appendEntry(type, payload) { + const id = `entry-${persisted.length + 1}`; + persisted.push({ id, type, payload: { ...payload } as Record }); + return id; + }, + setLabel() {}, + }, + }); + const executeWorkflow = makeExecuteWorkflowTool( + runtime, + () => undefined, + () => undefined, + ); + let registeredTool: PiToolOpts | undefined; + const extensionApi = { + registerTool(options: PiToolOpts) { + registeredTool = options; + }, + } as ExtensionAPI; + let launchPolicyMode: string | undefined; + registerWorkflowTool(extensionApi, executeWorkflow, async (policy, execute) => { + launchPolicyMode = policy.mode; + return await execute(); + }); + assert.ok(registeredTool); + const interactiveTool = registeredTool; + const interactiveContext: PiExecuteContext = { hasUI: true }; + let runId = ""; + let admissionContext: Context | undefined; + let providerContext: Context | undefined; + let failureReleased = false; + const workflowTool: AgentTool = { + name: interactiveTool.name, + label: interactiveTool.label, + description: interactiveTool.description, + parameters: interactiveTool.parameters as AgentTool["parameters"], + execute: async (toolCallId, params, signal) => { + const result = await interactiveTool.execute( + toolCallId, + params as WorkflowToolArgs, + signal, + undefined, + interactiveContext, + ); + runId = result.details.action === "run" ? result.details.runId : ""; + return { + content: result.content.map((part) => { + if (part.type !== "text") throw new Error("unexpected workflow tool image result"); + return part; + }), + details: result.details, + }; + }, + }; + harness = await createHarness({ tools: [workflowTool] }); + unsubscribeLifecycle = installWorkflowLifecycleNotifications({ + store, + state: lifecycleState, + config: lifecycleConfig, + seedExisting: false, + sendMessage: (message, options) => harness!.session.sendCustomMessage(message, options), + }); + harness.session.subscribe((event) => { + if ( + !failureReleased && + harness!.faux.state.callCount === 2 && + event.type === "message_update" && + event.assistantMessageEvent.type === "text_delta" + ) { + failureReleased = true; + releaseFailure.resolve(); + } + }); + harness.setResponses([ + fauxAssistantMessage( + fauxToolCall( + "workflow", + { + action: "run", + workflow: "post-admission-tool-failure", + }, + { id: "workflow-call-post-admission" }, + ), + { stopReason: "toolUse" }, + ), + (context) => { + admissionContext = context; + return fauxAssistantMessage("The admitted workflow is still running."); + }, + (context) => { + providerContext = context; + return fauxAssistantMessage("I inspected the failed workflow notice."); + }, + ]); - const prompt = harness.session.prompt("Run post-admission-tool-failure."); - await Promise.race([ - callbackEntered.promise, - Bun.sleep(2_000).then(() => { throw new Error(`callback did not start; calls=${harness!.faux.state.callCount} messages=${JSON.stringify(harness!.session.messages)} runs=${JSON.stringify(store.runs())}`); }), - ]); - await Promise.race([ - prompt, - Bun.sleep(2_000).then(() => { throw new Error(`prompt did not settle; calls=${harness!.faux.state.callCount} released=${failureReleased} runs=${JSON.stringify(store.runs())}`); }), - ]); + const prompt = harness.session.prompt("Run post-admission-tool-failure."); + await Promise.race([ + callbackEntered.promise, + sleep(2_000).then(() => { + throw new Error( + `callback did not start; calls=${harness!.faux.state.callCount} messages=${JSON.stringify(harness!.session.messages)} runs=${JSON.stringify(store.runs())}`, + ); + }), + ]); + await Promise.race([ + prompt, + sleep(2_000).then(() => { + throw new Error( + `prompt did not settle; calls=${harness!.faux.state.callCount} released=${failureReleased} runs=${JSON.stringify(store.runs())}`, + ); + }), + ]); - const failed = store.runs().find((run) => run.id === runId); - assert.ok(failed); - assert.equal(failed.status, "failed"); - assert.equal(failed.failureKind, "unknown"); - assert.equal(failed.failureCode, "unknown"); - assert.equal(failed.failureRecoverability, "unknown"); - assert.equal(failed.failureDisposition, "terminal_failed"); - assert.equal(failed.error, "commit hook rejected docs"); - assert.equal(failed.toolNodes?.length, 1); - assert.equal(failed.toolNodes?.[0]?.status, "failed"); - assert.equal(failed.toolNodes?.[0]?.error, "commit hook rejected docs"); - assert.equal(failed.failedToolNodeId, failed.toolNodes?.[0]?.id); - const persistedRunEnd = persisted.find( - (entry) => entry.type === "workflow.run.end" && entry.payload["runId"] === runId, - ); - assert.equal(persistedRunEnd?.payload["failedToolNodeId"], failed.failedToolNodeId); - assert.equal( - (persistedRunEnd?.payload["failedToolNode"] as { status?: string } | undefined)?.status, - "failed", - ); - const failureCheckpoint = backend.listCheckpoints(runId).find( - (checkpoint) => checkpoint.kind === "tool" && checkpoint.throwingFailureError !== undefined, - ); - assert.equal(failureCheckpoint?.kind, "tool"); - assert.equal(failureCheckpoint?.kind === "tool" ? failureCheckpoint.throwingFailureError : undefined, "commit hook rejected docs"); - assert.equal(backend.getToolCheckpoint(runId, failed.toolNodes![0]!.argsHash), undefined); - assert.equal(failureReleased, true, "the durable callback fails only after the startup result reaches chat"); - assert.ok(admissionContext); - const admittedResult = admissionContext.messages.find( - (message) => message.role === "toolResult" && message.toolName === "workflow", - ); - assert.equal(admittedResult?.role, "toolResult"); - assert.equal( - admittedResult?.role === "toolResult" - ? (admittedResult.details as { status?: string } | undefined)?.status - : undefined, - "running", - "the callback must throw only after the named launch reports startup admission", - ); - assert.equal(launchPolicyMode, "interactive"); - assert.equal(harness.faux.state.callCount, 3, "the hidden reconciliation must schedule a correcting turn"); + const failed = store.runs().find((run) => run.id === runId); + assert.ok(failed); + assert.equal(failed.status, "failed"); + assert.equal(failed.failureKind, "unknown"); + assert.equal(failed.failureCode, "unknown"); + assert.equal(failed.failureRecoverability, "unknown"); + assert.equal(failed.failureDisposition, "terminal_failed"); + assert.equal(failed.error, "commit hook rejected docs"); + assert.equal(failed.toolNodes?.length, 1); + assert.equal(failed.toolNodes?.[0]?.status, "failed"); + assert.equal(failed.toolNodes?.[0]?.error, "commit hook rejected docs"); + assert.equal(failed.failedToolNodeId, failed.toolNodes?.[0]?.id); + const persistedRunEnd = persisted.find( + (entry) => entry.type === "workflow.run.end" && entry.payload.runId === runId, + ); + assert.equal(persistedRunEnd?.payload.failedToolNodeId, failed.failedToolNodeId); + assert.equal((persistedRunEnd?.payload.failedToolNode as { status?: string } | undefined)?.status, "failed"); + const failureCheckpoint = backend + .listCheckpoints(runId) + .find((checkpoint) => checkpoint.kind === "tool" && checkpoint.throwingFailureError !== undefined); + assert.equal(failureCheckpoint?.kind, "tool"); + assert.equal( + failureCheckpoint?.kind === "tool" ? failureCheckpoint.throwingFailureError : undefined, + "commit hook rejected docs", + ); + assert.equal(backend.getToolCheckpoint(runId, failed.toolNodes![0]!.argsHash), undefined); + assert.equal(failureReleased, true, "the durable callback fails only after the startup result reaches chat"); + assert.ok(admissionContext); + const admittedResult = admissionContext.messages.find( + (message) => message.role === "toolResult" && message.toolName === "workflow", + ); + assert.equal(admittedResult?.role, "toolResult"); + assert.equal( + admittedResult?.role === "toolResult" + ? (admittedResult.details as { status?: string } | undefined)?.status + : undefined, + "running", + "the callback must throw only after the named launch reports startup admission", + ); + assert.equal(launchPolicyMode, "interactive"); + assert.equal(harness.faux.state.callCount, 3, "the hidden reconciliation must schedule a correcting turn"); - const cards = harness.session.messages.filter( - (message) => message.role === "custom" && message.customType === LIFECYCLE_NOTICE_CUSTOM_TYPE, - ); - assert.equal(cards.length, 1); - const details = cards[0]?.role === "custom" - ? cards[0].details as WorkflowLifecycleNoticeDetails - : undefined; - assert.equal(details?.kind, "failed"); - assert.equal(details?.runId, runId); - assert.equal(details?.error, "commit hook rejected docs"); - const cardContent = String(cards[0]?.role === "custom" ? cards[0].content : ""); - assert.ok(cardContent.includes(runId)); - assert.match(cardContent, /commit hook rejected docs/); - assert.doesNotMatch(cardContent, /outer callback wrapper/); + const cards = harness.session.messages.filter( + (message) => message.role === "custom" && message.customType === LIFECYCLE_NOTICE_CUSTOM_TYPE, + ); + assert.equal(cards.length, 1); + const details = cards[0]?.role === "custom" ? (cards[0].details as WorkflowLifecycleNoticeDetails) : undefined; + assert.equal(details?.kind, "failed"); + assert.equal(details?.runId, runId); + assert.equal(details?.error, "commit hook rejected docs"); + const cardContent = String(cards[0]?.role === "custom" ? cards[0].content : ""); + assert.ok(cardContent.includes(runId)); + assert.match(cardContent, /commit hook rejected docs/); + assert.doesNotMatch(cardContent, /outer callback wrapper/); - assert.ok(providerContext); - assert.equal(providerContext.messages.filter( - (message) => message.role === "user" && getMessageText(message).includes(`failed (run ${runId}`), - ).length, 1, "the failed notice must trigger one hidden reconciliation turn"); - assert.equal(harness.sessionManager.getEntries().filter( - (entry) => entry.type === "custom_message" && entry.customType === PROTECTED_RECONCILIATION_CUSTOM_TYPE, - ).length, 1); - const status = await interactiveTool.execute( - "workflow-status-after-failure", - { action: "status", runId }, - undefined, - undefined, - interactiveContext, - ); - assert.equal(status.details.action, "statusDetail"); - assert.ok("detail" in status.details); - assert.equal(status.details.detail.status, "failed"); - assert.equal(harness.session.messages.filter( - (message) => message.role === "custom" && message.customType === LIFECYCLE_NOTICE_CUSTOM_TYPE, - ).length, 1, "status inspection must not duplicate the notice"); + assert.ok(providerContext); + assert.equal( + providerContext.messages.filter( + (message) => message.role === "user" && getMessageText(message).includes(`failed (run ${runId}`), + ).length, + 1, + "the failed notice must trigger one hidden reconciliation turn", + ); + assert.equal( + harness.sessionManager + .getEntries() + .filter( + (entry) => entry.type === "custom_message" && entry.customType === PROTECTED_RECONCILIATION_CUSTOM_TYPE, + ).length, + 1, + ); + const status = await interactiveTool.execute( + "workflow-status-after-failure", + { action: "status", runId }, + undefined, + undefined, + interactiveContext, + ); + assert.equal(status.details.action, "statusDetail"); + assert.ok("detail" in status.details); + assert.equal(status.details.detail.status, "failed"); + assert.equal( + harness.session.messages.filter( + (message) => message.role === "custom" && message.customType === LIFECYCLE_NOTICE_CUSTOM_TYPE, + ).length, + 1, + "status inspection must not duplicate the notice", + ); - unsubscribeLifecycle(); - unsubscribeLifecycle = undefined; - store.removeRun(runId); - const productionState = createWorkflowExtensionRuntimeState({} as ExtensionAPI, {} as never); - const resumableCatalog = await productionState.runtimeProxy.prepareDurableResumable(runId); - const completedCatalog = await productionState.runtimeProxy.prepareCompletedDurable?.(); - assert.deepEqual( - resumableCatalog.map((entry) => entry.workflowId), - [runId], - "a checkpointed resumable failure must remain on the execution-resume path", - ); - assert.deepEqual( - completedCatalog?.map((entry) => entry.workflowId), - [], - "completed history must not shadow a resumable failed root", - ); - assert.equal(harness.session.messages.filter( - (message) => message.role === "custom" && message.customType === LIFECYCLE_NOTICE_CUSTOM_TYPE, - ).length, 1, "durable catalog inspection must not duplicate the notice"); + unsubscribeLifecycle(); + unsubscribeLifecycle = undefined; + store.removeRun(runId); + const productionState = createWorkflowExtensionRuntimeState({} as ExtensionAPI, {} as never); + const resumableCatalog = await productionState.runtimeProxy.prepareDurableResumable(runId); + const completedCatalog = await productionState.runtimeProxy.prepareCompletedDurable?.(); + assert.deepEqual( + resumableCatalog.map((entry) => entry.workflowId), + [runId], + "a checkpointed resumable failure must remain on the execution-resume path", + ); + assert.deepEqual( + completedCatalog?.map((entry) => entry.workflowId), + [], + "completed history must not shadow a resumable failed root", + ); + assert.equal( + harness.session.messages.filter( + (message) => message.role === "custom" && message.customType === LIFECYCLE_NOTICE_CUSTOM_TYPE, + ).length, + 1, + "durable catalog inspection must not duplicate the notice", + ); - const restoredStore = createStore(); - restoreOnSessionStart( - { getEntries: () => persisted as SessionEntry[] }, - { resumeInFlight: "never", persistRuns: true }, - restoredStore, - ); - const restored = restoredStore.runs().find((run) => run.id === runId); - assert.equal(restored?.status, "failed"); - assert.equal(restored?.failureKind, "unknown"); - assert.equal(restored?.failureCode, "unknown"); - assert.equal(restored?.failureRecoverability, "unknown"); - assert.equal(restored?.failureDisposition, "terminal_failed"); - assert.equal(restored?.failedToolNodeId, failed.failedToolNodeId); - assert.equal(restored?.toolNodes?.length, 1); - assert.equal(restored?.toolNodes?.[0]?.id, failed.failedToolNodeId); - assert.equal(restored?.toolNodes?.[0]?.name, "commit-docs"); - assert.equal(restored?.toolNodes?.[0]?.status, "failed"); - assert.equal(restored?.toolNodes?.[0]?.error, "commit hook rejected docs"); - const restoredNotices: WorkflowLifecycleNoticeDetails[] = []; - const stopRestoredNotifications = installWorkflowLifecycleNotifications({ - store: restoredStore, - state: createWorkflowLifecycleNotificationState(), - config: lifecycleConfig, - sendMessage(message) { - restoredNotices.push((message as { details: WorkflowLifecycleNoticeDetails }).details); - }, - }); - stopRestoredNotifications(); - assert.deepEqual(restoredNotices, [], "restoring a historical terminal run must seed dedupe without re-notifying"); - }); + const restoredStore = createStore(); + restoreOnSessionStart( + { getEntries: () => persisted as SessionEntry[] }, + { resumeInFlight: "never", persistRuns: true }, + restoredStore, + ); + const restored = restoredStore.runs().find((run) => run.id === runId); + assert.equal(restored?.status, "failed"); + assert.equal(restored?.failureKind, "unknown"); + assert.equal(restored?.failureCode, "unknown"); + assert.equal(restored?.failureRecoverability, "unknown"); + assert.equal(restored?.failureDisposition, "terminal_failed"); + assert.equal(restored?.failedToolNodeId, failed.failedToolNodeId); + assert.equal(restored?.toolNodes?.length, 1); + assert.equal(restored?.toolNodes?.[0]?.id, failed.failedToolNodeId); + assert.equal(restored?.toolNodes?.[0]?.name, "commit-docs"); + assert.equal(restored?.toolNodes?.[0]?.status, "failed"); + assert.equal(restored?.toolNodes?.[0]?.error, "commit hook rejected docs"); + const restoredNotices: WorkflowLifecycleNoticeDetails[] = []; + const stopRestoredNotifications = installWorkflowLifecycleNotifications({ + store: restoredStore, + state: createWorkflowLifecycleNotificationState(), + config: lifecycleConfig, + sendMessage(message) { + restoredNotices.push((message as { details: WorkflowLifecycleNoticeDetails }).details); + }, + }); + stopRestoredNotifications(); + assert.deepEqual( + restoredNotices, + [], + "restoring a historical terminal run must seed dedupe without re-notifying", + ); + }); - test("keeps abort wrappers with empty process buffers classified as cancellation", () => { - const failure = classifyWorkflowFailure({ - name: "AbortError", - message: "workflow killed", - code: "ABORT_ERR", - exitCode: null, - stdout: "", - stderr: new Uint8Array(), - }); - assert.equal(failure.kind, "cancelled"); - assert.equal(failure.code, "cancelled"); - assert.equal(failure.disposition, "terminal_killed"); - }); + test("keeps abort wrappers with empty process buffers classified as cancellation", () => { + const failure = classifyWorkflowFailure({ + name: "AbortError", + message: "workflow killed", + code: "ABORT_ERR", + exitCode: null, + stdout: "", + stderr: new Uint8Array(), + }); + assert.equal(failure.kind, "cancelled"); + assert.equal(failure.code, "cancelled"); + assert.equal(failure.disposition, "terminal_killed"); + }); - test("lets nested command failure evidence beat a cancellation-like wrapper", () => { - const failure = classifyWorkflowFailure(Object.assign(new Error("wrapper aborted"), { - code: "CANCELLED", - cause: Object.assign(new Error("commit hook rejected docs"), { - exitCode: 1, - stderr: "docs check failed", - }), - })); - assert.equal(failure.kind, "unknown"); - assert.equal(failure.code, "unknown"); - assert.equal(failure.disposition, "terminal_failed"); - }); + test("lets nested command failure evidence beat a cancellation-like wrapper", () => { + const failure = classifyWorkflowFailure( + Object.assign(new Error("wrapper aborted"), { + code: "CANCELLED", + cause: Object.assign(new Error("commit hook rejected docs"), { + exitCode: 1, + stderr: "docs check failed", + }), + }), + ); + assert.equal(failure.kind, "unknown"); + assert.equal(failure.code, "unknown"); + assert.equal(failure.disposition, "terminal_failed"); + }); - test("lets aggregate command failure evidence beat a cancellation-like member", () => { - const failure = classifyWorkflowFailure(new AggregateError([ - Object.assign(new Error("commit hook rejected docs"), { - code: "CANCELLED", - exitCode: 1, - stderr: "docs check failed", - }), - ], "aggregate command failure")); - assert.equal(failure.kind, "unknown"); - assert.equal(failure.code, "unknown"); - assert.equal(failure.disposition, "terminal_failed"); - }); + test("lets aggregate command failure evidence beat a cancellation-like member", () => { + const failure = classifyWorkflowFailure( + new AggregateError( + [ + Object.assign(new Error("commit hook rejected docs"), { + code: "CANCELLED", + exitCode: 1, + stderr: "docs check failed", + }), + ], + "aggregate command failure", + ), + ); + assert.equal(failure.kind, "unknown"); + assert.equal(failure.code, "unknown"); + assert.equal(failure.disposition, "terminal_failed"); + }); }); diff --git a/test/unit/workflow-failures-01.test.ts b/test/unit/workflow-failures-01.test.ts index f2dd8f0b9..8751eca5b 100644 --- a/test/unit/workflow-failures-01.test.ts +++ b/test/unit/workflow-failures-01.test.ts @@ -3,495 +3,513 @@ * Unit tests for workflow-local failure classification. */ -import { describe, test } from "bun:test"; import assert from "node:assert/strict"; +import { describe, test } from "vitest"; import { - WORKFLOW_AUTH_FAILURE_MESSAGE, - WORKFLOW_INVALID_PROVIDER_CREDENTIALS_MESSAGE, - WORKFLOW_MISSING_API_KEY_FAILURE_MESSAGE, - WORKFLOW_UNKNOWN_MODEL_MESSAGE, - classifyWorkflowFailure, + classifyWorkflowFailure, + WORKFLOW_AUTH_FAILURE_MESSAGE, + WORKFLOW_INVALID_PROVIDER_CREDENTIALS_MESSAGE, + WORKFLOW_MISSING_API_KEY_FAILURE_MESSAGE, } from "../../packages/workflows/src/shared/workflow-failures.js"; + describe("classifyWorkflowFailure", () => { - test("normalizes missing provider key failures to recoverable active-blocked auth", () => { - const failure = classifyWorkflowFailure(new Error("No API key found for provider")); - assert.equal(failure.kind, "auth"); - assert.equal(failure.code, "missing_api_key"); - assert.equal(failure.userMessage, WORKFLOW_MISSING_API_KEY_FAILURE_MESSAGE); - assert.equal(failure.message, "No API key found for provider"); - assert.equal(failure.retryable, true); - assert.equal(failure.resumable, true); - assert.equal(failure.recoverability, "recoverable"); - assert.equal(failure.disposition, "active_blocked"); - }); - test("classifies 429/quota failures as recoverable active-blocked rate limits", () => { - const failure = classifyWorkflowFailure(new Error("HTTP 429 quota exceeded")); - assert.equal(failure.kind, "rate_limit"); - assert.equal(failure.code, "rate_limited"); - assert.equal(failure.userMessage, "HTTP 429 quota exceeded"); - assert.equal(failure.retryable, true); - assert.equal(failure.resumable, true); - assert.equal(failure.recoverability, "recoverable"); - assert.equal(failure.disposition, "active_blocked"); - }); - test("classifies quota-only fallback text as recoverable active-blocked", () => { - const failure = classifyWorkflowFailure(new Error("quota exceeded")); - assert.equal(failure.kind, "rate_limit"); - assert.equal(failure.code, "quota_limited"); - assert.equal(failure.disposition, "active_blocked"); - assert.equal(failure.resumable, true); - }); - test("classifies string-only rate limit fallback text as recoverable active-blocked", () => { - const failure = classifyWorkflowFailure(new Error("rate limit exceeded")); - assert.equal(failure.kind, "rate_limit"); - assert.equal(failure.code, "rate_limited"); - assert.equal(failure.retryable, true); - assert.equal(failure.resumable, true); - assert.equal(failure.recoverability, "recoverable"); - assert.equal(failure.disposition, "active_blocked"); - }); - test("classifies assistant errorMessage rate limit fallback as recoverable active-blocked", () => { - const failure = classifyWorkflowFailure({ - role: "assistant", - stopReason: "error", - errorMessage: "rate limit exceeded", - }); - assert.equal(failure.kind, "rate_limit"); - assert.equal(failure.code, "rate_limited"); - assert.equal(failure.retryable, true); - assert.equal(failure.resumable, true); - assert.equal(failure.recoverability, "recoverable"); - assert.equal(failure.disposition, "active_blocked"); - }); - test("classifies abort errors as non-resumable terminal cancellation", () => { - const failure = classifyWorkflowFailure(new DOMException("workflow killed", "AbortError")); - assert.equal(failure.kind, "cancelled"); - assert.equal(failure.code, "cancelled"); - assert.equal(failure.retryable, false); - assert.equal(failure.resumable, false); - assert.equal(failure.recoverability, "non_recoverable"); - assert.equal(failure.disposition, "terminal_killed"); - }); - test("classifies provider/model outages separately from auth", () => { - const failure = classifyWorkflowFailure(new Error("model provider service unavailable")); - assert.equal(failure.kind, "provider"); - assert.equal(failure.code, "provider_unavailable"); - assert.equal(failure.retryable, true); - assert.equal(failure.resumable, true); - assert.equal(failure.disposition, "active_blocked"); - }); - test("uses structured HTTP statuses before message fallback", () => { - const auth = classifyWorkflowFailure({ message: "request failed", status: 401 }); - assert.equal(auth.kind, "auth"); - assert.equal(auth.code, "invalid_api_key"); - assert.equal(auth.userMessage, WORKFLOW_INVALID_PROVIDER_CREDENTIALS_MESSAGE); + test("normalizes missing provider key failures to recoverable active-blocked auth", () => { + const failure = classifyWorkflowFailure(new Error("No API key found for provider")); + assert.equal(failure.kind, "auth"); + assert.equal(failure.code, "missing_api_key"); + assert.equal(failure.userMessage, WORKFLOW_MISSING_API_KEY_FAILURE_MESSAGE); + assert.equal(failure.message, "No API key found for provider"); + assert.equal(failure.retryable, true); + assert.equal(failure.resumable, true); + assert.equal(failure.recoverability, "recoverable"); + assert.equal(failure.disposition, "active_blocked"); + }); + test("classifies 429/quota failures as recoverable active-blocked rate limits", () => { + const failure = classifyWorkflowFailure(new Error("HTTP 429 quota exceeded")); + assert.equal(failure.kind, "rate_limit"); + assert.equal(failure.code, "rate_limited"); + assert.equal(failure.userMessage, "HTTP 429 quota exceeded"); + assert.equal(failure.retryable, true); + assert.equal(failure.resumable, true); + assert.equal(failure.recoverability, "recoverable"); + assert.equal(failure.disposition, "active_blocked"); + }); + test("classifies quota-only fallback text as recoverable active-blocked", () => { + const failure = classifyWorkflowFailure(new Error("quota exceeded")); + assert.equal(failure.kind, "rate_limit"); + assert.equal(failure.code, "quota_limited"); + assert.equal(failure.disposition, "active_blocked"); + assert.equal(failure.resumable, true); + }); + test("classifies string-only rate limit fallback text as recoverable active-blocked", () => { + const failure = classifyWorkflowFailure(new Error("rate limit exceeded")); + assert.equal(failure.kind, "rate_limit"); + assert.equal(failure.code, "rate_limited"); + assert.equal(failure.retryable, true); + assert.equal(failure.resumable, true); + assert.equal(failure.recoverability, "recoverable"); + assert.equal(failure.disposition, "active_blocked"); + }); + test("classifies assistant errorMessage rate limit fallback as recoverable active-blocked", () => { + const failure = classifyWorkflowFailure({ + role: "assistant", + stopReason: "error", + errorMessage: "rate limit exceeded", + }); + assert.equal(failure.kind, "rate_limit"); + assert.equal(failure.code, "rate_limited"); + assert.equal(failure.retryable, true); + assert.equal(failure.resumable, true); + assert.equal(failure.recoverability, "recoverable"); + assert.equal(failure.disposition, "active_blocked"); + }); + test("classifies abort errors as non-resumable terminal cancellation", () => { + const failure = classifyWorkflowFailure(new DOMException("workflow killed", "AbortError")); + assert.equal(failure.kind, "cancelled"); + assert.equal(failure.code, "cancelled"); + assert.equal(failure.retryable, false); + assert.equal(failure.resumable, false); + assert.equal(failure.recoverability, "non_recoverable"); + assert.equal(failure.disposition, "terminal_killed"); + }); + test("classifies provider/model outages separately from auth", () => { + const failure = classifyWorkflowFailure(new Error("model provider service unavailable")); + assert.equal(failure.kind, "provider"); + assert.equal(failure.code, "provider_unavailable"); + assert.equal(failure.retryable, true); + assert.equal(failure.resumable, true); + assert.equal(failure.disposition, "active_blocked"); + }); + test("uses structured HTTP statuses before message fallback", () => { + const auth = classifyWorkflowFailure({ message: "request failed", status: 401 }); + assert.equal(auth.kind, "auth"); + assert.equal(auth.code, "invalid_api_key"); + assert.equal(auth.userMessage, WORKFLOW_INVALID_PROVIDER_CREDENTIALS_MESSAGE); - const rateLimit = classifyWorkflowFailure({ message: "request failed", statusCode: 429 }); - assert.equal(rateLimit.kind, "rate_limit"); - assert.equal(rateLimit.code, "rate_limited"); - assert.equal(rateLimit.retryable, true); + const rateLimit = classifyWorkflowFailure({ message: "request failed", statusCode: 429 }); + assert.equal(rateLimit.kind, "rate_limit"); + assert.equal(rateLimit.code, "rate_limited"); + assert.equal(rateLimit.retryable, true); - const provider = classifyWorkflowFailure({ message: "request failed", status: 503 }); - assert.equal(provider.kind, "provider"); - assert.equal(provider.code, "provider_unavailable"); - assert.equal(provider.retryable, true); - }); - test("lets structured local login codes beat wrapper 401 defaults", () => { - for (const code of ["login_required", "auth_required", "authentication_required", "not_logged_in"] as const) { - const failure = classifyWorkflowFailure({ status: 401, code, message: "wrapper 401" }); - assert.equal(failure.kind, "auth"); - assert.equal(failure.code, "login_required"); - assert.equal(failure.recoverability, "recoverable"); - assert.equal(failure.disposition, "active_blocked"); - assert.equal(failure.resumable, true); - assert.equal(failure.userMessage, WORKFLOW_AUTH_FAILURE_MESSAGE); - } - }); - test("uses auth-required diagnostics before generic wrapper 401 defaults", () => { - const failure = classifyWorkflowFailure({ - status: 401, - message: "provider request failed", - diagnostics: [{ error: { code: "auth_required", message: "Please log in to continue" } }], - }); + const provider = classifyWorkflowFailure({ message: "request failed", status: 503 }); + assert.equal(provider.kind, "provider"); + assert.equal(provider.code, "provider_unavailable"); + assert.equal(provider.retryable, true); + }); + test("lets structured local login codes beat wrapper 401 defaults", () => { + for (const code of ["login_required", "auth_required", "authentication_required", "not_logged_in"] as const) { + const failure = classifyWorkflowFailure({ status: 401, code, message: "wrapper 401" }); + assert.equal(failure.kind, "auth"); + assert.equal(failure.code, "login_required"); + assert.equal(failure.recoverability, "recoverable"); + assert.equal(failure.disposition, "active_blocked"); + assert.equal(failure.resumable, true); + assert.equal(failure.userMessage, WORKFLOW_AUTH_FAILURE_MESSAGE); + } + }); + test("uses auth-required diagnostics before generic wrapper 401 defaults", () => { + const failure = classifyWorkflowFailure({ + status: 401, + message: "provider request failed", + diagnostics: [{ error: { code: "auth_required", message: "Please log in to continue" } }], + }); - assert.equal(failure.kind, "auth"); - assert.equal(failure.code, "login_required"); - assert.equal(failure.recoverability, "recoverable"); - assert.equal(failure.disposition, "active_blocked"); - assert.equal(failure.resumable, true); - assert.equal(failure.message, "Please log in to continue"); - assert.equal(failure.userMessage, WORKFLOW_AUTH_FAILURE_MESSAGE); - }); - test("uses clear local login wrapper-401 messages before provider credential defaults", () => { - for (const message of [ - "Please log in to continue", - "not logged in", - "login required", - "Run /login to continue", - "Authentication failed for \"openai\". Credentials may have expired or network is unavailable. Run '/login openai' to re-authenticate.", - ] as const) { - const failure = classifyWorkflowFailure({ status: 401, message }); - assert.equal(failure.kind, "auth"); - assert.equal(failure.code, "login_required"); - assert.equal(failure.recoverability, "recoverable"); - assert.equal(failure.disposition, "active_blocked"); - assert.equal(failure.resumable, true); - assert.equal(failure.userMessage, WORKFLOW_AUTH_FAILURE_MESSAGE); - } - }); - test("keeps provider 401 auth text classified as invalid provider credentials", () => { - for (const message of ["Unauthorized", "authentication required"]) { - const failure = classifyWorkflowFailure({ status: 401, message }); - assert.equal(failure.kind, "auth"); - assert.equal(failure.code, "invalid_api_key"); - assert.equal(failure.recoverability, "non_recoverable"); - assert.equal(failure.disposition, "terminal_killed"); - assert.equal(failure.userMessage, WORKFLOW_INVALID_PROVIDER_CREDENTIALS_MESSAGE); - } - }); - test("classifies string-only provider auth fallback as invalid credentials", () => { - for (const error of [ - new Error("OpenAI API error (401): Unauthorized"), - new Error("Unauthorized"), - "authentication required", - ] as const) { - const failure = classifyWorkflowFailure(error); - assert.equal(failure.kind, "auth"); - assert.equal(failure.code, "invalid_api_key"); - assert.equal(failure.retryable, false); - assert.equal(failure.resumable, false); - assert.equal(failure.recoverability, "non_recoverable"); - assert.equal(failure.disposition, "terminal_killed"); - assert.equal(failure.userMessage, WORKFLOW_INVALID_PROVIDER_CREDENTIALS_MESSAGE); - } - }); - test("classifies non-contiguous invalid API key fallback messages as invalid credentials", () => { - for (const message of [ - "The API key provided is invalid", - "The API key you supplied is incorrect", - ] as const) { - const failure = classifyWorkflowFailure(new Error(message)); - assert.equal(failure.kind, "auth"); - assert.equal(failure.code, "invalid_api_key"); - assert.equal(failure.retryable, false); - assert.equal(failure.resumable, false); - assert.equal(failure.recoverability, "non_recoverable"); - assert.equal(failure.disposition, "terminal_killed"); - assert.equal(failure.userMessage, WORKFLOW_INVALID_PROVIDER_CREDENTIALS_MESSAGE); - } - }); - test("keeps clear string-only local login fallback recoverable", () => { - for (const error of [ - new Error("Run /login to continue"), - new Error("not logged in"), - "login required", - "please login", - "please log in", - "log in to continue", - ] as const) { - const failure = classifyWorkflowFailure(error); - assert.equal(failure.kind, "auth"); - assert.equal(failure.code, "login_required"); - assert.equal(failure.retryable, true); - assert.equal(failure.resumable, true); - assert.equal(failure.recoverability, "recoverable"); - assert.equal(failure.disposition, "active_blocked"); - assert.equal(failure.userMessage, WORKFLOW_AUTH_FAILURE_MESSAGE); - } - }); - test("provider credential messages and causes override broad auth wrapper codes", () => { - const failures = [ - classifyWorkflowFailure({ - status: 401, - code: "auth_required", - message: "Incorrect API key provided", - }), - classifyWorkflowFailure({ - status: 401, - code: "auth_required", - message: "wrapper 401", - cause: { code: "invalid_api_key", message: "Incorrect API key provided" }, - }), - ]; + assert.equal(failure.kind, "auth"); + assert.equal(failure.code, "login_required"); + assert.equal(failure.recoverability, "recoverable"); + assert.equal(failure.disposition, "active_blocked"); + assert.equal(failure.resumable, true); + assert.equal(failure.message, "Please log in to continue"); + assert.equal(failure.userMessage, WORKFLOW_AUTH_FAILURE_MESSAGE); + }); + test("uses clear local login wrapper-401 messages before provider credential defaults", () => { + for (const message of [ + "Please log in to continue", + "not logged in", + "login required", + "Run /login to continue", + "Authentication failed for \"openai\". Credentials may have expired or network is unavailable. Run '/login openai' to re-authenticate.", + ] as const) { + const failure = classifyWorkflowFailure({ status: 401, message }); + assert.equal(failure.kind, "auth"); + assert.equal(failure.code, "login_required"); + assert.equal(failure.recoverability, "recoverable"); + assert.equal(failure.disposition, "active_blocked"); + assert.equal(failure.resumable, true); + assert.equal(failure.userMessage, WORKFLOW_AUTH_FAILURE_MESSAGE); + } + }); + test("keeps provider 401 auth text classified as invalid provider credentials", () => { + for (const message of ["Unauthorized", "authentication required"]) { + const failure = classifyWorkflowFailure({ status: 401, message }); + assert.equal(failure.kind, "auth"); + assert.equal(failure.code, "invalid_api_key"); + assert.equal(failure.recoverability, "non_recoverable"); + assert.equal(failure.disposition, "terminal_killed"); + assert.equal(failure.userMessage, WORKFLOW_INVALID_PROVIDER_CREDENTIALS_MESSAGE); + } + }); + test("classifies string-only provider auth fallback as invalid credentials", () => { + for (const error of [ + new Error("OpenAI API error (401): Unauthorized"), + new Error("Unauthorized"), + "authentication required", + ] as const) { + const failure = classifyWorkflowFailure(error); + assert.equal(failure.kind, "auth"); + assert.equal(failure.code, "invalid_api_key"); + assert.equal(failure.retryable, false); + assert.equal(failure.resumable, false); + assert.equal(failure.recoverability, "non_recoverable"); + assert.equal(failure.disposition, "terminal_killed"); + assert.equal(failure.userMessage, WORKFLOW_INVALID_PROVIDER_CREDENTIALS_MESSAGE); + } + }); + test("classifies non-contiguous invalid API key fallback messages as invalid credentials", () => { + for (const message of ["The API key provided is invalid", "The API key you supplied is incorrect"] as const) { + const failure = classifyWorkflowFailure(new Error(message)); + assert.equal(failure.kind, "auth"); + assert.equal(failure.code, "invalid_api_key"); + assert.equal(failure.retryable, false); + assert.equal(failure.resumable, false); + assert.equal(failure.recoverability, "non_recoverable"); + assert.equal(failure.disposition, "terminal_killed"); + assert.equal(failure.userMessage, WORKFLOW_INVALID_PROVIDER_CREDENTIALS_MESSAGE); + } + }); + test("keeps clear string-only local login fallback recoverable", () => { + for (const error of [ + new Error("Run /login to continue"), + new Error("not logged in"), + "login required", + "please login", + "please log in", + "log in to continue", + ] as const) { + const failure = classifyWorkflowFailure(error); + assert.equal(failure.kind, "auth"); + assert.equal(failure.code, "login_required"); + assert.equal(failure.retryable, true); + assert.equal(failure.resumable, true); + assert.equal(failure.recoverability, "recoverable"); + assert.equal(failure.disposition, "active_blocked"); + assert.equal(failure.userMessage, WORKFLOW_AUTH_FAILURE_MESSAGE); + } + }); + test("provider credential messages and causes override broad auth wrapper codes", () => { + const failures = [ + classifyWorkflowFailure({ + status: 401, + code: "auth_required", + message: "Incorrect API key provided", + }), + classifyWorkflowFailure({ + status: 401, + code: "auth_required", + message: "wrapper 401", + cause: { code: "invalid_api_key", message: "Incorrect API key provided" }, + }), + ]; - for (const failure of failures) { - assert.equal(failure.kind, "auth"); - assert.equal(failure.code, "invalid_api_key"); - assert.equal(failure.recoverability, "non_recoverable"); - assert.equal(failure.disposition, "terminal_killed"); - assert.equal(failure.userMessage, WORKFLOW_INVALID_PROVIDER_CREDENTIALS_MESSAGE); - } - }); - test("uses missing API key diagnostics before generic wrapper 401 defaults", () => { - const failure = classifyWorkflowFailure({ - status: 401, - message: "provider request failed", - diagnostics: [{ error: { code: "missing_api_key", message: "No API key found" } }], - }); + for (const failure of failures) { + assert.equal(failure.kind, "auth"); + assert.equal(failure.code, "invalid_api_key"); + assert.equal(failure.recoverability, "non_recoverable"); + assert.equal(failure.disposition, "terminal_killed"); + assert.equal(failure.userMessage, WORKFLOW_INVALID_PROVIDER_CREDENTIALS_MESSAGE); + } + }); + test("uses missing API key diagnostics before generic wrapper 401 defaults", () => { + const failure = classifyWorkflowFailure({ + status: 401, + message: "provider request failed", + diagnostics: [{ error: { code: "missing_api_key", message: "No API key found" } }], + }); - assert.equal(failure.kind, "auth"); - assert.equal(failure.code, "missing_api_key"); - assert.equal(failure.recoverability, "recoverable"); - assert.equal(failure.disposition, "active_blocked"); - assert.equal(failure.resumable, true); - assert.equal(failure.message, "No API key found"); - assert.equal(failure.userMessage, WORKFLOW_MISSING_API_KEY_FAILURE_MESSAGE); - }); - test("uses missing API key diagnostics before generic wrapper code 401 defaults", () => { - for (const code of [401, "401"] as const) { - const failure = classifyWorkflowFailure({ - code, - message: "provider request failed", - diagnostics: [{ error: { code: "missing_api_key", message: "No API key found" } }], - }); + assert.equal(failure.kind, "auth"); + assert.equal(failure.code, "missing_api_key"); + assert.equal(failure.recoverability, "recoverable"); + assert.equal(failure.disposition, "active_blocked"); + assert.equal(failure.resumable, true); + assert.equal(failure.message, "No API key found"); + assert.equal(failure.userMessage, WORKFLOW_MISSING_API_KEY_FAILURE_MESSAGE); + }); + test("uses missing API key diagnostics before generic wrapper code 401 defaults", () => { + for (const code of [401, "401"] as const) { + const failure = classifyWorkflowFailure({ + code, + message: "provider request failed", + diagnostics: [{ error: { code: "missing_api_key", message: "No API key found" } }], + }); - assert.equal(failure.kind, "auth"); - assert.equal(failure.code, "missing_api_key"); - assert.equal(failure.recoverability, "recoverable"); - assert.equal(failure.disposition, "active_blocked"); - assert.equal(failure.resumable, true); - assert.equal(failure.message, "No API key found"); - assert.equal(failure.userMessage, WORKFLOW_MISSING_API_KEY_FAILURE_MESSAGE); - } - }); - test("keeps generic wrapper code 401 without stronger diagnostics as invalid provider credentials", () => { - for (const code of [401, "401"] as const) { - const failure = classifyWorkflowFailure({ - code, - message: "provider request failed", - }); + assert.equal(failure.kind, "auth"); + assert.equal(failure.code, "missing_api_key"); + assert.equal(failure.recoverability, "recoverable"); + assert.equal(failure.disposition, "active_blocked"); + assert.equal(failure.resumable, true); + assert.equal(failure.message, "No API key found"); + assert.equal(failure.userMessage, WORKFLOW_MISSING_API_KEY_FAILURE_MESSAGE); + } + }); + test("keeps generic wrapper code 401 without stronger diagnostics as invalid provider credentials", () => { + for (const code of [401, "401"] as const) { + const failure = classifyWorkflowFailure({ + code, + message: "provider request failed", + }); - assert.equal(failure.kind, "auth"); - assert.equal(failure.code, "invalid_api_key"); - assert.equal(failure.recoverability, "non_recoverable"); - assert.equal(failure.disposition, "terminal_killed"); - assert.equal(failure.resumable, false); - assert.equal(failure.userMessage, WORKFLOW_INVALID_PROVIDER_CREDENTIALS_MESSAGE); - } - }); - test("uses structured codes and causes before message fallback", () => { - const auth = classifyWorkflowFailure({ message: "provider error", code: "AUTH_REQUIRED" }); - assert.equal(auth.kind, "auth"); - assert.equal(auth.code, "login_required"); + assert.equal(failure.kind, "auth"); + assert.equal(failure.code, "invalid_api_key"); + assert.equal(failure.recoverability, "non_recoverable"); + assert.equal(failure.disposition, "terminal_killed"); + assert.equal(failure.resumable, false); + assert.equal(failure.userMessage, WORKFLOW_INVALID_PROVIDER_CREDENTIALS_MESSAGE); + } + }); + test("uses structured codes and causes before message fallback", () => { + const auth = classifyWorkflowFailure({ message: "provider error", code: "AUTH_REQUIRED" }); + assert.equal(auth.kind, "auth"); + assert.equal(auth.code, "login_required"); - const rateLimit = classifyWorkflowFailure(new Error("outer failure", { - cause: { message: "inner failure", code: "rate_limit_exceeded" }, - })); - assert.equal(rateLimit.kind, "rate_limit"); - assert.equal(rateLimit.code, "rate_limited"); + const rateLimit = classifyWorkflowFailure( + new Error("outer failure", { + cause: { message: "inner failure", code: "rate_limit_exceeded" }, + }), + ); + assert.equal(rateLimit.kind, "rate_limit"); + assert.equal(rateLimit.code, "rate_limited"); - const cancelled = classifyWorkflowFailure({ message: "stopped", code: "AbortError" }); - assert.equal(cancelled.kind, "cancelled"); - assert.equal(cancelled.disposition, "terminal_killed"); - }); - test("treats broad auth wrapper codes as weak when the message names provider credentials", () => { - const failure = classifyWorkflowFailure({ - code: "auth", - message: "Incorrect API key provided", - }); - assert.equal(failure.kind, "auth"); - assert.equal(failure.code, "invalid_api_key"); - assert.equal(failure.recoverability, "non_recoverable"); - assert.equal(failure.disposition, "terminal_killed"); - assert.equal(failure.userMessage, WORKFLOW_INVALID_PROVIDER_CREDENTIALS_MESSAGE); - }); - test("uses SDK assistant error shapes", () => { - const failure = classifyWorkflowFailure({ - role: "assistant", - stopReason: "error", - errorMessage: "provider request failed", - diagnostics: [{ error: { code: 429, message: "quota exceeded" } }], - }); - assert.equal(failure.kind, "rate_limit"); - assert.equal(failure.code, "rate_limited"); - assert.equal(failure.message, "quota exceeded"); + const cancelled = classifyWorkflowFailure({ message: "stopped", code: "AbortError" }); + assert.equal(cancelled.kind, "cancelled"); + assert.equal(cancelled.disposition, "terminal_killed"); + }); + test("treats broad auth wrapper codes as weak when the message names provider credentials", () => { + const failure = classifyWorkflowFailure({ + code: "auth", + message: "Incorrect API key provided", + }); + assert.equal(failure.kind, "auth"); + assert.equal(failure.code, "invalid_api_key"); + assert.equal(failure.recoverability, "non_recoverable"); + assert.equal(failure.disposition, "terminal_killed"); + assert.equal(failure.userMessage, WORKFLOW_INVALID_PROVIDER_CREDENTIALS_MESSAGE); + }); + test("uses SDK assistant error shapes", () => { + const failure = classifyWorkflowFailure({ + role: "assistant", + stopReason: "error", + errorMessage: "provider request failed", + diagnostics: [{ error: { code: 429, message: "quota exceeded" } }], + }); + assert.equal(failure.kind, "rate_limit"); + assert.equal(failure.code, "rate_limited"); + assert.equal(failure.message, "quota exceeded"); - const cancelled = classifyWorkflowFailure({ - role: "assistant", - stopReason: "aborted", - errorMessage: "stream aborted", - }); - assert.equal(cancelled.kind, "cancelled"); - assert.equal(cancelled.disposition, "terminal_killed"); - }); - test("classifies OpenAI-style invalid API key diagnostics as terminal killed", () => { - const failure = classifyWorkflowFailure({ - role: "assistant", - stopReason: "error", - errorMessage: "provider request failed", - diagnostics: [{ - error: { - status: 401, - code: "invalid_api_key", - message: "Incorrect API key provided: sk-testsecret123456789", - }, - }], - }); + const cancelled = classifyWorkflowFailure({ + role: "assistant", + stopReason: "aborted", + errorMessage: "stream aborted", + }); + assert.equal(cancelled.kind, "cancelled"); + assert.equal(cancelled.disposition, "terminal_killed"); + }); + test("classifies OpenAI-style invalid API key diagnostics as terminal killed", () => { + const failure = classifyWorkflowFailure({ + role: "assistant", + stopReason: "error", + errorMessage: "provider request failed", + diagnostics: [ + { + error: { + status: 401, + code: "invalid_api_key", + message: "Incorrect API key provided: sk-testsecret123456789", + }, + }, + ], + }); - assert.equal(failure.kind, "auth"); - assert.equal(failure.code, "invalid_api_key"); - assert.equal(failure.recoverability, "non_recoverable"); - assert.equal(failure.disposition, "terminal_killed"); - assert.equal(failure.retryable, false); - assert.equal(failure.resumable, false); - assert.equal(failure.userMessage, WORKFLOW_INVALID_PROVIDER_CREDENTIALS_MESSAGE); - assert.doesNotMatch(failure.message, /sk-testsecret123456789/); - assert.doesNotMatch(failure.userMessage, /sk-testsecret/); - }); - test("uses diagnostic-only invalid key messages as the decisive failure message", () => { - const failure = classifyWorkflowFailure({ - role: "assistant", - stopReason: "error", - errorMessage: "provider request failed", - diagnostics: [{ - error: { - message: "Incorrect API key provided", - }, - }], - }); + assert.equal(failure.kind, "auth"); + assert.equal(failure.code, "invalid_api_key"); + assert.equal(failure.recoverability, "non_recoverable"); + assert.equal(failure.disposition, "terminal_killed"); + assert.equal(failure.retryable, false); + assert.equal(failure.resumable, false); + assert.equal(failure.userMessage, WORKFLOW_INVALID_PROVIDER_CREDENTIALS_MESSAGE); + assert.doesNotMatch(failure.message, /sk-testsecret123456789/); + assert.doesNotMatch(failure.userMessage, /sk-testsecret/); + }); + test("uses diagnostic-only invalid key messages as the decisive failure message", () => { + const failure = classifyWorkflowFailure({ + role: "assistant", + stopReason: "error", + errorMessage: "provider request failed", + diagnostics: [ + { + error: { + message: "Incorrect API key provided", + }, + }, + ], + }); - assert.equal(failure.kind, "auth"); - assert.equal(failure.code, "invalid_api_key"); - assert.equal(failure.message, "Incorrect API key provided"); - assert.equal(failure.userMessage, WORKFLOW_INVALID_PROVIDER_CREDENTIALS_MESSAGE); - }); - test("classifies diagnostic-only provider 401 unauthorized messages as terminal invalid credentials", () => { - for (const diagnostic of [ - { error: { message: "401 Unauthorized" } }, - { message: "401 Unauthorized" }, - { error: { message: "OpenAI API error (401): Unauthorized" } }, - ] as const) { - const failure = classifyWorkflowFailure({ - role: "assistant", - stopReason: "error", - errorMessage: "provider request failed", - diagnostics: [diagnostic], - }); + assert.equal(failure.kind, "auth"); + assert.equal(failure.code, "invalid_api_key"); + assert.equal(failure.message, "Incorrect API key provided"); + assert.equal(failure.userMessage, WORKFLOW_INVALID_PROVIDER_CREDENTIALS_MESSAGE); + }); + test("classifies diagnostic-only provider 401 unauthorized messages as terminal invalid credentials", () => { + for (const diagnostic of [ + { error: { message: "401 Unauthorized" } }, + { message: "401 Unauthorized" }, + { error: { message: "OpenAI API error (401): Unauthorized" } }, + ] as const) { + const failure = classifyWorkflowFailure({ + role: "assistant", + stopReason: "error", + errorMessage: "provider request failed", + diagnostics: [diagnostic], + }); - assert.equal(failure.kind, "auth"); - assert.equal(failure.code, "invalid_api_key"); - assert.equal(failure.recoverability, "non_recoverable"); - assert.equal(failure.disposition, "terminal_killed"); - assert.equal(failure.retryable, false); - assert.equal(failure.resumable, false); - assert.equal(failure.userMessage, WORKFLOW_INVALID_PROVIDER_CREDENTIALS_MESSAGE); - } - }); - test("lets invalid credential diagnostics beat rate limits regardless of diagnostic order", () => { - const diagnosticSets = [ - [ - { error: { status: 429, message: "too many requests" } }, - { error: { status: 401, code: "invalid_api_key", message: "Incorrect API key provided" } }, - ], - [ - { error: { status: 401, code: "invalid_api_key", message: "Incorrect API key provided" } }, - { error: { status: 429, message: "too many requests" } }, - ], - ] as const; + assert.equal(failure.kind, "auth"); + assert.equal(failure.code, "invalid_api_key"); + assert.equal(failure.recoverability, "non_recoverable"); + assert.equal(failure.disposition, "terminal_killed"); + assert.equal(failure.retryable, false); + assert.equal(failure.resumable, false); + assert.equal(failure.userMessage, WORKFLOW_INVALID_PROVIDER_CREDENTIALS_MESSAGE); + } + }); + test("lets invalid credential diagnostics beat rate limits regardless of diagnostic order", () => { + const diagnosticSets = [ + [ + { error: { status: 429, message: "too many requests" } }, + { error: { status: 401, code: "invalid_api_key", message: "Incorrect API key provided" } }, + ], + [ + { error: { status: 401, code: "invalid_api_key", message: "Incorrect API key provided" } }, + { error: { status: 429, message: "too many requests" } }, + ], + ] as const; - for (const diagnostics of diagnosticSets) { - const failure = classifyWorkflowFailure({ - role: "assistant", - stopReason: "error", - errorMessage: "provider request failed", - diagnostics, - }); + for (const diagnostics of diagnosticSets) { + const failure = classifyWorkflowFailure({ + role: "assistant", + stopReason: "error", + errorMessage: "provider request failed", + diagnostics, + }); - assert.equal(failure.kind, "auth"); - assert.equal(failure.code, "invalid_api_key"); - assert.equal(failure.recoverability, "non_recoverable"); - assert.equal(failure.disposition, "terminal_killed"); - assert.equal(failure.resumable, false); - assert.equal(failure.userMessage, WORKFLOW_INVALID_PROVIDER_CREDENTIALS_MESSAGE); - } - }); - test("keeps all-recoverable diagnostics active-blocked", () => { - const failure = classifyWorkflowFailure({ - role: "assistant", - stopReason: "error", - errorMessage: "provider request failed", - diagnostics: [ - { error: { status: 429, message: "too many requests", retryAfterMs: 2500 } }, - { error: { status: 503, message: "provider unavailable" } }, - ], - }); + assert.equal(failure.kind, "auth"); + assert.equal(failure.code, "invalid_api_key"); + assert.equal(failure.recoverability, "non_recoverable"); + assert.equal(failure.disposition, "terminal_killed"); + assert.equal(failure.resumable, false); + assert.equal(failure.userMessage, WORKFLOW_INVALID_PROVIDER_CREDENTIALS_MESSAGE); + } + }); + test("keeps all-recoverable diagnostics active-blocked", () => { + const failure = classifyWorkflowFailure({ + role: "assistant", + stopReason: "error", + errorMessage: "provider request failed", + diagnostics: [ + { error: { status: 429, message: "too many requests", retryAfterMs: 2500 } }, + { error: { status: 503, message: "provider unavailable" } }, + ], + }); - assert.equal(failure.kind, "rate_limit"); - assert.equal(failure.code, "rate_limited"); - assert.equal(failure.recoverability, "recoverable"); - assert.equal(failure.disposition, "active_blocked"); - assert.equal(failure.resumable, true); - assert.equal(failure.retryAfterMs, 2500); - }); - test("preserves retry hints from later all-recoverable diagnostics", () => { - const failure = classifyWorkflowFailure({ - role: "assistant", - stopReason: "error", - errorMessage: "provider request failed", - diagnostics: [ - { error: { status: 503, message: "provider unavailable" } }, - { error: { status: 429, message: "too many requests", retryAfterMs: 2500 } }, - ], - }); + assert.equal(failure.kind, "rate_limit"); + assert.equal(failure.code, "rate_limited"); + assert.equal(failure.recoverability, "recoverable"); + assert.equal(failure.disposition, "active_blocked"); + assert.equal(failure.resumable, true); + assert.equal(failure.retryAfterMs, 2500); + }); + test("preserves retry hints from later all-recoverable diagnostics", () => { + const failure = classifyWorkflowFailure({ + role: "assistant", + stopReason: "error", + errorMessage: "provider request failed", + diagnostics: [ + { error: { status: 503, message: "provider unavailable" } }, + { error: { status: 429, message: "too many requests", retryAfterMs: 2500 } }, + ], + }); - assert.equal(failure.kind, "rate_limit"); - assert.equal(failure.code, "rate_limited"); - assert.equal(failure.recoverability, "recoverable"); - assert.equal(failure.disposition, "active_blocked"); - assert.equal(failure.resumable, true); - assert.equal(failure.retryAfterMs, 2500); - }); - test("classifies AggregateError inner provider failures before wrapper text", () => { - const rateLimited = classifyWorkflowFailure(new AggregateError([ - { status: 429, message: "too many requests" }, - ], "atomic-workflows: 1 parallel step failed")); - assert.equal(rateLimited.kind, "rate_limit"); - assert.equal(rateLimited.code, "rate_limited"); - assert.equal(rateLimited.disposition, "active_blocked"); + assert.equal(failure.kind, "rate_limit"); + assert.equal(failure.code, "rate_limited"); + assert.equal(failure.recoverability, "recoverable"); + assert.equal(failure.disposition, "active_blocked"); + assert.equal(failure.resumable, true); + assert.equal(failure.retryAfterMs, 2500); + }); + test("classifies AggregateError inner provider failures before wrapper text", () => { + const rateLimited = classifyWorkflowFailure( + new AggregateError( + [{ status: 429, message: "too many requests" }], + "atomic-workflows: 1 parallel step failed", + ), + ); + assert.equal(rateLimited.kind, "rate_limit"); + assert.equal(rateLimited.code, "rate_limited"); + assert.equal(rateLimited.disposition, "active_blocked"); - const invalidKey = classifyWorkflowFailure(new AggregateError([ - { status: 401, message: "Unauthorized" }, - ], "atomic-workflows: 1 parallel step failed")); - assert.equal(invalidKey.kind, "auth"); - assert.equal(invalidKey.code, "invalid_api_key"); - assert.equal(invalidKey.disposition, "terminal_killed"); - }); - test("classifies mixed ordinary and rate-limit aggregate failures as terminal failed", () => { - const failure = classifyWorkflowFailure(new Error("wrapper", { - cause: new AggregateError([ - new Error("domain validation failed"), - { status: 429, message: "too many requests" }, - ], "atomic-workflows: 2 parallel steps failed"), - })); + const invalidKey = classifyWorkflowFailure( + new AggregateError([{ status: 401, message: "Unauthorized" }], "atomic-workflows: 1 parallel step failed"), + ); + assert.equal(invalidKey.kind, "auth"); + assert.equal(invalidKey.code, "invalid_api_key"); + assert.equal(invalidKey.disposition, "terminal_killed"); + }); + test("classifies mixed ordinary and rate-limit aggregate failures as terminal failed", () => { + const failure = classifyWorkflowFailure( + new Error("wrapper", { + cause: new AggregateError( + [new Error("domain validation failed"), { status: 429, message: "too many requests" }], + "atomic-workflows: 2 parallel steps failed", + ), + }), + ); - assert.equal(failure.kind, "unknown"); - assert.equal(failure.code, "unknown"); - assert.equal(failure.recoverability, "unknown"); - assert.equal(failure.disposition, "terminal_failed"); - assert.equal(failure.resumable, true); - }); - test("preserves all-recoverable aggregate failures as active-blocked", () => { - const failure = classifyWorkflowFailure(new AggregateError([ - { status: 429, message: "too many requests", retryAfterMs: 2500 }, - { status: 503, message: "provider unavailable" }, - ], "atomic-workflows: 2 parallel steps failed")); + assert.equal(failure.kind, "unknown"); + assert.equal(failure.code, "unknown"); + assert.equal(failure.recoverability, "unknown"); + assert.equal(failure.disposition, "terminal_failed"); + assert.equal(failure.resumable, true); + }); + test("preserves all-recoverable aggregate failures as active-blocked", () => { + const failure = classifyWorkflowFailure( + new AggregateError( + [ + { status: 429, message: "too many requests", retryAfterMs: 2500 }, + { status: 503, message: "provider unavailable" }, + ], + "atomic-workflows: 2 parallel steps failed", + ), + ); - assert.equal(failure.kind, "rate_limit"); - assert.equal(failure.code, "rate_limited"); - assert.equal(failure.recoverability, "recoverable"); - assert.equal(failure.disposition, "active_blocked"); - assert.equal(failure.retryAfterMs, 2500); - }); - test("preserves retry hints from later all-recoverable aggregate failures", () => { - const failure = classifyWorkflowFailure(new AggregateError([ - { status: 503, message: "provider unavailable" }, - { status: 429, message: "too many requests", retryAfterMs: 2500 }, - ], "atomic-workflows: 2 parallel steps failed")); + assert.equal(failure.kind, "rate_limit"); + assert.equal(failure.code, "rate_limited"); + assert.equal(failure.recoverability, "recoverable"); + assert.equal(failure.disposition, "active_blocked"); + assert.equal(failure.retryAfterMs, 2500); + }); + test("preserves retry hints from later all-recoverable aggregate failures", () => { + const failure = classifyWorkflowFailure( + new AggregateError( + [ + { status: 503, message: "provider unavailable" }, + { status: 429, message: "too many requests", retryAfterMs: 2500 }, + ], + "atomic-workflows: 2 parallel steps failed", + ), + ); - assert.equal(failure.kind, "rate_limit"); - assert.equal(failure.code, "rate_limited"); - assert.equal(failure.recoverability, "recoverable"); - assert.equal(failure.disposition, "active_blocked"); - assert.equal(failure.retryAfterMs, 2500); - }); + assert.equal(failure.kind, "rate_limit"); + assert.equal(failure.code, "rate_limited"); + assert.equal(failure.recoverability, "recoverable"); + assert.equal(failure.disposition, "active_blocked"); + assert.equal(failure.retryAfterMs, 2500); + }); }); diff --git a/test/unit/workflow-failures-02.test.ts b/test/unit/workflow-failures-02.test.ts index a7077db52..1ebd56ca5 100644 --- a/test/unit/workflow-failures-02.test.ts +++ b/test/unit/workflow-failures-02.test.ts @@ -3,202 +3,205 @@ * Unit tests for workflow-local failure classification. */ -import { describe, test } from "bun:test"; import assert from "node:assert/strict"; +import { describe, test } from "vitest"; import { - WORKFLOW_AUTH_FAILURE_MESSAGE, - WORKFLOW_INVALID_PROVIDER_CREDENTIALS_MESSAGE, - WORKFLOW_MISSING_API_KEY_FAILURE_MESSAGE, - WORKFLOW_UNKNOWN_MODEL_MESSAGE, - classifyWorkflowFailure, + classifyWorkflowFailure, + WORKFLOW_AUTH_FAILURE_MESSAGE, + WORKFLOW_INVALID_PROVIDER_CREDENTIALS_MESSAGE, + WORKFLOW_UNKNOWN_MODEL_MESSAGE, } from "../../packages/workflows/src/shared/workflow-failures.js"; + describe("classifyWorkflowFailure", () => { - test("lets invalid credentials win over rate limits in aggregate failures", () => { - const failure = classifyWorkflowFailure(new AggregateError([ - { status: 429, message: "too many requests" }, - { status: 401, message: "Unauthorized" }, - ], "atomic-workflows: 2 parallel steps failed")); + test("lets invalid credentials win over rate limits in aggregate failures", () => { + const failure = classifyWorkflowFailure( + new AggregateError( + [ + { status: 429, message: "too many requests" }, + { status: 401, message: "Unauthorized" }, + ], + "atomic-workflows: 2 parallel steps failed", + ), + ); - assert.equal(failure.kind, "auth"); - assert.equal(failure.code, "invalid_api_key"); - assert.equal(failure.recoverability, "non_recoverable"); - assert.equal(failure.disposition, "terminal_killed"); - assert.equal(failure.resumable, false); - }); - test("extracts retry-after metadata from structured rate limits", () => { - const failure = classifyWorkflowFailure({ - message: "slow down", - status: 429, - headers: { "retry-after": "3" }, - }); - assert.equal(failure.kind, "rate_limit"); - assert.equal(failure.disposition, "active_blocked"); - assert.equal(failure.retryAfterMs, 3000); - }); - test("treats bare retryAfter as seconds while explicit retryAfterMs remains milliseconds", () => { - const explicitMs = classifyWorkflowFailure({ - message: "slow down", - status: 429, - retryAfterMs: 2500, - }); - assert.equal(explicitMs.kind, "rate_limit"); - assert.equal(explicitMs.retryAfterMs, 2500); + assert.equal(failure.kind, "auth"); + assert.equal(failure.code, "invalid_api_key"); + assert.equal(failure.recoverability, "non_recoverable"); + assert.equal(failure.disposition, "terminal_killed"); + assert.equal(failure.resumable, false); + }); + test("extracts retry-after metadata from structured rate limits", () => { + const failure = classifyWorkflowFailure({ + message: "slow down", + status: 429, + headers: { "retry-after": "3" }, + }); + assert.equal(failure.kind, "rate_limit"); + assert.equal(failure.disposition, "active_blocked"); + assert.equal(failure.retryAfterMs, 3000); + }); + test("treats bare retryAfter as seconds while explicit retryAfterMs remains milliseconds", () => { + const explicitMs = classifyWorkflowFailure({ + message: "slow down", + status: 429, + retryAfterMs: 2500, + }); + assert.equal(explicitMs.kind, "rate_limit"); + assert.equal(explicitMs.retryAfterMs, 2500); - const direct = classifyWorkflowFailure({ - message: "slow down", - status: 429, - retryAfter: 3, - }); - assert.equal(direct.kind, "rate_limit"); - assert.equal(direct.retryAfterMs, 3000); + const direct = classifyWorkflowFailure({ + message: "slow down", + status: 429, + retryAfter: 3, + }); + assert.equal(direct.kind, "rate_limit"); + assert.equal(direct.retryAfterMs, 3000); - const seconds = classifyWorkflowFailure({ - message: "slow down", - status: 429, - retryAfterSeconds: 3, - }); - assert.equal(seconds.kind, "rate_limit"); - assert.equal(seconds.retryAfterMs, 3000); + const seconds = classifyWorkflowFailure({ + message: "slow down", + status: 429, + retryAfterSeconds: 3, + }); + assert.equal(seconds.kind, "rate_limit"); + assert.equal(seconds.retryAfterMs, 3000); - const header = classifyWorkflowFailure({ - message: "slow down", - status: 429, - "retry-after": "3", - }); - assert.equal(header.kind, "rate_limit"); - assert.equal(header.retryAfterMs, 3000); - }); - test("structured 429 wins over misleading auth text", () => { - const failure = classifyWorkflowFailure({ - message: "Incorrect API key mentioned in provider retry body", - status: 429, - }); - assert.equal(failure.kind, "rate_limit"); - assert.equal(failure.code, "rate_limited"); - assert.equal(failure.disposition, "active_blocked"); - }); - test("redacts top-level structured invalid provider credential messages", () => { - for (const secret of [ - "sk-testsecret1234567890", - "api_key=super-secret-value", - "token=super-secret-value", - "credential=super-secret-value", - "secret=super-secret-value", - "Authorization: Bearer secret-token-value", - "Bearer secret-token-value", - ] as const) { - const failure = classifyWorkflowFailure({ - status: 401, - code: "invalid_api_key", - message: `Incorrect API key provided: ${secret}`, - }); + const header = classifyWorkflowFailure({ + message: "slow down", + status: 429, + "retry-after": "3", + }); + assert.equal(header.kind, "rate_limit"); + assert.equal(header.retryAfterMs, 3000); + }); + test("structured 429 wins over misleading auth text", () => { + const failure = classifyWorkflowFailure({ + message: "Incorrect API key mentioned in provider retry body", + status: 429, + }); + assert.equal(failure.kind, "rate_limit"); + assert.equal(failure.code, "rate_limited"); + assert.equal(failure.disposition, "active_blocked"); + }); + test("redacts top-level structured invalid provider credential messages", () => { + for (const secret of [ + "sk-testsecret1234567890", + "api_key=super-secret-value", + "token=super-secret-value", + "credential=super-secret-value", + "secret=super-secret-value", + "Authorization: Bearer secret-token-value", + "Bearer secret-token-value", + ] as const) { + const failure = classifyWorkflowFailure({ + status: 401, + code: "invalid_api_key", + message: `Incorrect API key provided: ${secret}`, + }); - assert.equal(failure.kind, "auth"); - assert.equal(failure.code, "invalid_api_key"); - assert.equal(failure.userMessage, WORKFLOW_INVALID_PROVIDER_CREDENTIALS_MESSAGE); - assert.equal(failure.message.includes(secret), false); - assert.equal(failure.userMessage.includes(secret), false); - assert.match(failure.message, /\[redacted\]/); - } - }); - test("redacts string-only invalid provider key fallback messages", () => { - const secret = "sk-testsecret1234567890"; - const failure = classifyWorkflowFailure(new Error(`Incorrect API key provided: ${secret}`)); + assert.equal(failure.kind, "auth"); + assert.equal(failure.code, "invalid_api_key"); + assert.equal(failure.userMessage, WORKFLOW_INVALID_PROVIDER_CREDENTIALS_MESSAGE); + assert.equal(failure.message.includes(secret), false); + assert.equal(failure.userMessage.includes(secret), false); + assert.match(failure.message, /\[redacted\]/); + } + }); + test("redacts string-only invalid provider key fallback messages", () => { + const secret = "sk-testsecret1234567890"; + const failure = classifyWorkflowFailure(new Error(`Incorrect API key provided: ${secret}`)); - assert.equal(failure.kind, "auth"); - assert.equal(failure.code, "invalid_api_key"); - assert.equal(failure.userMessage, WORKFLOW_INVALID_PROVIDER_CREDENTIALS_MESSAGE); - assert.equal(failure.message.includes(secret), false); - assert.match(failure.message, /\[redacted\]/); - }); - test("redacts sensitive fallback unknown messages", () => { - for (const secret of [ - "api_key=super-secret-value", - "token=super-secret-value", - "credential=super-secret-value", - "secret=super-secret-value", - "Authorization: Bearer secret-token-value", - "Bearer secret-token-value", - ] as const) { - const failure = classifyWorkflowFailure(new Error(`tool failed with ${secret}`)); - assert.equal(failure.kind, "unknown"); - assert.equal(failure.code, "unknown"); - assert.equal(failure.message.includes(secret), false); - assert.equal(failure.userMessage.includes(secret), false); - assert.match(failure.message, /\[redacted\]/); - assert.match(failure.userMessage, /\[redacted\]/); - } - }); - test("does not treat log information/input errors as auth failures", () => { - for (const message of [ - "failed to log information about request", - "failed to log input before validation", - ]) { - const failure = classifyWorkflowFailure(new Error(message)); - assert.equal(failure.kind, "unknown"); - assert.equal(failure.userMessage, message); - assert.equal(failure.retryable, false); - assert.equal(failure.disposition, "terminal_failed"); - } - }); - test("still treats bounded log in guidance as auth failure", () => { - const failure = classifyWorkflowFailure(new Error("Please log in to continue")); - assert.equal(failure.kind, "auth"); - assert.equal(failure.code, "login_required"); - assert.equal(failure.userMessage, WORKFLOW_AUTH_FAILURE_MESSAGE); - assert.equal(failure.disposition, "active_blocked"); - }); - test("does not treat generic domain/tool model errors as provider outages", () => { - for (const message of [ - "domain model validation failed", - "invalid model parameter passed to tool", - ]) { - const failure = classifyWorkflowFailure(new Error(message)); - assert.equal(failure.kind, "unknown"); - assert.equal(failure.retryable, false); - } - }); - test("distinguishes unavailable providers from unknown models", () => { - const unavailable = classifyWorkflowFailure(new Error("model unavailable")); - assert.equal(unavailable.kind, "provider"); - assert.equal(unavailable.code, "provider_unavailable"); - assert.equal(unavailable.retryable, true); + assert.equal(failure.kind, "auth"); + assert.equal(failure.code, "invalid_api_key"); + assert.equal(failure.userMessage, WORKFLOW_INVALID_PROVIDER_CREDENTIALS_MESSAGE); + assert.equal(failure.message.includes(secret), false); + assert.match(failure.message, /\[redacted\]/); + }); + test("redacts sensitive fallback unknown messages", () => { + for (const secret of [ + "api_key=super-secret-value", + "token=super-secret-value", + "credential=super-secret-value", + "secret=super-secret-value", + "Authorization: Bearer secret-token-value", + "Bearer secret-token-value", + ] as const) { + const failure = classifyWorkflowFailure(new Error(`tool failed with ${secret}`)); + assert.equal(failure.kind, "unknown"); + assert.equal(failure.code, "unknown"); + assert.equal(failure.message.includes(secret), false); + assert.equal(failure.userMessage.includes(secret), false); + assert.match(failure.message, /\[redacted\]/); + assert.match(failure.userMessage, /\[redacted\]/); + } + }); + test("does not treat log information/input errors as auth failures", () => { + for (const message of ["failed to log information about request", "failed to log input before validation"]) { + const failure = classifyWorkflowFailure(new Error(message)); + assert.equal(failure.kind, "unknown"); + assert.equal(failure.userMessage, message); + assert.equal(failure.retryable, false); + assert.equal(failure.disposition, "terminal_failed"); + } + }); + test("still treats bounded log in guidance as auth failure", () => { + const failure = classifyWorkflowFailure(new Error("Please log in to continue")); + assert.equal(failure.kind, "auth"); + assert.equal(failure.code, "login_required"); + assert.equal(failure.userMessage, WORKFLOW_AUTH_FAILURE_MESSAGE); + assert.equal(failure.disposition, "active_blocked"); + }); + test("does not treat generic domain/tool model errors as provider outages", () => { + for (const message of ["domain model validation failed", "invalid model parameter passed to tool"]) { + const failure = classifyWorkflowFailure(new Error(message)); + assert.equal(failure.kind, "unknown"); + assert.equal(failure.retryable, false); + } + }); + test("distinguishes unavailable providers from unknown models", () => { + const unavailable = classifyWorkflowFailure(new Error("model unavailable")); + assert.equal(unavailable.kind, "provider"); + assert.equal(unavailable.code, "provider_unavailable"); + assert.equal(unavailable.retryable, true); - const missing = classifyWorkflowFailure(new Error("model not found")); - assert.equal(missing.kind, "provider"); - assert.equal(missing.code, "unknown_model"); - assert.equal(missing.userMessage, WORKFLOW_UNKNOWN_MODEL_MESSAGE); - assert.equal(missing.retryable, false); - assert.equal(missing.resumable, false); - assert.equal(missing.disposition, "terminal_killed"); - }); - test("does not treat generic OAuth metadata errors as auth failures", () => { - const failure = classifyWorkflowFailure(new Error("OAuth callback metadata parse failed")); - assert.equal(failure.kind, "unknown"); - assert.equal(failure.userMessage, "OAuth callback metadata parse failed"); - }); + const missing = classifyWorkflowFailure(new Error("model not found")); + assert.equal(missing.kind, "provider"); + assert.equal(missing.code, "unknown_model"); + assert.equal(missing.userMessage, WORKFLOW_UNKNOWN_MODEL_MESSAGE); + assert.equal(missing.retryable, false); + assert.equal(missing.resumable, false); + assert.equal(missing.disposition, "terminal_killed"); + }); + test("does not treat generic OAuth metadata errors as auth failures", () => { + const failure = classifyWorkflowFailure(new Error("OAuth callback metadata parse failed")); + assert.equal(failure.kind, "unknown"); + assert.equal(failure.userMessage, "OAuth callback metadata parse failed"); + }); - test("classifies git subprocess timeouts without treating them as repository setup errors", () => { - const failure = classifyWorkflowFailure(new Error("Timed out while checking the Git repository for gitWorktreeDir from /repo. Git reported: git command timed out after 60000ms (ETIMEDOUT): spawnSync git ETIMEDOUT")); - assert.equal(failure.kind, "provider"); - assert.equal(failure.code, "provider_unavailable"); - assert.equal(failure.retryable, true); - assert.equal(failure.disposition, "active_blocked"); - assert.doesNotMatch(failure.userMessage, /not inside a Git repository/); - }); + test("classifies git subprocess timeouts without treating them as repository setup errors", () => { + const failure = classifyWorkflowFailure( + new Error( + "Timed out while checking the Git repository for gitWorktreeDir from /repo. Git reported: git command timed out after 60000ms (ETIMEDOUT): spawnSync git ETIMEDOUT", + ), + ); + assert.equal(failure.kind, "provider"); + assert.equal(failure.code, "provider_unavailable"); + assert.equal(failure.retryable, true); + assert.equal(failure.disposition, "active_blocked"); + assert.doesNotMatch(failure.userMessage, /not inside a Git repository/); + }); - test("does not treat unrelated local timeout messages as provider outages", () => { - for (const message of ["local database timeout while acquiring lock", "unit test timeout exceeded"]) { - const failure = classifyWorkflowFailure(new Error(message)); - assert.equal(failure.kind, "unknown"); - assert.equal(failure.retryable, false); - assert.equal(failure.disposition, "terminal_failed"); - } - }); - test("still treats OAuth token errors as auth failures", () => { - const failure = classifyWorkflowFailure(new Error("OAuth token expired")); - assert.equal(failure.kind, "auth"); - assert.equal(failure.code, "login_required"); - assert.equal(failure.userMessage, WORKFLOW_AUTH_FAILURE_MESSAGE); - }); + test("does not treat unrelated local timeout messages as provider outages", () => { + for (const message of ["local database timeout while acquiring lock", "unit test timeout exceeded"]) { + const failure = classifyWorkflowFailure(new Error(message)); + assert.equal(failure.kind, "unknown"); + assert.equal(failure.retryable, false); + assert.equal(failure.disposition, "terminal_failed"); + } + }); + test("still treats OAuth token errors as auth failures", () => { + const failure = classifyWorkflowFailure(new Error("OAuth token expired")); + assert.equal(failure.kind, "auth"); + assert.equal(failure.code, "login_required"); + assert.equal(failure.userMessage, WORKFLOW_AUTH_FAILURE_MESSAGE); + }); }); diff --git a/test/unit/workflow-hil-answer-notifications.test.ts b/test/unit/workflow-hil-answer-notifications.test.ts index 9aab19a61..89aea88d4 100644 --- a/test/unit/workflow-hil-answer-notifications.test.ts +++ b/test/unit/workflow-hil-answer-notifications.test.ts @@ -1,326 +1,336 @@ -import { describe, test } from "bun:test"; import assert from "node:assert/strict"; +import { describe, test } from "vitest"; import { - HIL_ANSWER_NOTICE_CUSTOM_TYPE, - installWorkflowHilAnswerNotifications, - registerHilAnswerNoticeRenderer, - type WorkflowHilAnswerNoticeDetails, + HIL_ANSWER_NOTICE_CUSTOM_TYPE, + installWorkflowHilAnswerNotifications, + registerHilAnswerNoticeRenderer, + type WorkflowHilAnswerNoticeDetails, } from "../../packages/workflows/src/extension/hil-answer-notifications.js"; -import { StageUiBroker, type StageCustomUiRequest } from "../../packages/workflows/src/shared/stage-ui-broker.js"; import { buildStagePromptAdapter } from "../../packages/workflows/src/shared/stage-prompt.js"; +import { type StageCustomUiRequest, StageUiBroker } from "../../packages/workflows/src/shared/stage-ui-broker.js"; import { createStore } from "../../packages/workflows/src/shared/store.js"; import type { PendingPrompt, StageSnapshot } from "../../packages/workflows/src/shared/store-types.js"; interface SentMessage { - readonly customType: string; - readonly content?: string; - readonly display?: boolean; - readonly details?: WorkflowHilAnswerNoticeDetails; + readonly customType: string; + readonly content?: string; + readonly display?: boolean; + readonly details?: WorkflowHilAnswerNoticeDetails; } interface CardComponent { - render(width: number): string[]; + render(width: number): string[]; } interface RegisteredRenderer { - readonly event: string; - readonly renderer: (payload: unknown) => unknown; + readonly event: string; + readonly renderer: (payload: unknown) => unknown; } type SendOptions = { - readonly triggerTurn?: boolean; - readonly deliverAs?: "steer" | "followUp" | "nextTurn" | "interrupt"; - readonly excludeFromContext?: boolean; - readonly interruptAbortMessage?: string; + readonly triggerTurn?: boolean; + readonly deliverAs?: "steer" | "followUp" | "nextTurn" | "interrupt"; + readonly excludeFromContext?: boolean; + readonly interruptAbortMessage?: string; }; const COLOR_ARGS = { - questions: [ - { - question: "What color?", - options: [{ label: "Red" }, { label: "Blue" }], - }, - ], + questions: [ + { + question: "What color?", + options: [{ label: "Red" }, { label: "Blue" }], + }, + ], }; function runningStage(overrides: Partial = {}): StageSnapshot { - return { - id: "stage-1", - name: "review", - status: "running", - parentIds: [], - toolEvents: [], - ...overrides, - }; + return { + id: "stage-1", + name: "review", + status: "running", + parentIds: [], + toolEvents: [], + ...overrides, + }; } function pendingPrompt(overrides: Partial = {}): PendingPrompt { - return { - id: "prompt-1", - kind: "input", - message: "Secret passphrase?", - createdAt: 10, - ...overrides, - }; + return { + id: "prompt-1", + kind: "input", + message: "Secret passphrase?", + createdAt: 10, + ...overrides, + }; } function setup() { - const store = createStore(); - const broker = new StageUiBroker(store); - const sent: SentMessage[] = []; - const options: SendOptions[] = []; - const unsubscribe = installWorkflowHilAnswerNotifications({ - store, - stageUiBroker: broker, - sendMessage(message, sendOptions) { - sent.push(message as SentMessage); - options.push(sendOptions ?? {}); - }, - }); - store.recordRunStart({ id: "run-1", name: "release", inputs: {}, status: "running", stages: [], startedAt: 1 }); - store.recordStageStart("run-1", runningStage()); - return { store, broker, sent, options, unsubscribe }; + const store = createStore(); + const broker = new StageUiBroker(store); + const sent: SentMessage[] = []; + const options: SendOptions[] = []; + const unsubscribe = installWorkflowHilAnswerNotifications({ + store, + stageUiBroker: broker, + sendMessage(message, sendOptions) { + sent.push(message as SentMessage); + options.push(sendOptions ?? {}); + }, + }); + store.recordRunStart({ id: "run-1", name: "release", inputs: {}, status: "running", stages: [], startedAt: 1 }); + store.recordStageStart("run-1", runningStage()); + return { store, broker, sent, options, unsubscribe }; } describe("installWorkflowHilAnswerNotifications", () => { - test("emits one display-only notice when a simple stage prompt is answered", () => { - const { store, sent, options, unsubscribe } = setup(); - - assert.equal(store.recordStagePendingPrompt("run-1", "stage-1", pendingPrompt()), true); - assert.equal(store.resolveStagePendingPrompt("run-1", "stage-1", "prompt-1", "swordfish"), true); - store.recordNotice({ id: "tick", level: "info", message: "force notify", createdAt: 20 }); - store.clearStagePromptAnswer("run-1", "stage-1"); - - assert.equal(sent.length, 1); - assert.deepEqual(options[0], { triggerTurn: false, excludeFromContext: true }); - assert.equal(sent[0]?.customType, HIL_ANSWER_NOTICE_CUSTOM_TYPE); - assert.equal(sent[0]?.display, true); - assert.equal(sent[0]?.details?.kind, "hil_answered"); - assert.equal(sent[0]?.details?.scope, "stage"); - assert.equal(sent[0]?.details?.runId, "run-1"); - assert.equal(sent[0]?.details?.workflowName, "release"); - assert.equal(sent[0]?.details?.stageId, "stage-1"); - assert.equal(sent[0]?.details?.stageName, "review"); - assert.equal(sent[0]?.details?.promptId, "prompt-1"); - assert.equal(sent[0]?.details?.promptKind, "input"); - assert.equal(sent[0]?.details?.answerAvailable, true); - assert.equal(sent[0]?.details?.answerIncluded, true); - assert.equal(sent[0]?.details?.answerSummary, "swordfish"); - assert.equal(sent[0]?.details?.promptMessage, "Secret passphrase?"); - assert.equal(typeof sent[0]?.details?.answeredAt, "number"); - assert.match(sent[0]?.content ?? "", /received the user's response/); - assert.match(sent[0]?.content ?? "", /User responded with: swordfish/); - assert.match(sent[0]?.content ?? "", /Do not ask the same question again/); - assert.match(sent[0]?.content ?? "", /No main-chat action is needed/); - assert.match(sent[0]?.content ?? "", /do not answer any other workflow human-in-the-loop prompt unless the user explicitly provides that answer/); - unsubscribe(); - }); - - test("does not notify when a simple prompt is cleared without recording an answer", () => { - const { store, sent, unsubscribe } = setup(); - - assert.equal(store.recordStagePendingPrompt("run-1", "stage-1", pendingPrompt()), true); - assert.equal( - store.resolveStagePendingPrompt("run-1", "stage-1", "prompt-1", "discarded", { recordAnswer: false }), - true, - ); - - assert.deepEqual(sent, []); - unsubscribe(); - }); - - test("does not notify when a simple prompt is answered by the workflow tool", () => { - const { store, sent, unsubscribe } = setup(); - - assert.equal(store.recordStagePendingPrompt("run-1", "stage-1", pendingPrompt()), true); - assert.equal( - store.resolveStagePendingPrompt("run-1", "stage-1", "prompt-1", "from tool", { answerSource: "workflow_tool" }), - true, - ); - store.recordNotice({ id: "tick", level: "info", message: "force notify", createdAt: 20 }); - - assert.deepEqual(sent, []); - unsubscribe(); - }); - - test("emits exactly one custom prompt notice when awaiting clears before the answer is recorded", async () => { - const { store, broker, sent, options, unsubscribe } = setup(); - const prompt = pendingPrompt({ - id: "custom-1", - kind: "custom", - message: "Approval widget", - customIdentityHash: "identity-hash", - customIdentitySource: "caller", - }); - store.recordStageStart( - "run-1", - runningStage({ - id: "custom-stage", - name: "custom", - promptFootprint: prompt, - }), - ); - - let request: StageCustomUiRequest | undefined; - const unregisterHost = broker.registerHost("run-1", "custom-stage", { - showCustomUi(next) { - request = next as StageCustomUiRequest; - }, - }); - try { - const pending = broker.requestCustomUi("run-1", "custom-stage", () => ({ - render: () => [], - invalidate: () => {}, - })); - assert.ok(request, "custom request should mount on the registered host"); - broker.resolve(request, "approved"); - assert.equal(await pending, "approved"); - - const afterBrokerResolve = store.runs()[0]?.stages.find((stage) => stage.id === "custom-stage"); - assert.equal(afterBrokerResolve?.status, "running"); - assert.equal(afterBrokerResolve?.promptAnswerState, undefined); - assert.equal(sent.length, 0); - - assert.equal(store.recordStagePromptAnswer("run-1", "custom-stage", prompt, "approved"), true); - store.recordNotice({ id: "tick-1", level: "info", message: "force notify", createdAt: 21 }); - assert.equal(store.recordStagePromptAnswer("run-1", "custom-stage", prompt, "approved-again"), true); - store.recordNotice({ id: "tick-2", level: "info", message: "force notify again", createdAt: 22 }); - - assert.equal(sent.length, 1); - assert.deepEqual(options[0], { triggerTurn: false, excludeFromContext: true }); - assert.equal(sent[0]?.customType, HIL_ANSWER_NOTICE_CUSTOM_TYPE); - assert.equal(sent[0]?.display, true); - assert.equal(sent[0]?.details?.promptId, "custom-1"); - assert.equal(sent[0]?.details?.promptKind, "custom"); - assert.equal(sent[0]?.details?.promptMessage, "Approval widget"); - assert.equal(sent[0]?.details?.answerSummary, "approved"); - assert.match(sent[0]?.content ?? "", /User responded with: approved/); - } finally { - unregisterHost(); - unsubscribe(); - } - }); - - test("does not notify when a custom prompt answer comes from the workflow tool", () => { - const { store, sent, unsubscribe } = setup(); - const prompt = pendingPrompt({ - id: "custom-tool-1", - kind: "custom", - message: "Tool-supplied widget", - customIdentityHash: "identity-hash", - customIdentitySource: "caller", - }); - store.recordStageStart( - "run-1", - runningStage({ - id: "custom-tool-stage", - name: "custom", - promptFootprint: prompt, - }), - ); - - assert.equal( - store.recordStagePromptAnswer("run-1", "custom-tool-stage", prompt, "from tool", { answerSource: "workflow_tool" }), - true, - ); - store.recordNotice({ id: "tick", level: "info", message: "force notify", createdAt: 20 }); - - assert.deepEqual(sent, []); - unsubscribe(); - }); - - test("emits a display-only notice when a brokered structured prompt is answered", async () => { - const { broker, sent, options, unsubscribe } = setup(); - const adapter = buildStagePromptAdapter("ask-1", "ask_user_question", COLOR_ARGS, 1)!; - broker.provideStagePrompt("run-1", "stage-1", adapter); - - const pending = broker.requestCustomUi("run-1", "stage-1", () => ({ - render: () => [], - invalidate: () => {}, - })); - - assert.equal(broker.answerStagePrompt("run-1", "stage-1", { text: "Blue" }), true); - await pending; - - assert.equal(sent.length, 1); - assert.deepEqual(options[0], { triggerTurn: false, excludeFromContext: true }); - assert.equal(sent[0]?.customType, HIL_ANSWER_NOTICE_CUSTOM_TYPE); - assert.equal(sent[0]?.details?.promptId, "ask-1"); - assert.equal(sent[0]?.details?.promptKind, "ask_user_question"); - assert.equal(sent[0]?.details?.answerAvailable, true); - assert.equal(sent[0]?.details?.answerIncluded, true); - assert.equal(sent[0]?.details?.answerSummary, "What color? → Blue"); - assert.equal(sent[0]?.details?.promptMessage, "What color?"); - assert.match(sent[0]?.content ?? "", /User responded with: What color\? → Blue/); - assert.match(sent[0]?.content ?? "", /No main-chat action is needed/); - unsubscribe(); - }); - - test("does not notify when a brokered structured prompt is answered by the workflow tool", async () => { - const { broker, sent, unsubscribe } = setup(); - const adapter = buildStagePromptAdapter("ask-1", "ask_user_question", COLOR_ARGS, 1)!; - broker.provideStagePrompt("run-1", "stage-1", adapter); - - const pending = broker.requestCustomUi("run-1", "stage-1", () => ({ - render: () => [], - invalidate: () => {}, - })); - - assert.equal(broker.answerStagePrompt("run-1", "stage-1", { text: "Blue" }, { answerSource: "workflow_tool" }), true); - await pending; - - assert.deepEqual(sent, []); - unsubscribe(); - }); - - test("registers HiL answer renderer once per host and returns a notice card", () => { - const host = {}; - const registered: RegisteredRenderer[] = []; - registerHilAnswerNoticeRenderer({ - rendererHost: host, - registerMessageRenderer(event, renderer) { - registered.push({ event, renderer: renderer as (payload: unknown) => unknown }); - }, - }); - registerHilAnswerNoticeRenderer({ - rendererHost: host, - registerMessageRenderer(event, renderer) { - registered.push({ event, renderer: renderer as (payload: unknown) => unknown }); - }, - }); - - assert.equal(registered.length, 1); - assert.equal(registered[0]?.event, HIL_ANSWER_NOTICE_CUSTOM_TYPE); - const rendered = registered[0]?.renderer({ - details: { - kind: "hil_answered", - scope: "stage", - runId: "run-card", - workflowName: "release", - stageId: "stage-1", - stageName: "review", - promptId: "prompt-1", - promptKind: "input", - promptMessage: "Secret passphrase?", - answeredAt: 1, - answerAvailable: true, - answerIncluded: true, - answerSummary: "swordfish", - } satisfies WorkflowHilAnswerNoticeDetails, - }); - - assert.equal(typeof rendered, "object"); - assert.notEqual(rendered, null); - const lines = (rendered as CardComponent).render(80); - const text = lines.join("\n"); - assert.match(text, /╭ HIL ANSWERED/); - assert.match(text, /✓ Workflow "release" received the user's response/); - assert.match(text, /stage\s+review/); - assert.match(text, /answer\s+swordfish/); - for (const width of [80, 40, 24]) { - for (const line of (rendered as CardComponent).render(width)) { - assert.ok(line.length === 0 || line.replace(/\x1B\[[0-?]*[ -/]*[@-~]/g, "").length <= width); - } - } - }); + test("emits one display-only notice when a simple stage prompt is answered", () => { + const { store, sent, options, unsubscribe } = setup(); + + assert.equal(store.recordStagePendingPrompt("run-1", "stage-1", pendingPrompt()), true); + assert.equal(store.resolveStagePendingPrompt("run-1", "stage-1", "prompt-1", "swordfish"), true); + store.recordNotice({ id: "tick", level: "info", message: "force notify", createdAt: 20 }); + store.clearStagePromptAnswer("run-1", "stage-1"); + + assert.equal(sent.length, 1); + assert.deepEqual(options[0], { triggerTurn: false, excludeFromContext: true }); + assert.equal(sent[0]?.customType, HIL_ANSWER_NOTICE_CUSTOM_TYPE); + assert.equal(sent[0]?.display, true); + assert.equal(sent[0]?.details?.kind, "hil_answered"); + assert.equal(sent[0]?.details?.scope, "stage"); + assert.equal(sent[0]?.details?.runId, "run-1"); + assert.equal(sent[0]?.details?.workflowName, "release"); + assert.equal(sent[0]?.details?.stageId, "stage-1"); + assert.equal(sent[0]?.details?.stageName, "review"); + assert.equal(sent[0]?.details?.promptId, "prompt-1"); + assert.equal(sent[0]?.details?.promptKind, "input"); + assert.equal(sent[0]?.details?.answerAvailable, true); + assert.equal(sent[0]?.details?.answerIncluded, true); + assert.equal(sent[0]?.details?.answerSummary, "swordfish"); + assert.equal(sent[0]?.details?.promptMessage, "Secret passphrase?"); + assert.equal(typeof sent[0]?.details?.answeredAt, "number"); + assert.match(sent[0]?.content ?? "", /received the user's response/); + assert.match(sent[0]?.content ?? "", /User responded with: swordfish/); + assert.match(sent[0]?.content ?? "", /Do not ask the same question again/); + assert.match(sent[0]?.content ?? "", /No main-chat action is needed/); + assert.match( + sent[0]?.content ?? "", + /do not answer any other workflow human-in-the-loop prompt unless the user explicitly provides that answer/, + ); + unsubscribe(); + }); + + test("does not notify when a simple prompt is cleared without recording an answer", () => { + const { store, sent, unsubscribe } = setup(); + + assert.equal(store.recordStagePendingPrompt("run-1", "stage-1", pendingPrompt()), true); + assert.equal( + store.resolveStagePendingPrompt("run-1", "stage-1", "prompt-1", "discarded", { recordAnswer: false }), + true, + ); + + assert.deepEqual(sent, []); + unsubscribe(); + }); + + test("does not notify when a simple prompt is answered by the workflow tool", () => { + const { store, sent, unsubscribe } = setup(); + + assert.equal(store.recordStagePendingPrompt("run-1", "stage-1", pendingPrompt()), true); + assert.equal( + store.resolveStagePendingPrompt("run-1", "stage-1", "prompt-1", "from tool", { + answerSource: "workflow_tool", + }), + true, + ); + store.recordNotice({ id: "tick", level: "info", message: "force notify", createdAt: 20 }); + + assert.deepEqual(sent, []); + unsubscribe(); + }); + + test("emits exactly one custom prompt notice when awaiting clears before the answer is recorded", async () => { + const { store, broker, sent, options, unsubscribe } = setup(); + const prompt = pendingPrompt({ + id: "custom-1", + kind: "custom", + message: "Approval widget", + customIdentityHash: "identity-hash", + customIdentitySource: "caller", + }); + store.recordStageStart( + "run-1", + runningStage({ + id: "custom-stage", + name: "custom", + promptFootprint: prompt, + }), + ); + + let request: StageCustomUiRequest | undefined; + const unregisterHost = broker.registerHost("run-1", "custom-stage", { + showCustomUi(next) { + request = next as StageCustomUiRequest; + }, + }); + try { + const pending = broker.requestCustomUi("run-1", "custom-stage", () => ({ + render: () => [], + invalidate: () => {}, + })); + assert.ok(request, "custom request should mount on the registered host"); + broker.resolve(request, "approved"); + assert.equal(await pending, "approved"); + + const afterBrokerResolve = store.runs()[0]?.stages.find((stage) => stage.id === "custom-stage"); + assert.equal(afterBrokerResolve?.status, "running"); + assert.equal(afterBrokerResolve?.promptAnswerState, undefined); + assert.equal(sent.length, 0); + + assert.equal(store.recordStagePromptAnswer("run-1", "custom-stage", prompt, "approved"), true); + store.recordNotice({ id: "tick-1", level: "info", message: "force notify", createdAt: 21 }); + assert.equal(store.recordStagePromptAnswer("run-1", "custom-stage", prompt, "approved-again"), true); + store.recordNotice({ id: "tick-2", level: "info", message: "force notify again", createdAt: 22 }); + + assert.equal(sent.length, 1); + assert.deepEqual(options[0], { triggerTurn: false, excludeFromContext: true }); + assert.equal(sent[0]?.customType, HIL_ANSWER_NOTICE_CUSTOM_TYPE); + assert.equal(sent[0]?.display, true); + assert.equal(sent[0]?.details?.promptId, "custom-1"); + assert.equal(sent[0]?.details?.promptKind, "custom"); + assert.equal(sent[0]?.details?.promptMessage, "Approval widget"); + assert.equal(sent[0]?.details?.answerSummary, "approved"); + assert.match(sent[0]?.content ?? "", /User responded with: approved/); + } finally { + unregisterHost(); + unsubscribe(); + } + }); + + test("does not notify when a custom prompt answer comes from the workflow tool", () => { + const { store, sent, unsubscribe } = setup(); + const prompt = pendingPrompt({ + id: "custom-tool-1", + kind: "custom", + message: "Tool-supplied widget", + customIdentityHash: "identity-hash", + customIdentitySource: "caller", + }); + store.recordStageStart( + "run-1", + runningStage({ + id: "custom-tool-stage", + name: "custom", + promptFootprint: prompt, + }), + ); + + assert.equal( + store.recordStagePromptAnswer("run-1", "custom-tool-stage", prompt, "from tool", { + answerSource: "workflow_tool", + }), + true, + ); + store.recordNotice({ id: "tick", level: "info", message: "force notify", createdAt: 20 }); + + assert.deepEqual(sent, []); + unsubscribe(); + }); + + test("emits a display-only notice when a brokered structured prompt is answered", async () => { + const { broker, sent, options, unsubscribe } = setup(); + const adapter = buildStagePromptAdapter("ask-1", "ask_user_question", COLOR_ARGS, 1)!; + broker.provideStagePrompt("run-1", "stage-1", adapter); + + const pending = broker.requestCustomUi("run-1", "stage-1", () => ({ + render: () => [], + invalidate: () => {}, + })); + + assert.equal(broker.answerStagePrompt("run-1", "stage-1", { text: "Blue" }), true); + await pending; + + assert.equal(sent.length, 1); + assert.deepEqual(options[0], { triggerTurn: false, excludeFromContext: true }); + assert.equal(sent[0]?.customType, HIL_ANSWER_NOTICE_CUSTOM_TYPE); + assert.equal(sent[0]?.details?.promptId, "ask-1"); + assert.equal(sent[0]?.details?.promptKind, "ask_user_question"); + assert.equal(sent[0]?.details?.answerAvailable, true); + assert.equal(sent[0]?.details?.answerIncluded, true); + assert.equal(sent[0]?.details?.answerSummary, "What color? → Blue"); + assert.equal(sent[0]?.details?.promptMessage, "What color?"); + assert.match(sent[0]?.content ?? "", /User responded with: What color\? → Blue/); + assert.match(sent[0]?.content ?? "", /No main-chat action is needed/); + unsubscribe(); + }); + + test("does not notify when a brokered structured prompt is answered by the workflow tool", async () => { + const { broker, sent, unsubscribe } = setup(); + const adapter = buildStagePromptAdapter("ask-1", "ask_user_question", COLOR_ARGS, 1)!; + broker.provideStagePrompt("run-1", "stage-1", adapter); + + const pending = broker.requestCustomUi("run-1", "stage-1", () => ({ + render: () => [], + invalidate: () => {}, + })); + + assert.equal( + broker.answerStagePrompt("run-1", "stage-1", { text: "Blue" }, { answerSource: "workflow_tool" }), + true, + ); + await pending; + + assert.deepEqual(sent, []); + unsubscribe(); + }); + + test("registers HiL answer renderer once per host and returns a notice card", () => { + const host = {}; + const registered: RegisteredRenderer[] = []; + registerHilAnswerNoticeRenderer({ + rendererHost: host, + registerMessageRenderer(event, renderer) { + registered.push({ event, renderer: renderer as (payload: unknown) => unknown }); + }, + }); + registerHilAnswerNoticeRenderer({ + rendererHost: host, + registerMessageRenderer(event, renderer) { + registered.push({ event, renderer: renderer as (payload: unknown) => unknown }); + }, + }); + + assert.equal(registered.length, 1); + assert.equal(registered[0]?.event, HIL_ANSWER_NOTICE_CUSTOM_TYPE); + const rendered = registered[0]?.renderer({ + details: { + kind: "hil_answered", + scope: "stage", + runId: "run-card", + workflowName: "release", + stageId: "stage-1", + stageName: "review", + promptId: "prompt-1", + promptKind: "input", + promptMessage: "Secret passphrase?", + answeredAt: 1, + answerAvailable: true, + answerIncluded: true, + answerSummary: "swordfish", + } satisfies WorkflowHilAnswerNoticeDetails, + }); + + assert.equal(typeof rendered, "object"); + assert.notEqual(rendered, null); + const lines = (rendered as CardComponent).render(80); + const text = lines.join("\n"); + assert.match(text, /╭ HIL ANSWERED/); + assert.match(text, /✓ Workflow "release" received the user's response/); + assert.match(text, /stage\s+review/); + assert.match(text, /answer\s+swordfish/); + for (const width of [80, 40, 24]) { + for (const line of (rendered as CardComponent).render(width)) { + assert.ok(line.length === 0 || line.replace(/\x1B\[[0-?]*[ -/]*[@-~]/g, "").length <= width); + } + } + }); }); diff --git a/test/unit/workflow-idle-prompt-start-race.test.ts b/test/unit/workflow-idle-prompt-start-race.test.ts index 185818c48..d4486dcbb 100644 --- a/test/unit/workflow-idle-prompt-start-race.test.ts +++ b/test/unit/workflow-idle-prompt-start-race.test.ts @@ -1,131 +1,142 @@ -import { test } from "bun:test"; import assert from "node:assert/strict"; import type { AgentMessage } from "@earendil-works/pi-agent-core"; -import { - _runAgentPrompt, - prompt, - sendUserMessage, -} from "../../packages/coding-agent/src/core/agent-session-prompt.js"; -import type { InternalStageContext, StageSessionRuntime } from "../../packages/workflows/src/runs/foreground/stage-runner.js"; +import { test } from "vitest"; +import { _runAgentPrompt, prompt, sendUserMessage } from "../../packages/coding-agent/src/core/agent-session-prompt.js"; +import type { + InternalStageContext, + StageSessionRuntime, +} from "../../packages/workflows/src/runs/foreground/stage-runner.js"; import { createStageContext, makeOpts } from "./stage-runner-helpers.js"; function messageText(messages: AgentMessage | AgentMessage[]): string { - const message = (Array.isArray(messages) ? messages : [messages]).find((item) => item.role === "user"); - if (!message) return ""; - if (typeof message.content === "string") return message.content; - return message.content.filter((part) => part.type === "text").map((part) => part.text).join("\n"); + const message = (Array.isArray(messages) ? messages : [messages]).find((item) => item.role === "user"); + if (!message) return ""; + if (typeof message.content === "string") return message.content; + return message.content + .filter((part) => part.type === "text") + .map((part) => part.text) + .join("\n"); } test("production prompt wiring holds idle admission until the first agent turn starts", async () => { - const allowPromptStartup = Promise.withResolvers(); - const promptStartupRequested = Promise.withResolvers(); - const firstPromptStarted = Promise.withResolvers(); - const firstTurn = Promise.withResolvers(); - let streaming = false; - let startupRequests = 0; - let promptStarts = 0; - const consumed: string[] = []; - const followUps: string[] = []; + const allowPromptStartup = Promise.withResolvers(); + const promptStartupRequested = Promise.withResolvers(); + const firstPromptStarted = Promise.withResolvers(); + const firstTurn = Promise.withResolvers(); + let streaming = false; + let startupRequests = 0; + let promptStarts = 0; + const consumed: string[] = []; + const followUps: string[] = []; - const surface = { - agent: { - state: { systemPrompt: "base" }, - prompt(messages: AgentMessage | AgentMessage[]) { - promptStarts += 1; - if (streaming) return Promise.reject(new Error("duplicate prompt startup")); - streaming = true; - consumed.push(messageText(messages)); - firstPromptStarted.resolve(); - return firstTurn.promise.finally(() => { streaming = false; }); - }, - }, - get isStreaming() { return streaming; }, - prompt(text: string, options?: Parameters[1]) { - return prompt.call(surface as never, text, options); - }, - sendUserMessage(content: Parameters[0], options?: Parameters[1]) { - return sendUserMessage.call(surface as never, content, options); - }, - async _runAgentPrompt(messages: AgentMessage | AgentMessage[], started?: () => void) { - startupRequests += 1; - promptStartupRequested.resolve(); - await allowPromptStartup.promise; - await _runAgentPrompt.call(surface as never, messages, started); - }, - _extensionRunner: { - hasHandlers: () => false, - emitBeforeAgentStart: async () => undefined, - }, - _flushPendingBashMessages() {}, - model: { provider: "test", id: "test" }, - _modelRuntime: { hasConfiguredAuth: () => true }, - _findLastAssistantMessage: () => undefined, - _pendingNextTurnMessages: [] as AgentMessage[], - _baseSystemPrompt: "base", - _baseSystemPromptOptions: {}, - _systemPromptOverride: undefined, - async waitForRetry() {}, - async _continueQueuedAgentMessages() {}, - async _awaitPendingPostCompactionContinuation() {}, - async _queueFollowUp(text: string) { - followUps.push(text); - consumed.push(text); - }, - async _queueSteer() {}, - promptTemplates: [], - }; + const surface = { + agent: { + state: { systemPrompt: "base" }, + prompt(messages: AgentMessage | AgentMessage[]) { + promptStarts += 1; + if (streaming) return Promise.reject(new Error("duplicate prompt startup")); + streaming = true; + consumed.push(messageText(messages)); + firstPromptStarted.resolve(); + return firstTurn.promise.finally(() => { + streaming = false; + }); + }, + }, + get isStreaming() { + return streaming; + }, + prompt(text: string, options?: Parameters[1]) { + return prompt.call(surface as never, text, options); + }, + sendUserMessage(content: Parameters[0], options?: Parameters[1]) { + return sendUserMessage.call(surface as never, content, options); + }, + async _runAgentPrompt(messages: AgentMessage | AgentMessage[], started?: () => void) { + startupRequests += 1; + promptStartupRequested.resolve(); + await allowPromptStartup.promise; + await _runAgentPrompt.call(surface as never, messages, started); + }, + _extensionRunner: { + hasHandlers: () => false, + emitBeforeAgentStart: async () => undefined, + }, + _flushPendingBashMessages() {}, + model: { provider: "test", id: "test" }, + _modelRuntime: { hasConfiguredAuth: () => true }, + _findLastAssistantMessage: () => undefined, + _pendingNextTurnMessages: [] as AgentMessage[], + _baseSystemPrompt: "base", + _baseSystemPromptOptions: {}, + _systemPromptOverride: undefined, + async waitForRetry() {}, + async _continueQueuedAgentMessages() {}, + async _awaitPendingPostCompactionContinuation() {}, + async _queueFollowUp(text: string) { + followUps.push(text); + consumed.push(text); + }, + async _queueSteer() {}, + promptTemplates: [], + }; - const runtime = { - ...surface, - steer: async () => {}, - followUp: async () => {}, - subscribe: () => () => {}, - sessionFile: undefined, - sessionId: "race-session", - setModel: async () => {}, - setThinkingLevel: () => {}, - cycleModel: async () => undefined, - cycleThinkingLevel: () => undefined, - thinkingLevel: "off", - messages: [], - navigateTree: async () => ({ cancelled: false }), - compact: async () => undefined, - abortCompaction: () => {}, - abort: async () => {}, - dispose: () => {}, - } as unknown as StageSessionRuntime; - Object.defineProperty(runtime, "isStreaming", { get: () => streaming }); - const ctx = createStageContext(makeOpts({ - adapters: { agentSession: { async create() { return runtime; } } }, - })) as InternalStageContext; + const runtime = { + ...surface, + steer: async () => {}, + followUp: async () => {}, + subscribe: () => () => {}, + sessionFile: undefined, + sessionId: "race-session", + setModel: async () => {}, + setThinkingLevel: () => {}, + cycleModel: async () => undefined, + cycleThinkingLevel: () => undefined, + thinkingLevel: "off", + messages: [], + navigateTree: async () => ({ cancelled: false }), + compact: async () => undefined, + abortCompaction: () => {}, + abort: async () => {}, + dispose: () => {}, + } as unknown as StageSessionRuntime; + Object.defineProperty(runtime, "isStreaming", { get: () => streaming }); + const ctx = createStageContext( + makeOpts({ + adapters: { + agentSession: { + async create() { + return runtime; + }, + }, + }, + }), + ) as InternalStageContext; - const first = ctx.__sendUserMessage("first"); - const second = ctx.__sendUserMessage("second"); - await promptStartupRequested.promise; - await new Promise((resolve) => queueMicrotask(() => queueMicrotask(resolve))); + const first = ctx.__sendUserMessage("first"); + const second = ctx.__sendUserMessage("second"); + await promptStartupRequested.promise; + await new Promise((resolve) => queueMicrotask(() => queueMicrotask(resolve))); - let earlyAdmissionError: Error | undefined; - try { - assert.equal(startupRequests, 1); - assert.equal(promptStarts, 0); - } catch (error) { - earlyAdmissionError = error instanceof Error ? error : new Error(String(error)); - } + let earlyAdmissionError: Error | undefined; + try { + assert.equal(startupRequests, 1); + assert.equal(promptStarts, 0); + } catch (error) { + earlyAdmissionError = error instanceof Error ? error : new Error(String(error)); + } - allowPromptStartup.resolve(); - await firstPromptStarted.promise; - await new Promise((resolve) => queueMicrotask(() => queueMicrotask(resolve))); - const followUpsBeforeTurnEnd = [...followUps]; - firstTurn.resolve(); - const [firstOutcome, secondOutcome] = await Promise.all([ - Promise.allSettled([first]), - Promise.allSettled([second]), - ]); - if (earlyAdmissionError) throw earlyAdmissionError; + allowPromptStartup.resolve(); + await firstPromptStarted.promise; + await new Promise((resolve) => queueMicrotask(() => queueMicrotask(resolve))); + const followUpsBeforeTurnEnd = [...followUps]; + firstTurn.resolve(); + const [firstOutcome, secondOutcome] = await Promise.all([Promise.allSettled([first]), Promise.allSettled([second])]); + if (earlyAdmissionError) throw earlyAdmissionError; - assert.deepEqual(followUpsBeforeTurnEnd, ["second"]); - assert.deepEqual(secondOutcome, [{ status: "fulfilled", value: "followUp" }]); - assert.deepEqual(firstOutcome, [{ status: "fulfilled", value: "prompt" }]); - assert.equal(promptStarts, 1); - assert.deepEqual(consumed, ["first", "second"]); + assert.deepEqual(followUpsBeforeTurnEnd, ["second"]); + assert.deepEqual(secondOutcome, [{ status: "fulfilled", value: "followUp" }]); + assert.deepEqual(firstOutcome, [{ status: "fulfilled", value: "prompt" }]); + assert.equal(promptStarts, 1); + assert.deepEqual(consumed, ["first", "second"]); }); diff --git a/test/unit/workflow-invocation-intercom-group-wiring.test.ts b/test/unit/workflow-invocation-intercom-group-wiring.test.ts index e9fa8b0f6..022fb8423 100644 --- a/test/unit/workflow-invocation-intercom-group-wiring.test.ts +++ b/test/unit/workflow-invocation-intercom-group-wiring.test.ts @@ -1,42 +1,45 @@ -import { test } from "bun:test"; import assert from "node:assert/strict"; import type { CreateAgentSessionOptions } from "@bastani/atomic"; -import { buildRuntimeAdapters } from "../../packages/workflows/src/extension/wiring.js"; +import { test } from "vitest"; import { resolveHomeGroup } from "../../packages/intercom/group.js"; +import { buildRuntimeAdapters } from "../../packages/workflows/src/extension/wiring.js"; import { createStore, mockSession, run, workflow } from "./executor-shared.js"; test("the embedded session adapter keeps the workflow invocation group", async () => { - const optionsSeen: CreateAgentSessionOptions[] = []; - const definition = workflow({ - name: "embedded-invocation-group", - description: "", - inputs: {}, - outputs: {}, - run: async (ctx) => { - await ctx.task("embedded-stage", { prompt: "run" }); - return {}; - }, - }); - const adapters = buildRuntimeAdapters({}, { - async createAgentSession(options) { - optionsSeen.push(options ?? {}); - return { session: mockSession() }; - }, - }); + const optionsSeen: CreateAgentSessionOptions[] = []; + const definition = workflow({ + name: "embedded-invocation-group", + description: "", + inputs: {}, + outputs: {}, + run: async (ctx) => { + await ctx.task("embedded-stage", { prompt: "run" }); + return {}; + }, + }); + const adapters = buildRuntimeAdapters( + {}, + { + async createAgentSession(options) { + optionsSeen.push(options ?? {}); + return { session: mockSession() }; + }, + }, + ); - const result = await run(definition, {}, { store: createStore(), adapters }); + const result = await run(definition, {}, { store: createStore(), adapters }); - assert.equal(result.status, "completed"); - assert.equal(optionsSeen.length, 1); - const group = optionsSeen[0]?.orchestrationContext?.intercomGroup; - assert.ok(group); - assert.notEqual(group, "default"); - const savedGroup = process.env.ATOMIC_INTERCOM_GROUP; - process.env.ATOMIC_INTERCOM_GROUP = "environment-group"; - try { - assert.equal(resolveHomeGroup({ group: "config-group" }, optionsSeen[0]), group); - } finally { - if (savedGroup === undefined) delete process.env.ATOMIC_INTERCOM_GROUP; - else process.env.ATOMIC_INTERCOM_GROUP = savedGroup; - } + assert.equal(result.status, "completed"); + assert.equal(optionsSeen.length, 1); + const group = optionsSeen[0]?.orchestrationContext?.intercomGroup; + assert.ok(group); + assert.notEqual(group, "default"); + const savedGroup = process.env.ATOMIC_INTERCOM_GROUP; + process.env.ATOMIC_INTERCOM_GROUP = "environment-group"; + try { + assert.equal(resolveHomeGroup({ group: "config-group" }, optionsSeen[0]), group); + } finally { + if (savedGroup === undefined) delete process.env.ATOMIC_INTERCOM_GROUP; + else process.env.ATOMIC_INTERCOM_GROUP = savedGroup; + } }); diff --git a/test/unit/workflow-invocation-intercom-group.test.ts b/test/unit/workflow-invocation-intercom-group.test.ts index 147d4d569..014bb6ec3 100644 --- a/test/unit/workflow-invocation-intercom-group.test.ts +++ b/test/unit/workflow-invocation-intercom-group.test.ts @@ -1,427 +1,479 @@ -import { test } from "bun:test"; import assert from "node:assert/strict"; import type net from "node:net"; import type { CreateAgentSessionOptions } from "@bastani/atomic"; -import { createStore, mockSession, run, workflow } from "./executor-shared.js"; -import { DbosDurableBackend } from "../../packages/workflows/src/durable/dbos-backend.js"; -import { createMockSdk } from "./durable-dbos-backend-helpers.js"; +import { test } from "vitest"; import { DeliveredMessageCache } from "../../packages/intercom/broker/delivered-message-cache.js"; -import { handleBrokerSend, type BrokerConnectedSession } from "../../packages/intercom/broker/send-handler.js"; +import { type BrokerConnectedSession, handleBrokerSend } from "../../packages/intercom/broker/send-handler.js"; import { SupervisorChannelCache } from "../../packages/intercom/broker/supervisor-channel.js"; import type { BrokerMessage, Message, SessionInfo } from "../../packages/intercom/types.js"; +import { DbosDurableBackend } from "../../packages/workflows/src/durable/dbos-backend.js"; +import { createMockSdk } from "./durable-dbos-backend-helpers.js"; +import { createStore, mockSession, run, workflow } from "./executor-shared.js"; function capturedGroup(options: CreateAgentSessionOptions): string | undefined { - return options.orchestrationContext?.intercomGroup; + return options.orchestrationContext?.intercomGroup; } test("stages in one workflow invocation share a non-default Intercom group", async () => { - const groups: Array = []; - const definition = workflow({ - name: "shared-invocation-group", - description: "", - inputs: {}, - outputs: {}, - run: async (ctx) => { - await ctx.task("first", { prompt: "first" }); - await ctx.task("second", { prompt: "second" }); - return {}; - }, - }); + const groups: Array = []; + const definition = workflow({ + name: "shared-invocation-group", + description: "", + inputs: {}, + outputs: {}, + run: async (ctx) => { + await ctx.task("first", { prompt: "first" }); + await ctx.task("second", { prompt: "second" }); + return {}; + }, + }); - const result = await run(definition, {}, { - store: createStore(), - adapters: { - agentSession: { - async create(options) { - groups.push(capturedGroup(options)); - return mockSession(); - }, - }, - }, - }); + const result = await run( + definition, + {}, + { + store: createStore(), + adapters: { + agentSession: { + async create(options) { + groups.push(capturedGroup(options)); + return mockSession(); + }, + }, + }, + }, + ); - assert.equal(result.status, "completed"); - assert.equal(groups.length, 2); - assert.equal(groups[0], groups[1]); - assert.notEqual(groups[0], undefined); - assert.notEqual(groups[0], "default"); + assert.equal(result.status, "completed"); + assert.equal(groups.length, 2); + assert.equal(groups[0], groups[1]); + assert.notEqual(groups[0], undefined); + assert.notEqual(groups[0], "default"); }); test("separate top-level workflow invocations receive different Intercom groups", async () => { - const groups: string[] = []; - const definition = workflow({ - name: "separate-invocation-groups", - description: "", - inputs: {}, - outputs: {}, - run: async (ctx) => { - await ctx.task("only", { prompt: "only" }); - return {}; - }, - }); - const options = { - adapters: { - agentSession: { - async create(sessionOptions: CreateAgentSessionOptions) { - const group = capturedGroup(sessionOptions); - assert.ok(group); - groups.push(group); - return mockSession(); - }, - }, - }, - }; + const groups: string[] = []; + const definition = workflow({ + name: "separate-invocation-groups", + description: "", + inputs: {}, + outputs: {}, + run: async (ctx) => { + await ctx.task("only", { prompt: "only" }); + return {}; + }, + }); + const options = { + adapters: { + agentSession: { + async create(sessionOptions: CreateAgentSessionOptions) { + const group = capturedGroup(sessionOptions); + assert.ok(group); + groups.push(group); + return mockSession(); + }, + }, + }, + }; - assert.equal((await run(definition, {}, { ...options, store: createStore() })).status, "completed"); - assert.equal((await run(definition, {}, { ...options, store: createStore() })).status, "completed"); - assert.equal(groups.length, 2); - assert.notEqual(groups[0], groups[1]); + assert.equal((await run(definition, {}, { ...options, store: createStore() })).status, "completed"); + assert.equal((await run(definition, {}, { ...options, store: createStore() })).status, "completed"); + assert.equal(groups.length, 2); + assert.notEqual(groups[0], groups[1]); }); test("explicit named and default stage groups override the workflow invocation group", async () => { - const groups: Array = []; - const definition = workflow({ - name: "explicit-invocation-group-overrides", - description: "", - inputs: {}, - outputs: {}, - run: async (ctx) => { - await ctx.task("inherited", { prompt: "inherited" }); - await ctx.task("named", { prompt: "named", group: "reviewers" }); - await ctx.task("shared-default", { prompt: "default", group: "default" }); - return {}; - }, - }); + const groups: Array = []; + const definition = workflow({ + name: "explicit-invocation-group-overrides", + description: "", + inputs: {}, + outputs: {}, + run: async (ctx) => { + await ctx.task("inherited", { prompt: "inherited" }); + await ctx.task("named", { prompt: "named", group: "reviewers" }); + await ctx.task("shared-default", { prompt: "default", group: "default" }); + return {}; + }, + }); - const result = await run(definition, {}, { - store: createStore(), - adapters: { - agentSession: { - async create(options) { - groups.push(capturedGroup(options)); - return mockSession(); - }, - }, - }, - }); + const result = await run( + definition, + {}, + { + store: createStore(), + adapters: { + agentSession: { + async create(options) { + groups.push(capturedGroup(options)); + return mockSession(); + }, + }, + }, + }, + ); - assert.equal(result.status, "completed"); - assert.notEqual(groups[0], undefined); - assert.notEqual(groups[0], "default"); - assert.deepEqual(groups.slice(1), ["reviewers", "default"]); + assert.equal(result.status, "completed"); + assert.notEqual(groups[0], undefined); + assert.notEqual(groups[0], "default"); + assert.deepEqual(groups.slice(1), ["reviewers", "default"]); }); test("stages without Intercom access receive no group", async () => { - const groups: Array = []; - const definition = workflow({ - name: "intercom-capability-gate", - description: "", - inputs: {}, - outputs: {}, - run: async (ctx) => { - await ctx.task("no-tools", { prompt: "none", noTools: "all" }); - await ctx.task("allowlist", { prompt: "allowlist", tools: ["read"] }); - await ctx.task("excluded", { prompt: "excluded", excludedTools: ["intercom"] }); - await ctx.task("extension-tools", { prompt: "intercom remains", noTools: "builtin" }); - return {}; - }, - }); + const groups: Array = []; + const definition = workflow({ + name: "intercom-capability-gate", + description: "", + inputs: {}, + outputs: {}, + run: async (ctx) => { + await ctx.task("no-tools", { prompt: "none", noTools: "all" }); + await ctx.task("allowlist", { prompt: "allowlist", tools: ["read"] }); + await ctx.task("excluded", { prompt: "excluded", excludedTools: ["intercom"] }); + await ctx.task("extension-tools", { prompt: "intercom remains", noTools: "builtin" }); + return {}; + }, + }); - const result = await run(definition, {}, { - store: createStore(), - adapters: { - agentSession: { - async create(options) { - groups.push(capturedGroup(options)); - return mockSession(); - }, - }, - }, - }); + const result = await run( + definition, + {}, + { + store: createStore(), + adapters: { + agentSession: { + async create(options) { + groups.push(capturedGroup(options)); + return mockSession(); + }, + }, + }, + }, + ); - assert.equal(result.status, "completed"); - assert.deepEqual(groups.slice(0, 3), [undefined, undefined, undefined]); - assert.ok(groups[3]); - assert.notEqual(groups[3], "default"); + assert.equal(result.status, "completed"); + assert.deepEqual(groups.slice(0, 3), [undefined, undefined, undefined]); + assert.ok(groups[3]); + assert.notEqual(groups[3], "default"); }); test("nested workflow stages stay in the top-level invocation group", async () => { - const groups: string[] = []; - const child = workflow({ - name: "nested-group-child", - description: "", - inputs: {}, - outputs: {}, - run: async (ctx) => { - await ctx.task("child-stage", { prompt: "child" }); - return {}; - }, - }); - const parent = workflow({ - name: "nested-group-parent", - description: "", - inputs: {}, - outputs: {}, - run: async (ctx) => { - await ctx.task("parent-stage", { prompt: "parent" }); - await ctx.workflow(child); - return {}; - }, - }); + const groups: string[] = []; + const child = workflow({ + name: "nested-group-child", + description: "", + inputs: {}, + outputs: {}, + run: async (ctx) => { + await ctx.task("child-stage", { prompt: "child" }); + return {}; + }, + }); + const parent = workflow({ + name: "nested-group-parent", + description: "", + inputs: {}, + outputs: {}, + run: async (ctx) => { + await ctx.task("parent-stage", { prompt: "parent" }); + await ctx.workflow(child); + return {}; + }, + }); - const result = await run(parent, {}, { - store: createStore(), - adapters: { - agentSession: { - async create(options) { - const group = capturedGroup(options); - assert.ok(group); - groups.push(group); - return mockSession(); - }, - }, - }, - }); + const result = await run( + parent, + {}, + { + store: createStore(), + adapters: { + agentSession: { + async create(options) { + const group = capturedGroup(options); + assert.ok(group); + groups.push(group); + return mockSession(); + }, + }, + }, + }, + ); - assert.equal(result.status, "completed"); - assert.equal(groups.length, 2); - assert.equal(groups[0], groups[1]); + assert.equal(result.status, "completed"); + assert.equal(groups.length, 2); + assert.equal(groups[0], groups[1]); }); test("model fallback replacement sessions keep the workflow invocation group", async () => { - const groups: string[] = []; - const definition = workflow({ - name: "fallback-invocation-group", - description: "", - inputs: {}, - outputs: {}, - run: async (ctx) => { - await ctx.task("fallback-stage", { - prompt: "run", - model: "anthropic/primary", - fallbackModels: ["openai/fallback"], - }); - return {}; - }, - }); + const groups: string[] = []; + const definition = workflow({ + name: "fallback-invocation-group", + description: "", + inputs: {}, + outputs: {}, + run: async (ctx) => { + await ctx.task("fallback-stage", { + prompt: "run", + model: "anthropic/primary", + fallbackModels: ["openai/fallback"], + }); + return {}; + }, + }); - const result = await run(definition, {}, { - store: createStore(), - adapters: { - agentSession: { - async create(options) { - const group = capturedGroup(options); - assert.ok(group); - groups.push(group); - const model = typeof options.model === "string" - ? options.model - : `${String(options.model?.provider)}/${String(options.model?.id)}`; - return { - ...mockSession(), - async prompt() { - if (model === "anthropic/primary") throw new Error("429 rate limit exceeded"); - }, - getLastAssistantText() { - return model === "openai/fallback" ? "done" : undefined; - }, - }; - }, - }, - }, - }); + const result = await run( + definition, + {}, + { + store: createStore(), + adapters: { + agentSession: { + async create(options) { + const group = capturedGroup(options); + assert.ok(group); + groups.push(group); + const model = + typeof options.model === "string" + ? options.model + : `${String(options.model?.provider)}/${String(options.model?.id)}`; + return { + ...mockSession(), + async prompt() { + if (model === "anthropic/primary") throw new Error("429 rate limit exceeded"); + }, + getLastAssistantText() { + return model === "openai/fallback" ? "done" : undefined; + }, + }; + }, + }, + }, + }, + ); - assert.equal(result.status, "completed"); - assert.equal(groups.length, 2); - assert.equal(groups[0], groups[1]); + assert.equal(result.status, "completed"); + assert.equal(groups.length, 2); + assert.equal(groups[0], groups[1]); }); test("fresh durable replay restores a nested stage to the top-level invocation group", async () => { - const rootRunId = "durable-invocation-group-root"; - const sdk = createMockSdk(); - const firstBackend = new DbosDurableBackend(sdk, { executorId: "group-first" }); - const firstStore = createStore(); - const promptStarted = Promise.withResolvers(); - const releasePrompt = Promise.withResolvers(); - let initialGroup: string | undefined; + const rootRunId = "durable-invocation-group-root"; + const sdk = createMockSdk(); + const firstBackend = new DbosDurableBackend(sdk, { executorId: "group-first" }); + const firstStore = createStore(); + const promptStarted = Promise.withResolvers(); + const releasePrompt = Promise.withResolvers(); + let initialGroup: string | undefined; - const child = workflow({ - name: "durable-invocation-group-child", - description: "", - inputs: {}, - outputs: {}, - run: async (ctx) => { - await ctx.task("pending-child-stage", { prompt: "wait" }); - return {}; - }, - }); - const root = workflow({ - name: "durable-invocation-group-root", - description: "", - inputs: {}, - outputs: {}, - run: async (ctx) => { - await ctx.workflow(child); - return {}; - }, - }); + const child = workflow({ + name: "durable-invocation-group-child", + description: "", + inputs: {}, + outputs: {}, + run: async (ctx) => { + await ctx.task("pending-child-stage", { prompt: "wait" }); + return {}; + }, + }); + const root = workflow({ + name: "durable-invocation-group-root", + description: "", + inputs: {}, + outputs: {}, + run: async (ctx) => { + await ctx.workflow(child); + return {}; + }, + }); - const firstController = new AbortController(); - const firstPromise = run(root, {}, { - runId: rootRunId, - store: firstStore, - durableBackend: firstBackend, - signal: firstController.signal, - adapters: { - agentSession: { - async create(options) { - initialGroup = capturedGroup(options); - return { - ...mockSession(), - async prompt() { - promptStarted.resolve(); - await releasePrompt.promise; - }, - }; - }, - }, - }, - }); - await promptStarted.promise; - await firstBackend.flush(); - const firstRoot = firstStore.runs().find((candidate) => candidate.id === rootRunId)!; - const childBoundary = firstRoot.stages.find((stage) => stage.name === `workflow:${child.name}`)!; - const firstChild = firstStore.runs().find((candidate) => candidate.parentStageId === childBoundary.id)!; - assert.ok(initialGroup); - assert.equal(initialGroup, `workflow:${rootRunId}`); + const firstController = new AbortController(); + const firstPromise = run( + root, + {}, + { + runId: rootRunId, + store: firstStore, + durableBackend: firstBackend, + signal: firstController.signal, + adapters: { + agentSession: { + async create(options) { + initialGroup = capturedGroup(options); + return { + ...mockSession(), + async prompt() { + promptStarted.resolve(); + await releasePrompt.promise; + }, + }; + }, + }, + }, + }, + ); + await promptStarted.promise; + await firstBackend.flush(); + const firstRoot = firstStore.runs().find((candidate) => candidate.id === rootRunId)!; + const childBoundary = firstRoot.stages.find((stage) => stage.name === `workflow:${child.name}`)!; + const firstChild = firstStore.runs().find((candidate) => candidate.parentStageId === childBoundary.id)!; + assert.ok(initialGroup); + assert.equal(initialGroup, `workflow:${rootRunId}`); - const persisted = createMockSdk(); - for (const [key, value] of sdk.state.workflows) persisted.state.workflows.set(key, { ...value }); - for (const [key, value] of sdk.state.steps) persisted.state.steps.set(key, structuredClone(value)); - firstController.abort(new Error("first process stopped")); - releasePrompt.resolve(); - await firstPromise; + const persisted = createMockSdk(); + for (const [key, value] of sdk.state.workflows) persisted.state.workflows.set(key, { ...value }); + for (const [key, value] of sdk.state.steps) persisted.state.steps.set(key, structuredClone(value)); + firstController.abort(new Error("first process stopped")); + releasePrompt.resolve(); + await firstPromise; - const captures: Array<{ - metaRunId?: string; - workflowRunId?: string; - intercomGroup?: string; - }> = []; - const freshBackend = new DbosDurableBackend(persisted, { executorId: "group-fresh" }); - await freshBackend.hydrateWorkflow(rootRunId); - const resumed = await run(root, {}, { - runId: rootRunId, - store: createStore(), - durableBackend: freshBackend, - adapters: { - agentSession: { - async create(options, meta) { - captures.push({ - metaRunId: meta?.runId, - workflowRunId: options.orchestrationContext?.workflowRunId, - intercomGroup: capturedGroup(options), - }); - return mockSession(); - }, - }, - }, - }); + const captures: Array<{ + metaRunId?: string; + workflowRunId?: string; + intercomGroup?: string; + }> = []; + const freshBackend = new DbosDurableBackend(persisted, { executorId: "group-fresh" }); + await freshBackend.hydrateWorkflow(rootRunId); + const resumed = await run( + root, + {}, + { + runId: rootRunId, + store: createStore(), + durableBackend: freshBackend, + adapters: { + agentSession: { + async create(options, meta) { + captures.push({ + metaRunId: meta?.runId, + workflowRunId: options.orchestrationContext?.workflowRunId, + intercomGroup: capturedGroup(options), + }); + return mockSession(); + }, + }, + }, + }, + ); - assert.equal(resumed.status, "completed", resumed.error); - assert.deepEqual(captures, [{ - metaRunId: firstChild.id, - workflowRunId: firstChild.id, - intercomGroup: `workflow:${rootRunId}`, - }]); - assert.notEqual(captures[0]?.intercomGroup, `workflow:${firstChild.id}`); + assert.equal(resumed.status, "completed", resumed.error); + assert.deepEqual(captures, [ + { + metaRunId: firstChild.id, + workflowRunId: firstChild.id, + intercomGroup: `workflow:${rootRunId}`, + }, + ]); + assert.notEqual(captures[0]?.intercomGroup, `workflow:${firstChild.id}`); }); test("parallel and per-task group overrides take precedence over the workflow group", async () => { - const groups: string[] = []; - const definition = workflow({ - name: "parallel-invocation-group-overrides", - description: "", - inputs: {}, - outputs: {}, - run: async (ctx) => { - await ctx.parallel( - [ - { name: "parallel-group", prompt: "parallel" }, - { name: "task-default", prompt: "default", group: "default" }, - ], - { group: "parallel-reviewers" }, - ); - return {}; - }, - }); + const groups: string[] = []; + const definition = workflow({ + name: "parallel-invocation-group-overrides", + description: "", + inputs: {}, + outputs: {}, + run: async (ctx) => { + await ctx.parallel( + [ + { name: "parallel-group", prompt: "parallel" }, + { name: "task-default", prompt: "default", group: "default" }, + ], + { group: "parallel-reviewers" }, + ); + return {}; + }, + }); - const result = await run(definition, {}, { - store: createStore(), - adapters: { - agentSession: { - async create(options) { - const group = capturedGroup(options); - assert.ok(group); - groups.push(group); - return mockSession(); - }, - }, - }, - }); + const result = await run( + definition, + {}, + { + store: createStore(), + adapters: { + agentSession: { + async create(options) { + const group = capturedGroup(options); + assert.ok(group); + groups.push(group); + return mockSession(); + }, + }, + }, + }, + ); - assert.equal(result.status, "completed"); - assert.deepEqual(groups.sort(), ["default", "parallel-reviewers"]); + assert.equal(result.status, "completed"); + assert.deepEqual(groups.sort(), ["default", "parallel-reviewers"]); }); test("ordinary workflow traffic cannot reach an unrelated shared-default main chat", async () => { - let workflowGroup: string | undefined; - const definition = workflow({ - name: "default-main-chat-isolation", - description: "", - inputs: {}, - outputs: {}, - run: async (ctx) => { - await ctx.task("sender", { prompt: "send" }); - return {}; - }, - }); - await run(definition, {}, { - store: createStore(), - adapters: { - agentSession: { - async create(options) { - workflowGroup = capturedGroup(options); - return mockSession(); - }, - }, - }, - }); - assert.ok(workflowGroup); + let workflowGroup: string | undefined; + const definition = workflow({ + name: "default-main-chat-isolation", + description: "", + inputs: {}, + outputs: {}, + run: async (ctx) => { + await ctx.task("sender", { prompt: "send" }); + return {}; + }, + }); + await run( + definition, + {}, + { + store: createStore(), + adapters: { + agentSession: { + async create(options) { + workflowGroup = capturedGroup(options); + return mockSession(); + }, + }, + }, + }, + ); + assert.ok(workflowGroup); - const stageSocket = {} as net.Socket; - const mainSocket = {} as net.Socket; - const sessionInfo = (id: string, name: string, group?: string): SessionInfo => ({ - id, name, group, cwd: "/tmp", model: "test", pid: 1, startedAt: 1, lastActivity: 1, - }); - const sessions = new Map([ - ["stage", { socket: stageSocket, info: sessionInfo("stage", "workflow-stage", workflowGroup) }], - ["main", { socket: mainSocket, info: sessionInfo("main", "main-chat") }], - ]); - const writes: Array<{ socket: net.Socket; message: BrokerMessage }> = []; - const message: Message = { - id: "workflow-message", - timestamp: 1, - content: { text: "workflow-only notice" }, - }; - handleBrokerSend( - stageSocket, - { type: "send", to: "main", message }, - "stage", - sessions, - new DeliveredMessageCache(), - (socket, brokerMessage) => writes.push({ socket, message: brokerMessage }), - new SupervisorChannelCache(), - ); + const stageSocket = {} as net.Socket; + const mainSocket = {} as net.Socket; + const sessionInfo = (id: string, name: string, group?: string): SessionInfo => ({ + id, + name, + group, + cwd: "/tmp", + model: "test", + pid: 1, + startedAt: 1, + lastActivity: 1, + }); + const sessions = new Map([ + ["stage", { socket: stageSocket, info: sessionInfo("stage", "workflow-stage", workflowGroup) }], + ["main", { socket: mainSocket, info: sessionInfo("main", "main-chat") }], + ]); + const writes: Array<{ socket: net.Socket; message: BrokerMessage }> = []; + const message: Message = { + id: "workflow-message", + timestamp: 1, + content: { text: "workflow-only notice" }, + }; + handleBrokerSend( + stageSocket, + { type: "send", to: "main", message }, + "stage", + sessions, + new DeliveredMessageCache(), + (socket, brokerMessage) => writes.push({ socket, message: brokerMessage }), + new SupervisorChannelCache(), + ); - assert.equal(writes.some((write) => write.socket === mainSocket && write.message.type === "message"), false); - assert.equal(writes.some((write) => write.socket === stageSocket && write.message.type === "delivery_failed"), true); + assert.equal( + writes.some((write) => write.socket === mainSocket && write.message.type === "message"), + false, + ); + assert.equal( + writes.some((write) => write.socket === stageSocket && write.message.type === "delivery_failed"), + true, + ); }); diff --git a/test/unit/workflow-invocation-intercom-pause.test.ts b/test/unit/workflow-invocation-intercom-pause.test.ts index a398200d2..84a262c47 100644 --- a/test/unit/workflow-invocation-intercom-pause.test.ts +++ b/test/unit/workflow-invocation-intercom-pause.test.ts @@ -1,70 +1,78 @@ -import { test } from "bun:test"; import assert from "node:assert/strict"; +import { test } from "vitest"; import { - createStageControlRegistry, - createStore, - deferred, - mockSession, - pauseRun, - resumeRun, - run, - workflow, + createStageControlRegistry, + createStore, + deferred, + mockSession, + pauseRun, + resumeRun, + run, + workflow, } from "./executor-shared.js"; test("live pause and resume keep the workflow invocation group", async () => { - const promptStarted = deferred(); - const stageStarted = deferred<{ runId: string; stageId: string }>(); - let rejectPrompt: ((error: Error) => void) | undefined; - const groups: string[] = []; - let promptCalls = 0; - const session = { - ...mockSession(), - async prompt() { - promptCalls += 1; - if (promptCalls !== 1) return; - promptStarted.resolve(); - return new Promise((_resolve, reject) => { - rejectPrompt = reject; - }); - }, - async abort() { - rejectPrompt?.(new Error("AbortError")); - }, - }; - const definition = workflow({ - name: "pause-resume-invocation-group", - description: "", - inputs: {}, - outputs: {}, - run: async (ctx) => { - await ctx.stage("paused-stage").prompt("run"); - return {}; - }, - }); - const store = createStore(); - const stageControlRegistry = createStageControlRegistry(); - const runPromise = run(definition, {}, { - store, - stageControlRegistry, - adapters: { agentSession: { async create(options) { - const group = options.orchestrationContext?.intercomGroup; - assert.ok(group); - groups.push(group); - return session; - } } }, - onStageStart: (runId, stage) => stageStarted.resolve({ runId, stageId: stage.id }), - }); + const promptStarted = deferred(); + const stageStarted = deferred<{ runId: string; stageId: string }>(); + let rejectPrompt: ((error: Error) => void) | undefined; + const groups: string[] = []; + let promptCalls = 0; + const session = { + ...mockSession(), + async prompt() { + promptCalls += 1; + if (promptCalls !== 1) return undefined; + promptStarted.resolve(); + return new Promise((_resolve, reject) => { + rejectPrompt = reject; + }); + }, + async abort() { + rejectPrompt?.(new Error("AbortError")); + }, + }; + const definition = workflow({ + name: "pause-resume-invocation-group", + description: "", + inputs: {}, + outputs: {}, + run: async (ctx) => { + await ctx.stage("paused-stage").prompt("run"); + return {}; + }, + }); + const store = createStore(); + const stageControlRegistry = createStageControlRegistry(); + const runPromise = run( + definition, + {}, + { + store, + stageControlRegistry, + adapters: { + agentSession: { + async create(options) { + const group = options.orchestrationContext?.intercomGroup; + assert.ok(group); + groups.push(group); + return session; + }, + }, + }, + onStageStart: (runId, stage) => stageStarted.resolve({ runId, stageId: stage.id }), + }, + ); - const [{ runId }] = await Promise.all([stageStarted.promise, promptStarted.promise]); - const paused = await pauseRun(runId, { store, stageControlRegistry }); - assert.equal(paused.ok, true); - assert.equal(store.runs().find((candidate) => candidate.id === runId)?.status, "paused"); + const [{ runId }] = await Promise.all([stageStarted.promise, promptStarted.promise]); + const paused = await pauseRun(runId, { store, stageControlRegistry }); + assert.equal(paused.ok, true); + assert.equal(store.runs().find((candidate) => candidate.id === runId)?.status, "paused"); - const resumed = await resumeRun(runId, { store, stageControlRegistry }); - assert.equal(resumed.ok, true); - const result = await runPromise; + const resumed = await resumeRun(runId, { store, stageControlRegistry }); + assert.equal(resumed.ok, true); + const result = await runPromise; - assert.equal(result.status, "completed"); - assert.equal(groups.length, 1, "resume must keep the existing grouped session"); - assert.notEqual(groups[0], "default"); + assert.equal(result.status, "completed"); + assert.equal(groups.length, 1, "resume must keep the existing grouped session"); + assert.notEqual(groups[0], "default"); }); diff --git a/test/unit/workflow-invocation-intercom-subagent-async.test.ts b/test/unit/workflow-invocation-intercom-subagent-async.test.ts index 076456439..0576da0c2 100644 --- a/test/unit/workflow-invocation-intercom-subagent-async.test.ts +++ b/test/unit/workflow-invocation-intercom-subagent-async.test.ts @@ -1,235 +1,275 @@ -import { test } from "bun:test"; import assert from "node:assert/strict"; import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import type { ExtensionContext } from "@bastani/atomic"; +import { test } from "vitest"; import { executeAsyncChain } from "../../packages/subagents/src/runs/background/async-execution-chain.js"; import { executeAsyncSingle } from "../../packages/subagents/src/runs/background/async-execution-single.js"; import { runSingleStep } from "../../packages/subagents/src/runs/background/subagent-runner-step.js"; import { createSubagentExecutor } from "../../packages/subagents/src/runs/foreground/subagent-executor.js"; -import { successEvent, withFakeCli } from "./subagents-attempt-watchdog-helpers.js"; import type { ExecutorDeps } from "../../packages/subagents/src/runs/foreground/subagent-executor-types.js"; +import { successEvent, withFakeCli } from "./subagents-attempt-watchdog-helpers.js"; interface CapturedRunnerStep { - intercomGroup?: string; - parallel?: CapturedRunnerStep[]; + intercomGroup?: string; + parallel?: CapturedRunnerStep[]; } interface CapturedRunnerConfig { - steps: CapturedRunnerStep[]; + steps: CapturedRunnerStep[]; } function capturedGroups(config: CapturedRunnerConfig): Array { - return config.steps.flatMap((step) => step.parallel?.map((child) => child.intercomGroup) ?? [step.intercomGroup]); + return config.steps.flatMap((step) => step.parallel?.map((child) => child.intercomGroup) ?? [step.intercomGroup]); } function makeState(): ExecutorDeps["state"] { - return { - baseCwd: "", - currentSessionId: null, - asyncJobs: new Map(), - foregroundRuns: new Map(), - foregroundControls: new Map(), - lastForegroundControlId: null, - cleanupTimers: new Map(), - lastUiContext: null, - poller: null, - completionSeen: new Map(), - watcher: null, - watcherRestartTimer: null, - resultFileCoalescer: { schedule: () => false, clear: () => {} }, - }; + return { + baseCwd: "", + currentSessionId: null, + asyncJobs: new Map(), + foregroundRuns: new Map(), + foregroundControls: new Map(), + lastForegroundControlId: null, + cleanupTimers: new Map(), + lastUiContext: null, + poller: null, + completionSeen: new Map(), + watcher: null, + watcherRestartTimer: null, + resultFileCoalescer: { schedule: () => false, clear: () => {} }, + }; } function stageContext(cwd: string): ExtensionContext { - return { - cwd, - mode: "tui", - hasUI: false, - ui: { custom: async () => undefined as T } as unknown as ExtensionContext["ui"], - model: undefined, - modelRegistry: { getAvailable: () => [] } as unknown as ExtensionContext["modelRegistry"], - sessionManager: { - getSessionFile: () => undefined, - getSessionId: () => "workflow-stage-session", - getLeafId: () => null, - } as ExtensionContext["sessionManager"], - orchestrationContext: { - kind: "workflow-stage", - workflowRunId: "root", - workflowStageId: "launcher", - workflowStageName: "launcher", - intercomGroup: "workflow:root", - constraints: { disableWorkflowTool: true, maxSubagentDepth: 2 }, - }, - isIdle: () => true, - isProjectTrusted: () => true, - signal: undefined, - abort: () => {}, - hasPendingMessages: () => false, - shutdown: () => {}, - getContextUsage: () => undefined, - compact: () => {}, - getSystemPrompt: () => "", - } satisfies ExtensionContext; + return { + cwd, + mode: "tui", + hasUI: false, + ui: { custom: async () => undefined as T } as unknown as ExtensionContext["ui"], + model: undefined, + modelRegistry: { getAvailable: () => [] } as unknown as ExtensionContext["modelRegistry"], + sessionManager: { + getSessionFile: () => undefined, + getSessionId: () => "workflow-stage-session", + getLeafId: () => null, + } as ExtensionContext["sessionManager"], + orchestrationContext: { + kind: "workflow-stage", + workflowRunId: "root", + workflowStageId: "launcher", + workflowStageName: "launcher", + intercomGroup: "workflow:root", + constraints: { disableWorkflowTool: true, maxSubagentDepth: 2 }, + }, + isIdle: () => true, + isProjectTrusted: () => true, + signal: undefined, + abort: () => {}, + hasPendingMessages: () => false, + shutdown: () => {}, + getContextUsage: () => undefined, + compact: () => {}, + getSystemPrompt: () => "", + } satisfies ExtensionContext; } test("async single, parallel, and chain children inherit the workflow group with explicit overrides", async () => { - const root = mkdtempSync(join(tmpdir(), "atomic-workflow-group-async-")); - const configs: CapturedRunnerConfig[] = []; - const state = makeState(); - const sessionFile = join(root, "child.jsonl"); - writeFileSync(sessionFile, "{}\n"); - const pi: Pick = { - events: { on: () => () => {}, emit: () => {} }, - getSessionName: () => "workflow-stage", - }; - try { - const executor = createSubagentExecutor({ - pi: pi as ExecutorDeps["pi"], - state, - config: { maxSubagentDepth: 2, parallel: { concurrency: 2, maxTasks: 10 } }, - asyncByDefault: false, - tempArtifactsDir: join(root, "artifacts"), - getSubagentSessionRoot: () => join(root, "sessions"), - expandTilde: (path) => path, - discoverAgents: () => ({ agents: [{ - name: "worker", - description: "test worker", - systemPromptMode: "replace", - inheritProjectContext: false, - inheritSkills: false, - systemPrompt: "work", - source: "project", - filePath: join(root, "worker.md"), - }] }), - runtime: { - runSync: async (_cwd, _agents, agent, task) => ({ - agent, - task, - exitCode: 0, - messages: [], - usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, turns: 0 }, - finalOutput: "done", - sessionFile, - }), - isAsyncAvailable: () => true, - executeAsyncSingle: (id, params) => executeAsyncSingle(id, { - ...params, - spawnRunner(config) { - configs.push(config as CapturedRunnerConfig); - return { pid: 1234 }; - }, - }), - executeAsyncChain: (id, params) => executeAsyncChain(id, { - ...params, - spawnRunner(config) { - configs.push(config as CapturedRunnerConfig); - return { pid: 1234 }; - }, - }), - }, - }); - const context = stageContext(root); - for (const params of [ - { agent: "worker", task: "inherit", async: true }, - { agent: "worker", task: "default", async: true, group: "default" }, - ] as const) { - const result = await executor.execute("async-single", params, new AbortController().signal, undefined, context); - assert.equal(result.isError, undefined); - } - const parallel = await executor.execute("async-parallel", { - tasks: [ - { agent: "worker", task: "parallel-inherit" }, - { agent: "worker", task: "parallel-default", group: "default" }, - ], - group: "parallel-set", - async: true, - }, new AbortController().signal, undefined, context); - assert.equal(parallel.isError, undefined); + const root = mkdtempSync(join(tmpdir(), "atomic-workflow-group-async-")); + const configs: CapturedRunnerConfig[] = []; + const state = makeState(); + const sessionFile = join(root, "child.jsonl"); + writeFileSync(sessionFile, "{}\n"); + const pi: Pick = { + events: { on: () => () => {}, emit: () => {} }, + getSessionName: () => "workflow-stage", + }; + try { + const executor = createSubagentExecutor({ + pi: pi as ExecutorDeps["pi"], + state, + config: { maxSubagentDepth: 2, parallel: { concurrency: 2, maxTasks: 10 } }, + asyncByDefault: false, + tempArtifactsDir: join(root, "artifacts"), + getSubagentSessionRoot: () => join(root, "sessions"), + expandTilde: (path) => path, + discoverAgents: () => ({ + agents: [ + { + name: "worker", + description: "test worker", + systemPromptMode: "replace", + inheritProjectContext: false, + inheritSkills: false, + systemPrompt: "work", + source: "project", + filePath: join(root, "worker.md"), + }, + ], + }), + runtime: { + runSync: async (_cwd, _agents, agent, task) => ({ + agent, + task, + exitCode: 0, + messages: [], + usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, turns: 0 }, + finalOutput: "done", + sessionFile, + }), + isAsyncAvailable: () => true, + executeAsyncSingle: (id, params) => + executeAsyncSingle(id, { + ...params, + spawnRunner(config) { + configs.push(config as CapturedRunnerConfig); + return { pid: 1234 }; + }, + }), + executeAsyncChain: (id, params) => + executeAsyncChain(id, { + ...params, + spawnRunner(config) { + configs.push(config as CapturedRunnerConfig); + return { pid: 1234 }; + }, + }), + }, + }); + const context = stageContext(root); + for (const params of [ + { agent: "worker", task: "inherit", async: true }, + { agent: "worker", task: "default", async: true, group: "default" }, + ] as const) { + const result = await executor.execute( + "async-single", + params, + new AbortController().signal, + undefined, + context, + ); + assert.equal(result.isError, undefined); + } + const parallel = await executor.execute( + "async-parallel", + { + tasks: [ + { agent: "worker", task: "parallel-inherit" }, + { agent: "worker", task: "parallel-default", group: "default" }, + ], + group: "parallel-set", + async: true, + }, + new AbortController().signal, + undefined, + context, + ); + assert.equal(parallel.isError, undefined); - const chain = await executor.execute("async-chain", { - chain: [ - { agent: "worker", task: "chain-inherit" }, - { agent: "worker", task: "chain-default", group: "default" }, - ], - group: "chain-top", - async: true, - }, new AbortController().signal, undefined, context); - assert.equal(chain.isError, undefined); + const chain = await executor.execute( + "async-chain", + { + chain: [ + { agent: "worker", task: "chain-inherit" }, + { agent: "worker", task: "chain-default", group: "default" }, + ], + group: "chain-top", + async: true, + }, + new AbortController().signal, + undefined, + context, + ); + assert.equal(chain.isError, undefined); - const foreground = await executor.execute("foreground-source", { - agent: "worker", - task: "persist source", - }, new AbortController().signal, undefined, context); - assert.equal(foreground.isError, undefined); - assert.ok(foreground.details.runId); - for (const group of [undefined, "default"] as const) { - const revived = await executor.execute("revive", { - action: "resume", - id: foreground.details.runId, - message: "continue", - ...(group ? { group } : {}), - }, new AbortController().signal, undefined, context); - assert.equal(revived.isError, undefined); - } - } finally { - rmSync(root, { recursive: true, force: true }); - } + const foreground = await executor.execute( + "foreground-source", + { + agent: "worker", + task: "persist source", + }, + new AbortController().signal, + undefined, + context, + ); + assert.equal(foreground.isError, undefined); + assert.ok(foreground.details.runId); + for (const group of [undefined, "default"] as const) { + const revived = await executor.execute( + "revive", + { + action: "resume", + id: foreground.details.runId, + message: "continue", + ...(group ? { group } : {}), + }, + new AbortController().signal, + undefined, + context, + ); + assert.equal(revived.isError, undefined); + } + } finally { + rmSync(root, { recursive: true, force: true }); + } - assert.deepEqual(configs.map(capturedGroups), [ - ["workflow:root"], - ["default"], - ["parallel-set", "default"], - ["chain-top", "default"], - ["workflow:root"], - ["default"], - ]); + assert.deepEqual(configs.map(capturedGroups), [ + ["workflow:root"], + ["default"], + ["parallel-set", "default"], + ["chain-top", "default"], + ["workflow:root"], + ["default"], + ]); }); test("the async runner clears a serialized group when the child lacks Intercom access", async () => { - const previousGroup = process.env.ATOMIC_INTERCOM_GROUP; - process.env.ATOMIC_INTERCOM_GROUP = "ambient-group"; - try { - await withFakeCli(` + const previousGroup = process.env.ATOMIC_INTERCOM_GROUP; + process.env.ATOMIC_INTERCOM_GROUP = "ambient-group"; + try { + await withFakeCli( + ` import { writeFileSync } from "node:fs"; writeFileSync(new URL("./intercom-group.txt", import.meta.url), process.env.ATOMIC_INTERCOM_GROUP ?? "missing"); console.log(${JSON.stringify(successEvent("done"))}); - `, async (dir) => { - const step = { - agent: "worker", - task: "work", - intercomGroup: "workflow:root", - inheritProjectContext: false, - inheritSkills: false, - }; - const context = { - previousOutput: "", - placeholder: "{previous}", - cwd: dir, - sessionEnabled: false, - id: "async-group-runner", - flatIndex: 0, - flatStepCount: 1, - outputFile: join(dir, "output.txt"), - }; - const grouped = await runSingleStep(step, { - ...context, - childIntercomTarget: "async-child", - }); - assert.equal(grouped.exitCode, 0); - assert.equal(readFileSync(join(dir, "intercom-group.txt"), "utf8"), "workflow:root"); + `, + async (dir) => { + const step = { + agent: "worker", + task: "work", + intercomGroup: "workflow:root", + inheritProjectContext: false, + inheritSkills: false, + }; + const context = { + previousOutput: "", + placeholder: "{previous}", + cwd: dir, + sessionEnabled: false, + id: "async-group-runner", + flatIndex: 0, + flatStepCount: 1, + outputFile: join(dir, "output.txt"), + }; + const grouped = await runSingleStep(step, { + ...context, + childIntercomTarget: "async-child", + }); + assert.equal(grouped.exitCode, 0); + assert.equal(readFileSync(join(dir, "intercom-group.txt"), "utf8"), "workflow:root"); - const ungrouped = await runSingleStep(step, { - ...context, - outputFile: join(dir, "ungrouped-output.txt"), - }); - assert.equal(ungrouped.exitCode, 0); - assert.equal(readFileSync(join(dir, "intercom-group.txt"), "utf8"), ""); - }, { idleMs: 4_000, wallMs: 8_000 }); - } finally { - if (previousGroup === undefined) delete process.env.ATOMIC_INTERCOM_GROUP; - else process.env.ATOMIC_INTERCOM_GROUP = previousGroup; - } + const ungrouped = await runSingleStep(step, { + ...context, + outputFile: join(dir, "ungrouped-output.txt"), + }); + assert.equal(ungrouped.exitCode, 0); + assert.equal(readFileSync(join(dir, "intercom-group.txt"), "utf8"), ""); + }, + { idleMs: 4_000, wallMs: 8_000 }, + ); + } finally { + if (previousGroup === undefined) delete process.env.ATOMIC_INTERCOM_GROUP; + else process.env.ATOMIC_INTERCOM_GROUP = previousGroup; + } }); diff --git a/test/unit/workflow-invocation-intercom-subagent.test.ts b/test/unit/workflow-invocation-intercom-subagent.test.ts index b8b4a5174..2512ce749 100644 --- a/test/unit/workflow-invocation-intercom-subagent.test.ts +++ b/test/unit/workflow-invocation-intercom-subagent.test.ts @@ -1,223 +1,267 @@ -import { test } from "bun:test"; import assert from "node:assert/strict"; import { mkdtempSync, rmSync } from "node:fs"; import type net from "node:net"; import { tmpdir } from "node:os"; import { join } from "node:path"; import type { CreateAgentSessionOptions, ExtensionContext } from "@bastani/atomic"; -import { createSubagentExecutor } from "../../packages/subagents/src/runs/foreground/subagent-executor.js"; -import type { - ExecutorDeps, - SubagentExecutorRuntimeDeps, -} from "../../packages/subagents/src/runs/foreground/subagent-executor-types.js"; +import { test } from "vitest"; import { DeliveredMessageCache } from "../../packages/intercom/broker/delivered-message-cache.js"; -import { handleBrokerSend, type BrokerConnectedSession } from "../../packages/intercom/broker/send-handler.js"; +import { type BrokerConnectedSession, handleBrokerSend } from "../../packages/intercom/broker/send-handler.js"; import { SupervisorChannelCache } from "../../packages/intercom/broker/supervisor-channel.js"; import type { BrokerMessage, Message, SessionInfo } from "../../packages/intercom/types.js"; +import { createSubagentExecutor } from "../../packages/subagents/src/runs/foreground/subagent-executor.js"; +import type { + ExecutorDeps, + SubagentExecutorRuntimeDeps, +} from "../../packages/subagents/src/runs/foreground/subagent-executor-types.js"; import { createStore, mockSession, run, workflow } from "./executor-shared.js"; const usage = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, turns: 0 }; function makeState(): ExecutorDeps["state"] { - return { - baseCwd: "", - currentSessionId: null, - asyncJobs: new Map(), - foregroundRuns: new Map(), - foregroundControls: new Map(), - lastForegroundControlId: null, - cleanupTimers: new Map(), - lastUiContext: null, - poller: null, - completionSeen: new Map(), - watcher: null, - watcherRestartTimer: null, - resultFileCoalescer: { schedule: () => false, clear: () => {} }, - }; + return { + baseCwd: "", + currentSessionId: null, + asyncJobs: new Map(), + foregroundRuns: new Map(), + foregroundControls: new Map(), + lastForegroundControlId: null, + cleanupTimers: new Map(), + lastUiContext: null, + poller: null, + completionSeen: new Map(), + watcher: null, + watcherRestartTimer: null, + resultFileCoalescer: { schedule: () => false, clear: () => {} }, + }; } -function stageContext(cwd: string, orchestrationContext: NonNullable): ExtensionContext { - const ui: Pick = { - custom: async () => undefined as T, - }; - const modelRegistry: Pick = { - getAvailable: () => [], - }; - const sessionManager: Pick = { - getSessionFile: () => undefined, - getSessionId: () => "workflow-stage-session", - getLeafId: () => null, - }; - return { - cwd, - mode: "tui", - hasUI: false, - ui: ui as ExtensionContext["ui"], - model: undefined, - modelRegistry: modelRegistry as ExtensionContext["modelRegistry"], - sessionManager: sessionManager as ExtensionContext["sessionManager"], - orchestrationContext, - isIdle: () => true, - isProjectTrusted: () => true, - signal: undefined, - abort: () => {}, - hasPendingMessages: () => false, - shutdown: () => {}, - getContextUsage: () => undefined, - compact: () => {}, - getSystemPrompt: () => "", - } satisfies ExtensionContext; +function stageContext( + cwd: string, + orchestrationContext: NonNullable, +): ExtensionContext { + const ui: Pick = { + custom: async () => undefined as T, + }; + const modelRegistry: Pick = { + getAvailable: () => [], + }; + const sessionManager: Pick = { + getSessionFile: () => undefined, + getSessionId: () => "workflow-stage-session", + getLeafId: () => null, + }; + return { + cwd, + mode: "tui", + hasUI: false, + ui: ui as ExtensionContext["ui"], + model: undefined, + modelRegistry: modelRegistry as ExtensionContext["modelRegistry"], + sessionManager: sessionManager as ExtensionContext["sessionManager"], + orchestrationContext, + isIdle: () => true, + isProjectTrusted: () => true, + signal: undefined, + abort: () => {}, + hasPendingMessages: () => false, + shutdown: () => {}, + getContextUsage: () => undefined, + compact: () => {}, + getSystemPrompt: () => "", + } satisfies ExtensionContext; } test("a real foreground subagent inherits its workflow group and stays outside default chat", async () => { - let stageOrchestrationContext: CreateAgentSessionOptions["orchestrationContext"]; - const definition = workflow({ - name: "subagent-invocation-group-inheritance", - description: "", - inputs: {}, - outputs: {}, - run: async (ctx) => { - await ctx.task("launcher", { prompt: "launch" }); - return {}; - }, - }); - const workflowResult = await run(definition, {}, { - store: createStore(), - adapters: { agentSession: { async create(options) { - stageOrchestrationContext = options.orchestrationContext; - return mockSession(); - } } }, - }); - assert.equal(workflowResult.status, "completed"); - assert.ok(stageOrchestrationContext?.intercomGroup); + let stageOrchestrationContext: CreateAgentSessionOptions["orchestrationContext"]; + const definition = workflow({ + name: "subagent-invocation-group-inheritance", + description: "", + inputs: {}, + outputs: {}, + run: async (ctx) => { + await ctx.task("launcher", { prompt: "launch" }); + return {}; + }, + }); + const workflowResult = await run( + definition, + {}, + { + store: createStore(), + adapters: { + agentSession: { + async create(options) { + stageOrchestrationContext = options.orchestrationContext; + return mockSession(); + }, + }, + }, + }, + ); + assert.equal(workflowResult.status, "completed"); + assert.ok(stageOrchestrationContext?.intercomGroup); - const root = mkdtempSync(join(tmpdir(), "atomic-workflow-group-subagent-")); - const childGroups: Array = []; - const runSync: SubagentExecutorRuntimeDeps["runSync"] = async (_cwd, _agents, agent, task, options) => { - childGroups.push(options.intercomGroup); - return { agent, task, exitCode: 0, messages: [], usage, finalOutput: "done" }; - }; - const pi: Pick = { - events: { on: () => () => {}, emit: () => {} }, - getSessionName: () => "workflow-stage", - }; - try { - const executor = createSubagentExecutor({ - pi: pi as ExecutorDeps["pi"], - state: makeState(), - config: { maxSubagentDepth: 2, parallel: { concurrency: 2, maxTasks: 10 } }, - asyncByDefault: false, - tempArtifactsDir: join(root, "artifacts"), - getSubagentSessionRoot: () => join(root, "sessions"), - expandTilde: (path) => path, - discoverAgents: () => ({ agents: [{ - name: "worker", - description: "test worker", - systemPromptMode: "replace", - inheritProjectContext: false, - inheritSkills: false, - systemPrompt: "work", - source: "project", - filePath: join(root, "worker.md"), - }] }), - runtime: { runSync }, - }); - const context = stageContext(root, stageOrchestrationContext); - for (const params of [ - { agent: "worker", task: "inherit" }, - { agent: "worker", task: "named", group: "child-group" }, - { agent: "worker", task: "default", group: "default" }, - ] as const) { - const result = await executor.execute("subagent-call", params, new AbortController().signal, undefined, context); - assert.equal(result.isError, undefined); - } - } finally { - rmSync(root, { recursive: true, force: true }); - } + const root = mkdtempSync(join(tmpdir(), "atomic-workflow-group-subagent-")); + const childGroups: Array = []; + const runSync: SubagentExecutorRuntimeDeps["runSync"] = async (_cwd, _agents, agent, task, options) => { + childGroups.push(options.intercomGroup); + return { agent, task, exitCode: 0, messages: [], usage, finalOutput: "done" }; + }; + const pi: Pick = { + events: { on: () => () => {}, emit: () => {} }, + getSessionName: () => "workflow-stage", + }; + try { + const executor = createSubagentExecutor({ + pi: pi as ExecutorDeps["pi"], + state: makeState(), + config: { maxSubagentDepth: 2, parallel: { concurrency: 2, maxTasks: 10 } }, + asyncByDefault: false, + tempArtifactsDir: join(root, "artifacts"), + getSubagentSessionRoot: () => join(root, "sessions"), + expandTilde: (path) => path, + discoverAgents: () => ({ + agents: [ + { + name: "worker", + description: "test worker", + systemPromptMode: "replace", + inheritProjectContext: false, + inheritSkills: false, + systemPrompt: "work", + source: "project", + filePath: join(root, "worker.md"), + }, + ], + }), + runtime: { runSync }, + }); + const context = stageContext(root, stageOrchestrationContext); + for (const params of [ + { agent: "worker", task: "inherit" }, + { agent: "worker", task: "named", group: "child-group" }, + { agent: "worker", task: "default", group: "default" }, + ] as const) { + const result = await executor.execute( + "subagent-call", + params, + new AbortController().signal, + undefined, + context, + ); + assert.equal(result.isError, undefined); + } + } finally { + rmSync(root, { recursive: true, force: true }); + } - const workflowGroup = stageOrchestrationContext.intercomGroup; - assert.deepEqual(childGroups, [workflowGroup, "child-group", "default"]); - assert.ok(workflowGroup); - const childSocket = {} as net.Socket; - const mainSocket = {} as net.Socket; - const sessionInfo = (id: string, name: string, group?: string): SessionInfo => ({ - id, name, group, cwd: "/tmp", model: "test", pid: 1, startedAt: 1, lastActivity: 1, - }); - const sessions = new Map([ - ["child", { socket: childSocket, info: sessionInfo("child", "workflow-child", workflowGroup) }], - ["main", { socket: mainSocket, info: sessionInfo("main", "main-chat") }], - ]); - const writes: Array<{ socket: net.Socket; message: BrokerMessage }> = []; - const message: Message = { id: "child-notice", timestamp: 1, content: { text: "subagent result" } }; - handleBrokerSend( - childSocket, - { type: "send", to: "main", message }, - "child", - sessions, - new DeliveredMessageCache(), - (socket, brokerMessage) => writes.push({ socket, message: brokerMessage }), - new SupervisorChannelCache(), - ); - assert.equal(writes.some((write) => write.socket === mainSocket && write.message.type === "message"), false); - assert.equal(writes.some((write) => write.socket === childSocket && write.message.type === "delivery_failed"), true); + const workflowGroup = stageOrchestrationContext.intercomGroup; + assert.deepEqual(childGroups, [workflowGroup, "child-group", "default"]); + assert.ok(workflowGroup); + const childSocket = {} as net.Socket; + const mainSocket = {} as net.Socket; + const sessionInfo = (id: string, name: string, group?: string): SessionInfo => ({ + id, + name, + group, + cwd: "/tmp", + model: "test", + pid: 1, + startedAt: 1, + lastActivity: 1, + }); + const sessions = new Map([ + ["child", { socket: childSocket, info: sessionInfo("child", "workflow-child", workflowGroup) }], + ["main", { socket: mainSocket, info: sessionInfo("main", "main-chat") }], + ]); + const writes: Array<{ socket: net.Socket; message: BrokerMessage }> = []; + const message: Message = { id: "child-notice", timestamp: 1, content: { text: "subagent result" } }; + handleBrokerSend( + childSocket, + { type: "send", to: "main", message }, + "child", + sessions, + new DeliveredMessageCache(), + (socket, brokerMessage) => writes.push({ socket, message: brokerMessage }), + new SupervisorChannelCache(), + ); + assert.equal( + writes.some((write) => write.socket === mainSocket && write.message.type === "message"), + false, + ); + assert.equal( + writes.some((write) => write.socket === childSocket && write.message.type === "delivery_failed"), + true, + ); }); test("foreground chain children inherit the workflow group and keep explicit overrides", async () => { - const root = mkdtempSync(join(tmpdir(), "atomic-workflow-group-chain-")); - const childGroups: Array = []; - const runSync: SubagentExecutorRuntimeDeps["runSync"] = async (_cwd, _agents, agent, task, options) => { - childGroups.push(options.intercomGroup); - return { agent, task, exitCode: 0, messages: [], usage, finalOutput: "done" }; - }; - const pi: Pick = { - events: { on: () => () => {}, emit: () => {} }, - getSessionName: () => "workflow-stage", - }; - try { - const executor = createSubagentExecutor({ - pi: pi as ExecutorDeps["pi"], - state: makeState(), - config: { maxSubagentDepth: 2, parallel: { concurrency: 2, maxTasks: 10 } }, - asyncByDefault: false, - tempArtifactsDir: join(root, "artifacts"), - getSubagentSessionRoot: () => join(root, "sessions"), - expandTilde: (path) => path, - discoverAgents: () => ({ agents: [{ - name: "worker", - description: "test worker", - systemPromptMode: "replace", - inheritProjectContext: false, - inheritSkills: false, - systemPrompt: "work", - source: "project", - filePath: join(root, "worker.md"), - }] }), - runtime: { runSync }, - }); - const context = stageContext(root, { - kind: "workflow-stage", - workflowRunId: "root", - workflowStageId: "launcher", - workflowStageName: "launcher", - intercomGroup: "workflow:root", - constraints: { disableWorkflowTool: true, maxSubagentDepth: 2 }, - }); - const result = await executor.execute("chain", { - chain: [ - { agent: "worker", task: "inherit" }, - { agent: "worker", task: "default", group: "default" }, - { - parallel: [ - { agent: "worker", task: "parallel-set" }, - { agent: "worker", task: "parallel-default", group: "default" }, - ], - group: "parallel-group", - }, - ], - }, new AbortController().signal, undefined, context); - assert.equal(result.isError, undefined); - assert.deepEqual(childGroups, ["workflow:root", "default", "parallel-group", "default"]); - } finally { - rmSync(root, { recursive: true, force: true }); - } + const root = mkdtempSync(join(tmpdir(), "atomic-workflow-group-chain-")); + const childGroups: Array = []; + const runSync: SubagentExecutorRuntimeDeps["runSync"] = async (_cwd, _agents, agent, task, options) => { + childGroups.push(options.intercomGroup); + return { agent, task, exitCode: 0, messages: [], usage, finalOutput: "done" }; + }; + const pi: Pick = { + events: { on: () => () => {}, emit: () => {} }, + getSessionName: () => "workflow-stage", + }; + try { + const executor = createSubagentExecutor({ + pi: pi as ExecutorDeps["pi"], + state: makeState(), + config: { maxSubagentDepth: 2, parallel: { concurrency: 2, maxTasks: 10 } }, + asyncByDefault: false, + tempArtifactsDir: join(root, "artifacts"), + getSubagentSessionRoot: () => join(root, "sessions"), + expandTilde: (path) => path, + discoverAgents: () => ({ + agents: [ + { + name: "worker", + description: "test worker", + systemPromptMode: "replace", + inheritProjectContext: false, + inheritSkills: false, + systemPrompt: "work", + source: "project", + filePath: join(root, "worker.md"), + }, + ], + }), + runtime: { runSync }, + }); + const context = stageContext(root, { + kind: "workflow-stage", + workflowRunId: "root", + workflowStageId: "launcher", + workflowStageName: "launcher", + intercomGroup: "workflow:root", + constraints: { disableWorkflowTool: true, maxSubagentDepth: 2 }, + }); + const result = await executor.execute( + "chain", + { + chain: [ + { agent: "worker", task: "inherit" }, + { agent: "worker", task: "default", group: "default" }, + { + parallel: [ + { agent: "worker", task: "parallel-set" }, + { agent: "worker", task: "parallel-default", group: "default" }, + ], + group: "parallel-group", + }, + ], + }, + new AbortController().signal, + undefined, + context, + ); + assert.equal(result.isError, undefined); + assert.deepEqual(childGroups, ["workflow:root", "default", "parallel-group", "default"]); + } finally { + rmSync(root, { recursive: true, force: true }); + } }); diff --git a/test/unit/workflow-lazy-startup-continuation.test.ts b/test/unit/workflow-lazy-startup-continuation.test.ts index 2e6487398..0fa971ac0 100644 --- a/test/unit/workflow-lazy-startup-continuation.test.ts +++ b/test/unit/workflow-lazy-startup-continuation.test.ts @@ -1,29 +1,29 @@ -import { afterEach, beforeEach, describe, test } from "bun:test"; import assert from "node:assert/strict"; import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { WORKFLOW_STAGE_SUBAGENT_GUARD_ENV } from "@bastani/atomic"; +import { Type } from "typebox"; +import { afterEach, beforeEach, describe, test } from "vitest"; +import { workflow } from "../../packages/workflows/src/authoring/workflow.js"; +import { InMemoryDurableBackend } from "../../packages/workflows/src/durable/backend.js"; +import { createInMemoryTestBackend, setDurableBackend } from "../../packages/workflows/src/durable/factory.js"; import factory, { type ExtensionAPI, type PiCommandOptions } from "../../packages/workflows/src/extension/index.js"; -import type { ChatSurfacePayload } from "../../packages/workflows/src/tui/chat-surface-message.js"; -import type { SessionEntry } from "../../packages/workflows/src/shared/persistence-restore.js"; -import { store } from "../../packages/workflows/src/shared/store.js"; +import { createExtensionRuntime, type ExtensionRuntime } from "../../packages/workflows/src/extension/runtime.js"; +import { makeExecuteWorkflowTool } from "../../packages/workflows/src/extension/workflow-tool.js"; +import { cancellationRegistry } from "../../packages/workflows/src/runs/background/cancellation-registry.js"; import { jobTracker } from "../../packages/workflows/src/runs/background/job-tracker.js"; import { killAllRuns } from "../../packages/workflows/src/runs/background/status.js"; -import { cancellationRegistry } from "../../packages/workflows/src/runs/background/cancellation-registry.js"; +import type { SessionEntry } from "../../packages/workflows/src/shared/persistence-restore.js"; +import { store } from "../../packages/workflows/src/shared/store.js"; +import type { ChatSurfacePayload } from "../../packages/workflows/src/tui/chat-surface-message.js"; import { createRegistry } from "../../packages/workflows/src/workflows/registry.js"; -import { workflow } from "../../packages/workflows/src/authoring/workflow.js"; -import { Type } from "typebox"; -import { createExtensionRuntime, type ExtensionRuntime } from "../../packages/workflows/src/extension/runtime.js"; -import { makeExecuteWorkflowTool } from "../../packages/workflows/src/extension/workflow-tool.js"; -import { WORKFLOW_STAGE_SUBAGENT_GUARD_ENV } from "@bastani/atomic"; -import { InMemoryDurableBackend } from "../../packages/workflows/src/durable/backend.js"; -import { createInMemoryTestBackend, setDurableBackend } from "../../packages/workflows/src/durable/factory.js"; interface SentMessage { - customType?: string; - content?: string; - details?: unknown; + customType?: string; + content?: string; + details?: unknown; } type Handler = (event?: unknown, ctx?: unknown) => Promise | void; @@ -31,75 +31,81 @@ type Handler = (event?: unknown, ctx?: unknown) => Promise | void; const originalCwd = process.cwd(); async function cleanupJobs(): Promise { - await Promise.all(jobTracker.runIds().map((runId) => jobTracker.get(runId)?.promise)); + await Promise.all(jobTracker.runIds().map((runId) => jobTracker.get(runId)?.promise)); } beforeEach(() => { - setDurableBackend(createInMemoryTestBackend()); + setDurableBackend(createInMemoryTestBackend()); }); afterEach(async () => { - delete process.env[WORKFLOW_STAGE_SUBAGENT_GUARD_ENV]; - process.chdir(originalCwd); - killAllRuns({ store, cancellation: cancellationRegistry }); - await cleanupJobs(); - store.clear(); - setDurableBackend(undefined); + delete process.env[WORKFLOW_STAGE_SUBAGENT_GUARD_ENV]; + process.chdir(originalCwd); + killAllRuns({ store, cancellation: cancellationRegistry }); + await cleanupJobs(); + store.clear(); + setDurableBackend(undefined); }); function workflowConfigDir(root: string): string { - return join(root, ".atomic", "extensions", "workflow"); + return join(root, ".atomic", "extensions", "workflow"); } function registerFactory(piOverrides: Partial = {}): { - handlers: Map; - commands: Array<{ name: string; options: PiCommandOptions }>; - sent: SentMessage[]; + handlers: Map; + commands: Array<{ name: string; options: PiCommandOptions }>; + sent: SentMessage[]; } { - const handlers = new Map(); - const commands: Array<{ name: string; options: PiCommandOptions }> = []; - const sent: SentMessage[] = []; - const pi = { - registerTool: () => undefined, - registerCommand: (name: string, options: PiCommandOptions) => { commands.push({ name, options }); }, - registerMessageRenderer: () => undefined, - registerFlag: () => undefined, - registerShortcut: () => undefined, - sendMessage: (message: SentMessage) => { sent.push(message); }, - createAgentSession: async () => ({ - session: { - prompt: async () => "ok", - steer: async () => undefined, - followUp: async () => undefined, - subscribe: () => () => undefined, - sessionFile: undefined, - sessionId: "workflow-lazy-test-session", - setModel: async () => undefined, - setThinkingLevel: () => undefined, - dispose: async () => undefined, - }, - }), - on: (event: string, handler: Handler) => handlers.set(event, handler), - ...piOverrides, - } as unknown as ExtensionAPI; - factory(pi); - return { handlers, commands, sent }; + const handlers = new Map(); + const commands: Array<{ name: string; options: PiCommandOptions }> = []; + const sent: SentMessage[] = []; + const pi = { + registerTool: () => undefined, + registerCommand: (name: string, options: PiCommandOptions) => { + commands.push({ name, options }); + }, + registerMessageRenderer: () => undefined, + registerFlag: () => undefined, + registerShortcut: () => undefined, + sendMessage: (message: SentMessage) => { + sent.push(message); + }, + createAgentSession: async () => ({ + session: { + prompt: async () => "ok", + steer: async () => undefined, + followUp: async () => undefined, + subscribe: () => () => undefined, + sessionFile: undefined, + sessionId: "workflow-lazy-test-session", + setModel: async () => undefined, + setThinkingLevel: () => undefined, + dispose: async () => undefined, + }, + }), + on: (event: string, handler: Handler) => handlers.set(event, handler), + ...piOverrides, + } as unknown as ExtensionAPI; + factory(pi); + return { handlers, commands, sent }; } function inFlightEntry(runId: string, name = "config-restore-wf"): SessionEntry { - return { id: `${runId}-start`, type: "workflow.run.start", payload: { runId, name, inputs: {}, ts: 1 } }; + return { id: `${runId}-start`, type: "workflow.run.start", payload: { runId, name, inputs: {}, ts: 1 } }; } function listPayload(sent: readonly SentMessage[]): ChatSurfacePayload | undefined { - const message = sent.find((entry) => { - const details = entry.details; - return typeof details === "object" && details !== null && "kind" in details && details.kind === "list"; - }); - return message?.details as ChatSurfacePayload | undefined; + const message = sent.find((entry) => { + const details = entry.details; + return typeof details === "object" && details !== null && "kind" in details && details.kind === "list"; + }); + return message?.details as ChatSurfacePayload | undefined; } async function writeWorkflowFixture(filePath: string, name: string): Promise { - await writeFile(filePath, `import { workflow } from "@bastani/workflows"; + await writeFile( + filePath, + `import { workflow } from "@bastani/workflows"; export default workflow({ name: ${JSON.stringify(name)}, description: "", @@ -107,11 +113,15 @@ export default workflow({ outputs: {}, run: async () => ({}), }); -`, "utf8"); +`, + "utf8", + ); } async function writePromptWorkflowFixture(filePath: string, name: string): Promise { - await writeFile(filePath, `import { workflow } from "@bastani/workflows"; + await writeFile( + filePath, + `import { workflow } from "@bastani/workflows"; export default workflow({ name: ${JSON.stringify(name)}, description: "", @@ -119,281 +129,389 @@ export default workflow({ outputs: { value: { type: "string" } }, run: async (ctx) => ({ value: await ctx.stage("retry").prompt("retry") }), }); -`, "utf8"); +`, + "utf8", + ); } describe("workflow lazy-startup continuation fixes", () => { - test("session_start ignores session workflow state without discovering workflow modules", async () => { - const root = mkdtempSync(join(tmpdir(), "atomic-workflow-config-restore-")); - try { - mkdirSync(workflowConfigDir(root), { recursive: true }); - - writeFileSync(join(workflowConfigDir(root), "config.json"), JSON.stringify({ persistRuns: false }), "utf8"); - process.chdir(root); - let resourceCalls = 0; - const { handlers } = registerFactory({ disableAsyncDiscovery: true, getWorkflowResources: () => { resourceCalls += 1; return []; } }); - const sessionStart = handlers.get("session_start"); - assert.ok(sessionStart); - await sessionStart({}, { sessionManager: { getEntries: () => [inFlightEntry("persist-off-run")] } }); - assert.equal(store.runs().length, 0); - assert.equal(resourceCalls, 0); - } finally { - process.chdir(originalCwd); - rmSync(root, { recursive: true, force: true }); - } - }); - - test("resumeInFlight cannot restore workflow state from session JSONL", async () => { - const root = mkdtempSync(join(tmpdir(), "atomic-workflow-config-auto-")); - try { - mkdirSync(workflowConfigDir(root), { recursive: true }); - writeFileSync(join(workflowConfigDir(root), "config.json"), JSON.stringify({ resumeInFlight: "auto" }), "utf8"); - process.chdir(root); - const { handlers } = registerFactory({ disableAsyncDiscovery: true }); - await handlers.get("session_start")?.({}, { sessionManager: { getEntries: () => [inFlightEntry("auto-run")] } }); - assert.equal(store.runs().some((run) => run.id === "auto-run"), false); - } finally { - process.chdir(originalCwd); - rmSync(root, { recursive: true, force: true }); - } - }); - - test("session_start emits immediate config diagnostics without workflow discovery", async () => { - const root = mkdtempSync(join(tmpdir(), "atomic-workflow-config-diagnostics-")); - try { - mkdirSync(workflowConfigDir(root), { recursive: true }); - writeFileSync(join(workflowConfigDir(root), "config.json"), "{ not valid json", "utf8"); - process.chdir(root); - let resourceCalls = 0; - const notifications: string[] = []; - const { handlers } = registerFactory({ disableAsyncDiscovery: true, getWorkflowResources: () => { resourceCalls += 1; return []; } }); - await handlers.get("session_start")?.({}, { ui: { notify: (message: string) => notifications.push(message) } }); - assert.equal(resourceCalls, 0); - assert.match(notifications.join("\n"), /CONFIG_INVALID/); - } finally { - process.chdir(originalCwd); - rmSync(root, { recursive: true, force: true }); - } - }); - - test("/workflow list retries after a transient lazy discovery failure", async () => { - const dir = await mkdtemp(join(tmpdir(), "atomic-workflow-lazy-retry-")); - try { - const workflowPath = join(dir, "retry-workflow.ts"); - await writeWorkflowFixture(workflowPath, "retry workflow"); - let refreshCalls = 0; - const { commands, sent } = registerFactory({ - refreshWorkflowResources: async () => { - refreshCalls += 1; - if (refreshCalls === 1) throw new Error("transient refresh failure"); - return [{ path: workflowPath, enabled: true }]; - }, - }); - const workflowCmd = commands.find((command) => command.name === "workflow"); - assert.ok(workflowCmd); - const notices: string[] = []; - const headlessCtx = { hasUI: false, ui: { notify: (message: string) => { notices.push(message); } } }; - await workflowCmd.options.handler?.("list", headlessCtx); - assert.equal(refreshCalls, 1); - assert.match(notices.join("\n"), /transient refresh failure/); - sent.length = 0; - await workflowCmd.options.handler?.("list", headlessCtx); - assert.equal(refreshCalls, 2); - assert.equal(listPayload(sent)?.kind, "list"); - assert.match(sent.map((entry) => entry.content ?? "").join("\n"), /retry-workflow/); - } finally { - await rm(dir, { recursive: true, force: true }); - } - }); - - test("/workflow autocomplete falls back to admin completions when lazy discovery fails", async () => { - let refreshCalls = 0; - const { commands } = registerFactory({ - refreshWorkflowResources: async () => { - refreshCalls += 1; - throw new Error("discovery failed"); - }, - }); - const workflowCmd = commands.find((command) => command.name === "workflow"); - assert.ok(workflowCmd?.options.getArgumentCompletions); - - const completions = await workflowCmd.options.getArgumentCompletions(""); - - assert.equal(refreshCalls, 1); - assert.ok(Array.isArray(completions)); - assert.ok(completions.some((item) => item.value === "list ")); - assert.ok(completions.some((item) => item.value === "resume ")); - }); - - test("/workflow resume for paused live runs does not force workflow discovery", async () => { - let refreshCalls = 0; - const { commands } = registerFactory({ - refreshWorkflowResources: async () => { - refreshCalls += 1; - throw new Error("discovery failed"); - }, - }); - const runId = "paused-slash-resume-source"; - store.recordRunStart({ id: runId, name: "paused workflow", inputs: {}, status: "running", stages: [], startedAt: Date.now() }); - assert.equal(store.recordRunPaused(runId), true); - const workflowCmd = commands.find((command) => command.name === "workflow"); - assert.ok(workflowCmd); - - await workflowCmd.options.handler?.(`resume ${runId}`, { hasUI: false, ui: { notify: () => undefined } }); - - assert.equal(refreshCalls, 0); - assert.equal(store.runs().find((run) => run.id === runId)?.status, "running"); - }); - - - - - test("workflow tool paused resume bypasses workflow discovery", async () => { - let ensureCalls = 0; - const runId = "paused-tool-resume-source"; - store.recordRunStart({ id: runId, name: "paused tool workflow", inputs: {}, status: "running", stages: [], startedAt: Date.now() }); - assert.equal(store.recordRunPaused(runId), true); - const runtime = createExtensionRuntime({ registry: createRegistry([]), store }); - const handler = makeExecuteWorkflowTool( - runtime, - () => undefined, - async () => { - ensureCalls += 1; - throw new Error("discovery failed"); - }, - ); - - const result = await handler({ action: "resume", runId }, {} as never); - - assert.equal(ensureCalls, 0); - assert.equal(result.action, "resume"); - assert.equal(result.status, "ok"); - assert.equal(store.runs().find((run) => run.id === runId)?.status, "running"); - }); - - test("/workflow resume lazy-loads resources before failed-run registry lookup", async () => { - class CatalogCountingBackend extends InMemoryDurableBackend { - completedCatalogCalls = 0; - - override listCompletedWorkflows() { - this.completedCatalogCalls += 1; - return super.listCompletedWorkflows(); - } - } - - const backend = new CatalogCountingBackend(); - setDurableBackend(backend); - const dir = await mkdtemp(join(tmpdir(), "atomic-workflow-slash-resume-lazy-")); - try { - const workflowPath = join(dir, "slash-resume-lazy.ts"); - await writePromptWorkflowFixture(workflowPath, "slash-resume-lazy"); - let refreshCalls = 0; - const { commands, sent } = registerFactory({ - refreshWorkflowResources: async () => { - refreshCalls += 1; - return [{ path: workflowPath, enabled: true }]; - }, - }); - const sourceRunId = "lazy-slash-resume-source"; - store.recordRunStart({ id: sourceRunId, name: "slash-resume-lazy", inputs: {}, status: "running", stages: [], startedAt: Date.now() }); - store.recordStageStart(sourceRunId, { id: "retry-old", name: "retry", status: "failed", parentIds: [], toolEvents: [], error: "boom" }); - store.recordStageEnd(sourceRunId, { id: "retry-old", name: "retry", status: "failed", parentIds: [], toolEvents: [], error: "boom" }); - store.recordRunEnd(sourceRunId, "failed", undefined, "boom", { resumable: true, failedStageId: "retry-old" }); - backend.registerWorkflow({ workflowId: sourceRunId, name: "slash-resume-lazy", inputs: {}, createdAt: Date.now(), status: "failed", resumable: true }); - const workflowCmd = commands.find((command) => command.name === "workflow"); - assert.ok(workflowCmd); - await workflowCmd.options.handler?.(`resume ${sourceRunId}`, { hasUI: false, ui: { notify: () => undefined } }); - assert.equal(refreshCalls, 1); - assert.equal(backend.completedCatalogCalls, 1); - const output = sent.map((entry) => entry.content ?? "").join("\n"); - assert.match(output, /Resum/); - assert.doesNotMatch(output, /Run not found/); - } finally { - await rm(dir, { recursive: true, force: true }); - } - }); - - - test("workflow tool resume lazy-loads resources before failed-run registry lookup", async () => { - delete process.env[WORKFLOW_STAGE_SUBAGENT_GUARD_ENV]; - const def = workflow({ - name: "lazy resume workflow", - description: "", - inputs: {}, - outputs: { value: Type.Optional(Type.String()) }, - run: async (ctx) => ({ value: await ctx.stage("retry").prompt("retry") }), - }); - const sourceRunId = "lazy-tool-resume-source"; - store.recordRunStart({ id: sourceRunId, name: def.name, inputs: {}, status: "running", stages: [], startedAt: Date.now() }); - store.recordStageStart(sourceRunId, { id: "retry-old", name: "retry", status: "failed", parentIds: [], toolEvents: [], error: "boom" }); - store.recordStageEnd(sourceRunId, { id: "retry-old", name: "retry", status: "failed", parentIds: [], toolEvents: [], error: "boom" }); - store.recordRunEnd(sourceRunId, "failed", undefined, "boom", { resumable: true, failedStageId: "retry-old" }); - let runtime: ExtensionRuntime = createExtensionRuntime({ registry: createRegistry([]) }); - let ensureCalls = 0; - const handler = makeExecuteWorkflowTool( - () => runtime, - () => undefined, - async () => { - ensureCalls += 1; - runtime = createExtensionRuntime({ registry: createRegistry([def]), store, adapters: { prompt: { prompt: async () => "new" } } }); - }, - ); - const result = await handler({ action: "resume", runId: sourceRunId }, { model: { provider: "fake", id: "model" } } as never); - assert.equal(ensureCalls, 1); - assert.equal(result.action, "resume"); - assert.equal(result.status, "running"); - assert.match(result.message ?? "", /Resum/); - }); - - test("session_start invalidates stale workflow warmups before they publish old registries", async () => { - const dir = await mkdtemp(join(tmpdir(), "atomic-workflow-stale-warmup-")); - try { - const oldPath = join(dir, "old-workflow.ts"); - const newPath = join(dir, "new-workflow.ts"); - await writeWorkflowFixture(oldPath, "old workflow"); - await writeWorkflowFixture(newPath, "new workflow"); - - let refreshCalls = 0; - const resolvers: Array<(resources: Array<{ path: string; enabled: true }>) => void> = []; - const refreshStarted: Promise[] = []; - const waitForRefresh = async (index: number): Promise => { - while (refreshStarted.length <= index) { - await new Promise((resolve) => setImmediate(resolve)); - } - await refreshStarted[index]; - }; - const { handlers, commands, sent } = registerFactory({ - refreshWorkflowResources: () => { - refreshCalls += 1; - let markStarted: () => void = () => undefined; - refreshStarted.push(new Promise((resolve) => { markStarted = resolve; })); - markStarted(); - return new Promise((resolve) => { resolvers.push(resolve); }); - }, - }); - const sessionStart = handlers.get("session_start"); - assert.ok(sessionStart); - - await sessionStart({}, { ui: { notify: () => undefined } }); - await waitForRefresh(0); - await sessionStart({}, { ui: { notify: () => undefined } }); - - // The permanent reload coordinator serializes generations. Release the - // stale pass before waiting for the new session's trailing pass to start. - assert.equal(refreshCalls, 1); - resolvers[0]?.([{ path: oldPath, enabled: true }]); - await waitForRefresh(1); - assert.equal(refreshCalls, 2); - resolvers[1]?.([{ path: newPath, enabled: true }]); - - const workflowCmd = commands.find((command) => command.name === "workflow"); - assert.ok(workflowCmd); - await workflowCmd.options.handler?.("list", { hasUI: false, ui: { notify: () => undefined } }); - const output = sent.map((entry) => entry.content ?? "").join("\n"); - assert.match(output, /new-workflow/); - assert.doesNotMatch(output, /old-workflow/); - } finally { - await rm(dir, { recursive: true, force: true }); - } - }); + test("session_start ignores session workflow state without discovering workflow modules", async () => { + const root = mkdtempSync(join(tmpdir(), "atomic-workflow-config-restore-")); + try { + mkdirSync(workflowConfigDir(root), { recursive: true }); + + writeFileSync(join(workflowConfigDir(root), "config.json"), JSON.stringify({ persistRuns: false }), "utf8"); + process.chdir(root); + let resourceCalls = 0; + const { handlers } = registerFactory({ + disableAsyncDiscovery: true, + getWorkflowResources: () => { + resourceCalls += 1; + return []; + }, + }); + const sessionStart = handlers.get("session_start"); + assert.ok(sessionStart); + await sessionStart({}, { sessionManager: { getEntries: () => [inFlightEntry("persist-off-run")] } }); + assert.equal(store.runs().length, 0); + assert.equal(resourceCalls, 0); + } finally { + process.chdir(originalCwd); + rmSync(root, { recursive: true, force: true }); + } + }); + + test("resumeInFlight cannot restore workflow state from session JSONL", async () => { + const root = mkdtempSync(join(tmpdir(), "atomic-workflow-config-auto-")); + try { + mkdirSync(workflowConfigDir(root), { recursive: true }); + writeFileSync( + join(workflowConfigDir(root), "config.json"), + JSON.stringify({ resumeInFlight: "auto" }), + "utf8", + ); + process.chdir(root); + const { handlers } = registerFactory({ disableAsyncDiscovery: true }); + await handlers.get("session_start")?.( + {}, + { sessionManager: { getEntries: () => [inFlightEntry("auto-run")] } }, + ); + assert.equal( + store.runs().some((run) => run.id === "auto-run"), + false, + ); + } finally { + process.chdir(originalCwd); + rmSync(root, { recursive: true, force: true }); + } + }); + + test("session_start emits immediate config diagnostics without workflow discovery", async () => { + const root = mkdtempSync(join(tmpdir(), "atomic-workflow-config-diagnostics-")); + try { + mkdirSync(workflowConfigDir(root), { recursive: true }); + writeFileSync(join(workflowConfigDir(root), "config.json"), "{ not valid json", "utf8"); + process.chdir(root); + let resourceCalls = 0; + const notifications: string[] = []; + const { handlers } = registerFactory({ + disableAsyncDiscovery: true, + getWorkflowResources: () => { + resourceCalls += 1; + return []; + }, + }); + await handlers.get("session_start")?.( + {}, + { ui: { notify: (message: string) => notifications.push(message) } }, + ); + assert.equal(resourceCalls, 0); + assert.match(notifications.join("\n"), /CONFIG_INVALID/); + } finally { + process.chdir(originalCwd); + rmSync(root, { recursive: true, force: true }); + } + }); + + test("/workflow list retries after a transient lazy discovery failure", async () => { + const dir = await mkdtemp(join(tmpdir(), "atomic-workflow-lazy-retry-")); + try { + const workflowPath = join(dir, "retry-workflow.ts"); + await writeWorkflowFixture(workflowPath, "retry workflow"); + let refreshCalls = 0; + const { commands, sent } = registerFactory({ + refreshWorkflowResources: async () => { + refreshCalls += 1; + if (refreshCalls === 1) throw new Error("transient refresh failure"); + return [{ path: workflowPath, enabled: true }]; + }, + }); + const workflowCmd = commands.find((command) => command.name === "workflow"); + assert.ok(workflowCmd); + const notices: string[] = []; + const headlessCtx = { + hasUI: false, + ui: { + notify: (message: string) => { + notices.push(message); + }, + }, + }; + await workflowCmd.options.handler?.("list", headlessCtx); + assert.equal(refreshCalls, 1); + assert.match(notices.join("\n"), /transient refresh failure/); + sent.length = 0; + await workflowCmd.options.handler?.("list", headlessCtx); + assert.equal(refreshCalls, 2); + assert.equal(listPayload(sent)?.kind, "list"); + assert.match(sent.map((entry) => entry.content ?? "").join("\n"), /retry-workflow/); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); + + test("/workflow autocomplete falls back to admin completions when lazy discovery fails", async () => { + let refreshCalls = 0; + const { commands } = registerFactory({ + refreshWorkflowResources: async () => { + refreshCalls += 1; + throw new Error("discovery failed"); + }, + }); + const workflowCmd = commands.find((command) => command.name === "workflow"); + assert.ok(workflowCmd?.options.getArgumentCompletions); + + const completions = await workflowCmd.options.getArgumentCompletions(""); + + assert.equal(refreshCalls, 1); + assert.ok(Array.isArray(completions)); + assert.ok(completions.some((item) => item.value === "list ")); + assert.ok(completions.some((item) => item.value === "resume ")); + }); + + test("/workflow resume for paused live runs does not force workflow discovery", async () => { + let refreshCalls = 0; + const { commands } = registerFactory({ + refreshWorkflowResources: async () => { + refreshCalls += 1; + throw new Error("discovery failed"); + }, + }); + const runId = "paused-slash-resume-source"; + store.recordRunStart({ + id: runId, + name: "paused workflow", + inputs: {}, + status: "running", + stages: [], + startedAt: Date.now(), + }); + assert.equal(store.recordRunPaused(runId), true); + const workflowCmd = commands.find((command) => command.name === "workflow"); + assert.ok(workflowCmd); + + await workflowCmd.options.handler?.(`resume ${runId}`, { hasUI: false, ui: { notify: () => undefined } }); + + assert.equal(refreshCalls, 0); + assert.equal(store.runs().find((run) => run.id === runId)?.status, "running"); + }); + + test("workflow tool paused resume bypasses workflow discovery", async () => { + let ensureCalls = 0; + const runId = "paused-tool-resume-source"; + store.recordRunStart({ + id: runId, + name: "paused tool workflow", + inputs: {}, + status: "running", + stages: [], + startedAt: Date.now(), + }); + assert.equal(store.recordRunPaused(runId), true); + const runtime = createExtensionRuntime({ registry: createRegistry([]), store }); + const handler = makeExecuteWorkflowTool( + runtime, + () => undefined, + async () => { + ensureCalls += 1; + throw new Error("discovery failed"); + }, + ); + + const result = await handler({ action: "resume", runId }, {} as never); + + assert.equal(ensureCalls, 0); + assert.equal(result.action, "resume"); + assert.equal(result.status, "ok"); + assert.equal(store.runs().find((run) => run.id === runId)?.status, "running"); + }); + + test("/workflow resume lazy-loads resources before failed-run registry lookup", async () => { + class CatalogCountingBackend extends InMemoryDurableBackend { + completedCatalogCalls = 0; + + override listCompletedWorkflows() { + this.completedCatalogCalls += 1; + return super.listCompletedWorkflows(); + } + } + + const backend = new CatalogCountingBackend(); + setDurableBackend(backend); + const dir = await mkdtemp(join(tmpdir(), "atomic-workflow-slash-resume-lazy-")); + try { + const workflowPath = join(dir, "slash-resume-lazy.ts"); + await writePromptWorkflowFixture(workflowPath, "slash-resume-lazy"); + let refreshCalls = 0; + const { commands, sent } = registerFactory({ + refreshWorkflowResources: async () => { + refreshCalls += 1; + return [{ path: workflowPath, enabled: true }]; + }, + }); + const sourceRunId = "lazy-slash-resume-source"; + store.recordRunStart({ + id: sourceRunId, + name: "slash-resume-lazy", + inputs: {}, + status: "running", + stages: [], + startedAt: Date.now(), + }); + store.recordStageStart(sourceRunId, { + id: "retry-old", + name: "retry", + status: "failed", + parentIds: [], + toolEvents: [], + error: "boom", + }); + store.recordStageEnd(sourceRunId, { + id: "retry-old", + name: "retry", + status: "failed", + parentIds: [], + toolEvents: [], + error: "boom", + }); + store.recordRunEnd(sourceRunId, "failed", undefined, "boom", { resumable: true, failedStageId: "retry-old" }); + backend.registerWorkflow({ + workflowId: sourceRunId, + name: "slash-resume-lazy", + inputs: {}, + createdAt: Date.now(), + status: "failed", + resumable: true, + }); + const workflowCmd = commands.find((command) => command.name === "workflow"); + assert.ok(workflowCmd); + await workflowCmd.options.handler?.(`resume ${sourceRunId}`, { + hasUI: false, + ui: { notify: () => undefined }, + }); + assert.equal(refreshCalls, 1); + assert.equal(backend.completedCatalogCalls, 1); + const output = sent.map((entry) => entry.content ?? "").join("\n"); + assert.match(output, /Resum/); + assert.doesNotMatch(output, /Run not found/); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); + + test("workflow tool resume lazy-loads resources before failed-run registry lookup", async () => { + delete process.env[WORKFLOW_STAGE_SUBAGENT_GUARD_ENV]; + const def = workflow({ + name: "lazy resume workflow", + description: "", + inputs: {}, + outputs: { value: Type.Optional(Type.String()) }, + run: async (ctx) => ({ value: await ctx.stage("retry").prompt("retry") }), + }); + const sourceRunId = "lazy-tool-resume-source"; + store.recordRunStart({ + id: sourceRunId, + name: def.name, + inputs: {}, + status: "running", + stages: [], + startedAt: Date.now(), + }); + store.recordStageStart(sourceRunId, { + id: "retry-old", + name: "retry", + status: "failed", + parentIds: [], + toolEvents: [], + error: "boom", + }); + store.recordStageEnd(sourceRunId, { + id: "retry-old", + name: "retry", + status: "failed", + parentIds: [], + toolEvents: [], + error: "boom", + }); + store.recordRunEnd(sourceRunId, "failed", undefined, "boom", { resumable: true, failedStageId: "retry-old" }); + let runtime: ExtensionRuntime = createExtensionRuntime({ registry: createRegistry([]) }); + let ensureCalls = 0; + const handler = makeExecuteWorkflowTool( + () => runtime, + () => undefined, + async () => { + ensureCalls += 1; + runtime = createExtensionRuntime({ + registry: createRegistry([def]), + store, + adapters: { prompt: { prompt: async () => "new" } }, + }); + }, + ); + const result = await handler({ action: "resume", runId: sourceRunId }, { + model: { provider: "fake", id: "model" }, + } as never); + assert.equal(ensureCalls, 1); + assert.equal(result.action, "resume"); + assert.equal(result.status, "running"); + assert.match(result.message ?? "", /Resum/); + }); + + test("session_start invalidates stale workflow warmups before they publish old registries", async () => { + const dir = await mkdtemp(join(tmpdir(), "atomic-workflow-stale-warmup-")); + try { + const oldPath = join(dir, "old-workflow.ts"); + const newPath = join(dir, "new-workflow.ts"); + await writeWorkflowFixture(oldPath, "old workflow"); + await writeWorkflowFixture(newPath, "new workflow"); + + let refreshCalls = 0; + const resolvers: Array<(resources: Array<{ path: string; enabled: true }>) => void> = []; + const refreshStarted: Promise[] = []; + const waitForRefresh = async (index: number): Promise => { + while (refreshStarted.length <= index) { + await new Promise((resolve) => setImmediate(resolve)); + } + await refreshStarted[index]; + }; + const { handlers, commands, sent } = registerFactory({ + refreshWorkflowResources: () => { + refreshCalls += 1; + let markStarted: () => void = () => undefined; + refreshStarted.push( + new Promise((resolve) => { + markStarted = resolve; + }), + ); + markStarted(); + return new Promise((resolve) => { + resolvers.push(resolve); + }); + }, + }); + const sessionStart = handlers.get("session_start"); + assert.ok(sessionStart); + + await sessionStart({}, { ui: { notify: () => undefined } }); + await waitForRefresh(0); + await sessionStart({}, { ui: { notify: () => undefined } }); + + // The permanent reload coordinator serializes generations. Release the + // stale pass before waiting for the new session's trailing pass to start. + assert.equal(refreshCalls, 1); + resolvers[0]?.([{ path: oldPath, enabled: true }]); + await waitForRefresh(1); + assert.equal(refreshCalls, 2); + resolvers[1]?.([{ path: newPath, enabled: true }]); + + const workflowCmd = commands.find((command) => command.name === "workflow"); + assert.ok(workflowCmd); + await workflowCmd.options.handler?.("list", { hasUI: false, ui: { notify: () => undefined } }); + const output = sent.map((entry) => entry.content ?? "").join("\n"); + assert.match(output, /new-workflow/); + assert.doesNotMatch(output, /old-workflow/); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); }); diff --git a/test/unit/workflow-lazy-startup-review-followup.test.ts b/test/unit/workflow-lazy-startup-review-followup.test.ts index f0ad6d546..b43e8bdcc 100644 --- a/test/unit/workflow-lazy-startup-review-followup.test.ts +++ b/test/unit/workflow-lazy-startup-review-followup.test.ts @@ -1,295 +1,416 @@ -import { afterEach, beforeEach, describe, test } from "bun:test"; import assert from "node:assert/strict"; -import factory, { type ExtensionAPI, type PiCommandOptions } from "../../packages/workflows/src/extension/index.js"; -import { store } from "../../packages/workflows/src/shared/store.js"; -import type { ExtensionRuntime } from "../../packages/workflows/src/extension/runtime.js"; -import { makeExecuteWorkflowTool } from "../../packages/workflows/src/extension/workflow-tool.js"; -import type { WorkflowToolResult } from "../../packages/workflows/src/extension/render-result.js"; -import { handleRunControlCommand, type WorkflowRunControlDeps } from "../../packages/workflows/src/extension/workflow-run-control-command.js"; import { WORKFLOW_STAGE_SUBAGENT_GUARD_ENV } from "@bastani/atomic"; +import { afterEach, beforeEach, describe, test } from "vitest"; import { InMemoryDurableBackend } from "../../packages/workflows/src/durable/backend.js"; import { setDurableBackend } from "../../packages/workflows/src/durable/factory.js"; +import factory, { type ExtensionAPI, type PiCommandOptions } from "../../packages/workflows/src/extension/index.js"; +import type { WorkflowToolResult } from "../../packages/workflows/src/extension/render-result.js"; +import type { ExtensionRuntime } from "../../packages/workflows/src/extension/runtime.js"; +import { + handleRunControlCommand, + type WorkflowRunControlDeps, +} from "../../packages/workflows/src/extension/workflow-run-control-command.js"; +import { makeExecuteWorkflowTool } from "../../packages/workflows/src/extension/workflow-tool.js"; +import { store } from "../../packages/workflows/src/shared/store.js"; const previousWorkflowStageSubagentGuard = process.env[WORKFLOW_STAGE_SUBAGENT_GUARD_ENV]; class HydrationCapableBackend extends InMemoryDurableBackend { - hydrateCalls = 0; - async hydrateWorkflow(): Promise { this.hydrateCalls += 1; } + hydrateCalls = 0; + async hydrateWorkflow(): Promise { + this.hydrateCalls += 1; + } } class UnclassifiedHydrationBackend extends HydrationCapableBackend { - loadable = false; - override isWorkflowLoadable(): boolean { return this.loadable; } - async hydrateResumableWorkflows(): Promise { - this.hydrateCalls += 1; - this.loadable = true; - } + loadable = false; + override isWorkflowLoadable(): boolean { + return this.loadable; + } + async hydrateResumableWorkflows(): Promise { + this.hydrateCalls += 1; + this.loadable = true; + } } function registerFactory(piOverrides: Partial = {}): Array<{ name: string; options: PiCommandOptions }> { - const commands: Array<{ name: string; options: PiCommandOptions }> = []; - const pi = { - registerTool: () => undefined, - registerCommand: (name: string, options: PiCommandOptions) => { commands.push({ name, options }); }, - registerMessageRenderer: () => undefined, - registerFlag: () => undefined, - registerShortcut: () => undefined, - sendMessage: () => undefined, - createAgentSession: async () => ({ - session: { - prompt: async () => "ok", - steer: async () => undefined, - followUp: async () => undefined, - subscribe: () => () => undefined, - sessionFile: undefined, - sessionId: "workflow-review-followup-session", - setModel: async () => undefined, - setThinkingLevel: () => undefined, - dispose: async () => undefined, - }, - }), - on: () => undefined, - ...piOverrides, - } as unknown as ExtensionAPI; - factory(pi); - return commands; + const commands: Array<{ name: string; options: PiCommandOptions }> = []; + const pi = { + registerTool: () => undefined, + registerCommand: (name: string, options: PiCommandOptions) => { + commands.push({ name, options }); + }, + registerMessageRenderer: () => undefined, + registerFlag: () => undefined, + registerShortcut: () => undefined, + sendMessage: () => undefined, + createAgentSession: async () => ({ + session: { + prompt: async () => "ok", + steer: async () => undefined, + followUp: async () => undefined, + subscribe: () => () => undefined, + sessionFile: undefined, + sessionId: "workflow-review-followup-session", + setModel: async () => undefined, + setThinkingLevel: () => undefined, + dispose: async () => undefined, + }, + }), + on: () => undefined, + ...piOverrides, + } as unknown as ExtensionAPI; + factory(pi); + return commands; } beforeEach(() => { - delete process.env[WORKFLOW_STAGE_SUBAGENT_GUARD_ENV]; - setDurableBackend(new InMemoryDurableBackend()); + delete process.env[WORKFLOW_STAGE_SUBAGENT_GUARD_ENV]; + setDurableBackend(new InMemoryDurableBackend()); }); afterEach(() => { - store.clear(); - setDurableBackend(undefined); - if (previousWorkflowStageSubagentGuard === undefined) { - delete process.env[WORKFLOW_STAGE_SUBAGENT_GUARD_ENV]; - return; - } - process.env[WORKFLOW_STAGE_SUBAGENT_GUARD_ENV] = previousWorkflowStageSubagentGuard; + store.clear(); + setDurableBackend(undefined); + if (previousWorkflowStageSubagentGuard === undefined) { + delete process.env[WORKFLOW_STAGE_SUBAGENT_GUARD_ENV]; + return; + } + process.env[WORKFLOW_STAGE_SUBAGENT_GUARD_ENV] = previousWorkflowStageSubagentGuard; }); describe("workflow lazy-startup review follow-up fixes", () => { - test("/workflow resume picker shows live runs when lazy discovery fails", async () => { - let refreshCalls = 0; - let pickerCalls = 0; - const commands = registerFactory({ - refreshWorkflowResources: async () => { - refreshCalls += 1; - throw new Error("discovery failed"); - }, - }); - const runId = "picker-live-resume-source"; - store.recordRunStart({ id: runId, name: "picker workflow", inputs: {}, status: "running", stages: [], startedAt: Date.now() }); - assert.equal(store.recordRunPaused(runId), true); - const workflowCmd = commands.find((command) => command.name === "workflow"); - assert.ok(workflowCmd); + test("/workflow resume picker shows live runs when lazy discovery fails", async () => { + let refreshCalls = 0; + let pickerCalls = 0; + const commands = registerFactory({ + refreshWorkflowResources: async () => { + refreshCalls += 1; + throw new Error("discovery failed"); + }, + }); + const runId = "picker-live-resume-source"; + store.recordRunStart({ + id: runId, + name: "picker workflow", + inputs: {}, + status: "running", + stages: [], + startedAt: Date.now(), + }); + assert.equal(store.recordRunPaused(runId), true); + const workflowCmd = commands.find((command) => command.name === "workflow"); + assert.ok(workflowCmd); - await workflowCmd.options.handler?.("resume", { - hasUI: true, - ui: { - notify: () => undefined, - custom: () => undefined, - hostSessionPicker: () => { - pickerCalls += 1; - return { - // The picker must open before any resource/catalog discovery runs. - // Discovery happens lazily via hydrate(), so keep the picker open - // until it has been attempted (and failed), then cancel — proving - // the picker stayed open the whole time. - result: (async (): Promise => { - // Wait on a generous wall-clock deadline (not a fixed tick count): - // under a loaded event loop the hydrate failure can take many more - // than a handful of setImmediate turns to land, so a small tick - // budget would cancel prematurely and flake. - const deadline = Date.now() + 5_000; - while (refreshCalls === 0 && Date.now() < deadline) { - await new Promise((resolve) => setImmediate(resolve)); - } - return undefined; - })(), - update: () => undefined, - error: () => undefined, - close: () => undefined, - }; - }, - }, - }); - assert.equal(refreshCalls, 1); - assert.equal(pickerCalls, 1); - }); + await workflowCmd.options.handler?.("resume", { + hasUI: true, + ui: { + notify: () => undefined, + custom: () => undefined, + hostSessionPicker: () => { + pickerCalls += 1; + return { + // The picker must open before any resource/catalog discovery runs. + // Discovery happens lazily via hydrate(), so keep the picker open + // until it has been attempted (and failed), then cancel — proving + // the picker stayed open the whole time. + result: (async (): Promise => { + // Wait on a generous wall-clock deadline (not a fixed tick count): + // under a loaded event loop the hydrate failure can take many more + // than a handful of setImmediate turns to land, so a small tick + // budget would cancel prematurely and flake. + const deadline = Date.now() + 5_000; + while (refreshCalls === 0 && Date.now() < deadline) { + await new Promise((resolve) => setImmediate(resolve)); + } + return undefined; + })(), + update: () => undefined, + error: () => undefined, + close: () => undefined, + }; + }, + }, + }); + assert.equal(refreshCalls, 1); + assert.equal(pickerCalls, 1); + }); - test("/workflow list surfaces lazy discovery failures as warnings", async () => { - const notices: string[] = []; - const commands = registerFactory({ - refreshWorkflowResources: async () => { - throw new Error("broken workflow file"); - }, - }); - const workflowCmd = commands.find((command) => command.name === "workflow"); - assert.ok(workflowCmd); + test("/workflow list surfaces lazy discovery failures as warnings", async () => { + const notices: string[] = []; + const commands = registerFactory({ + refreshWorkflowResources: async () => { + throw new Error("broken workflow file"); + }, + }); + const workflowCmd = commands.find((command) => command.name === "workflow"); + assert.ok(workflowCmd); - await workflowCmd.options.handler?.("list", { hasUI: true, ui: { notify: (message) => { notices.push(message); } } }); + await workflowCmd.options.handler?.("list", { + hasUI: true, + ui: { + notify: (message) => { + notices.push(message); + }, + }, + }); - assert.equal(notices.length, 1); - assert.match(notices[0]!, /Workflow discovery diagnostics/); - assert.match(notices[0]!, /broken workflow file/); - assert.match(notices[0]!, /currently loaded workflow registry/); - }); + assert.equal(notices.length, 1); + assert.match(notices[0]!, /Workflow discovery diagnostics/); + assert.match(notices[0]!, /broken workflow file/); + assert.match(notices[0]!, /currently loaded workflow registry/); + }); - test("/workflow resume for quit-paused live runs does not force durable discovery", async () => { - let refreshCalls = 0; - const commands = registerFactory({ - refreshWorkflowResources: async () => { - refreshCalls += 1; - throw new Error("discovery failed"); - }, - }); - const runId = "quit-paused-resume-source"; - store.recordRunStart({ id: runId, name: "quit paused workflow", inputs: {}, status: "running", stages: [], startedAt: Date.now() }); - assert.equal(store.recordRunPaused(runId, Date.now(), { resumable: true, exitReason: "quit" }), true); - const workflowCmd = commands.find((command) => command.name === "workflow"); - assert.ok(workflowCmd); + test("/workflow resume for quit-paused live runs does not force durable discovery", async () => { + let refreshCalls = 0; + const commands = registerFactory({ + refreshWorkflowResources: async () => { + refreshCalls += 1; + throw new Error("discovery failed"); + }, + }); + const runId = "quit-paused-resume-source"; + store.recordRunStart({ + id: runId, + name: "quit paused workflow", + inputs: {}, + status: "running", + stages: [], + startedAt: Date.now(), + }); + assert.equal(store.recordRunPaused(runId, Date.now(), { resumable: true, exitReason: "quit" }), true); + const workflowCmd = commands.find((command) => command.name === "workflow"); + assert.ok(workflowCmd); - await workflowCmd.options.handler?.(`resume ${runId}`, { hasUI: false, ui: { notify: () => undefined } }); + await workflowCmd.options.handler?.(`resume ${runId}`, { hasUI: false, ui: { notify: () => undefined } }); - assert.equal(refreshCalls, 0); - assert.equal(store.runs().find((run) => run.id === runId)?.status, "running"); - }); + assert.equal(refreshCalls, 0); + assert.equal(store.runs().find((run) => run.id === runId)?.status, "running"); + }); - test("/workflow resume keeps hydration-capable current live runs on the lazy fast path", async () => { - const backend = new HydrationCapableBackend(); - setDurableBackend(backend); - let refreshCalls = 0; - const commands = registerFactory({ refreshWorkflowResources: async () => { refreshCalls += 1; return []; } }); - const runId = "dbos-like-current-live"; - store.recordRunStart({ id: runId, name: "current live", inputs: {}, status: "running", stages: [], startedAt: Date.now() }); - assert.equal(store.recordRunPaused(runId), true); - const workflowCmd = commands.find((command) => command.name === "workflow"); - assert.ok(workflowCmd); + test("/workflow resume keeps hydration-capable current live runs on the lazy fast path", async () => { + const backend = new HydrationCapableBackend(); + setDurableBackend(backend); + let refreshCalls = 0; + const commands = registerFactory({ + refreshWorkflowResources: async () => { + refreshCalls += 1; + return []; + }, + }); + const runId = "dbos-like-current-live"; + store.recordRunStart({ + id: runId, + name: "current live", + inputs: {}, + status: "running", + stages: [], + startedAt: Date.now(), + }); + assert.equal(store.recordRunPaused(runId), true); + const workflowCmd = commands.find((command) => command.name === "workflow"); + assert.ok(workflowCmd); - await workflowCmd.options.handler?.(`resume ${runId}`, { hasUI: false, ui: { notify: () => undefined } }); + await workflowCmd.options.handler?.(`resume ${runId}`, { hasUI: false, ui: { notify: () => undefined } }); - assert.equal(refreshCalls, 0); - assert.equal(backend.hydrateCalls, 0); - assert.equal(store.runs().find((run) => run.id === runId)?.status, "running"); - }); + assert.equal(refreshCalls, 0); + assert.equal(backend.hydrateCalls, 0); + assert.equal(store.runs().find((run) => run.id === runId)?.status, "running"); + }); - test("/workflow resume prepares unclassified hydration-capable restored runs before choosing live path", async () => { - const backend = new UnclassifiedHydrationBackend(); - setDurableBackend(backend); - let refreshCalls = 0; - const commands = registerFactory({ refreshWorkflowResources: async () => { refreshCalls += 1; return []; } }); - const runId = "dbos-like-unclassified-restored"; - store.recordRunStart({ id: runId, name: "restored", inputs: {}, status: "running", stages: [], startedAt: Date.now() }); - assert.equal(store.recordRunPaused(runId), true); - const workflowCmd = commands.find((command) => command.name === "workflow"); - assert.ok(workflowCmd); - await workflowCmd.options.handler?.(`resume ${runId}`, { hasUI: false, ui: { notify: () => undefined } }); - assert.equal(refreshCalls, 1); - assert.ok(backend.hydrateCalls >= 1); - assert.equal(store.runs().find((run) => run.id === runId)?.status, "running"); - }); + test("/workflow resume prepares unclassified hydration-capable restored runs before choosing live path", async () => { + const backend = new UnclassifiedHydrationBackend(); + setDurableBackend(backend); + let refreshCalls = 0; + const commands = registerFactory({ + refreshWorkflowResources: async () => { + refreshCalls += 1; + return []; + }, + }); + const runId = "dbos-like-unclassified-restored"; + store.recordRunStart({ + id: runId, + name: "restored", + inputs: {}, + status: "running", + stages: [], + startedAt: Date.now(), + }); + assert.equal(store.recordRunPaused(runId), true); + const workflowCmd = commands.find((command) => command.name === "workflow"); + assert.ok(workflowCmd); + await workflowCmd.options.handler?.(`resume ${runId}`, { hasUI: false, ui: { notify: () => undefined } }); + assert.equal(refreshCalls, 1); + assert.ok(backend.hydrateCalls >= 1); + assert.equal(store.runs().find((run) => run.id === runId)?.status, "running"); + }); - test("/workflow resume routes quit durable shadows through durable resume", async () => { - const runId = "quit-shadow-durable-resume"; - store.recordRunStart({ id: runId, name: "quit shadow workflow", inputs: {}, status: "running", stages: [], startedAt: Date.now(), exitReason: "quit", resumable: true }); - let ensureCalls = 0; - let preparedTarget: string | undefined; - let resumedTarget: string | undefined; - const opened: string[] = []; - const messages: string[] = []; - const runtime = { - prepareDurableResumable: async (target?: string) => { - preparedTarget = target; - return [{ workflowId: runId, name: "quit shadow workflow", status: "paused", completedCheckpoints: 1, pendingPrompts: 0, createdAt: Date.now(), updatedAt: Date.now() }]; - }, - resumeDurableWorkflow: (target: string) => { - resumedTarget = target; - return { ok: true, runId: target, message: `Resumed durable ${target}` }; - }, - registry: { has: () => true }, - } as unknown as ExtensionRuntime; - const deps: WorkflowRunControlDeps = { - pi: {} as never, - overlay: { open: (id) => { if (id) opened.push(id); }, toggle: () => undefined, close: () => undefined }, - runtimeForContext: () => runtime, - ensureWorkflowResourcesLoaded: () => { ensureCalls += 1; }, - }; + test("/workflow resume routes quit durable shadows through durable resume", async () => { + const runId = "quit-shadow-durable-resume"; + store.recordRunStart({ + id: runId, + name: "quit shadow workflow", + inputs: {}, + status: "running", + stages: [], + startedAt: Date.now(), + exitReason: "quit", + resumable: true, + }); + let ensureCalls = 0; + let preparedTarget: string | undefined; + let resumedTarget: string | undefined; + const opened: string[] = []; + const messages: string[] = []; + const runtime = { + prepareDurableResumable: async (target?: string) => { + preparedTarget = target; + return [ + { + workflowId: runId, + name: "quit shadow workflow", + status: "paused", + completedCheckpoints: 1, + pendingPrompts: 0, + createdAt: Date.now(), + updatedAt: Date.now(), + }, + ]; + }, + resumeDurableWorkflow: (target: string) => { + resumedTarget = target; + return { ok: true, runId: target, message: `Resumed durable ${target}` }; + }, + registry: { has: () => true }, + } as unknown as ExtensionRuntime; + const deps: WorkflowRunControlDeps = { + pi: {} as never, + overlay: { + open: (id) => { + if (id) opened.push(id); + }, + toggle: () => undefined, + close: () => undefined, + }, + runtimeForContext: () => runtime, + ensureWorkflowResourcesLoaded: () => { + ensureCalls += 1; + }, + }; - await handleRunControlCommand("resume", [runId], { hasUI: true, ui: { notify: () => undefined } }, { info: (message) => messages.push(message), error: (message) => messages.push(message) }, deps); + await handleRunControlCommand( + "resume", + [runId], + { hasUI: true, ui: { notify: () => undefined } }, + { info: (message) => messages.push(message), error: (message) => messages.push(message) }, + deps, + ); - assert.equal(ensureCalls, 1); - assert.equal(preparedTarget, runId); - assert.equal(resumedTarget, runId); - assert.deepEqual(opened, [runId]); - assert.deepEqual(messages, [`Resumed durable ${runId}`]); - assert.equal(store.runs().find((run) => run.id === runId)?.status, "running"); - }); + assert.equal(ensureCalls, 1); + assert.equal(preparedTarget, runId); + assert.equal(resumedTarget, runId); + assert.deepEqual(opened, [runId]); + assert.deepEqual(messages, [`Resumed durable ${runId}`]); + assert.equal(store.runs().find((run) => run.id === runId)?.status, "running"); + }); - test("workflow tool named run re-resolves runtime after lazy discovery", async () => { - let ensureCalls = 0; - let registryLoaded = false; - const runtimeForCurrentRegistry = (): ExtensionRuntime => { - const canSeeLazyWorkflow = registryLoaded; - return { - dispatch: async (): Promise => canSeeLazyWorkflow - ? { action: "run", name: "lazy model run", runId: "model-run", status: "running", stages: [] } - : { action: "run", name: "lazy model run", runId: "", status: "failed", error: "Workflow not found: lazy model run", stages: [] }, - } as unknown as ExtensionRuntime; - }; - const handler = makeExecuteWorkflowTool( - runtimeForCurrentRegistry, - () => undefined, - async () => { - ensureCalls += 1; - registryLoaded = true; - }, - ); + test("workflow tool named run re-resolves runtime after lazy discovery", async () => { + let ensureCalls = 0; + let registryLoaded = false; + const runtimeForCurrentRegistry = (): ExtensionRuntime => { + const canSeeLazyWorkflow = registryLoaded; + return { + dispatch: async (): Promise => + canSeeLazyWorkflow + ? { action: "run", name: "lazy model run", runId: "model-run", status: "running", stages: [] } + : { + action: "run", + name: "lazy model run", + runId: "", + status: "failed", + error: "Workflow not found: lazy model run", + stages: [], + }, + } as unknown as ExtensionRuntime; + }; + const handler = makeExecuteWorkflowTool( + runtimeForCurrentRegistry, + () => undefined, + async () => { + ensureCalls += 1; + registryLoaded = true; + }, + ); - const result = await handler({ action: "run", workflow: "lazy model run", inputs: {} }, { model: { provider: "fake", id: "model" } } as never); + const result = await handler({ action: "run", workflow: "lazy model run", inputs: {} }, { + model: { provider: "fake", id: "model" }, + } as never); - assert.equal(ensureCalls, 1); - assert.equal(result.action, "run"); - assert.equal(result.status, "running"); - assert.equal(result.runId, "model-run"); - }); + assert.equal(ensureCalls, 1); + assert.equal(result.action, "run"); + assert.equal(result.status, "running"); + assert.equal(result.runId, "model-run"); + }); - test("workflow tool failed resume re-resolves runtime after lazy discovery", async () => { - const backend = new HydrationCapableBackend(); - setDurableBackend(backend); - const sourceRunId = "lazy-tool-model-resume-source"; - store.recordRunStart({ id: sourceRunId, name: "lazy model resume", inputs: {}, status: "running", stages: [], startedAt: Date.now() }); - store.recordStageStart(sourceRunId, { id: "retry-old", name: "retry", status: "failed", parentIds: [], toolEvents: [], error: "boom" }); - store.recordStageEnd(sourceRunId, { id: "retry-old", name: "retry", status: "failed", parentIds: [], toolEvents: [], error: "boom" }); - store.recordRunEnd(sourceRunId, "failed", undefined, "boom", { resumable: true, failedStageId: "retry-old" }); - let ensureCalls = 0; - let registryLoaded = false; - const runtimeForCurrentRegistry = (): ExtensionRuntime => { - const canResume = registryLoaded; - return { - resumeFailedRun: () => canResume - ? { ok: true, runId: "continued-run", message: "Resuming failed workflow" } - : { ok: false, reason: "workflow_not_found", message: "workflow_not_found: lazy model resume" }, - } as unknown as ExtensionRuntime; - }; - const handler = makeExecuteWorkflowTool( - runtimeForCurrentRegistry, - () => undefined, - async () => { - ensureCalls += 1; - registryLoaded = true; - }, - ); + test("workflow tool failed resume re-resolves runtime after lazy discovery", async () => { + const backend = new HydrationCapableBackend(); + setDurableBackend(backend); + const sourceRunId = "lazy-tool-model-resume-source"; + store.recordRunStart({ + id: sourceRunId, + name: "lazy model resume", + inputs: {}, + status: "running", + stages: [], + startedAt: Date.now(), + }); + store.recordStageStart(sourceRunId, { + id: "retry-old", + name: "retry", + status: "failed", + parentIds: [], + toolEvents: [], + error: "boom", + }); + store.recordStageEnd(sourceRunId, { + id: "retry-old", + name: "retry", + status: "failed", + parentIds: [], + toolEvents: [], + error: "boom", + }); + store.recordRunEnd(sourceRunId, "failed", undefined, "boom", { resumable: true, failedStageId: "retry-old" }); + let ensureCalls = 0; + let registryLoaded = false; + const runtimeForCurrentRegistry = (): ExtensionRuntime => { + const canResume = registryLoaded; + return { + resumeFailedRun: () => + canResume + ? { ok: true, runId: "continued-run", message: "Resuming failed workflow" } + : { ok: false, reason: "workflow_not_found", message: "workflow_not_found: lazy model resume" }, + } as unknown as ExtensionRuntime; + }; + const handler = makeExecuteWorkflowTool( + runtimeForCurrentRegistry, + () => undefined, + async () => { + ensureCalls += 1; + registryLoaded = true; + }, + ); - const result = await handler({ action: "resume", runId: sourceRunId }, { model: { provider: "fake", id: "model" } } as never); + const result = await handler({ action: "resume", runId: sourceRunId }, { + model: { provider: "fake", id: "model" }, + } as never); - assert.equal(ensureCalls, 1); - assert.equal(backend.hydrateCalls, 0); - assert.equal(result.action, "resume"); - assert.equal(result.status, "running"); - assert.equal(result.runId, "continued-run"); - assert.match(result.message ?? "", /Resuming failed workflow/); - }); + assert.equal(ensureCalls, 1); + assert.equal(backend.hydrateCalls, 0); + assert.equal(result.action, "resume"); + assert.equal(result.status, "running"); + assert.equal(result.runId, "continued-run"); + assert.match(result.message ?? "", /Resuming failed workflow/); + }); }); diff --git a/test/unit/workflow-lifecycle-notification-delivery-failures.test.ts b/test/unit/workflow-lifecycle-notification-delivery-failures.test.ts index 22811f3ba..392fcc799 100644 --- a/test/unit/workflow-lifecycle-notification-delivery-failures.test.ts +++ b/test/unit/workflow-lifecycle-notification-delivery-failures.test.ts @@ -1,348 +1,348 @@ -import { describe, test } from "bun:test"; import assert from "node:assert/strict"; +import { describe, test } from "vitest"; import factory, { type ExtensionAPI } from "../../packages/workflows/src/extension/index.js"; import { - createWorkflowLifecycleNotificationState, - installWorkflowLifecycleNotifications, - LIFECYCLE_NOTICE_CUSTOM_TYPE, - type WorkflowLifecycleNoticeDetails, - type WorkflowLifecycleNoticeKind, + createWorkflowLifecycleNotificationState, + installWorkflowLifecycleNotifications, + LIFECYCLE_NOTICE_CUSTOM_TYPE, + type WorkflowLifecycleNoticeDetails, + type WorkflowLifecycleNoticeKind, } from "../../packages/workflows/src/extension/lifecycle-notifications.js"; import { createStore, store as extensionStore } from "../../packages/workflows/src/shared/store.js"; import type { RunSnapshot } from "../../packages/workflows/src/shared/store-types.js"; interface CapturedNotice { - readonly customType: string; - readonly content: string; - readonly display: boolean; - readonly details?: WorkflowLifecycleNoticeDetails; + readonly customType: string; + readonly content: string; + readonly display: boolean; + readonly details?: WorkflowLifecycleNoticeDetails; } interface CapturedSendOptions { - readonly triggerTurn?: boolean; - readonly deliverAs?: "steer" | "followUp"; - readonly persistWhenStreaming?: boolean; + readonly triggerTurn?: boolean; + readonly deliverAs?: "steer" | "followUp"; + readonly persistWhenStreaming?: boolean; } interface CapturedAdmission { - readonly message: CapturedNotice; - readonly options: CapturedSendOptions | undefined; + readonly message: CapturedNotice; + readonly options: CapturedSendOptions | undefined; } type ExtensionHandler = (event: unknown, context?: unknown) => unknown; interface ScheduledTimer { - readonly callback: () => void; - readonly delay: number; - active: boolean; + readonly callback: () => void; + readonly delay: number; + active: boolean; } function captureAdmission(message: object, options?: object): CapturedAdmission { - return { - message: message as CapturedNotice, - options: options as CapturedSendOptions | undefined, - }; + return { + message: message as CapturedNotice, + options: options as CapturedSendOptions | undefined, + }; } function snapshotAdmission(admission: CapturedAdmission): CapturedAdmission { - return structuredClone(admission); + return structuredClone(admission); } function installTimerHarness(): { - readonly delays: () => number[]; - readonly latest: () => ScheduledTimer | undefined; - readonly runNext: () => void; - readonly activeCount: () => number; - readonly restore: () => void; + readonly delays: () => number[]; + readonly latest: () => ScheduledTimer | undefined; + readonly runNext: () => void; + readonly activeCount: () => number; + readonly restore: () => void; } { - const originalSetTimeout = globalThis.setTimeout; - const originalClearTimeout = globalThis.clearTimeout; - const timers: ScheduledTimer[] = []; + const originalSetTimeout = globalThis.setTimeout; + const originalClearTimeout = globalThis.clearTimeout; + const timers: ScheduledTimer[] = []; - globalThis.setTimeout = ((callback: () => void, delay = 0) => { - const timer: ScheduledTimer = { callback, delay, active: true }; - timers.push(timer); - return timer as never; - }) as unknown as typeof setTimeout; - globalThis.clearTimeout = ((handle: ReturnType) => { - (handle as unknown as ScheduledTimer).active = false; - }) as typeof clearTimeout; + globalThis.setTimeout = ((callback: () => void, delay = 0) => { + const timer: ScheduledTimer = { callback, delay, active: true }; + timers.push(timer); + return timer as never; + }) as unknown as typeof setTimeout; + globalThis.clearTimeout = ((handle: ReturnType) => { + (handle as unknown as ScheduledTimer).active = false; + }) as typeof clearTimeout; - return { - delays: () => timers.map((timer) => timer.delay), - latest: () => timers.at(-1), - runNext() { - const timer = timers.find((candidate) => candidate.active); - assert.ok(timer, "expected an active lifecycle retry timer"); - timer.active = false; - timer.callback(); - }, - activeCount: () => timers.filter((timer) => timer.active).length, - restore() { - globalThis.setTimeout = originalSetTimeout; - globalThis.clearTimeout = originalClearTimeout; - }, - }; + return { + delays: () => timers.map((timer) => timer.delay), + latest: () => timers.at(-1), + runNext() { + const timer = timers.find((candidate) => candidate.active); + assert.ok(timer, "expected an active lifecycle retry timer"); + timer.active = false; + timer.callback(); + }, + activeCount: () => timers.filter((timer) => timer.active).length, + restore() { + globalThis.setTimeout = originalSetTimeout; + globalThis.clearTimeout = originalClearTimeout; + }, + }; } async function flushMicrotasks(): Promise { - for (let index = 0; index < 4; index += 1) await Promise.resolve(); + for (let index = 0; index < 4; index += 1) await Promise.resolve(); } function startRun(store: ReturnType, id: string, name: string): RunSnapshot { - const run: RunSnapshot = { id, name, inputs: {}, status: "running", stages: [], startedAt: 1 }; - store.recordRunStart(run); - return run; + const run: RunSnapshot = { id, name, inputs: {}, status: "running", stages: [], startedAt: 1 }; + store.recordRunStart(run); + return run; } const completedConfig = { enabled: true, notifyOn: ["completed"] as const }; describe("workflow lifecycle admission failure recovery", () => { - test("rejected admission retries the complete original envelope with capped exponential delays", async () => { - const timers = installTimerHarness(); - const store = createStore(); - const state = createWorkflowLifecycleNotificationState(); - const attempts: CapturedAdmission[] = []; - const notifyOn: WorkflowLifecycleNoticeKind[] = ["completed"]; - let unsubscribe: (() => void) | undefined; - try { - unsubscribe = installWorkflowLifecycleNotifications({ - store, - state, - seedExisting: false, - config: { enabled: true, notifyOn }, - sendMessage(message, options) { - attempts.push(captureAdmission(message, options)); - return Promise.reject(new Error("parent admission rejected")); - }, - }); - const liveRun = startRun(store, "run-backoff", 'original "workflow" \\ raw'); - assert.equal(store.recordRunEnd("run-backoff", "completed", {}), true); - await flushMicrotasks(); + test("rejected admission retries the complete original envelope with capped exponential delays", async () => { + const timers = installTimerHarness(); + const store = createStore(); + const state = createWorkflowLifecycleNotificationState(); + const attempts: CapturedAdmission[] = []; + const notifyOn: WorkflowLifecycleNoticeKind[] = ["completed"]; + let unsubscribe: (() => void) | undefined; + try { + unsubscribe = installWorkflowLifecycleNotifications({ + store, + state, + seedExisting: false, + config: { enabled: true, notifyOn }, + sendMessage(message, options) { + attempts.push(captureAdmission(message, options)); + return Promise.reject(new Error("parent admission rejected")); + }, + }); + const liveRun = startRun(store, "run-backoff", 'original "workflow" \\ raw'); + assert.equal(store.recordRunEnd("run-backoff", "completed", {}), true); + await flushMicrotasks(); - const originalAttempt = attempts[0]; - assert.ok(originalAttempt); - const originalEnvelope = snapshotAdmission(originalAttempt); - const originalDetails = originalAttempt.message.details; - assert.ok(originalDetails); - assert.equal( - originalEnvelope.message.content, - '✓ Workflow "original \\"workflow\\" \\\\ raw" completed (run run-backoff). Inspect: /workflow status run-backoff', - ); - assert.equal(originalEnvelope.message.customType, LIFECYCLE_NOTICE_CUSTOM_TYPE); - assert.equal(originalEnvelope.message.display, true); - assert.deepEqual(originalEnvelope.options, { - triggerTurn: true, - deliverAs: "steer", - persistWhenStreaming: true, - }); - assert.deepEqual(Object.keys(originalDetails).sort(), [ - "createdAt", - "durationMs", - "kind", - "runId", - "scope", - "status", - "workflowName", - ]); + const originalAttempt = attempts[0]; + assert.ok(originalAttempt); + const originalEnvelope = snapshotAdmission(originalAttempt); + const originalDetails = originalAttempt.message.details; + assert.ok(originalDetails); + assert.equal( + originalEnvelope.message.content, + '✓ Workflow "original \\"workflow\\" \\\\ raw" completed (run run-backoff). Inspect: /workflow status run-backoff', + ); + assert.equal(originalEnvelope.message.customType, LIFECYCLE_NOTICE_CUSTOM_TYPE); + assert.equal(originalEnvelope.message.display, true); + assert.deepEqual(originalEnvelope.options, { + triggerTurn: true, + deliverAs: "steer", + persistWhenStreaming: true, + }); + assert.deepEqual(Object.keys(originalDetails).sort(), [ + "createdAt", + "durationMs", + "kind", + "runId", + "scope", + "status", + "workflowName", + ]); - const mutableRunSource = liveRun as { name: string; durationMs?: number }; - mutableRunSource.name = "mutated workflow source"; - mutableRunSource.durationMs = -1; - notifyOn[0] = "failed"; + const mutableRunSource = liveRun as { name: string; durationMs?: number }; + mutableRunSource.name = "mutated workflow source"; + mutableRunSource.durationMs = -1; + notifyOn[0] = "failed"; - for (const expectedDelay of [20, 40, 80, 160, 320, 640, 1_000]) { - assert.equal(timers.delays().at(-1), expectedDelay); - timers.runNext(); - await flushMicrotasks(); - } + for (const expectedDelay of [20, 40, 80, 160, 320, 640, 1_000]) { + assert.equal(timers.delays().at(-1), expectedDelay); + timers.runNext(); + await flushMicrotasks(); + } - assert.deepEqual(timers.delays(), [20, 40, 80, 160, 320, 640, 1_000, 1_000]); - assert.equal(attempts.length, 8); - for (const attempt of attempts) { - assert.deepEqual(snapshotAdmission(attempt), originalEnvelope); - assert.equal(attempt.message.details, originalDetails); - } - assert.equal(state.deliveredTerminalRuns.size, 0); - assert.equal(state.retryableTerminalNotices.size, 1); - } finally { - unsubscribe?.(); - timers.restore(); - } - }); + assert.deepEqual(timers.delays(), [20, 40, 80, 160, 320, 640, 1_000, 1_000]); + assert.equal(attempts.length, 8); + for (const attempt of attempts) { + assert.deepEqual(snapshotAdmission(attempt), originalEnvelope); + assert.equal(attempt.message.details, originalDetails); + } + assert.equal(state.deliveredTerminalRuns.size, 0); + assert.equal(state.retryableTerminalNotices.size, 1); + } finally { + unsubscribe?.(); + timers.restore(); + } + }); - test("a failed admission retries its retained envelope after reinstall without a store snapshot", async () => { - const timers = installTimerHarness(); - const store = createStore(); - const state = createWorkflowLifecycleNotificationState(); - const firstAdmissions: CapturedAdmission[] = []; - const replacementAdmissions: CapturedAdmission[] = []; - let firstUnsubscribe: (() => void) | undefined; - let replacementUnsubscribe: (() => void) | undefined; - try { - firstUnsubscribe = installWorkflowLifecycleNotifications({ - store, - state, - seedExisting: false, - config: completedConfig, - sendMessage(message, options) { - firstAdmissions.push(captureAdmission(message, options)); - return Promise.reject(new Error("retained admission rejected")); - }, - }); - const liveRun = startRun(store, "run-failed-reinstall", "retained original workflow"); - assert.equal(store.recordRunEnd("run-failed-reinstall", "completed", { version: "original" }), true); - await flushMicrotasks(); + test("a failed admission retries its retained envelope after reinstall without a store snapshot", async () => { + const timers = installTimerHarness(); + const store = createStore(); + const state = createWorkflowLifecycleNotificationState(); + const firstAdmissions: CapturedAdmission[] = []; + const replacementAdmissions: CapturedAdmission[] = []; + let firstUnsubscribe: (() => void) | undefined; + let replacementUnsubscribe: (() => void) | undefined; + try { + firstUnsubscribe = installWorkflowLifecycleNotifications({ + store, + state, + seedExisting: false, + config: completedConfig, + sendMessage(message, options) { + firstAdmissions.push(captureAdmission(message, options)); + return Promise.reject(new Error("retained admission rejected")); + }, + }); + const liveRun = startRun(store, "run-failed-reinstall", "retained original workflow"); + assert.equal(store.recordRunEnd("run-failed-reinstall", "completed", { version: "original" }), true); + await flushMicrotasks(); - assert.equal(firstAdmissions.length, 1); - const originalAdmission = firstAdmissions[0]; - assert.ok(originalAdmission); - const originalEnvelope = snapshotAdmission(originalAdmission); - const originalDetails = originalAdmission.message.details; - assert.ok(originalDetails); - assert.equal(state.retryableTerminalNotices.size, 1); - assert.equal(timers.activeCount(), 1); + assert.equal(firstAdmissions.length, 1); + const originalAdmission = firstAdmissions[0]; + assert.ok(originalAdmission); + const originalEnvelope = snapshotAdmission(originalAdmission); + const originalDetails = originalAdmission.message.details; + assert.ok(originalDetails); + assert.equal(state.retryableTerminalNotices.size, 1); + assert.equal(timers.activeCount(), 1); - const mutableRunSource = liveRun as { name: string; durationMs?: number }; - mutableRunSource.name = "mutated after terminal failure"; - mutableRunSource.durationMs = -1; - assert.equal(store.removeRun("run-failed-reinstall"), true); - assert.equal(store.snapshot().runs.length, 0, "fresh scanning must have no terminal notice to reconstruct"); - firstUnsubscribe(); - firstUnsubscribe = undefined; - assert.equal(timers.activeCount(), 0); + const mutableRunSource = liveRun as { name: string; durationMs?: number }; + mutableRunSource.name = "mutated after terminal failure"; + mutableRunSource.durationMs = -1; + assert.equal(store.removeRun("run-failed-reinstall"), true); + assert.equal(store.snapshot().runs.length, 0, "fresh scanning must have no terminal notice to reconstruct"); + firstUnsubscribe(); + firstUnsubscribe = undefined; + assert.equal(timers.activeCount(), 0); - replacementUnsubscribe = installWorkflowLifecycleNotifications({ - store, - state, - config: completedConfig, - sendMessage(message, options) { - replacementAdmissions.push(captureAdmission(message, options)); - }, - }); + replacementUnsubscribe = installWorkflowLifecycleNotifications({ + store, + state, + config: completedConfig, + sendMessage(message, options) { + replacementAdmissions.push(captureAdmission(message, options)); + }, + }); - assert.equal(replacementAdmissions.length, 1); - assert.deepEqual(snapshotAdmission(replacementAdmissions[0]!), originalEnvelope); - assert.equal(replacementAdmissions[0]?.message.details, originalDetails); - assert.equal(state.retryableTerminalNotices.size, 0); - assert.equal(state.deliveredTerminalRuns.size, 1); - } finally { - replacementUnsubscribe?.(); - firstUnsubscribe?.(); - timers.restore(); - } - }); + assert.equal(replacementAdmissions.length, 1); + assert.deepEqual(snapshotAdmission(replacementAdmissions[0]!), originalEnvelope); + assert.equal(replacementAdmissions[0]?.message.details, originalDetails); + assert.equal(state.retryableTerminalNotices.size, 0); + assert.equal(state.deliveredTerminalRuns.size, 1); + } finally { + replacementUnsubscribe?.(); + firstUnsubscribe?.(); + timers.restore(); + } + }); - test("an in-flight rejection hands the original payload to the active reinstallation", async () => { - const store = createStore(); - const state = createWorkflowLifecycleNotificationState(); - const firstAdmission = Promise.withResolvers(); - const firstMessages: CapturedAdmission[] = []; - const replacementMessages: CapturedAdmission[] = []; - const firstUnsubscribe = installWorkflowLifecycleNotifications({ - store, - state, - seedExisting: false, - config: { enabled: true, notifyOn: ["completed", "failed"] }, - sendMessage(message, options) { - firstMessages.push(captureAdmission(message, options)); - return firstAdmission.promise; - }, - }); - let replacementUnsubscribe: (() => void) | undefined; - try { - startRun(store, "run-reinstall", "original workflow"); - assert.equal(store.recordRunEnd("run-reinstall", "completed", { version: "original" }), true); - assert.equal(firstMessages.length, 1); - const originalDetails = firstMessages[0]?.message.details; - assert.ok(originalDetails); + test("an in-flight rejection hands the original payload to the active reinstallation", async () => { + const store = createStore(); + const state = createWorkflowLifecycleNotificationState(); + const firstAdmission = Promise.withResolvers(); + const firstMessages: CapturedAdmission[] = []; + const replacementMessages: CapturedAdmission[] = []; + const firstUnsubscribe = installWorkflowLifecycleNotifications({ + store, + state, + seedExisting: false, + config: { enabled: true, notifyOn: ["completed", "failed"] }, + sendMessage(message, options) { + firstMessages.push(captureAdmission(message, options)); + return firstAdmission.promise; + }, + }); + let replacementUnsubscribe: (() => void) | undefined; + try { + startRun(store, "run-reinstall", "original workflow"); + assert.equal(store.recordRunEnd("run-reinstall", "completed", { version: "original" }), true); + assert.equal(firstMessages.length, 1); + const originalDetails = firstMessages[0]?.message.details; + assert.ok(originalDetails); - assert.equal(store.removeRun("run-reinstall"), true); - startRun(store, "run-reinstall", "mutated workflow"); - assert.equal(store.recordRunEnd("run-reinstall", "completed", { version: "mutated" }), true); - firstUnsubscribe(); - replacementUnsubscribe = installWorkflowLifecycleNotifications({ - store, - state, - seedExisting: false, - config: completedConfig, - sendMessage(message, options) { - replacementMessages.push(captureAdmission(message, options)); - }, - }); + assert.equal(store.removeRun("run-reinstall"), true); + startRun(store, "run-reinstall", "mutated workflow"); + assert.equal(store.recordRunEnd("run-reinstall", "completed", { version: "mutated" }), true); + firstUnsubscribe(); + replacementUnsubscribe = installWorkflowLifecycleNotifications({ + store, + state, + seedExisting: false, + config: completedConfig, + sendMessage(message, options) { + replacementMessages.push(captureAdmission(message, options)); + }, + }); - firstAdmission.reject(new Error("old installation rejected admission")); - await flushMicrotasks(); + firstAdmission.reject(new Error("old installation rejected admission")); + await flushMicrotasks(); - assert.equal(firstMessages.length, 1); - assert.equal(replacementMessages.length, 1); - assert.equal(replacementMessages[0]?.message.details, originalDetails); - assert.equal(replacementMessages[0]?.message.details?.workflowName, "original workflow"); - assert.equal(replacementMessages[0]?.message.details?.runId, "run-reinstall"); - assert.equal(state.retryableTerminalNotices.size, 0); - assert.equal(state.deliveredTerminalRuns.size, 1); - } finally { - replacementUnsubscribe?.(); - firstUnsubscribe(); - } - }); + assert.equal(firstMessages.length, 1); + assert.equal(replacementMessages.length, 1); + assert.equal(replacementMessages[0]?.message.details, originalDetails); + assert.equal(replacementMessages[0]?.message.details?.workflowName, "original workflow"); + assert.equal(replacementMessages[0]?.message.details?.runId, "run-reinstall"); + assert.equal(state.retryableTerminalNotices.size, 0); + assert.equal(state.deliveredTerminalRuns.size, 1); + } finally { + replacementUnsubscribe?.(); + firstUnsubscribe(); + } + }); - test("the registered replacement lifecycle cancels old retries and does not wake the new chat", async () => { - const timers = installTimerHarness(); - const handlers = new Map(); - const oldAdmissions: CapturedAdmission[] = []; - let replacementWakeCount = 0; - let activeChat: "old" | "replacement" = "old"; - const pi: ExtensionAPI = { - registerTool: () => undefined, - registerCommand: () => undefined, - registerMessageRenderer: () => undefined, - registerFlag: () => undefined, - registerShortcut: () => undefined, - on: (event, handler) => { - handlers.set(event, handler as ExtensionHandler); - }, - sendMessage(message, options) { - if (activeChat === "old") { - oldAdmissions.push(captureAdmission(message, options)); - return Promise.reject(new Error("old chat rejected admission")); - } - replacementWakeCount += 1; - }, - disableAsyncDiscovery: true, - }; - extensionStore.clear(); - factory(pi); - const sessionStart = handlers.get("session_start"); - const sessionShutdown = handlers.get("session_shutdown"); - assert.ok(sessionStart); - assert.ok(sessionShutdown); - try { - await Promise.resolve(sessionStart({ reason: "startup" }, { hasUI: false })); - startRun(extensionStore, "run-old-session", "old session workflow"); - assert.equal(extensionStore.recordRunEnd("run-old-session", "completed", {}), true); - await flushMicrotasks(); - assert.equal(oldAdmissions.length, 1); - const oldRetryTimer = timers.latest(); - assert.ok(oldRetryTimer); - assert.equal(oldRetryTimer.delay, 20); - assert.equal(oldRetryTimer.active, true); + test("the registered replacement lifecycle cancels old retries and does not wake the new chat", async () => { + const timers = installTimerHarness(); + const handlers = new Map(); + const oldAdmissions: CapturedAdmission[] = []; + let replacementWakeCount = 0; + let activeChat: "old" | "replacement" = "old"; + const pi: ExtensionAPI = { + registerTool: () => undefined, + registerCommand: () => undefined, + registerMessageRenderer: () => undefined, + registerFlag: () => undefined, + registerShortcut: () => undefined, + on: (event, handler) => { + handlers.set(event, handler as ExtensionHandler); + }, + sendMessage(message, options) { + if (activeChat === "old") { + oldAdmissions.push(captureAdmission(message, options)); + return Promise.reject(new Error("old chat rejected admission")); + } + replacementWakeCount += 1; + }, + disableAsyncDiscovery: true, + }; + extensionStore.clear(); + factory(pi); + const sessionStart = handlers.get("session_start"); + const sessionShutdown = handlers.get("session_shutdown"); + assert.ok(sessionStart); + assert.ok(sessionShutdown); + try { + await Promise.resolve(sessionStart({ reason: "startup" }, { hasUI: false })); + startRun(extensionStore, "run-old-session", "old session workflow"); + assert.equal(extensionStore.recordRunEnd("run-old-session", "completed", {}), true); + await flushMicrotasks(); + assert.equal(oldAdmissions.length, 1); + const oldRetryTimer = timers.latest(); + assert.ok(oldRetryTimer); + assert.equal(oldRetryTimer.delay, 20); + assert.equal(oldRetryTimer.active, true); - await Promise.resolve(sessionShutdown({ reason: "new" })); - assert.equal(oldRetryTimer.active, false, "the host boundary must cancel the old retry timer"); - activeChat = "replacement"; - await Promise.resolve(sessionStart({ reason: "new" }, { hasUI: false })); - await flushMicrotasks(); + await Promise.resolve(sessionShutdown({ reason: "new" })); + assert.equal(oldRetryTimer.active, false, "the host boundary must cancel the old retry timer"); + activeChat = "replacement"; + await Promise.resolve(sessionStart({ reason: "new" }, { hasUI: false })); + await flushMicrotasks(); - assert.equal(replacementWakeCount, 0, "retained old-chat delivery must clear before replacement activation"); - assert.equal(extensionStore.snapshot().runs.length, 0); - startRun(extensionStore, "run-unrelated", "unrelated new chat workflow"); - await flushMicrotasks(); - assert.equal(replacementWakeCount, 0, "a non-terminal unrelated run must not wake the replacement chat"); - assert.equal(oldRetryTimer.active, false); - } finally { - await Promise.resolve(sessionShutdown({ reason: "new" })); - extensionStore.clear(); - timers.restore(); - } - }); + assert.equal(replacementWakeCount, 0, "retained old-chat delivery must clear before replacement activation"); + assert.equal(extensionStore.snapshot().runs.length, 0); + startRun(extensionStore, "run-unrelated", "unrelated new chat workflow"); + await flushMicrotasks(); + assert.equal(replacementWakeCount, 0, "a non-terminal unrelated run must not wake the replacement chat"); + assert.equal(oldRetryTimer.active, false); + } finally { + await Promise.resolve(sessionShutdown({ reason: "new" })); + extensionStore.clear(); + timers.restore(); + } + }); }); diff --git a/test/unit/workflow-lifecycle-notifications-01.test.ts b/test/unit/workflow-lifecycle-notifications-01.test.ts index 15ac8b38b..b85fe8932 100644 --- a/test/unit/workflow-lifecycle-notifications-01.test.ts +++ b/test/unit/workflow-lifecycle-notifications-01.test.ts @@ -1,496 +1,559 @@ // @ts-nocheck -import { describe, test } from "bun:test"; + import assert from "node:assert/strict"; -import { visibleWidth } from "@earendil-works/pi-tui"; +import { describe, test } from "vitest"; import { - createWorkflowLifecycleNotificationState, - installWorkflowLifecycleNotifications, - formatWorkflowLifecycleNoticeText, - LIFECYCLE_NOTICE_CUSTOM_TYPE, - LIFECYCLE_NOTICE_SNIPPET_LIMIT, - registerLifecycleNoticeRenderer, - resetWorkflowLifecycleNotificationState, - seedWorkflowLifecycleNotificationState, - withWorkflowLifecycleNotificationsSuppressed, - withWorkflowLifecycleNotificationsSuppressedAsync, - type WorkflowLifecycleNoticeDetails, + createWorkflowLifecycleNotificationState, + installWorkflowLifecycleNotifications, + LIFECYCLE_NOTICE_CUSTOM_TYPE, + LIFECYCLE_NOTICE_SNIPPET_LIMIT, + resetWorkflowLifecycleNotificationState, + seedWorkflowLifecycleNotificationState, + type WorkflowLifecycleNoticeDetails, + withWorkflowLifecycleNotificationsSuppressed, } from "../../packages/workflows/src/extension/lifecycle-notifications.js"; import { restoreOnSessionStart, type SessionEntry } from "../../packages/workflows/src/shared/persistence-restore.js"; import { createStore } from "../../packages/workflows/src/shared/store.js"; import type { PendingPrompt, StageSnapshot } from "../../packages/workflows/src/shared/store-types.js"; interface SentMessage { - readonly customType: string; - readonly content?: string; - readonly display?: boolean; - readonly details?: WorkflowLifecycleNoticeDetails; -} - -interface CardComponent { - render(width: number): string[]; - invalidate?(): void; -} - -interface RegisteredRenderer { - readonly event: string; - readonly renderer: (payload: unknown) => unknown; + readonly customType: string; + readonly content?: string; + readonly display?: boolean; + readonly details?: WorkflowLifecycleNoticeDetails; } type SendOptions = { - readonly triggerTurn?: boolean; - readonly deliverAs?: "steer" | "followUp" | "nextTurn" | "interrupt"; + readonly triggerTurn?: boolean; + readonly deliverAs?: "steer" | "followUp" | "nextTurn" | "interrupt"; }; const config = { - enabled: true, - notifyOn: ["completed", "failed", "blocked", "awaiting_input"] as const, + enabled: true, + notifyOn: ["completed", "failed", "blocked", "awaiting_input"] as const, }; function runningStage(overrides: Partial = {}): StageSnapshot { - return { - id: "stage-1", - name: "planner", - status: "running", - parentIds: [], - toolEvents: [], - ...overrides, - }; + return { + id: "stage-1", + name: "planner", + status: "running", + parentIds: [], + toolEvents: [], + ...overrides, + }; } function prompt(overrides: Partial = {}): PendingPrompt { - return { - id: "prompt-1", - kind: "confirm", - message: "Proceed with this plan?", - createdAt: 10, - ...overrides, - }; + return { + id: "prompt-1", + kind: "confirm", + message: "Proceed with this plan?", + createdAt: 10, + ...overrides, + }; } function install() { - const store = createStore(); - const state = createWorkflowLifecycleNotificationState(); - const sent: SentMessage[] = []; - const options: SendOptions[] = []; - const unsubscribe = installWorkflowLifecycleNotifications({ - store, - config, - state, - sendMessage(message, sendOptions) { - sent.push(message as SentMessage); - options.push(sendOptions ?? {}); - }, - }); - return { store, state, sent, options, unsubscribe }; + const store = createStore(); + const state = createWorkflowLifecycleNotificationState(); + const sent: SentMessage[] = []; + const options: SendOptions[] = []; + const unsubscribe = installWorkflowLifecycleNotifications({ + store, + config, + state, + sendMessage(message, sendOptions) { + sent.push(message as SentMessage); + options.push(sendOptions ?? {}); + }, + }); + return { store, state, sent, options, unsubscribe }; } function installWithState( - store: ReturnType, - state: ReturnType, - sent: SentMessage[], + store: ReturnType, + state: ReturnType, + sent: SentMessage[], ): () => void { - return installWorkflowLifecycleNotifications({ - store, - config, - state, - seedExisting: true, - sendMessage(message) { sent.push(message as SentMessage); }, - }); + return installWorkflowLifecycleNotifications({ + store, + config, + state, + seedExisting: true, + sendMessage(message) { + sent.push(message as SentMessage); + }, + }); } function startRun(store: ReturnType, id: string, name = id): void { - store.recordRunStart({ id, name, inputs: {}, status: "running", stages: [], startedAt: 1 }); + store.recordRunStart({ id, name, inputs: {}, status: "running", stages: [], startedAt: 1 }); } describe("installWorkflowLifecycleNotifications", () => { - test("emits one completion notice when a run completes", () => { - const { store, sent, options } = install(); - store.recordRunStart({ id: "run-1", name: "release", inputs: {}, status: "running", stages: [], startedAt: 1 }); - - assert.equal(store.recordRunEnd("run-1", "completed", {}, undefined), true); - store.recordNotice({ id: "nudge", level: "info", message: "force notify", createdAt: 3 }); - - assert.equal(sent.length, 1); - assert.deepEqual(options, [{ triggerTurn: true, deliverAs: "steer", persistWhenStreaming: true }]); - assert.equal(sent[0]?.customType, LIFECYCLE_NOTICE_CUSTOM_TYPE); - assert.equal(sent[0]?.display, true); - assert.equal(sent[0]?.details?.kind, "completed"); - assert.equal(sent[0]?.details?.scope, "run"); - assert.equal(sent[0]?.details?.workflowName, "release"); - assert.match(sent[0]?.content ?? "", /\/workflow status run-1/); - }); - - test("uses blocked lifecycle notices for runs ending with blocked status", () => { - const { store, sent } = install(); - store.recordRunStart({ id: "run-blocked", name: "release", inputs: {}, status: "running", stages: [], startedAt: 1 }); - - assert.equal(store.recordRunEnd("run-blocked", "blocked", { status: "blocked", summary: "checks are still pending" }, "checks are still pending"), true); - - assert.equal(sent.length, 1); - assert.equal(sent[0]?.details?.kind, "blocked"); - assert.equal(sent[0]?.details?.status, "blocked"); - assert.equal(sent[0]?.details?.error, "checks are still pending"); - assert.match(sent[0]?.content ?? "", /ended blocked.*checks are still pending/u); - assert.doesNotMatch(sent[0]?.content ?? "", /✓/u); - }); - - test("uses blocked lifecycle notices for legacy completed runs whose returned status needs human", () => { - const { store, sent } = install(); - startRun(store, "run-needs-human", "adversarial-verification"); - - assert.equal(store.recordRunEnd("run-needs-human", "completed", { - status: "needs_human", - remaining_work: "Worker failed before producing a receipt: No API key for provider: github-copilot", - }), true); - - assert.equal(sent.length, 1); - assert.equal(sent[0]?.details?.kind, "blocked"); - assert.equal(sent[0]?.details?.status, "blocked"); - assert.match(sent[0]?.details?.error ?? "", /No API key for provider: github-copilot/u); - assert.doesNotMatch(sent[0]?.content ?? "", /completed/u); - assert.match(sent[0]?.content ?? "", /ended blocked.*No API key for provider: github-copilot/u); - }); - - test("uses blocked lifecycle notices for structured recoverable stage failures without returned status", () => { - const { store, sent } = install(); - startRun(store, "run-structured-auth", "adversarial-verification"); - const failure = { failureKind: "auth" as const, failureCode: "missing_api_key" as const, failureRecoverability: "recoverable" as const, failureDisposition: "active_blocked" as const, failureMessage: "No API key for provider: github-copilot" }; - const reviewer = runningStage({ id: "reviewer-a", name: "reviewer-a" }); - store.recordStageStart("run-structured-auth", reviewer); - store.recordStageEnd("run-structured-auth", { - ...reviewer, - ...failure, - status: "failed", - error: "A required model provider API key is missing. Configure the provider credentials and resume the workflow.", - endedAt: 2, - }); - - assert.equal(store.recordRunEnd("run-structured-auth", "completed", { remaining_work: "Reviewer execution failed" }, undefined, { - ...failure, - failedStageId: "reviewer-a", - resumable: true, - }), true); - - assert.equal(sent.length, 1); - assert.equal(sent[0]?.details?.kind, "blocked"); - assert.equal(sent[0]?.details?.status, "blocked"); - assert.match(sent[0]?.details?.error ?? "", /No API key for provider: github-copilot/u); - assert.doesNotMatch(sent[0]?.content ?? "", /completed/u); - assert.match(sent[0]?.content ?? "", /ended blocked.*No API key for provider: github-copilot/u); - }); - - test("includes ctx.exit blocked reasons in lifecycle notices", () => { - const { store, sent } = install(); - startRun(store, "run-exit-blocked", "release"); - - assert.equal( - store.recordRunEnd("run-exit-blocked", "blocked", undefined, undefined, { - exited: true, - exitReason: "waiting for approval", - }), - true, - ); - - assert.equal(sent.length, 1); - assert.equal(sent[0]?.details?.kind, "blocked"); - assert.equal(sent[0]?.details?.error, "waiting for approval"); - assert.match(sent[0]?.content ?? "", /ended blocked.*waiting for approval/u); - }); - - test("seeds historical completed runs using returned failed or blocked status", () => { - const store = createStore(); - const sent: SentMessage[] = []; - startRun(store, "run-legacy-failed", "legacy failed"); - store.recordRunEnd("run-legacy-failed", "completed", { status: "failed", summary: "old failure" }); - startRun(store, "run-legacy-blocked", "legacy blocked"); - store.recordRunEnd("run-legacy-blocked", "completed", { status: "blocked", summary: "old blocker" }); - - installWorkflowLifecycleNotifications({ - store, - config, - state: createWorkflowLifecycleNotificationState(), - sendMessage(message) { sent.push(message as SentMessage); }, - }); - store.recordNotice({ id: "history-tick", level: "info", message: "tick", createdAt: 13 }); - - assert.deepEqual(sent, []); - }); - - test("emits failure notice with stage and truncated error context", () => { - const { store, sent, options } = install(); - const longError = `${"No API key. ".repeat(40)}tail`; - store.recordRunStart({ id: "run-2", name: "deploy", inputs: {}, status: "running", stages: [], startedAt: 1 }); - store.recordStageStart("run-2", runningStage({ id: "stage-2", name: "publish" })); - - assert.equal(store.recordRunEnd("run-2", "failed", undefined, longError, { failedStageId: "stage-2" }), true); - - assert.equal(sent.length, 1); - assert.deepEqual(options, [{ triggerTurn: true, deliverAs: "steer", persistWhenStreaming: true }]); - assert.equal(sent[0]?.details?.kind, "failed"); - assert.equal(sent[0]?.details?.stageName, "publish"); - assert.equal(sent[0]?.details?.error?.length, LIFECYCLE_NOTICE_SNIPPET_LIMIT); - assert.match(sent[0]?.details?.error ?? "", /…$/); - }); - - test("tracks a stage pending prompt without waking the main chat", () => { - const { store, state, sent, options } = install(); - store.recordRunStart({ id: "run-3", name: "review", inputs: {}, status: "running", stages: [], startedAt: 1 }); - store.recordStageStart("run-3", runningStage()); - - assert.equal(store.recordStagePendingPrompt("run-3", "stage-1", prompt()), true); - store.recordNotice({ id: "tick", level: "info", message: "force notify", createdAt: 11 }); - - assert.equal(sent.length, 0); - assert.deepEqual(options, []); - assert.equal(state.deliveredInputPrompts.size, 1); - }); - - test("tracks ask_user_question-style stages without waking the main chat", () => { - const { store, state, sent, options } = install(); - store.recordRunStart({ id: "run-4", name: "qa", inputs: {}, status: "running", stages: [], startedAt: 1 }); - store.recordStageStart("run-4", runningStage({ id: "stage-ask", name: "question" })); - assert.equal(store.recordStageInputRequest("run-4", "stage-ask", { - id: "ask-1", - kind: "ask_user_question", - createdAt: 122, - questions: [{ question: "What color?", options: [{ label: "Red" }, { label: "Blue" }] }], - }), true); - - assert.equal(store.recordStageAwaitingInput("run-4", "stage-ask", true, 123), true); - - assert.equal(sent.length, 0); - assert.deepEqual(options, []); - assert.equal(state.deliveredInputPrompts.size, 1); - }); - - test("tracks a fresh promptless awaiting-input state after resolving a structured stage prompt", () => { - const { store, state, sent } = install(); - const runId = "run-stale-footprint"; - const stageId = "stage-mixed"; - - startRun(store, runId, "stale footprint"); - store.recordStageStart(runId, runningStage({ id: stageId, name: "mixed" })); - - assert.equal( - store.recordStagePendingPrompt( - runId, - stageId, - prompt({ id: "prompt-1", message: "Old structured prompt", createdAt: 10 }), - ), - true, - ); - assert.equal(store.resolveStagePendingPrompt(runId, stageId, "prompt-1", "accepted"), true); - assert.equal(store.recordStageAwaitingInput(runId, stageId, true, 123), true); - - assert.equal(sent.length, 0); - assert.equal(state.deliveredInputPrompts.size, 2); - }); - - test("dedupes repeated promptless pauses by awaitingInputSince instead of stale prompt footprint", () => { - const { store, state, sent } = install(); - const runId = "run-promptless-dedupe"; - const stageId = "stage-repeat"; - - startRun(store, runId, "promptless dedupe"); - store.recordStageStart(runId, runningStage({ id: stageId, name: "repeat" })); - - assert.equal( - store.recordStagePendingPrompt(runId, stageId, prompt({ id: "prompt-1", createdAt: 10 })), - true, - ); - assert.equal(store.resolveStagePendingPrompt(runId, stageId, "prompt-1", true), true); - assert.equal(store.recordStageAwaitingInput(runId, stageId, true, 123), true); - store.recordNotice({ id: "same-pause-tick", level: "info", message: "tick", createdAt: 124 }); - assert.equal(store.recordStageAwaitingInput(runId, stageId, false), true); - assert.equal(store.recordStageAwaitingInput(runId, stageId, true, 456), true); - - assert.equal(sent.length, 0); - assert.equal(state.deliveredInputPrompts.size, 3); - }); - - test("uses a new prompt id for a second structured stage prompt", () => { - const { store, state, sent } = install(); - const runId = "run-second-prompt"; - const stageId = "stage-structured"; - - startRun(store, runId, "second prompt"); - store.recordStageStart(runId, runningStage({ id: stageId, name: "structured" })); - - assert.equal( - store.recordStagePendingPrompt(runId, stageId, prompt({ id: "prompt-1", createdAt: 10 })), - true, - ); - assert.equal(store.resolveStagePendingPrompt(runId, stageId, "prompt-1", false), true); - assert.equal( - store.recordStagePendingPrompt( - runId, - stageId, - prompt({ id: "prompt-2", message: "New prompt", createdAt: 20 }), - ), - true, - ); - - assert.equal(sent.length, 0); - assert.equal(state.deliveredInputPrompts.size, 2); - }); - - test("respects disabled and notifyOn filtering", () => { - const store = createStore(); - const sent: SentMessage[] = []; - installWorkflowLifecycleNotifications({ - store, - config: { enabled: true, notifyOn: ["failed"] }, - sendMessage(message) { sent.push(message as SentMessage); }, - }); - store.recordRunStart({ id: "run-5", name: "filtered", inputs: {}, status: "running", stages: [], startedAt: 1 }); - store.recordRunEnd("run-5", "completed", {}); - assert.equal(sent.length, 0); - - installWorkflowLifecycleNotifications({ - store, - config: { enabled: false, notifyOn: ["completed", "failed", "awaiting_input"] }, - sendMessage(message) { sent.push(message as SentMessage); }, - }); - store.recordRunStart({ id: "run-6", name: "disabled", inputs: {}, status: "running", stages: [], startedAt: 1 }); - store.recordRunEnd("run-6", "failed", undefined, "boom"); - assert.equal(sent.length, 1); - }); - - test("tracks a run-level pending prompt without waking the main chat", () => { - const { store, state, sent, options } = install(); - startRun(store, "run-prompt", "legacy"); - - assert.equal(store.recordPendingPrompt("run-prompt", prompt({ id: "run-prompt-1" })), true); - - assert.equal(sent.length, 0); - assert.deepEqual(options, []); - assert.equal(state.deliveredInputPrompts.size, 1); - }); - - test("suppresses run-level pending prompt when notifyOn excludes awaiting_input", () => { - const store = createStore(); - const sent: SentMessage[] = []; - installWorkflowLifecycleNotifications({ - store, - config: { enabled: true, notifyOn: ["completed", "failed"] }, - sendMessage(message) { sent.push(message as SentMessage); }, - }); - startRun(store, "run-filtered-prompt", "legacy filtered"); - - assert.equal(store.recordPendingPrompt("run-filtered-prompt", prompt({ id: "filtered-prompt" })), true); - - assert.equal(sent.length, 0); - }); - - test("shared state dedupes terminal notices across reinstall", () => { - const store = createStore(); - const state = createWorkflowLifecycleNotificationState(); - const sent: SentMessage[] = []; - const unsubscribe = installWithState(store, state, sent); - startRun(store, "run-dedupe", "dedupe"); - store.recordRunEnd("run-dedupe", "completed", {}); - unsubscribe(); - installWithState(store, state, sent); - startRun(store, "run-other", "other"); - - assert.deepEqual(sent.map((message) => message.details?.runId), ["run-dedupe"]); - }); - - test("omitted seedExisting treats current terminal runs and prompts as history", () => { - const store = createStore(); - startRun(store, "run-old", "old"); - store.recordRunEnd("run-old", "completed", {}); - startRun(store, "run-old-prompt", "old prompt"); - store.recordPendingPrompt("run-old-prompt", prompt({ id: "old-prompt" })); - - const sent: SentMessage[] = []; - installWorkflowLifecycleNotifications({ - store, - config, - state: createWorkflowLifecycleNotificationState(), - sendMessage(message) { sent.push(message as SentMessage); }, - }); - store.recordNotice({ id: "tick", level: "info", message: "tick", createdAt: 11 }); - startRun(store, "run-new", "new"); - store.recordRunEnd("run-new", "completed", {}); - - assert.deepEqual(sent.map((message) => message.details?.runId), ["run-new"]); - }); - - test("resetting shared state allows reused run IDs across session boundaries", () => { - const store = createStore(); - const state = createWorkflowLifecycleNotificationState(); - const sent: SentMessage[] = []; - let unsubscribe = installWithState(store, state, sent); - startRun(store, "run-reused", "first session"); - store.recordRunEnd("run-reused", "completed", {}); - unsubscribe(); - - store.clear(); - resetWorkflowLifecycleNotificationState(state); - unsubscribe = installWithState(store, state, sent); - startRun(store, "run-reused", "second session"); - store.recordRunEnd("run-reused", "completed", {}); - unsubscribe(); - - assert.deepEqual(sent.map((message) => message.details?.workflowName), ["first session", "second session"]); - }); - - test("restore suppression after reset seeds restored history without emitting", () => { - const store = createStore(); - const state = createWorkflowLifecycleNotificationState(); - const sent: SentMessage[] = []; - installWorkflowLifecycleNotifications({ - store, - config, - state, - sendMessage(message) { sent.push(message as SentMessage); }, - }); - - startRun(store, "run-before-reset", "before reset"); - store.recordRunEnd("run-before-reset", "completed", {}); - store.clear(); - resetWorkflowLifecycleNotificationState(state); - - const entries: SessionEntry[] = [ - { id: "e1", type: "workflow.run.start", payload: { runId: "run-restored-after-reset", name: "restored after reset", inputs: {}, ts: 1 } }, - { id: "e2", type: "workflow.run.end", payload: { runId: "run-restored-after-reset", status: "completed", result: {}, ts: 2 } }, - ]; - - withWorkflowLifecycleNotificationsSuppressed(state, () => { - restoreOnSessionStart({ getEntries: () => entries }, { resumeInFlight: "never", persistRuns: true }, store); - seedWorkflowLifecycleNotificationState(state, store.snapshot()); - }); - store.recordNotice({ id: "after-reset-restore", level: "info", message: "tick", createdAt: 12 }); - startRun(store, "run-live-after-reset", "live after reset"); - store.recordRunEnd("run-live-after-reset", "completed", {}); - - assert.deepEqual(sent.map((message) => message.details?.runId), ["run-before-reset", "run-live-after-reset"]); - }); - - test("suppression seeds actual restore replay without emitting", () => { - const store = createStore(); - const state = createWorkflowLifecycleNotificationState(); - const sent: SentMessage[] = []; - installWorkflowLifecycleNotifications({ - store, - config, - state, - sendMessage(message) { sent.push(message as SentMessage); }, - }); - const entries: SessionEntry[] = [ - { id: "e1", type: "workflow.run.start", payload: { runId: "run-restored", name: "restored", inputs: {}, ts: 1 } }, - { id: "e2", type: "workflow.run.end", payload: { runId: "run-restored", status: "failed", error: "old failure", ts: 2 } }, - ]; - - withWorkflowLifecycleNotificationsSuppressed(state, () => { - restoreOnSessionStart({ getEntries: () => entries }, { resumeInFlight: "never", persistRuns: true }, store); - }); - store.recordNotice({ id: "after-restore", level: "info", message: "tick", createdAt: 12 }); - startRun(store, "run-live", "live"); - store.recordRunEnd("run-live", "failed", undefined, "live failure"); - - assert.deepEqual(sent.map((message) => message.details?.runId), ["run-live"]); - }); - + test("emits one completion notice when a run completes", () => { + const { store, sent, options } = install(); + store.recordRunStart({ id: "run-1", name: "release", inputs: {}, status: "running", stages: [], startedAt: 1 }); + + assert.equal(store.recordRunEnd("run-1", "completed", {}, undefined), true); + store.recordNotice({ id: "nudge", level: "info", message: "force notify", createdAt: 3 }); + + assert.equal(sent.length, 1); + assert.deepEqual(options, [{ triggerTurn: true, deliverAs: "steer", persistWhenStreaming: true }]); + assert.equal(sent[0]?.customType, LIFECYCLE_NOTICE_CUSTOM_TYPE); + assert.equal(sent[0]?.display, true); + assert.equal(sent[0]?.details?.kind, "completed"); + assert.equal(sent[0]?.details?.scope, "run"); + assert.equal(sent[0]?.details?.workflowName, "release"); + assert.match(sent[0]?.content ?? "", /\/workflow status run-1/); + }); + + test("uses blocked lifecycle notices for runs ending with blocked status", () => { + const { store, sent } = install(); + store.recordRunStart({ + id: "run-blocked", + name: "release", + inputs: {}, + status: "running", + stages: [], + startedAt: 1, + }); + + assert.equal( + store.recordRunEnd( + "run-blocked", + "blocked", + { status: "blocked", summary: "checks are still pending" }, + "checks are still pending", + ), + true, + ); + + assert.equal(sent.length, 1); + assert.equal(sent[0]?.details?.kind, "blocked"); + assert.equal(sent[0]?.details?.status, "blocked"); + assert.equal(sent[0]?.details?.error, "checks are still pending"); + assert.match(sent[0]?.content ?? "", /ended blocked.*checks are still pending/u); + assert.doesNotMatch(sent[0]?.content ?? "", /✓/u); + }); + + test("uses blocked lifecycle notices for legacy completed runs whose returned status needs human", () => { + const { store, sent } = install(); + startRun(store, "run-needs-human", "adversarial-verification"); + + assert.equal( + store.recordRunEnd("run-needs-human", "completed", { + status: "needs_human", + remaining_work: "Worker failed before producing a receipt: No API key for provider: github-copilot", + }), + true, + ); + + assert.equal(sent.length, 1); + assert.equal(sent[0]?.details?.kind, "blocked"); + assert.equal(sent[0]?.details?.status, "blocked"); + assert.match(sent[0]?.details?.error ?? "", /No API key for provider: github-copilot/u); + assert.doesNotMatch(sent[0]?.content ?? "", /completed/u); + assert.match(sent[0]?.content ?? "", /ended blocked.*No API key for provider: github-copilot/u); + }); + + test("uses blocked lifecycle notices for structured recoverable stage failures without returned status", () => { + const { store, sent } = install(); + startRun(store, "run-structured-auth", "adversarial-verification"); + const failure = { + failureKind: "auth" as const, + failureCode: "missing_api_key" as const, + failureRecoverability: "recoverable" as const, + failureDisposition: "active_blocked" as const, + failureMessage: "No API key for provider: github-copilot", + }; + const reviewer = runningStage({ id: "reviewer-a", name: "reviewer-a" }); + store.recordStageStart("run-structured-auth", reviewer); + store.recordStageEnd("run-structured-auth", { + ...reviewer, + ...failure, + status: "failed", + error: "A required model provider API key is missing. Configure the provider credentials and resume the workflow.", + endedAt: 2, + }); + + assert.equal( + store.recordRunEnd( + "run-structured-auth", + "completed", + { remaining_work: "Reviewer execution failed" }, + undefined, + { + ...failure, + failedStageId: "reviewer-a", + resumable: true, + }, + ), + true, + ); + + assert.equal(sent.length, 1); + assert.equal(sent[0]?.details?.kind, "blocked"); + assert.equal(sent[0]?.details?.status, "blocked"); + assert.match(sent[0]?.details?.error ?? "", /No API key for provider: github-copilot/u); + assert.doesNotMatch(sent[0]?.content ?? "", /completed/u); + assert.match(sent[0]?.content ?? "", /ended blocked.*No API key for provider: github-copilot/u); + }); + + test("includes ctx.exit blocked reasons in lifecycle notices", () => { + const { store, sent } = install(); + startRun(store, "run-exit-blocked", "release"); + + assert.equal( + store.recordRunEnd("run-exit-blocked", "blocked", undefined, undefined, { + exited: true, + exitReason: "waiting for approval", + }), + true, + ); + + assert.equal(sent.length, 1); + assert.equal(sent[0]?.details?.kind, "blocked"); + assert.equal(sent[0]?.details?.error, "waiting for approval"); + assert.match(sent[0]?.content ?? "", /ended blocked.*waiting for approval/u); + }); + + test("seeds historical completed runs using returned failed or blocked status", () => { + const store = createStore(); + const sent: SentMessage[] = []; + startRun(store, "run-legacy-failed", "legacy failed"); + store.recordRunEnd("run-legacy-failed", "completed", { status: "failed", summary: "old failure" }); + startRun(store, "run-legacy-blocked", "legacy blocked"); + store.recordRunEnd("run-legacy-blocked", "completed", { status: "blocked", summary: "old blocker" }); + + installWorkflowLifecycleNotifications({ + store, + config, + state: createWorkflowLifecycleNotificationState(), + sendMessage(message) { + sent.push(message as SentMessage); + }, + }); + store.recordNotice({ id: "history-tick", level: "info", message: "tick", createdAt: 13 }); + + assert.deepEqual(sent, []); + }); + + test("emits failure notice with stage and truncated error context", () => { + const { store, sent, options } = install(); + const longError = `${"No API key. ".repeat(40)}tail`; + store.recordRunStart({ id: "run-2", name: "deploy", inputs: {}, status: "running", stages: [], startedAt: 1 }); + store.recordStageStart("run-2", runningStage({ id: "stage-2", name: "publish" })); + + assert.equal(store.recordRunEnd("run-2", "failed", undefined, longError, { failedStageId: "stage-2" }), true); + + assert.equal(sent.length, 1); + assert.deepEqual(options, [{ triggerTurn: true, deliverAs: "steer", persistWhenStreaming: true }]); + assert.equal(sent[0]?.details?.kind, "failed"); + assert.equal(sent[0]?.details?.stageName, "publish"); + assert.equal(sent[0]?.details?.error?.length, LIFECYCLE_NOTICE_SNIPPET_LIMIT); + assert.match(sent[0]?.details?.error ?? "", /…$/); + }); + + test("tracks a stage pending prompt without waking the main chat", () => { + const { store, state, sent, options } = install(); + store.recordRunStart({ id: "run-3", name: "review", inputs: {}, status: "running", stages: [], startedAt: 1 }); + store.recordStageStart("run-3", runningStage()); + + assert.equal(store.recordStagePendingPrompt("run-3", "stage-1", prompt()), true); + store.recordNotice({ id: "tick", level: "info", message: "force notify", createdAt: 11 }); + + assert.equal(sent.length, 0); + assert.deepEqual(options, []); + assert.equal(state.deliveredInputPrompts.size, 1); + }); + + test("tracks ask_user_question-style stages without waking the main chat", () => { + const { store, state, sent, options } = install(); + store.recordRunStart({ id: "run-4", name: "qa", inputs: {}, status: "running", stages: [], startedAt: 1 }); + store.recordStageStart("run-4", runningStage({ id: "stage-ask", name: "question" })); + assert.equal( + store.recordStageInputRequest("run-4", "stage-ask", { + id: "ask-1", + kind: "ask_user_question", + createdAt: 122, + questions: [{ question: "What color?", options: [{ label: "Red" }, { label: "Blue" }] }], + }), + true, + ); + + assert.equal(store.recordStageAwaitingInput("run-4", "stage-ask", true, 123), true); + + assert.equal(sent.length, 0); + assert.deepEqual(options, []); + assert.equal(state.deliveredInputPrompts.size, 1); + }); + + test("tracks a fresh promptless awaiting-input state after resolving a structured stage prompt", () => { + const { store, state, sent } = install(); + const runId = "run-stale-footprint"; + const stageId = "stage-mixed"; + + startRun(store, runId, "stale footprint"); + store.recordStageStart(runId, runningStage({ id: stageId, name: "mixed" })); + + assert.equal( + store.recordStagePendingPrompt( + runId, + stageId, + prompt({ id: "prompt-1", message: "Old structured prompt", createdAt: 10 }), + ), + true, + ); + assert.equal(store.resolveStagePendingPrompt(runId, stageId, "prompt-1", "accepted"), true); + assert.equal(store.recordStageAwaitingInput(runId, stageId, true, 123), true); + + assert.equal(sent.length, 0); + assert.equal(state.deliveredInputPrompts.size, 2); + }); + + test("dedupes repeated promptless pauses by awaitingInputSince instead of stale prompt footprint", () => { + const { store, state, sent } = install(); + const runId = "run-promptless-dedupe"; + const stageId = "stage-repeat"; + + startRun(store, runId, "promptless dedupe"); + store.recordStageStart(runId, runningStage({ id: stageId, name: "repeat" })); + + assert.equal(store.recordStagePendingPrompt(runId, stageId, prompt({ id: "prompt-1", createdAt: 10 })), true); + assert.equal(store.resolveStagePendingPrompt(runId, stageId, "prompt-1", true), true); + assert.equal(store.recordStageAwaitingInput(runId, stageId, true, 123), true); + store.recordNotice({ id: "same-pause-tick", level: "info", message: "tick", createdAt: 124 }); + assert.equal(store.recordStageAwaitingInput(runId, stageId, false), true); + assert.equal(store.recordStageAwaitingInput(runId, stageId, true, 456), true); + + assert.equal(sent.length, 0); + assert.equal(state.deliveredInputPrompts.size, 3); + }); + + test("uses a new prompt id for a second structured stage prompt", () => { + const { store, state, sent } = install(); + const runId = "run-second-prompt"; + const stageId = "stage-structured"; + + startRun(store, runId, "second prompt"); + store.recordStageStart(runId, runningStage({ id: stageId, name: "structured" })); + + assert.equal(store.recordStagePendingPrompt(runId, stageId, prompt({ id: "prompt-1", createdAt: 10 })), true); + assert.equal(store.resolveStagePendingPrompt(runId, stageId, "prompt-1", false), true); + assert.equal( + store.recordStagePendingPrompt( + runId, + stageId, + prompt({ id: "prompt-2", message: "New prompt", createdAt: 20 }), + ), + true, + ); + + assert.equal(sent.length, 0); + assert.equal(state.deliveredInputPrompts.size, 2); + }); + + test("respects disabled and notifyOn filtering", () => { + const store = createStore(); + const sent: SentMessage[] = []; + installWorkflowLifecycleNotifications({ + store, + config: { enabled: true, notifyOn: ["failed"] }, + sendMessage(message) { + sent.push(message as SentMessage); + }, + }); + store.recordRunStart({ id: "run-5", name: "filtered", inputs: {}, status: "running", stages: [], startedAt: 1 }); + store.recordRunEnd("run-5", "completed", {}); + assert.equal(sent.length, 0); + + installWorkflowLifecycleNotifications({ + store, + config: { enabled: false, notifyOn: ["completed", "failed", "awaiting_input"] }, + sendMessage(message) { + sent.push(message as SentMessage); + }, + }); + store.recordRunStart({ id: "run-6", name: "disabled", inputs: {}, status: "running", stages: [], startedAt: 1 }); + store.recordRunEnd("run-6", "failed", undefined, "boom"); + assert.equal(sent.length, 1); + }); + + test("tracks a run-level pending prompt without waking the main chat", () => { + const { store, state, sent, options } = install(); + startRun(store, "run-prompt", "legacy"); + + assert.equal(store.recordPendingPrompt("run-prompt", prompt({ id: "run-prompt-1" })), true); + + assert.equal(sent.length, 0); + assert.deepEqual(options, []); + assert.equal(state.deliveredInputPrompts.size, 1); + }); + + test("suppresses run-level pending prompt when notifyOn excludes awaiting_input", () => { + const store = createStore(); + const sent: SentMessage[] = []; + installWorkflowLifecycleNotifications({ + store, + config: { enabled: true, notifyOn: ["completed", "failed"] }, + sendMessage(message) { + sent.push(message as SentMessage); + }, + }); + startRun(store, "run-filtered-prompt", "legacy filtered"); + + assert.equal(store.recordPendingPrompt("run-filtered-prompt", prompt({ id: "filtered-prompt" })), true); + + assert.equal(sent.length, 0); + }); + + test("shared state dedupes terminal notices across reinstall", () => { + const store = createStore(); + const state = createWorkflowLifecycleNotificationState(); + const sent: SentMessage[] = []; + const unsubscribe = installWithState(store, state, sent); + startRun(store, "run-dedupe", "dedupe"); + store.recordRunEnd("run-dedupe", "completed", {}); + unsubscribe(); + installWithState(store, state, sent); + startRun(store, "run-other", "other"); + + assert.deepEqual( + sent.map((message) => message.details?.runId), + ["run-dedupe"], + ); + }); + + test("omitted seedExisting treats current terminal runs and prompts as history", () => { + const store = createStore(); + startRun(store, "run-old", "old"); + store.recordRunEnd("run-old", "completed", {}); + startRun(store, "run-old-prompt", "old prompt"); + store.recordPendingPrompt("run-old-prompt", prompt({ id: "old-prompt" })); + + const sent: SentMessage[] = []; + installWorkflowLifecycleNotifications({ + store, + config, + state: createWorkflowLifecycleNotificationState(), + sendMessage(message) { + sent.push(message as SentMessage); + }, + }); + store.recordNotice({ id: "tick", level: "info", message: "tick", createdAt: 11 }); + startRun(store, "run-new", "new"); + store.recordRunEnd("run-new", "completed", {}); + + assert.deepEqual( + sent.map((message) => message.details?.runId), + ["run-new"], + ); + }); + + test("resetting shared state allows reused run IDs across session boundaries", () => { + const store = createStore(); + const state = createWorkflowLifecycleNotificationState(); + const sent: SentMessage[] = []; + let unsubscribe = installWithState(store, state, sent); + startRun(store, "run-reused", "first session"); + store.recordRunEnd("run-reused", "completed", {}); + unsubscribe(); + + store.clear(); + resetWorkflowLifecycleNotificationState(state); + unsubscribe = installWithState(store, state, sent); + startRun(store, "run-reused", "second session"); + store.recordRunEnd("run-reused", "completed", {}); + unsubscribe(); + + assert.deepEqual( + sent.map((message) => message.details?.workflowName), + ["first session", "second session"], + ); + }); + + test("restore suppression after reset seeds restored history without emitting", () => { + const store = createStore(); + const state = createWorkflowLifecycleNotificationState(); + const sent: SentMessage[] = []; + installWorkflowLifecycleNotifications({ + store, + config, + state, + sendMessage(message) { + sent.push(message as SentMessage); + }, + }); + + startRun(store, "run-before-reset", "before reset"); + store.recordRunEnd("run-before-reset", "completed", {}); + store.clear(); + resetWorkflowLifecycleNotificationState(state); + + const entries: SessionEntry[] = [ + { + id: "e1", + type: "workflow.run.start", + payload: { runId: "run-restored-after-reset", name: "restored after reset", inputs: {}, ts: 1 }, + }, + { + id: "e2", + type: "workflow.run.end", + payload: { runId: "run-restored-after-reset", status: "completed", result: {}, ts: 2 }, + }, + ]; + + withWorkflowLifecycleNotificationsSuppressed(state, () => { + restoreOnSessionStart({ getEntries: () => entries }, { resumeInFlight: "never", persistRuns: true }, store); + seedWorkflowLifecycleNotificationState(state, store.snapshot()); + }); + store.recordNotice({ id: "after-reset-restore", level: "info", message: "tick", createdAt: 12 }); + startRun(store, "run-live-after-reset", "live after reset"); + store.recordRunEnd("run-live-after-reset", "completed", {}); + + assert.deepEqual( + sent.map((message) => message.details?.runId), + ["run-before-reset", "run-live-after-reset"], + ); + }); + + test("suppression seeds actual restore replay without emitting", () => { + const store = createStore(); + const state = createWorkflowLifecycleNotificationState(); + const sent: SentMessage[] = []; + installWorkflowLifecycleNotifications({ + store, + config, + state, + sendMessage(message) { + sent.push(message as SentMessage); + }, + }); + const entries: SessionEntry[] = [ + { + id: "e1", + type: "workflow.run.start", + payload: { runId: "run-restored", name: "restored", inputs: {}, ts: 1 }, + }, + { + id: "e2", + type: "workflow.run.end", + payload: { runId: "run-restored", status: "failed", error: "old failure", ts: 2 }, + }, + ]; + + withWorkflowLifecycleNotificationsSuppressed(state, () => { + restoreOnSessionStart({ getEntries: () => entries }, { resumeInFlight: "never", persistRuns: true }, store); + }); + store.recordNotice({ id: "after-restore", level: "info", message: "tick", createdAt: 12 }); + startRun(store, "run-live", "live"); + store.recordRunEnd("run-live", "failed", undefined, "live failure"); + + assert.deepEqual( + sent.map((message) => message.details?.runId), + ["run-live"], + ); + }); }); diff --git a/test/unit/workflow-lifecycle-notifications-02.test.ts b/test/unit/workflow-lifecycle-notifications-02.test.ts index 737665ac3..4318e4f22 100644 --- a/test/unit/workflow-lifecycle-notifications-02.test.ts +++ b/test/unit/workflow-lifecycle-notifications-02.test.ts @@ -1,464 +1,547 @@ // @ts-nocheck -import { describe, test } from "bun:test"; + import assert from "node:assert/strict"; import { visibleWidth } from "@earendil-works/pi-tui"; +import { describe, test } from "vitest"; import { - createWorkflowLifecycleNotificationState, - installWorkflowLifecycleNotifications, - formatWorkflowLifecycleNoticeText, - LIFECYCLE_NOTICE_CUSTOM_TYPE, - LIFECYCLE_NOTICE_SNIPPET_LIMIT, - registerLifecycleNoticeRenderer, - resetWorkflowLifecycleNotificationState, - seedWorkflowLifecycleNotificationState, - withWorkflowLifecycleNotificationsSuppressed, - withWorkflowLifecycleNotificationsSuppressedAsync, - type WorkflowLifecycleNoticeDetails, + createWorkflowLifecycleNotificationState, + formatWorkflowLifecycleNoticeText, + installWorkflowLifecycleNotifications, + LIFECYCLE_NOTICE_CUSTOM_TYPE, + registerLifecycleNoticeRenderer, + seedWorkflowLifecycleNotificationState, + type WorkflowLifecycleNoticeDetails, + withWorkflowLifecycleNotificationsSuppressedAsync, } from "../../packages/workflows/src/extension/lifecycle-notifications.js"; -import { restoreOnSessionStart, type SessionEntry } from "../../packages/workflows/src/shared/persistence-restore.js"; import { createStore } from "../../packages/workflows/src/shared/store.js"; import type { PendingPrompt, StageSnapshot } from "../../packages/workflows/src/shared/store-types.js"; interface SentMessage { - readonly customType: string; - readonly content?: string; - readonly display?: boolean; - readonly details?: WorkflowLifecycleNoticeDetails; + readonly customType: string; + readonly content?: string; + readonly display?: boolean; + readonly details?: WorkflowLifecycleNoticeDetails; } interface CardComponent { - render(width: number): string[]; - invalidate?(): void; + render(width: number): string[]; + invalidate?(): void; } interface RegisteredRenderer { - readonly event: string; - readonly renderer: (payload: unknown) => unknown; + readonly event: string; + readonly renderer: (payload: unknown) => unknown; } type SendOptions = { - readonly triggerTurn?: boolean; - readonly deliverAs?: "steer" | "followUp" | "nextTurn" | "interrupt"; + readonly triggerTurn?: boolean; + readonly deliverAs?: "steer" | "followUp" | "nextTurn" | "interrupt"; }; const config = { - enabled: true, - notifyOn: ["completed", "failed", "blocked", "awaiting_input"] as const, + enabled: true, + notifyOn: ["completed", "failed", "blocked", "awaiting_input"] as const, }; function runningStage(overrides: Partial = {}): StageSnapshot { - return { - id: "stage-1", - name: "planner", - status: "running", - parentIds: [], - toolEvents: [], - ...overrides, - }; + return { + id: "stage-1", + name: "planner", + status: "running", + parentIds: [], + toolEvents: [], + ...overrides, + }; } -function prompt(overrides: Partial = {}): PendingPrompt { - return { - id: "prompt-1", - kind: "confirm", - message: "Proceed with this plan?", - createdAt: 10, - ...overrides, - }; +function _prompt(overrides: Partial = {}): PendingPrompt { + return { + id: "prompt-1", + kind: "confirm", + message: "Proceed with this plan?", + createdAt: 10, + ...overrides, + }; } function install() { - const store = createStore(); - const state = createWorkflowLifecycleNotificationState(); - const sent: SentMessage[] = []; - const options: SendOptions[] = []; - const unsubscribe = installWorkflowLifecycleNotifications({ - store, - config, - state, - sendMessage(message, sendOptions) { - sent.push(message as SentMessage); - options.push(sendOptions ?? {}); - }, - }); - return { store, state, sent, options, unsubscribe }; + const store = createStore(); + const state = createWorkflowLifecycleNotificationState(); + const sent: SentMessage[] = []; + const options: SendOptions[] = []; + const unsubscribe = installWorkflowLifecycleNotifications({ + store, + config, + state, + sendMessage(message, sendOptions) { + sent.push(message as SentMessage); + options.push(sendOptions ?? {}); + }, + }); + return { store, state, sent, options, unsubscribe }; } -function installWithState( - store: ReturnType, - state: ReturnType, - sent: SentMessage[], +function _installWithState( + store: ReturnType, + state: ReturnType, + sent: SentMessage[], ): () => void { - return installWorkflowLifecycleNotifications({ - store, - config, - state, - seedExisting: true, - sendMessage(message) { sent.push(message as SentMessage); }, - }); + return installWorkflowLifecycleNotifications({ + store, + config, + state, + seedExisting: true, + sendMessage(message) { + sent.push(message as SentMessage); + }, + }); } function startRun(store: ReturnType, id: string, name = id): void { - store.recordRunStart({ id, name, inputs: {}, status: "running", stages: [], startedAt: 1 }); + store.recordRunStart({ id, name, inputs: {}, status: "running", stages: [], startedAt: 1 }); } describe("installWorkflowLifecycleNotifications", () => { - test("emits one failure notice with tool origin and no fabricated stage id", () => { - const { store, sent } = install(); - store.recordRunStart({ id: "run-tool-fail", name: "mutate", inputs: {}, status: "running", stages: [], toolNodes: [], startedAt: 1 }); - store.recordToolNodeStart("run-tool-fail", { - kind: "tool", id: "tool:failure", name: "publish-api", argsHash: "hash", ordinal: 1, - parentIds: [], status: "pending", attachable: false, - }); - store.recordToolNodeRunning("run-tool-fail", "tool:failure", 2); - store.recordToolNodeEnd("run-tool-fail", "tool:failure", { status: "failed", endedAt: 3, error: "remote rejected" }); - - assert.equal(store.recordRunEnd("run-tool-fail", "failed", undefined, "remote rejected", { - failedToolNodeId: "tool:failure", - }), true); - store.recordNotice({ id: "tool-fail-tick", level: "info", message: "tick", createdAt: 4 }); - - assert.equal(sent.length, 1); - assert.equal(sent[0]?.details?.toolNodeId, "tool:failure"); - assert.equal(sent[0]?.details?.toolName, "publish-api"); - assert.equal(sent[0]?.details?.failedStageId, undefined); - assert.match(sent[0]?.content ?? "", /tool publish-api.*remote rejected/); - }); - - test("async suppression stays active until the awaited operation settles", async () => { - const store = createStore(); - const state = createWorkflowLifecycleNotificationState(); - const sent: SentMessage[] = []; - installWorkflowLifecycleNotifications({ - store, - config, - state, - sendMessage(message) { sent.push(message as SentMessage); }, - }); - - startRun(store, "run-async-suppressed", "async suppressed"); - let release!: () => void; - const gate = new Promise((resolve) => { - release = resolve; - }); - const suppressed = withWorkflowLifecycleNotificationsSuppressedAsync( - state, - async () => { - await gate; - return "done"; - }, - ); - - assert.equal(state.suppressionDepth, 1); - assert.equal(store.recordRunEnd("run-async-suppressed", "completed", {}), true); - assert.equal(sent.length, 0); - - release(); - assert.equal(await suppressed, "done"); - assert.equal(state.suppressionDepth, 0); - - store.recordNotice({ id: "after-async-suppression", level: "info", message: "tick", createdAt: 13 }); - assert.equal(sent.length, 0, "suppressed terminal notice should remain marked delivered"); - - startRun(store, "run-after-async-suppression", "after async suppression"); - store.recordRunEnd("run-after-async-suppression", "completed", {}); - assert.deepEqual(sent.map((message) => message.details?.runId), ["run-after-async-suppression"]); - }); - - test("escapes workflow names and structured response ids in notice text", () => { - const runId = 'run"\\id'; - const stageId = 'stage"\\id'; - const promptId = 'prompt"\\id'; - const text = formatWorkflowLifecycleNoticeText({ - kind: "awaiting_input", - scope: "stage", - runId, - workflowName: 'release "canary"', - status: "awaiting_input", - stageId, - stageName: 'review "gate"', - promptId, - promptKind: "confirm", - promptMessage: "Approve?", - createdAt: 1, - }); - - assert.match(text, /Workflow "release \\"canary\\"" needs input/); - assert.match(text, /Respond: \/workflow connect/); - assert.match(text, /workflow\(\{ action: "send"/); - assert.ok(text.includes(`runId: ${JSON.stringify(runId)}`)); - assert.ok(text.includes(`stageId: ${JSON.stringify(stageId)}`)); - assert.ok(text.includes(`promptId: ${JSON.stringify(promptId)}`)); - }); - - test("awaiting-input states do not enqueue visible steer messages", () => { - const store = createStore(); - const state = createWorkflowLifecycleNotificationState(); - const options: SendOptions[] = []; - installWorkflowLifecycleNotifications({ - store, - state, - config: { enabled: true, notifyOn: ["awaiting_input"] }, - sendMessage(_message, sendOptions) { options.push(sendOptions ?? {}); }, - }); - store.recordRunStart({ id: "run-awaiting-turn", name: "turn", inputs: {}, status: "running", stages: [], startedAt: 1 }); - store.recordStageStart("run-awaiting-turn", runningStage({ id: "stage-awaiting-turn" })); - assert.equal(store.recordStageAwaitingInput("run-awaiting-turn", "stage-awaiting-turn", true, 2), true); - assert.deepEqual(options, []); - assert.equal(state.deliveredInputPrompts.size, 1); - }); - - test("always triggers a steer turn for emitted terminal lifecycle notices", () => { - const store = createStore(); - const options: SendOptions[] = []; - installWorkflowLifecycleNotifications({ - store, - config: { enabled: true, notifyOn: ["completed"] }, - sendMessage(_message, sendOptions) { options.push(sendOptions ?? {}); }, - }); - store.recordRunStart({ id: "run-7", name: "turn", inputs: {}, status: "running", stages: [], startedAt: 1 }); - store.recordRunEnd("run-7", "completed", {}); - assert.deepEqual(options, [{ triggerTurn: true, deliverAs: "steer", persistWhenStreaming: true }]); - }); - - test("warns about send failures when workflow debug logging is enabled", () => { - const store = createStore(); - const previousDebug = process.env.ATOMIC_WORKFLOW_DEBUG; - const originalWarn = console.warn; - const warnings: unknown[][] = []; - process.env.ATOMIC_WORKFLOW_DEBUG = "1"; - console.warn = (...args: unknown[]) => { warnings.push(args); }; - try { - installWorkflowLifecycleNotifications({ - store, - config: { enabled: true, notifyOn: ["completed"] }, - sendMessage() { - throw new Error("send failed"); - }, - }); - store.recordRunStart({ id: "run-debug-throw", name: "debug", inputs: {}, status: "running", stages: [], startedAt: 1 }); - assert.equal(store.recordRunEnd("run-debug-throw", "completed", {}), true); - } finally { - console.warn = originalWarn; - if (previousDebug === undefined) { - delete process.env.ATOMIC_WORKFLOW_DEBUG; - } else { - process.env.ATOMIC_WORKFLOW_DEBUG = previousDebug; - } - } - - assert.equal(warnings.length, 1); - assert.match(String(warnings[0]?.[0] ?? ""), /workflow lifecycle notice/i); - assert.match(String(warnings[0]?.[1] ?? ""), /send failed/); - }); - - test("does not warn about send failures unless workflow debug logging is enabled", () => { - const store = createStore(); - const previousDebug = process.env.ATOMIC_WORKFLOW_DEBUG; - const originalWarn = console.warn; - const warnings: unknown[][] = []; - delete process.env.ATOMIC_WORKFLOW_DEBUG; - console.warn = (...args: unknown[]) => { warnings.push(args); }; - try { - installWorkflowLifecycleNotifications({ - store, - config: { enabled: true, notifyOn: ["completed"] }, - sendMessage() { - throw new Error("send failed"); - }, - }); - store.recordRunStart({ id: "run-debug-off", name: "debug off", inputs: {}, status: "running", stages: [], startedAt: 1 }); - assert.equal(store.recordRunEnd("run-debug-off", "completed", {}), true); - } finally { - console.warn = originalWarn; - if (previousDebug === undefined) { - delete process.env.ATOMIC_WORKFLOW_DEBUG; - } else { - process.env.ATOMIC_WORKFLOW_DEBUG = previousDebug; - } - } - - assert.equal(warnings.length, 0); - }); - - test("swallows synchronous send failures so sibling subscribers still receive snapshots", () => { - const store = createStore(); - const seenStatuses: string[] = []; - installWorkflowLifecycleNotifications({ - store, - config: { enabled: true, notifyOn: ["completed"] }, - sendMessage() { - throw new Error("send failed"); - }, - }); - const unsubscribeSibling = store.subscribe((snapshot) => { - const run = snapshot.runs.find((candidate) => candidate.id === "run-send-throw"); - if (run) seenStatuses.push(run.status); - }); - - store.recordRunStart({ id: "run-send-throw", name: "throw", inputs: {}, status: "running", stages: [], startedAt: 1 }); - assert.doesNotThrow(() => { - assert.equal(store.recordRunEnd("run-send-throw", "completed", {}), true); - }); - unsubscribeSibling(); - - assert.deepEqual(seenStatuses, ["running", "completed"]); - }); - - test("swallows rejected send promises without surfacing unhandled rejections", async () => { - const store = createStore(); - let siblingSawCompletion = false; - installWorkflowLifecycleNotifications({ - store, - config: { enabled: true, notifyOn: ["completed"] }, - sendMessage() { - return Promise.reject(new Error("send rejected")); - }, - }); - const unsubscribeSibling = store.subscribe((snapshot) => { - siblingSawCompletion ||= snapshot.runs.some( - (run) => run.id === "run-send-reject" && run.status === "completed", - ); - }); - - store.recordRunStart({ id: "run-send-reject", name: "reject", inputs: {}, status: "running", stages: [], startedAt: 1 }); - assert.equal(store.recordRunEnd("run-send-reject", "completed", {}), true); - await Promise.resolve(); - unsubscribeSibling(); - - assert.equal(siblingSawCompletion, true); - }); - - test("registers lifecycle renderer once per host and returns a notice card", () => { - const host = {}; - const registered: RegisteredRenderer[] = []; - registerLifecycleNoticeRenderer({ - rendererHost: host, - registerMessageRenderer(event, renderer) { - registered.push({ event, renderer: renderer as (payload: unknown) => unknown }); - }, - }); - registerLifecycleNoticeRenderer({ - rendererHost: host, - registerMessageRenderer(event, renderer) { - registered.push({ event, renderer: renderer as (payload: unknown) => unknown }); - }, - }); - - assert.equal(registered.length, 1); - assert.equal(registered[0]?.event, LIFECYCLE_NOTICE_CUSTOM_TYPE); - const rendered = registered[0]?.renderer({ - details: { - kind: "completed", - scope: "run", - runId: "run-card", - workflowName: "cards", - status: "completed", - createdAt: 1, - } satisfies WorkflowLifecycleNoticeDetails, - }); - - assert.equal(typeof rendered, "object"); - assert.notEqual(rendered, null); - const lines = (rendered as CardComponent).render(80); - const text = lines.join("\n"); - assert.match(text, /╭ WORKFLOW COMPLETE/); - assert.match(text, /✓ Workflow "cards" completed/); - assert.match(text, /workflow\s+cards/); - assert.match(text, /run\s+run-card/); - assert.match(text, /▸ \/workflow status run-card/); - }); - - test("wraps long lifecycle notices to the render width so no rendered line overflows the terminal (#1109 width-overflow crash)", () => { - const registered: RegisteredRenderer[] = []; - registerLifecycleNoticeRenderer({ - rendererHost: {}, - registerMessageRenderer(event, renderer) { - registered.push({ event, renderer: renderer as (payload: unknown) => unknown }); - }, - }); - - const details: WorkflowLifecycleNoticeDetails = { - kind: "completed", - scope: "run", - runId: "a3df3bfb-bea6-4c68-a05c-3f7bac10cd13", - workflowName: "fan-out-and-synthesize", - status: "completed", - createdAt: 1, - }; - const component = registered[0]?.renderer({ details }) as CardComponent; - - // Sanity: the single-line form really does overflow a normal terminal — - // this is the line that crashed pi-tui ("Rendered line N exceeds terminal width"). - assert.ok(visibleWidth(formatWorkflowLifecycleNoticeText(details)) > 120); - - // No rendered line may ever exceed the render width — this is the invariant - // pi-tui enforces with a hard throw, even at very narrow widths where the - // UUID itself must be hard-broken across lines. - for (const width of [120, 80, 40, 24]) { - for (const line of component.render(width)) { - assert.ok( - visibleWidth(line) <= width, - `line exceeds width ${width}: ${JSON.stringify(line)} (w=${visibleWidth(line)})`, - ); - } - } - - // Where the terminal is wide enough to hold the run id token, wrapping must - // not drop it so `/workflow status ` stays usable. - for (const width of [120, 80, 40]) { - const lines = component.render(width); - assert.ok( - lines.some((line) => line.includes(details.runId)), - `runId missing after wrap at width ${width}: ${JSON.stringify(lines)}`, - ); - } - }); - - test("restoration seeding preserves pending and retryable live terminal admissions", () => { - const state = createWorkflowLifecycleNotificationState(); - state.pendingTerminalRuns.set("completed:pending-live:", Symbol("pending")); - state.retryableTerminalRuns.add("completed:retry-live:"); - const completed = (id: string) => ({ - id, name: id, inputs: {}, status: "completed" as const, stages: [], startedAt: 1, endedAt: 2, - }); - - seedWorkflowLifecycleNotificationState(state, { - runs: [completed("pending-live"), completed("retry-live"), completed("history")], notices: [], version: 1, - }); - - assert.equal(state.deliveredTerminalRuns.has("completed:pending-live:"), false); - assert.equal(state.deliveredTerminalRuns.has("completed:retry-live:"), false); - assert.equal(state.deliveredTerminalRuns.has("completed:history:"), true); - assert.equal(state.pendingTerminalRuns.has("completed:pending-live:"), true); - assert.equal(state.retryableTerminalRuns.has("completed:retry-live:"), true); - }); - - test("failed lifecycle cards render tool origin with name and node-id fallback", () => { - const registered: RegisteredRenderer[] = []; - registerLifecycleNoticeRenderer({ - rendererHost: {}, - registerMessageRenderer(event, renderer) { - registered.push({ event, renderer: renderer as (payload: unknown) => unknown }); - }, - }); - const render = (details: WorkflowLifecycleNoticeDetails, width: number): string[] => - (registered[0]?.renderer({ details }) as CardComponent).render(width); - const base: WorkflowLifecycleNoticeDetails = { - kind: "failed", scope: "run", runId: "tool-failed", workflowName: "publish", status: "failed", createdAt: 1, - error: "publish rejected", toolNodeId: "tool:failure", toolName: "publish-api", - }; - - const named = render(base, 80).join("\n"); - assert.match(named, /tool\s+publish-api/); - assert.doesNotMatch(named, /stage\s+/); - const fallback = render({ ...base, toolName: "" }, 80).join("\n"); - assert.match(fallback, /tool\s+tool:failure/); - const stageWins = render({ ...base, stageName: "model-stage" }, 80).join("\n"); - assert.match(stageWins, /stage\s+model-stage/); - assert.doesNotMatch(stageWins, /tool\s+publish-api/); - const narrow = render({ ...base, toolName: "" }, 24); - assert.match(narrow.join("\n"), /tool[\s\S]*tool:failure/); - assert.ok(narrow.every((line) => visibleWidth(line) <= 24)); - }); + test("emits one failure notice with tool origin and no fabricated stage id", () => { + const { store, sent } = install(); + store.recordRunStart({ + id: "run-tool-fail", + name: "mutate", + inputs: {}, + status: "running", + stages: [], + toolNodes: [], + startedAt: 1, + }); + store.recordToolNodeStart("run-tool-fail", { + kind: "tool", + id: "tool:failure", + name: "publish-api", + argsHash: "hash", + ordinal: 1, + parentIds: [], + status: "pending", + attachable: false, + }); + store.recordToolNodeRunning("run-tool-fail", "tool:failure", 2); + store.recordToolNodeEnd("run-tool-fail", "tool:failure", { + status: "failed", + endedAt: 3, + error: "remote rejected", + }); + + assert.equal( + store.recordRunEnd("run-tool-fail", "failed", undefined, "remote rejected", { + failedToolNodeId: "tool:failure", + }), + true, + ); + store.recordNotice({ id: "tool-fail-tick", level: "info", message: "tick", createdAt: 4 }); + + assert.equal(sent.length, 1); + assert.equal(sent[0]?.details?.toolNodeId, "tool:failure"); + assert.equal(sent[0]?.details?.toolName, "publish-api"); + assert.equal(sent[0]?.details?.failedStageId, undefined); + assert.match(sent[0]?.content ?? "", /tool publish-api.*remote rejected/); + }); + + test("async suppression stays active until the awaited operation settles", async () => { + const store = createStore(); + const state = createWorkflowLifecycleNotificationState(); + const sent: SentMessage[] = []; + installWorkflowLifecycleNotifications({ + store, + config, + state, + sendMessage(message) { + sent.push(message as SentMessage); + }, + }); + + startRun(store, "run-async-suppressed", "async suppressed"); + let release!: () => void; + const gate = new Promise((resolve) => { + release = resolve; + }); + const suppressed = withWorkflowLifecycleNotificationsSuppressedAsync(state, async () => { + await gate; + return "done"; + }); + + assert.equal(state.suppressionDepth, 1); + assert.equal(store.recordRunEnd("run-async-suppressed", "completed", {}), true); + assert.equal(sent.length, 0); + + release(); + assert.equal(await suppressed, "done"); + assert.equal(state.suppressionDepth, 0); + + store.recordNotice({ id: "after-async-suppression", level: "info", message: "tick", createdAt: 13 }); + assert.equal(sent.length, 0, "suppressed terminal notice should remain marked delivered"); + + startRun(store, "run-after-async-suppression", "after async suppression"); + store.recordRunEnd("run-after-async-suppression", "completed", {}); + assert.deepEqual( + sent.map((message) => message.details?.runId), + ["run-after-async-suppression"], + ); + }); + + test("escapes workflow names and structured response ids in notice text", () => { + const runId = 'run"\\id'; + const stageId = 'stage"\\id'; + const promptId = 'prompt"\\id'; + const text = formatWorkflowLifecycleNoticeText({ + kind: "awaiting_input", + scope: "stage", + runId, + workflowName: 'release "canary"', + status: "awaiting_input", + stageId, + stageName: 'review "gate"', + promptId, + promptKind: "confirm", + promptMessage: "Approve?", + createdAt: 1, + }); + + assert.match(text, /Workflow "release \\"canary\\"" needs input/); + assert.match(text, /Respond: \/workflow connect/); + assert.match(text, /workflow\(\{ action: "send"/); + assert.ok(text.includes(`runId: ${JSON.stringify(runId)}`)); + assert.ok(text.includes(`stageId: ${JSON.stringify(stageId)}`)); + assert.ok(text.includes(`promptId: ${JSON.stringify(promptId)}`)); + }); + + test("awaiting-input states do not enqueue visible steer messages", () => { + const store = createStore(); + const state = createWorkflowLifecycleNotificationState(); + const options: SendOptions[] = []; + installWorkflowLifecycleNotifications({ + store, + state, + config: { enabled: true, notifyOn: ["awaiting_input"] }, + sendMessage(_message, sendOptions) { + options.push(sendOptions ?? {}); + }, + }); + store.recordRunStart({ + id: "run-awaiting-turn", + name: "turn", + inputs: {}, + status: "running", + stages: [], + startedAt: 1, + }); + store.recordStageStart("run-awaiting-turn", runningStage({ id: "stage-awaiting-turn" })); + assert.equal(store.recordStageAwaitingInput("run-awaiting-turn", "stage-awaiting-turn", true, 2), true); + assert.deepEqual(options, []); + assert.equal(state.deliveredInputPrompts.size, 1); + }); + + test("always triggers a steer turn for emitted terminal lifecycle notices", () => { + const store = createStore(); + const options: SendOptions[] = []; + installWorkflowLifecycleNotifications({ + store, + config: { enabled: true, notifyOn: ["completed"] }, + sendMessage(_message, sendOptions) { + options.push(sendOptions ?? {}); + }, + }); + store.recordRunStart({ id: "run-7", name: "turn", inputs: {}, status: "running", stages: [], startedAt: 1 }); + store.recordRunEnd("run-7", "completed", {}); + assert.deepEqual(options, [{ triggerTurn: true, deliverAs: "steer", persistWhenStreaming: true }]); + }); + + test("warns about send failures when workflow debug logging is enabled", () => { + const store = createStore(); + const previousDebug = process.env.ATOMIC_WORKFLOW_DEBUG; + const originalWarn = console.warn; + const warnings: unknown[][] = []; + process.env.ATOMIC_WORKFLOW_DEBUG = "1"; + console.warn = (...args: unknown[]) => { + warnings.push(args); + }; + try { + installWorkflowLifecycleNotifications({ + store, + config: { enabled: true, notifyOn: ["completed"] }, + sendMessage() { + throw new Error("send failed"); + }, + }); + store.recordRunStart({ + id: "run-debug-throw", + name: "debug", + inputs: {}, + status: "running", + stages: [], + startedAt: 1, + }); + assert.equal(store.recordRunEnd("run-debug-throw", "completed", {}), true); + } finally { + console.warn = originalWarn; + if (previousDebug === undefined) { + delete process.env.ATOMIC_WORKFLOW_DEBUG; + } else { + process.env.ATOMIC_WORKFLOW_DEBUG = previousDebug; + } + } + + assert.equal(warnings.length, 1); + assert.match(String(warnings[0]?.[0] ?? ""), /workflow lifecycle notice/i); + assert.match(String(warnings[0]?.[1] ?? ""), /send failed/); + }); + + test("does not warn about send failures unless workflow debug logging is enabled", () => { + const store = createStore(); + const previousDebug = process.env.ATOMIC_WORKFLOW_DEBUG; + const originalWarn = console.warn; + const warnings: unknown[][] = []; + delete process.env.ATOMIC_WORKFLOW_DEBUG; + console.warn = (...args: unknown[]) => { + warnings.push(args); + }; + try { + installWorkflowLifecycleNotifications({ + store, + config: { enabled: true, notifyOn: ["completed"] }, + sendMessage() { + throw new Error("send failed"); + }, + }); + store.recordRunStart({ + id: "run-debug-off", + name: "debug off", + inputs: {}, + status: "running", + stages: [], + startedAt: 1, + }); + assert.equal(store.recordRunEnd("run-debug-off", "completed", {}), true); + } finally { + console.warn = originalWarn; + if (previousDebug === undefined) { + delete process.env.ATOMIC_WORKFLOW_DEBUG; + } else { + process.env.ATOMIC_WORKFLOW_DEBUG = previousDebug; + } + } + + assert.equal(warnings.length, 0); + }); + + test("swallows synchronous send failures so sibling subscribers still receive snapshots", () => { + const store = createStore(); + const seenStatuses: string[] = []; + installWorkflowLifecycleNotifications({ + store, + config: { enabled: true, notifyOn: ["completed"] }, + sendMessage() { + throw new Error("send failed"); + }, + }); + const unsubscribeSibling = store.subscribe((snapshot) => { + const run = snapshot.runs.find((candidate) => candidate.id === "run-send-throw"); + if (run) seenStatuses.push(run.status); + }); + + store.recordRunStart({ + id: "run-send-throw", + name: "throw", + inputs: {}, + status: "running", + stages: [], + startedAt: 1, + }); + assert.doesNotThrow(() => { + assert.equal(store.recordRunEnd("run-send-throw", "completed", {}), true); + }); + unsubscribeSibling(); + + assert.deepEqual(seenStatuses, ["running", "completed"]); + }); + + test("swallows rejected send promises without surfacing unhandled rejections", async () => { + const store = createStore(); + let siblingSawCompletion = false; + installWorkflowLifecycleNotifications({ + store, + config: { enabled: true, notifyOn: ["completed"] }, + sendMessage() { + return Promise.reject(new Error("send rejected")); + }, + }); + const unsubscribeSibling = store.subscribe((snapshot) => { + siblingSawCompletion ||= snapshot.runs.some( + (run) => run.id === "run-send-reject" && run.status === "completed", + ); + }); + + store.recordRunStart({ + id: "run-send-reject", + name: "reject", + inputs: {}, + status: "running", + stages: [], + startedAt: 1, + }); + assert.equal(store.recordRunEnd("run-send-reject", "completed", {}), true); + await Promise.resolve(); + unsubscribeSibling(); + + assert.equal(siblingSawCompletion, true); + }); + + test("registers lifecycle renderer once per host and returns a notice card", () => { + const host = {}; + const registered: RegisteredRenderer[] = []; + registerLifecycleNoticeRenderer({ + rendererHost: host, + registerMessageRenderer(event, renderer) { + registered.push({ event, renderer: renderer as (payload: unknown) => unknown }); + }, + }); + registerLifecycleNoticeRenderer({ + rendererHost: host, + registerMessageRenderer(event, renderer) { + registered.push({ event, renderer: renderer as (payload: unknown) => unknown }); + }, + }); + + assert.equal(registered.length, 1); + assert.equal(registered[0]?.event, LIFECYCLE_NOTICE_CUSTOM_TYPE); + const rendered = registered[0]?.renderer({ + details: { + kind: "completed", + scope: "run", + runId: "run-card", + workflowName: "cards", + status: "completed", + createdAt: 1, + } satisfies WorkflowLifecycleNoticeDetails, + }); + + assert.equal(typeof rendered, "object"); + assert.notEqual(rendered, null); + const lines = (rendered as CardComponent).render(80); + const text = lines.join("\n"); + assert.match(text, /╭ WORKFLOW COMPLETE/); + assert.match(text, /✓ Workflow "cards" completed/); + assert.match(text, /workflow\s+cards/); + assert.match(text, /run\s+run-card/); + assert.match(text, /▸ \/workflow status run-card/); + }); + + test("wraps long lifecycle notices to the render width so no rendered line overflows the terminal (#1109 width-overflow crash)", () => { + const registered: RegisteredRenderer[] = []; + registerLifecycleNoticeRenderer({ + rendererHost: {}, + registerMessageRenderer(event, renderer) { + registered.push({ event, renderer: renderer as (payload: unknown) => unknown }); + }, + }); + + const details: WorkflowLifecycleNoticeDetails = { + kind: "completed", + scope: "run", + runId: "a3df3bfb-bea6-4c68-a05c-3f7bac10cd13", + workflowName: "fan-out-and-synthesize", + status: "completed", + createdAt: 1, + }; + const component = registered[0]?.renderer({ details }) as CardComponent; + + // Sanity: the single-line form really does overflow a normal terminal — + // this is the line that crashed pi-tui ("Rendered line N exceeds terminal width"). + assert.ok(visibleWidth(formatWorkflowLifecycleNoticeText(details)) > 120); + + // No rendered line may ever exceed the render width — this is the invariant + // pi-tui enforces with a hard throw, even at very narrow widths where the + // UUID itself must be hard-broken across lines. + for (const width of [120, 80, 40, 24]) { + for (const line of component.render(width)) { + assert.ok( + visibleWidth(line) <= width, + `line exceeds width ${width}: ${JSON.stringify(line)} (w=${visibleWidth(line)})`, + ); + } + } + + // Where the terminal is wide enough to hold the run id token, wrapping must + // not drop it so `/workflow status ` stays usable. + for (const width of [120, 80, 40]) { + const lines = component.render(width); + assert.ok( + lines.some((line) => line.includes(details.runId)), + `runId missing after wrap at width ${width}: ${JSON.stringify(lines)}`, + ); + } + }); + + test("restoration seeding preserves pending and retryable live terminal admissions", () => { + const state = createWorkflowLifecycleNotificationState(); + state.pendingTerminalRuns.set("completed:pending-live:", Symbol("pending")); + state.retryableTerminalRuns.add("completed:retry-live:"); + const completed = (id: string) => ({ + id, + name: id, + inputs: {}, + status: "completed" as const, + stages: [], + startedAt: 1, + endedAt: 2, + }); + + seedWorkflowLifecycleNotificationState(state, { + runs: [completed("pending-live"), completed("retry-live"), completed("history")], + notices: [], + version: 1, + }); + + assert.equal(state.deliveredTerminalRuns.has("completed:pending-live:"), false); + assert.equal(state.deliveredTerminalRuns.has("completed:retry-live:"), false); + assert.equal(state.deliveredTerminalRuns.has("completed:history:"), true); + assert.equal(state.pendingTerminalRuns.has("completed:pending-live:"), true); + assert.equal(state.retryableTerminalRuns.has("completed:retry-live:"), true); + }); + + test("failed lifecycle cards render tool origin with name and node-id fallback", () => { + const registered: RegisteredRenderer[] = []; + registerLifecycleNoticeRenderer({ + rendererHost: {}, + registerMessageRenderer(event, renderer) { + registered.push({ event, renderer: renderer as (payload: unknown) => unknown }); + }, + }); + const render = (details: WorkflowLifecycleNoticeDetails, width: number): string[] => { + const renderer = registered[0]?.renderer; + assert.ok(renderer); + return (renderer({ details }) as CardComponent).render(width); + }; + const base: WorkflowLifecycleNoticeDetails = { + kind: "failed", + scope: "run", + runId: "tool-failed", + workflowName: "publish", + status: "failed", + createdAt: 1, + error: "publish rejected", + toolNodeId: "tool:failure", + toolName: "publish-api", + }; + + const named = render(base, 80).join("\n"); + assert.match(named, /tool\s+publish-api/); + assert.doesNotMatch(named, /stage\s+/); + const fallback = render({ ...base, toolName: "" }, 80).join("\n"); + assert.match(fallback, /tool\s+tool:failure/); + const stageWins = render({ ...base, stageName: "model-stage" }, 80).join("\n"); + assert.match(stageWins, /stage\s+model-stage/); + assert.doesNotMatch(stageWins, /tool\s+publish-api/); + const narrow = render({ ...base, toolName: "" }, 24); + assert.match(narrow.join("\n"), /tool[\s\S]*tool:failure/); + assert.ok(narrow.every((line) => visibleWidth(line) <= 24)); + }); }); diff --git a/test/unit/workflow-lifecycle-parent-reconciliation-admission.test.ts b/test/unit/workflow-lifecycle-parent-reconciliation-admission.test.ts index 618ef81b8..55b085217 100644 --- a/test/unit/workflow-lifecycle-parent-reconciliation-admission.test.ts +++ b/test/unit/workflow-lifecycle-parent-reconciliation-admission.test.ts @@ -1,14 +1,14 @@ -import { afterEach, describe, test } from "bun:test"; import assert from "node:assert/strict"; import { mkdtempSync, readFileSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import type { AgentTool } from "@earendil-works/pi-agent-core"; -import { fauxAssistantMessage, fauxToolCall, type Context } from "@earendil-works/pi-ai/compat"; +import { type Context, fauxAssistantMessage, fauxToolCall } from "@earendil-works/pi-ai/compat"; import { Type } from "typebox"; -import { convertToLlm } from "../../packages/coding-agent/src/core/messages.js"; +import { afterEach, describe, test } from "vitest"; import type { AgentSessionInternalSurface } from "../../packages/coding-agent/src/core/agent-session-methods.js"; import { PROTECTED_RECONCILIATION_CUSTOM_TYPE } from "../../packages/coding-agent/src/core/agent-session-persistent-custom-messages.js"; +import { convertToLlm } from "../../packages/coding-agent/src/core/messages.js"; import { SessionManager } from "../../packages/coding-agent/src/core/session-manager.js"; import { createHarness, getMessageText, type Harness } from "../../packages/coding-agent/test/suite/harness.js"; import { @@ -17,6 +17,7 @@ import { type WorkflowLifecycleNoticeDetails, } from "../../packages/workflows/src/extension/lifecycle-notifications.js"; import { createStore } from "../../packages/workflows/src/shared/store.js"; +import { sleep } from "../helpers/runtime.js"; import { assertWorkflowToolOrdering, lifecycleConfig } from "./workflow-lifecycle-parent-reconciliation-support.js"; describe("workflow lifecycle parent reconciliation admission boundaries", () => { @@ -67,61 +68,78 @@ describe("workflow lifecycle parent reconciliation admission boundaries", () => if (cardAtAdmission?.role !== "custom") throw new Error("missing lifecycle card at admission"); assert.equal(cardAtAdmission.display, true); assert.equal(cardAtAdmission.content, sentNotice?.content, "raw lifecycle content must not be rewritten"); - assert.equal(cardAtAdmission.details, sentNotice?.details, "the visible card keeps the exact details object"); + assert.equal( + cardAtAdmission.details, + sentNotice?.details, + "the visible card keeps the exact details object", + ); const details = cardAtAdmission.details as WorkflowLifecycleNoticeDetails; assert.equal("error" in details, false, "omitted optional lifecycle fields stay omitted"); assert.equal("stageId" in details, false); - const persistedAtAdmission = sessionManager.getEntries().filter( - (entry) => entry.type === "custom_message" && entry.customType === LIFECYCLE_NOTICE_CUSTOM_TYPE, - ); + const persistedAtAdmission = sessionManager + .getEntries() + .filter((entry) => entry.type === "custom_message" && entry.customType === LIFECYCLE_NOTICE_CUSTOM_TYPE); assert.equal(persistedAtAdmission.length, 1, "send admission must include one durable lifecycle card"); const sessionFile = sessionManager.getSessionFile(); assert.ok(sessionFile); const reopenedAtAdmission = SessionManager.open(sessionFile, sessionDir, process.cwd()); assert.equal( - reopenedAtAdmission.getEntries().filter( - (entry) => entry.type === "custom_message" && entry.customType === LIFECYCLE_NOTICE_CUSTOM_TYPE, - ).length, + reopenedAtAdmission + .getEntries() + .filter( + (entry) => entry.type === "custom_message" && entry.customType === LIFECYCLE_NOTICE_CUSTOM_TYPE, + ).length, 1, "the admission receipt must be physically reopenable before the tool result exists", ); admissionObserved = true; return { - content: [{ type: "text", text: "Workflow tool-pending started in background (run-tool-pending). Status: running" }], + content: [ + { + type: "text", + text: "Workflow tool-pending started in background (run-tool-pending). Status: running", + }, + ], details: { action: "run", runId: "run-tool-pending", status: "running" }, }; }, }; harness = await createHarness({ tools: [workflowTool], sessionManager }); harnesses.push(harness); - unsubscriptions.push(harness.session.subscribe((event) => { - if ( - !queuedDisposalAttempted && - event.type === "message_start" && - event.message.role === "custom" && - event.message.customType === LIFECYCLE_NOTICE_CUSTOM_TYPE - ) { - queuedDisposalAttempted = true; - try { - harness.session.dispose(); - } catch (error) { - queuedDisposalError = error instanceof Error ? error : new Error(String(error)); + unsubscriptions.push( + harness.session.subscribe((event) => { + if ( + !queuedDisposalAttempted && + event.type === "message_start" && + event.message.role === "custom" && + event.message.customType === LIFECYCLE_NOTICE_CUSTOM_TYPE + ) { + queuedDisposalAttempted = true; + try { + harness.session.dispose(); + } catch (error) { + queuedDisposalError = error instanceof Error ? error : new Error(String(error)); + } } - } - })); - unsubscriptions.push(installWorkflowLifecycleNotifications({ - store, - config: lifecycleConfig, - seedExisting: false, - sendMessage: (message, options) => { - sentNotice = message; - delivery = harness.session.sendCustomMessage(message, options); - return delivery; - }, - })); + }), + ); + unsubscriptions.push( + installWorkflowLifecycleNotifications({ + store, + config: lifecycleConfig, + seedExisting: false, + sendMessage: (message, options) => { + sentNotice = message; + delivery = harness.session.sendCustomMessage(message, options); + return delivery; + }, + }), + ); let providerContext: Context | undefined; harness.setResponses([ - fauxAssistantMessage(fauxToolCall("workflow", {}, { id: "workflow-call-tool-pending" }), { stopReason: "toolUse" }), + fauxAssistantMessage(fauxToolCall("workflow", {}, { id: "workflow-call-tool-pending" }), { + stopReason: "toolUse", + }), (context) => { providerContext = context; return fauxAssistantMessage("tool-pending completed successfully."); @@ -141,8 +159,13 @@ describe("workflow lifecycle parent reconciliation admission boundaries", () => assert.equal(terminalUserMessages.length, 1, `provider context: ${JSON.stringify(providerContext.messages)}`); const sessionFile = sessionManager.getSessionFile(); assert.ok(sessionFile); - const rawEntries = readFileSync(sessionFile, "utf8").trim().split("\n").map((line) => JSON.parse(line)); - const rawCards = rawEntries.filter((entry) => entry.type === "custom_message" && entry.customType === LIFECYCLE_NOTICE_CUSTOM_TYPE); + const rawEntries = readFileSync(sessionFile, "utf8") + .trim() + .split("\n") + .map((line) => JSON.parse(line)); + const rawCards = rawEntries.filter( + (entry) => entry.type === "custom_message" && entry.customType === LIFECYCLE_NOTICE_CUSTOM_TYPE, + ); assert.equal(rawCards.length, 1); assert.equal(rawCards[0]?.content, sentNotice?.content); assert.deepEqual(rawCards[0]?.details, sentNotice?.details); @@ -152,7 +175,8 @@ describe("workflow lifecycle parent reconciliation admission boundaries", () => assertWorkflowToolOrdering({ messages: reopenedMessages }); assert.equal( reopenedMessages.filter( - (message) => message.role === "user" && getMessageText(message).includes('Workflow "tool-pending" completed'), + (message) => + message.role === "user" && getMessageText(message).includes('Workflow "tool-pending" completed'), ).length, 1, ); @@ -160,7 +184,14 @@ describe("workflow lifecycle parent reconciliation admission boundaries", () => test("a terminal notice between completed tool turns joins the next provider step", async () => { const store = createStore(); - store.recordRunStart({ id: "run-between-tools", name: "between-tools", inputs: {}, status: "running", stages: [], startedAt: 1 }); + store.recordRunStart({ + id: "run-between-tools", + name: "between-tools", + inputs: {}, + status: "running", + stages: [], + startedAt: 1, + }); const workflowTool: AgentTool = { name: "workflow", label: "Workflow", @@ -173,22 +204,28 @@ describe("workflow lifecycle parent reconciliation admission boundaries", () => }; const harness = await createHarness({ tools: [workflowTool] }); harnesses.push(harness); - unsubscriptions.push(installWorkflowLifecycleNotifications({ - store, - config: lifecycleConfig, - seedExisting: false, - sendMessage: (message, options) => harness.session.sendCustomMessage(message, options), - })); + unsubscriptions.push( + installWorkflowLifecycleNotifications({ + store, + config: lifecycleConfig, + seedExisting: false, + sendMessage: (message, options) => harness.session.sendCustomMessage(message, options), + }), + ); let terminalized = false; - unsubscriptions.push(harness.session.agent.subscribe((event) => { - if (!terminalized && event.type === "turn_end") { - terminalized = true; - assert.equal(store.recordRunEnd("run-between-tools", "completed", {}), true); - } - })); + unsubscriptions.push( + harness.session.agent.subscribe((event) => { + if (!terminalized && event.type === "turn_end") { + terminalized = true; + assert.equal(store.recordRunEnd("run-between-tools", "completed", {}), true); + } + }), + ); let providerContext: Context | undefined; harness.setResponses([ - fauxAssistantMessage(fauxToolCall("workflow", {}, { id: "workflow-call-between-tools" }), { stopReason: "toolUse" }), + fauxAssistantMessage(fauxToolCall("workflow", {}, { id: "workflow-call-between-tools" }), { + stopReason: "toolUse", + }), (context) => { providerContext = context; return fauxAssistantMessage("between-tools completed."); @@ -202,7 +239,8 @@ describe("workflow lifecycle parent reconciliation admission boundaries", () => assertWorkflowToolOrdering(providerContext); assert.equal( providerContext.messages.filter( - (message) => message.role === "user" && getMessageText(message).includes('Workflow "between-tools" completed'), + (message) => + message.role === "user" && getMessageText(message).includes('Workflow "between-tools" completed'), ).length, 1, ); @@ -214,20 +252,24 @@ describe("workflow lifecycle parent reconciliation admission boundaries", () => const harness = await createHarness(); harnesses.push(harness); let delivery: Promise | undefined; - unsubscriptions.push(installWorkflowLifecycleNotifications({ - store, - config: lifecycleConfig, - seedExisting: false, - sendMessage(message, options) { - delivery = harness.session.sendCustomMessage(message, options); - return delivery; - }, - })); + unsubscriptions.push( + installWorkflowLifecycleNotifications({ + store, + config: lifecycleConfig, + seedExisting: false, + sendMessage(message, options) { + delivery = harness.session.sendCustomMessage(message, options); + return delivery; + }, + }), + ); let providerContext: Context | undefined; - harness.setResponses([(context) => { - providerContext = context; - return fauxAssistantMessage("I saw idle complete."); - }]); + harness.setResponses([ + (context) => { + providerContext = context; + return fauxAssistantMessage("I saw idle complete."); + }, + ]); assert.equal(store.recordRunEnd("run-idle", "completed", {}), true); await delivery; @@ -241,18 +283,32 @@ describe("workflow lifecycle parent reconciliation admission boundaries", () => ).length, 1, ); - assert.equal(harness.session.messages.filter( - (message) => message.role === "custom" && message.customType === LIFECYCLE_NOTICE_CUSTOM_TYPE, - ).length, 1); - assert.equal(harness.sessionManager.getEntries().filter( - (entry) => entry.type === "custom_message" && entry.customType === LIFECYCLE_NOTICE_CUSTOM_TYPE, - ).length, 1); + assert.equal( + harness.session.messages.filter( + (message) => message.role === "custom" && message.customType === LIFECYCLE_NOTICE_CUSTOM_TYPE, + ).length, + 1, + ); + assert.equal( + harness.sessionManager + .getEntries() + .filter((entry) => entry.type === "custom_message" && entry.customType === LIFECYCLE_NOTICE_CUSTOM_TYPE) + .length, + 1, + ); }); test("prompt startup failure retries the hidden correction without duplicating its durable card", async () => { const runId = "run-prompt-start-retry"; const store = createStore(); - store.recordRunStart({ id: runId, name: "prompt-start-retry", inputs: {}, status: "running", stages: [], startedAt: 1 }); + store.recordRunStart({ + id: runId, + name: "prompt-start-retry", + inputs: {}, + status: "running", + stages: [], + startedAt: 1, + }); const harness = await createHarness(); harnesses.push(harness); const promptSession = harness.session as unknown as AgentSessionInternalSurface; @@ -265,45 +321,74 @@ describe("workflow lifecycle parent reconciliation admission boundaries", () => }; const providerStarted = Promise.withResolvers(); let providerContext: Context | undefined; - harness.setResponses([(context) => { - providerContext = context; - providerStarted.resolve(); - return fauxAssistantMessage("Recovered the failed workflow after prompt startup retry."); - }]); - unsubscriptions.push(installWorkflowLifecycleNotifications({ - store, - config: lifecycleConfig, - seedExisting: false, - sendMessage: (message, options) => harness.session.sendCustomMessage(message, options), - })); + harness.setResponses([ + (context) => { + providerContext = context; + providerStarted.resolve(); + return fauxAssistantMessage("Recovered the failed workflow after prompt startup retry."); + }, + ]); + unsubscriptions.push( + installWorkflowLifecycleNotifications({ + store, + config: lifecycleConfig, + seedExisting: false, + sendMessage: (message, options) => harness.session.sendCustomMessage(message, options), + }), + ); assert.equal(store.recordRunEnd(runId, "failed", undefined, "durable tool rejected"), true); await Promise.race([ providerStarted.promise, - Bun.sleep(1_000).then(() => { throw new Error("hidden reconciliation retry did not start"); }), + sleep(1_000).then(() => { + throw new Error("hidden reconciliation retry did not start"); + }), ]); await harness.session.agent.waitForIdle(); assert.equal(promptStarts, 1, "the failed prompt must not restart lifecycle card delivery"); assert.equal(harness.faux.state.callCount, 1, "the queued hidden correction must retry once"); - assert.equal(harness.session.messages.filter( - (message) => message.role === "custom" && message.customType === LIFECYCLE_NOTICE_CUSTOM_TYPE, - ).length, 1, "prompt retry must reuse the physically persisted lifecycle card"); - assert.equal(harness.sessionManager.getEntries().filter( - (entry) => entry.type === "custom_message" && entry.customType === LIFECYCLE_NOTICE_CUSTOM_TYPE, - ).length, 1); - assert.equal(harness.sessionManager.getEntries().filter( - (entry) => entry.type === "custom_message" && entry.customType === PROTECTED_RECONCILIATION_CUSTOM_TYPE, - ).length, 1); + assert.equal( + harness.session.messages.filter( + (message) => message.role === "custom" && message.customType === LIFECYCLE_NOTICE_CUSTOM_TYPE, + ).length, + 1, + "prompt retry must reuse the physically persisted lifecycle card", + ); + assert.equal( + harness.sessionManager + .getEntries() + .filter((entry) => entry.type === "custom_message" && entry.customType === LIFECYCLE_NOTICE_CUSTOM_TYPE) + .length, + 1, + ); + assert.equal( + harness.sessionManager + .getEntries() + .filter( + (entry) => entry.type === "custom_message" && entry.customType === PROTECTED_RECONCILIATION_CUSTOM_TYPE, + ).length, + 1, + ); assert.ok(providerContext); - assert.equal(providerContext.messages.filter( - (message) => message.role === "user" && getMessageText(message).includes(`failed (run ${runId}`), - ).length, 1); + assert.equal( + providerContext.messages.filter( + (message) => message.role === "user" && getMessageText(message).includes(`failed (run ${runId}`), + ).length, + 1, + ); }); test("a paced ordinary abort stops the stale turn but completes one correcting lifecycle response", async () => { const store = createStore(); - store.recordRunStart({ id: "run-abort-survival", name: "abort-survival", inputs: {}, status: "running", stages: [], startedAt: 1 }); + store.recordRunStart({ + id: "run-abort-survival", + name: "abort-survival", + inputs: {}, + status: "running", + stages: [], + startedAt: 1, + }); const workflowTool: AgentTool = { name: "workflow", label: "Workflow", @@ -319,24 +404,35 @@ describe("workflow lifecycle parent reconciliation admission boundaries", () => fauxProvider: { tokensPerSecond: 100, tokenSize: { min: 1, max: 1 } }, }); harnesses.push(harness); - unsubscriptions.push(installWorkflowLifecycleNotifications({ - store, - config: lifecycleConfig, - seedExisting: false, - sendMessage: (message, options) => harness.session.sendCustomMessage(message, options), - })); + unsubscriptions.push( + installWorkflowLifecycleNotifications({ + store, + config: lifecycleConfig, + seedExisting: false, + sendMessage: (message, options) => harness.session.sendCustomMessage(message, options), + }), + ); let terminalized = false; let abortPromise: Promise | undefined; let reconciliationContext: Context | undefined; - unsubscriptions.push(harness.session.subscribe((event) => { - if (!terminalized && harness.faux.state.callCount === 2 && event.type === "message_update" && event.assistantMessageEvent.type === "text_delta") { - terminalized = true; - assert.equal(store.recordRunEnd("run-abort-survival", "completed", {}), true); - abortPromise = harness.session.abort(); - } - })); + unsubscriptions.push( + harness.session.subscribe((event) => { + if ( + !terminalized && + harness.faux.state.callCount === 2 && + event.type === "message_update" && + event.assistantMessageEvent.type === "text_delta" + ) { + terminalized = true; + assert.equal(store.recordRunEnd("run-abort-survival", "completed", {}), true); + abortPromise = harness.session.abort(); + } + }), + ); harness.setResponses([ - fauxAssistantMessage(fauxToolCall("workflow", {}, { id: "workflow-call-abort-survival" }), { stopReason: "toolUse" }), + fauxAssistantMessage(fauxToolCall("workflow", {}, { id: "workflow-call-abort-survival" }), { + stopReason: "toolUse", + }), fauxAssistantMessage("This stale answer is long enough to be aborted after its first streaming delta."), (context) => { reconciliationContext = context; @@ -352,20 +448,33 @@ describe("workflow lifecycle parent reconciliation admission boundaries", () => const assistants = harness.session.messages.filter((message) => message.role === "assistant"); const stale = assistants.find((message) => getMessageText(message).startsWith("This")); assert.equal(stale?.role, "assistant"); - if (stale?.role === "assistant") assert.equal(stale.stopReason, "aborted", "the paced stale turn must be the aborted request"); + if (stale?.role === "assistant") + assert.equal(stale.stopReason, "aborted", "the paced stale turn must be the aborted request"); const correcting = assistants.find((message) => getMessageText(message).includes("terminal notice survived")); assert.equal(correcting?.role, "assistant"); - if (correcting?.role === "assistant") assert.equal(correcting.stopReason, "stop", "the correcting response must complete"); + if (correcting?.role === "assistant") + assert.equal(correcting.stopReason, "stop", "the correcting response must complete"); assert.ok(reconciliationContext); - assert.equal(reconciliationContext.messages.filter( - (message) => message.role === "user" && getMessageText(message).includes('Workflow "abort-survival" completed'), - ).length, 1); - assert.equal(harness.session.messages.filter( - (message) => message.role === "custom" && message.customType === LIFECYCLE_NOTICE_CUSTOM_TYPE, - ).length, 1); - assert.equal(harness.sessionManager.getEntries().filter( - (entry) => entry.type === "custom_message" && entry.customType === LIFECYCLE_NOTICE_CUSTOM_TYPE, - ).length, 1); + assert.equal( + reconciliationContext.messages.filter( + (message) => + message.role === "user" && getMessageText(message).includes('Workflow "abort-survival" completed'), + ).length, + 1, + ); + assert.equal( + harness.session.messages.filter( + (message) => message.role === "custom" && message.customType === LIFECYCLE_NOTICE_CUSTOM_TYPE, + ).length, + 1, + ); + assert.equal( + harness.sessionManager + .getEntries() + .filter((entry) => entry.type === "custom_message" && entry.customType === LIFECYCLE_NOTICE_CUSTOM_TYPE) + .length, + 1, + ); }); test("restores one queued hidden correction after a crash between card admission and consumption", async () => { @@ -378,35 +487,46 @@ describe("workflow lifecycle parent reconciliation admission boundaries", () => harnesses.push(original); original.session.pauseQueuedMessages(); const store = createStore(); - store.recordRunStart({ id: runId, name: "crash-reconciliation", inputs: {}, status: "running", stages: [], startedAt: 1 }); + store.recordRunStart({ + id: runId, + name: "crash-reconciliation", + inputs: {}, + status: "running", + stages: [], + startedAt: 1, + }); let delivery: Promise | undefined; - unsubscriptions.push(installWorkflowLifecycleNotifications({ - store, - config: lifecycleConfig, - seedExisting: false, - sendMessage(message, options) { - delivery = original.session.sendCustomMessage(message, options); - return delivery; - }, - })); + unsubscriptions.push( + installWorkflowLifecycleNotifications({ + store, + config: lifecycleConfig, + seedExisting: false, + sendMessage(message, options) { + delivery = original.session.sendCustomMessage(message, options); + return delivery; + }, + }), + ); assert.equal(store.recordRunEnd(runId, "failed", undefined, "commit hook rejected docs"), true); await delivery; const sessionFile = sessionManager.getSessionFile(); assert.ok(sessionFile); - const persistedCards = sessionManager.getBranch().filter( - (entry) => entry.type === "custom_message" && entry.customType === LIFECYCLE_NOTICE_CUSTOM_TYPE, - ); + const persistedCards = sessionManager + .getBranch() + .filter((entry) => entry.type === "custom_message" && entry.customType === LIFECYCLE_NOTICE_CUSTOM_TYPE); assert.equal(persistedCards.length, 1); assert.deepEqual( - (persistedCards[0] as { protectedReconciliation?: { delivery?: string } } | undefined)?.protectedReconciliation, + (persistedCards[0] as { protectedReconciliation?: { delivery?: string } } | undefined) + ?.protectedReconciliation, { delivery: "steer" }, "the durable card must atomically carry its recoverable hidden-turn intent", ); // Simulate process loss: discard native queue/protection without a graceful flush. - (original.session as typeof original.session & { _protectedStreamingCustomMessages: object[] }) - ._protectedStreamingCustomMessages = []; + ( + original.session as typeof original.session & { _protectedStreamingCustomMessages: object[] } + )._protectedStreamingCustomMessages = []; original.session.clearQueue(); const reopened = SessionManager.open(sessionFile, sessionDir, process.cwd()); @@ -415,18 +535,29 @@ describe("workflow lifecycle parent reconciliation admission boundaries", () => restored.session.pauseQueuedMessages(); harnesses.push(restored); let correctionContext: Context | undefined; - restored.setResponses([(context) => { - correctionContext = context; - return fauxAssistantMessage("Recovered the failed workflow after restart."); - }]); + restored.setResponses([ + (context) => { + correctionContext = context; + return fauxAssistantMessage("Recovered the failed workflow after restart."); + }, + ]); const restoredStore = createStore(); - restoredStore.recordRunStart({ id: runId, name: "crash-reconciliation", inputs: {}, status: "running", stages: [], startedAt: 1 }); + restoredStore.recordRunStart({ + id: runId, + name: "crash-reconciliation", + inputs: {}, + status: "running", + stages: [], + startedAt: 1, + }); restoredStore.recordRunEnd(runId, "failed", undefined, "commit hook rejected docs"); - unsubscriptions.push(installWorkflowLifecycleNotifications({ - store: restoredStore, - config: lifecycleConfig, - sendMessage: (message, options) => restored.session.sendCustomMessage(message, options), - })); + unsubscriptions.push( + installWorkflowLifecycleNotifications({ + store: restoredStore, + config: lifecycleConfig, + sendMessage: (message, options) => restored.session.sendCustomMessage(message, options), + }), + ); await restored.session.bindExtensions({}); await restored.session.bindExtensions({}); const restoredInternals = restored.session as typeof restored.session & { @@ -444,15 +575,29 @@ describe("workflow lifecycle parent reconciliation admission boundaries", () => assert.equal(restored.faux.state.callCount, 1); assert.ok(correctionContext); - assert.equal(correctionContext.messages.filter( - (message) => message.role === "user" && getMessageText(message).includes(`failed (run ${runId}`), - ).length, 1); - assert.equal(reopened.getBranch().filter( - (entry) => entry.type === "custom_message" && entry.customType === LIFECYCLE_NOTICE_CUSTOM_TYPE, - ).length, 1, "restore must not duplicate the visible lifecycle card"); - assert.equal(reopened.getBranch().filter( - (entry) => entry.type === "custom_message" && entry.customType === PROTECTED_RECONCILIATION_CUSTOM_TYPE, - ).length, 1, "the recovered correction must resolve its durable intent once"); + assert.equal( + correctionContext.messages.filter( + (message) => message.role === "user" && getMessageText(message).includes(`failed (run ${runId}`), + ).length, + 1, + ); + assert.equal( + reopened + .getBranch() + .filter((entry) => entry.type === "custom_message" && entry.customType === LIFECYCLE_NOTICE_CUSTOM_TYPE) + .length, + 1, + "restore must not duplicate the visible lifecycle card", + ); + assert.equal( + reopened + .getBranch() + .filter( + (entry) => entry.type === "custom_message" && entry.customType === PROTECTED_RECONCILIATION_CUSTOM_TYPE, + ).length, + 1, + "the recovered correction must resolve its durable intent once", + ); const reopenedAgain = SessionManager.open(sessionFile, sessionDir, process.cwd()); const later = await createHarness({ sessionManager: reopenedAgain }); @@ -460,7 +605,7 @@ describe("workflow lifecycle parent reconciliation admission boundaries", () => harnesses.push(later); later.setResponses([() => fauxAssistantMessage("unexpected duplicate correction")]); await later.session.bindExtensions({}); - await Bun.sleep(0); + await sleep(0); assert.equal(later.faux.state.callCount, 0, "a later restore must not repeat the resolved correction"); }); }); diff --git a/test/unit/workflow-lifecycle-parent-reconciliation-listener-races.test.ts b/test/unit/workflow-lifecycle-parent-reconciliation-listener-races.test.ts index 2058ccd48..1ada7b2f8 100644 --- a/test/unit/workflow-lifecycle-parent-reconciliation-listener-races.test.ts +++ b/test/unit/workflow-lifecycle-parent-reconciliation-listener-races.test.ts @@ -1,9 +1,9 @@ -import { afterEach, describe, test } from "bun:test"; import assert from "node:assert/strict"; import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { fauxAssistantMessage, type Context } from "@earendil-works/pi-ai/compat"; +import { type Context, fauxAssistantMessage } from "@earendil-works/pi-ai/compat"; +import { afterEach, describe, test } from "vitest"; import { SessionManager } from "../../packages/coding-agent/src/core/session-manager.js"; import { createHarness, getMessageText, type Harness } from "../../packages/coding-agent/test/suite/harness.js"; import { @@ -11,10 +11,8 @@ import { LIFECYCLE_NOTICE_CUSTOM_TYPE, } from "../../packages/workflows/src/extension/lifecycle-notifications.js"; import { createStore } from "../../packages/workflows/src/shared/store.js"; -import { - lifecycleConfig, - providerSawWorkflowState, -} from "./workflow-lifecycle-parent-reconciliation-support.js"; +import { sleep } from "../helpers/runtime.js"; +import { lifecycleConfig, providerSawWorkflowState } from "./workflow-lifecycle-parent-reconciliation-support.js"; const HIDDEN_RECONCILIATION_CUSTOM_TYPE = "atomic:protected-streaming-reconciliation"; @@ -22,11 +20,10 @@ async function waitUntil(check: () => boolean): Promise { const deadline = Date.now() + 5_000; while (!check()) { if (Date.now() > deadline) throw new Error("timed out waiting for streaming custom-message delivery"); - await Bun.sleep(2); + await sleep(2); } } - describe("workflow lifecycle listener and admission races", () => { const harnesses: Harness[] = []; const tempDirs: string[] = []; @@ -41,33 +38,48 @@ describe("workflow lifecycle listener and admission races", () => { for (const listenerEvent of ["message_start", "message_end"] as const) { test(`a one-shot public ${listenerEvent} listener error cannot duplicate an admitted lifecycle card`, async () => { const store = createStore(); - store.recordRunStart({ id: `run-${listenerEvent}`, name: listenerEvent, inputs: {}, status: "running", stages: [], startedAt: 1 }); + store.recordRunStart({ + id: `run-${listenerEvent}`, + name: listenerEvent, + inputs: {}, + status: "running", + stages: [], + startedAt: 1, + }); const harness = await createHarness({ fauxProvider: { tokensPerSecond: 100, tokenSize: { min: 1, max: 1 } } }); harnesses.push(harness); - unsubscriptions.push(installWorkflowLifecycleNotifications({ - store, - config: lifecycleConfig, - seedExisting: false, - sendMessage: (message, options) => harness.session.sendCustomMessage(message, options), - })); + unsubscriptions.push( + installWorkflowLifecycleNotifications({ + store, + config: lifecycleConfig, + seedExisting: false, + sendMessage: (message, options) => harness.session.sendCustomMessage(message, options), + }), + ); let terminalized = false; let threw = false; - unsubscriptions.push(harness.session.subscribe((event) => { - if (!terminalized && event.type === "message_update" && event.assistantMessageEvent.type === "text_delta") { - terminalized = true; - assert.equal(store.recordRunEnd(`run-${listenerEvent}`, "failed", { error: "boom" }), true); - return; - } - if ( - !threw && - event.type === listenerEvent && - event.message.role === "custom" && - event.message.customType === LIFECYCLE_NOTICE_CUSTOM_TYPE - ) { - threw = true; - throw new Error(`one-shot ${listenerEvent} subscriber failure after durable append`); - } - })); + unsubscriptions.push( + harness.session.subscribe((event) => { + if ( + !terminalized && + event.type === "message_update" && + event.assistantMessageEvent.type === "text_delta" + ) { + terminalized = true; + assert.equal(store.recordRunEnd(`run-${listenerEvent}`, "failed", { error: "boom" }), true); + return; + } + if ( + !threw && + event.type === listenerEvent && + event.message.role === "custom" && + event.message.customType === LIFECYCLE_NOTICE_CUSTOM_TYPE + ) { + threw = true; + throw new Error(`one-shot ${listenerEvent} subscriber failure after durable append`); + } + }), + ); harness.setResponses([ fauxAssistantMessage("This stale response is still proceeding."), fauxAssistantMessage(`Correction: ${listenerEvent} failed.`), @@ -78,9 +90,13 @@ describe("workflow lifecycle listener and admission races", () => { assert.equal(threw, true); assert.equal(harness.faux.state.callCount, 2, "listener failure must not make lifecycle delivery retry"); - assert.equal(harness.session.messages.filter( - (message) => message.role === "custom" && message.customType === LIFECYCLE_NOTICE_CUSTOM_TYPE, - ).length, 1, "the visible card remains live exactly once"); + assert.equal( + harness.session.messages.filter( + (message) => message.role === "custom" && message.customType === LIFECYCLE_NOTICE_CUSTOM_TYPE, + ).length, + 1, + "the visible card remains live exactly once", + ); const durable = harness.sessionManager.getEntries().filter((entry) => entry.type === "custom_message"); assert.equal(durable.filter((entry) => entry.customType === LIFECYCLE_NOTICE_CUSTOM_TYPE).length, 1); assert.equal(durable.filter((entry) => entry.customType === HIDDEN_RECONCILIATION_CUSTOM_TYPE).length, 1); @@ -89,7 +105,14 @@ describe("workflow lifecycle listener and admission races", () => { test("an unrelated prompt winning idle admission safely owns the queued reconciliation", async () => { const store = createStore(); - store.recordRunStart({ id: "idle-race", name: "idle-race", inputs: {}, status: "running", stages: [], startedAt: 1 }); + store.recordRunStart({ + id: "idle-race", + name: "idle-race", + inputs: {}, + status: "running", + stages: [], + startedAt: 1, + }); const harness = await createHarness(); harnesses.push(harness); const providerContexts: Context[] = []; @@ -106,30 +129,34 @@ describe("workflow lifecycle listener and admission races", () => { let unrelated: Promise | undefined; let delivery: Promise | undefined; let started = false; - unsubscriptions.push(harness.session.subscribe((event) => { - if ( - !started && - event.type === "message_start" && - event.message.role === "custom" && - event.message.customType === LIFECYCLE_NOTICE_CUSTOM_TYPE - ) { - started = true; - unrelated = harness.session.agent.prompt({ - role: "user", - content: [{ type: "text", text: "An unrelated user prompt won the idle admission race." }], - timestamp: Date.now(), - }); - } - })); - unsubscriptions.push(installWorkflowLifecycleNotifications({ - store, - config: lifecycleConfig, - seedExisting: false, - sendMessage(message, options) { - delivery = harness.session.sendCustomMessage(message, options); - return delivery; - }, - })); + unsubscriptions.push( + harness.session.subscribe((event) => { + if ( + !started && + event.type === "message_start" && + event.message.role === "custom" && + event.message.customType === LIFECYCLE_NOTICE_CUSTOM_TYPE + ) { + started = true; + unrelated = harness.session.agent.prompt({ + role: "user", + content: [{ type: "text", text: "An unrelated user prompt won the idle admission race." }], + timestamp: Date.now(), + }); + } + }), + ); + unsubscriptions.push( + installWorkflowLifecycleNotifications({ + store, + config: lifecycleConfig, + seedExisting: false, + sendMessage(message, options) { + delivery = harness.session.sendCustomMessage(message, options); + return delivery; + }, + }), + ); assert.equal(store.recordRunEnd("idle-race", "failed", { error: "failed immediately" }), true); await delivery; @@ -137,16 +164,28 @@ describe("workflow lifecycle listener and admission races", () => { await harness.session.agent.waitForIdle(); assert.equal(started, true); - assert.equal(providerContexts.some((context) => providerSawWorkflowState(context, "idle-race", "failed")), true); - assert.equal(harness.session.messages.filter( - (message) => message.role === "custom" && message.customType === LIFECYCLE_NOTICE_CUSTOM_TYPE, - ).length, 1); - assert.equal(harness.sessionManager.getEntries().filter( - (entry) => entry.type === "custom_message" && entry.customType === LIFECYCLE_NOTICE_CUSTOM_TYPE, - ).length, 1); - const protectedEntries = (harness.session as typeof harness.session & { - _protectedStreamingCustomMessages: object[]; - })._protectedStreamingCustomMessages; + assert.equal( + providerContexts.some((context) => providerSawWorkflowState(context, "idle-race", "failed")), + true, + ); + assert.equal( + harness.session.messages.filter( + (message) => message.role === "custom" && message.customType === LIFECYCLE_NOTICE_CUSTOM_TYPE, + ).length, + 1, + ); + assert.equal( + harness.sessionManager + .getEntries() + .filter((entry) => entry.type === "custom_message" && entry.customType === LIFECYCLE_NOTICE_CUSTOM_TYPE) + .length, + 1, + ); + const protectedEntries = ( + harness.session as typeof harness.session & { + _protectedStreamingCustomMessages: object[]; + } + )._protectedStreamingCustomMessages; assert.equal(protectedEntries.length, 0, "no protected reconciliation may be orphaned"); const unrelatedAssistant = harness.session.messages.find( (message) => message.role === "assistant" && getMessageText(message) === "Unrelated active chat finished.", @@ -159,37 +198,48 @@ describe("workflow lifecycle listener and admission races", () => { test("a one-shot hidden message_end listener error still persists the consumed boundary exactly once", async () => { const store = createStore(); - store.recordRunStart({ id: "run-hidden-listener", name: "hidden-listener", inputs: {}, status: "running", stages: [], startedAt: 1 }); + store.recordRunStart({ + id: "run-hidden-listener", + name: "hidden-listener", + inputs: {}, + status: "running", + stages: [], + startedAt: 1, + }); const sessionDir = mkdtempSync(join(tmpdir(), "atomic-lifecycle-hidden-listener-")); tempDirs.push(sessionDir); const sessionManager = SessionManager.create(process.cwd(), sessionDir); const harness = await createHarness({ sessionManager }); harnesses.push(harness); - unsubscriptions.push(installWorkflowLifecycleNotifications({ - store, - config: lifecycleConfig, - seedExisting: false, - sendMessage: (message, options) => harness.session.sendCustomMessage(message, options), - })); + unsubscriptions.push( + installWorkflowLifecycleNotifications({ + store, + config: lifecycleConfig, + seedExisting: false, + sendMessage: (message, options) => harness.session.sendCustomMessage(message, options), + }), + ); let terminalized = false; let threw = false; let reconciliationContext: Context | undefined; - unsubscriptions.push(harness.session.subscribe((event) => { - if (!terminalized && event.type === "message_update" && event.assistantMessageEvent.type === "text_delta") { - terminalized = true; - assert.equal(store.recordRunEnd("run-hidden-listener", "failed", undefined, "boom"), true); - return; - } - if ( - !threw && - event.type === "message_end" && - event.message.role === "custom" && - event.message.customType === HIDDEN_RECONCILIATION_CUSTOM_TYPE - ) { - threw = true; - throw new Error("one-shot hidden message listener failure before persistence"); - } - })); + unsubscriptions.push( + harness.session.subscribe((event) => { + if (!terminalized && event.type === "message_update" && event.assistantMessageEvent.type === "text_delta") { + terminalized = true; + assert.equal(store.recordRunEnd("run-hidden-listener", "failed", undefined, "boom"), true); + return; + } + if ( + !threw && + event.type === "message_end" && + event.message.role === "custom" && + event.message.customType === HIDDEN_RECONCILIATION_CUSTOM_TYPE + ) { + threw = true; + throw new Error("one-shot hidden message listener failure before persistence"); + } + }), + ); harness.setResponses([ fauxAssistantMessage("This stale response is still proceeding."), (context) => { @@ -207,9 +257,11 @@ describe("workflow lifecycle listener and admission races", () => { const entries = sessionManager.getEntries().filter((entry) => entry.type === "custom_message"); assert.equal(entries.filter((entry) => entry.customType === LIFECYCLE_NOTICE_CUSTOM_TYPE).length, 1); assert.equal(entries.filter((entry) => entry.customType === HIDDEN_RECONCILIATION_CUSTOM_TYPE).length, 1); - const protectedEntries = (harness.session as typeof harness.session & { - _protectedStreamingCustomMessages: object[]; - })._protectedStreamingCustomMessages; + const protectedEntries = ( + harness.session as typeof harness.session & { + _protectedStreamingCustomMessages: object[]; + } + )._protectedStreamingCustomMessages; assert.equal(protectedEntries.length, 0); const sessionFile = sessionManager.getSessionFile(); assert.ok(sessionFile); @@ -231,19 +283,24 @@ describe("workflow lifecycle listener and admission races", () => { fauxAssistantMessage("UNREQUESTED ASSISTANT RESPONSE"), ]); let delivery: Promise | undefined; - unsubscriptions.push(harness.session.subscribe((event) => { - if (!delivery && event.type === "message_update" && event.assistantMessageEvent.type === "text_delta") { - delivery = harness.session.sendCustomMessage({ - customType, - content: rawContent, - display: true, - details, - }, { - persistWhenStreaming: true, - ...(triggerTurn === undefined ? {} : { triggerTurn }), - }); - } - })); + unsubscriptions.push( + harness.session.subscribe((event) => { + if (!delivery && event.type === "message_update" && event.assistantMessageEvent.type === "text_delta") { + delivery = harness.session.sendCustomMessage( + { + customType, + content: rawContent, + display: true, + details, + }, + { + persistWhenStreaming: true, + ...(triggerTurn === undefined ? {} : { triggerTurn }), + }, + ); + } + }), + ); await harness.session.prompt("answer once"); await waitUntil(() => delivery !== undefined); @@ -252,7 +309,10 @@ describe("workflow lifecycle listener and admission races", () => { assert.equal(harness.faux.state.callCount, 1, "display-only persistence must not wake the provider"); assert.equal(harness.session.messages.filter((message) => message.role === "assistant").length, 1); - assert.equal(harness.session.messages.some((message) => getMessageText(message).includes("UNREQUESTED")), false); + assert.equal( + harness.session.messages.some((message) => getMessageText(message).includes("UNREQUESTED")), + false, + ); const cards = harness.session.messages.filter( (message) => message.role === "custom" && message.customType === customType, ); @@ -265,9 +325,9 @@ describe("workflow lifecycle listener and admission races", () => { assert.equal(card.details, details, "details object identity stays exact"); assert.equal(card.display, true); assert.equal("excludeFromContext" in card, false, "omitted optional fields stay omitted"); - const durable = harness.sessionManager.getEntries().filter( - (entry) => entry.type === "custom_message" && entry.customType === customType, - ); + const durable = harness.sessionManager + .getEntries() + .filter((entry) => entry.type === "custom_message" && entry.customType === customType); assert.equal(durable.length, 1); assert.equal(durable[0]?.type, "custom_message"); if (durable[0]?.type === "custom_message") { @@ -277,9 +337,11 @@ describe("workflow lifecycle listener and admission races", () => { assert.equal(durable[0].display, true); assert.equal("excludeFromContext" in durable[0], false); } - const protectedEntries = (harness.session as typeof harness.session & { - _protectedStreamingCustomMessages: object[]; - })._protectedStreamingCustomMessages; + const protectedEntries = ( + harness.session as typeof harness.session & { + _protectedStreamingCustomMessages: object[]; + } + )._protectedStreamingCustomMessages; assert.equal(protectedEntries.length, 0, "display-only persistence must not leave a protected orphan"); }); } @@ -302,28 +364,44 @@ describe("workflow lifecycle listener and admission races", () => { }, ]); let delivery: Promise | undefined; - unsubscriptions.push(harness.session.subscribe((event) => { - if (!delivery && event.type === "message_update" && event.assistantMessageEvent.type === "text_delta") { - delivery = harness.session.sendCustomMessage({ - customType, - content: sentinel, - display: true, - details, - }, { triggerTurn: true, persistWhenStreaming: true, excludeFromContext: true }); - } - })); + unsubscriptions.push( + harness.session.subscribe((event) => { + if (!delivery && event.type === "message_update" && event.assistantMessageEvent.type === "text_delta") { + delivery = harness.session.sendCustomMessage( + { + customType, + content: sentinel, + display: true, + details, + }, + { triggerTurn: true, persistWhenStreaming: true, excludeFromContext: true }, + ); + } + }), + ); await harness.session.prompt("answer once"); await waitUntil(() => delivery !== undefined); await delivery; await harness.session.agent.waitForIdle(); - assert.equal(harness.faux.state.callCount, 1, "excluded status must preserve the active turn's one provider call"); - assert.equal(providerContexts.some((context) => context.messages.some( - (message) => getMessageText(message).includes(sentinel), - )), false, "excluded raw content must never reach a provider"); + assert.equal( + harness.faux.state.callCount, + 1, + "excluded status must preserve the active turn's one provider call", + ); + assert.equal( + providerContexts.some((context) => + context.messages.some((message) => getMessageText(message).includes(sentinel)), + ), + false, + "excluded raw content must never reach a provider", + ); assert.equal(harness.session.messages.filter((message) => message.role === "assistant").length, 1); - assert.equal(harness.session.messages.some((message) => getMessageText(message).includes("UNREQUESTED PRIVATE")), false); + assert.equal( + harness.session.messages.some((message) => getMessageText(message).includes("UNREQUESTED PRIVATE")), + false, + ); const cards = harness.session.messages.filter( (message) => message.role === "custom" && message.customType === customType, ); @@ -337,12 +415,19 @@ describe("workflow lifecycle listener and admission races", () => { assert.equal("optionalNote" in details, false, "omitted detail fields stay omitted"); assert.equal(card.display, true); assert.equal((card as typeof card & { excludeFromContext?: boolean }).excludeFromContext, true); - assert.equal(harness.events.filter( - (event) => event.type === "message_start" && event.message.role === "custom" && event.message.customType === customType, - ).length, 1, "the excluded card is displayed exactly once"); - const durable = harness.sessionManager.getEntries().filter( - (entry) => entry.type === "custom_message" && entry.customType === customType, + assert.equal( + harness.events.filter( + (event) => + event.type === "message_start" && + event.message.role === "custom" && + event.message.customType === customType, + ).length, + 1, + "the excluded card is displayed exactly once", ); + const durable = harness.sessionManager + .getEntries() + .filter((entry) => entry.type === "custom_message" && entry.customType === customType); assert.equal(durable.length, 1); assert.equal(durable[0]?.type, "custom_message"); if (durable[0]?.type === "custom_message") { @@ -352,12 +437,19 @@ describe("workflow lifecycle listener and admission races", () => { assert.equal(durable[0].display, true); assert.equal(durable[0].excludeFromContext, true); } - assert.equal(harness.sessionManager.getEntries().filter( - (entry) => entry.type === "custom_message" && entry.customType === HIDDEN_RECONCILIATION_CUSTOM_TYPE, - ).length, 0); - const protectedEntries = (harness.session as typeof harness.session & { - _protectedStreamingCustomMessages: object[]; - })._protectedStreamingCustomMessages; + assert.equal( + harness.sessionManager + .getEntries() + .filter( + (entry) => entry.type === "custom_message" && entry.customType === HIDDEN_RECONCILIATION_CUSTOM_TYPE, + ).length, + 0, + ); + const protectedEntries = ( + harness.session as typeof harness.session & { + _protectedStreamingCustomMessages: object[]; + } + )._protectedStreamingCustomMessages; assert.equal(protectedEntries.length, 0, "excluded content must not leave a protected orphan"); }); @@ -367,21 +459,29 @@ describe("workflow lifecycle listener and admission races", () => { const sentinel = "IDLE-SECRET-MUST-NOT-ENTER-PROVIDER"; const details = { marker: "idle-verbatim" }; let providerContext: Context | undefined; - harness.setResponses([(context) => { - providerContext = context; - return fauxAssistantMessage("Idle excluded delivery completed."); - }]); + harness.setResponses([ + (context) => { + providerContext = context; + return fauxAssistantMessage("Idle excluded delivery completed."); + }, + ]); - await harness.session.sendCustomMessage({ - customType: "review:idle-private-status", - content: sentinel, - display: true, - details, - }, { triggerTurn: true, persistWhenStreaming: true, excludeFromContext: true }); + await harness.session.sendCustomMessage( + { + customType: "review:idle-private-status", + content: sentinel, + display: true, + details, + }, + { triggerTurn: true, persistWhenStreaming: true, excludeFromContext: true }, + ); await harness.session.agent.waitForIdle(); assert.equal(harness.faux.state.callCount, 1, "the direct idle trigger still owns one provider turn"); - assert.equal(providerContext?.messages.some((message) => getMessageText(message).includes(sentinel)), false); + assert.equal( + providerContext?.messages.some((message) => getMessageText(message).includes(sentinel)), + false, + ); const cards = harness.session.messages.filter( (message) => message.role === "custom" && message.customType === "review:idle-private-status", ); @@ -393,9 +493,9 @@ describe("workflow lifecycle listener and admission races", () => { assert.equal(card.details, details); assert.equal(card.display, true); assert.equal((card as typeof card & { excludeFromContext?: boolean }).excludeFromContext, true); - const durable = harness.sessionManager.getEntries().filter( - (entry) => entry.type === "custom_message" && entry.customType === "review:idle-private-status", - ); + const durable = harness.sessionManager + .getEntries() + .filter((entry) => entry.type === "custom_message" && entry.customType === "review:idle-private-status"); assert.equal(durable.length, 1); assert.equal(durable[0]?.type, "custom_message"); if (durable[0]?.type === "custom_message") { @@ -403,12 +503,19 @@ describe("workflow lifecycle listener and admission races", () => { assert.deepEqual(durable[0].details, details); assert.equal(durable[0].excludeFromContext, true); } - assert.equal(harness.sessionManager.getEntries().filter( - (entry) => entry.type === "custom_message" && entry.customType === HIDDEN_RECONCILIATION_CUSTOM_TYPE, - ).length, 0); - const protectedEntries = (harness.session as typeof harness.session & { - _protectedStreamingCustomMessages: object[]; - })._protectedStreamingCustomMessages; + assert.equal( + harness.sessionManager + .getEntries() + .filter( + (entry) => entry.type === "custom_message" && entry.customType === HIDDEN_RECONCILIATION_CUSTOM_TYPE, + ).length, + 0, + ); + const protectedEntries = ( + harness.session as typeof harness.session & { + _protectedStreamingCustomMessages: object[]; + } + )._protectedStreamingCustomMessages; assert.equal(protectedEntries.length, 0, "idle exclusion must not create a protected copy"); }); }); diff --git a/test/unit/workflow-lifecycle-parent-reconciliation-support.ts b/test/unit/workflow-lifecycle-parent-reconciliation-support.ts index 8a4c4f786..0aba4ec07 100644 --- a/test/unit/workflow-lifecycle-parent-reconciliation-support.ts +++ b/test/unit/workflow-lifecycle-parent-reconciliation-support.ts @@ -17,15 +17,22 @@ export function assertWorkflowToolOrdering(context: { messages: Context["message assert.equal((toolResult.details as { status?: string } | undefined)?.status, "running"); const callId = toolResult.toolCallId; const assistantIndex = context.messages.findIndex( - (message) => message.role === "assistant" && message.content.some( - (part) => part.type === "toolCall" && part.id === callId && part.name === "workflow", - ), + (message) => + message.role === "assistant" && + message.content.some((part) => part.type === "toolCall" && part.id === callId && part.name === "workflow"), + ); + assert.equal( + toolResultIndex, + assistantIndex + 1, + "no lifecycle user turn may split the workflow tool call from its result", ); - assert.equal(toolResultIndex, assistantIndex + 1, "no lifecycle user turn may split the workflow tool call from its result"); } export function providerSawWorkflowState(context: Context | undefined, workflowName: string, state: string): boolean { - return context?.messages.some( - (message) => message.role === "user" && getMessageText(message).includes(`Workflow "${workflowName}" ${state}`), - ) === true; + return ( + context?.messages.some( + (message) => + message.role === "user" && getMessageText(message).includes(`Workflow "${workflowName}" ${state}`), + ) === true + ); } diff --git a/test/unit/workflow-lifecycle-parent-reconciliation-teardown.test.ts b/test/unit/workflow-lifecycle-parent-reconciliation-teardown.test.ts index 9be264184..6d4181785 100644 --- a/test/unit/workflow-lifecycle-parent-reconciliation-teardown.test.ts +++ b/test/unit/workflow-lifecycle-parent-reconciliation-teardown.test.ts @@ -1,122 +1,140 @@ -import { afterEach, describe, test } from "bun:test"; import assert from "node:assert/strict"; import { fauxAssistantMessage } from "@earendil-works/pi-ai/compat"; +import { afterEach, describe, test } from "vitest"; +import { PROTECTED_RECONCILIATION_CUSTOM_TYPE } from "../../packages/coding-agent/src/core/agent-session-persistent-custom-messages.js"; import { AgentSessionRuntime } from "../../packages/coding-agent/src/core/agent-session-runtime.js"; import type { AgentSessionServices } from "../../packages/coding-agent/src/core/agent-session-services.js"; -import { PROTECTED_RECONCILIATION_CUSTOM_TYPE } from "../../packages/coding-agent/src/core/agent-session-persistent-custom-messages.js"; import { createHarness, type Harness } from "../../packages/coding-agent/test/suite/harness.js"; import { installWorkflowLifecycleNotifications } from "../../packages/workflows/src/extension/lifecycle-notifications.js"; import { createStore } from "../../packages/workflows/src/shared/store.js"; import { lifecycleConfig } from "./workflow-lifecycle-parent-reconciliation-support.js"; describe("workflow lifecycle parent reconciliation teardown", () => { - const harnesses: Harness[] = []; - const unsubscriptions: Array<() => void> = []; + const harnesses: Harness[] = []; + const unsubscriptions: Array<() => void> = []; - afterEach(() => { - while (unsubscriptions.length > 0) unsubscriptions.pop()?.(); - while (harnesses.length > 0) harnesses.pop()?.cleanup(); - }); + afterEach(() => { + while (unsubscriptions.length > 0) unsubscriptions.pop()?.(); + while (harnesses.length > 0) harnesses.pop()?.cleanup(); + }); - test("permanent consumed-reconciliation persistence failure stops host replacement before invalidation", async () => { - const store = createStore(); - store.recordRunStart({ - id: "run-permanent-persistence", - name: "permanent-persistence", - inputs: {}, - status: "running", - stages: [], - startedAt: 1, - }); - const harness = await createHarness({ - fauxProvider: { tokensPerSecond: 100, tokenSize: { min: 1, max: 1 } }, - }); - harnesses.push(harness); - const oldSession = harness.session; - const appendCustomMessageEntry = oldSession.sessionManager.appendCustomMessageEntry.bind(oldSession.sessionManager); - let hiddenPersistenceAttempts = 0; - oldSession.sessionManager.appendCustomMessageEntry = ((customType, content, display, details, excludeFromContext) => { - if (customType === PROTECTED_RECONCILIATION_CUSTOM_TYPE) { - hiddenPersistenceAttempts += 1; - throw new Error("permanent hidden reconciliation write failure"); - } - return appendCustomMessageEntry(customType, content, display, details, excludeFromContext); - }) as typeof oldSession.sessionManager.appendCustomMessageEntry; + test("permanent consumed-reconciliation persistence failure stops host replacement before invalidation", async () => { + const store = createStore(); + store.recordRunStart({ + id: "run-permanent-persistence", + name: "permanent-persistence", + inputs: {}, + status: "running", + stages: [], + startedAt: 1, + }); + const harness = await createHarness({ + fauxProvider: { tokensPerSecond: 100, tokenSize: { min: 1, max: 1 } }, + }); + harnesses.push(harness); + const oldSession = harness.session; + const appendCustomMessageEntry = oldSession.sessionManager.appendCustomMessageEntry.bind( + oldSession.sessionManager, + ); + let hiddenPersistenceAttempts = 0; + oldSession.sessionManager.appendCustomMessageEntry = (( + customType, + content, + display, + details, + excludeFromContext, + ) => { + if (customType === PROTECTED_RECONCILIATION_CUSTOM_TYPE) { + hiddenPersistenceAttempts += 1; + throw new Error("permanent hidden reconciliation write failure"); + } + return appendCustomMessageEntry(customType, content, display, details, excludeFromContext); + }) as typeof oldSession.sessionManager.appendCustomMessageEntry; - let runtime: AgentSessionRuntime | undefined; - try { - unsubscriptions.push(installWorkflowLifecycleNotifications({ - store, - config: lifecycleConfig, - seedExisting: false, - sendMessage: (message, options) => oldSession.sendCustomMessage(message, options), - })); - let terminalized = false; - unsubscriptions.push(oldSession.subscribe((event) => { - if (!terminalized && event.type === "message_update" && event.assistantMessageEvent.type === "text_delta") { - terminalized = true; - assert.equal(store.recordRunEnd("run-permanent-persistence", "completed", {}), true); - } - })); - harness.setResponses([ - fauxAssistantMessage("This stale response is still proceeding while the workflow completes."), - fauxAssistantMessage("permanent-persistence completed and was reconciled."), - ]); + let runtime: AgentSessionRuntime | undefined; + try { + unsubscriptions.push( + installWorkflowLifecycleNotifications({ + store, + config: lifecycleConfig, + seedExisting: false, + sendMessage: (message, options) => oldSession.sendCustomMessage(message, options), + }), + ); + let terminalized = false; + unsubscriptions.push( + oldSession.subscribe((event) => { + if ( + !terminalized && + event.type === "message_update" && + event.assistantMessageEvent.type === "text_delta" + ) { + terminalized = true; + assert.equal(store.recordRunEnd("run-permanent-persistence", "completed", {}), true); + } + }), + ); + harness.setResponses([ + fauxAssistantMessage("This stale response is still proceeding while the workflow completes."), + fauxAssistantMessage("permanent-persistence completed and was reconciled."), + ]); - await oldSession.prompt("Wait for permanent-persistence."); - await oldSession.agent.waitForIdle(); + await oldSession.prompt("Wait for permanent-persistence."); + await oldSession.agent.waitForIdle(); - const protectedEntries = (oldSession as typeof oldSession & { - _protectedStreamingCustomMessages: Array<{ - readonly message: object; - readonly delivery: "steer" | "followUp"; - phase: "queued" | "consumed-unpersisted" | "persistence-failed"; - }>; - })._protectedStreamingCustomMessages; - assert.equal(terminalized, true); - assert.ok(hiddenPersistenceAttempts >= 1); - assert.equal(protectedEntries.length, 1); - const protectedEntry = protectedEntries[0]; - assert.ok(protectedEntry); - assert.equal(protectedEntry.phase, "persistence-failed"); + const protectedEntries = ( + oldSession as typeof oldSession & { + _protectedStreamingCustomMessages: Array<{ + readonly message: object; + readonly delivery: "steer" | "followUp"; + phase: "queued" | "consumed-unpersisted" | "persistence-failed"; + }>; + } + )._protectedStreamingCustomMessages; + assert.equal(terminalized, true); + assert.ok(hiddenPersistenceAttempts >= 1); + assert.equal(protectedEntries.length, 1); + const protectedEntry = protectedEntries[0]; + assert.ok(protectedEntry); + assert.equal(protectedEntry.phase, "persistence-failed"); - let createRuntimeCalls = 0; - let beforeSessionInvalidateCalls = 0; - let rebindCalls = 0; - runtime = new AgentSessionRuntime( - oldSession, - { - cwd: oldSession.sessionManager.getCwd(), - agentDir: harness.tempDir, - } as AgentSessionServices, - async () => { - createRuntimeCalls += 1; - throw new Error("replacement factory must not run"); - }, - ); - runtime.setBeforeSessionInvalidate(() => { - beforeSessionInvalidateCalls += 1; - }); - runtime.setRebindSession(async () => { - rebindCalls += 1; - }); - const liveContextCwd = oldSession.extensionRunner.createContext().cwd; + let createRuntimeCalls = 0; + let beforeSessionInvalidateCalls = 0; + let rebindCalls = 0; + runtime = new AgentSessionRuntime( + oldSession, + { + cwd: oldSession.sessionManager.getCwd(), + agentDir: harness.tempDir, + } as AgentSessionServices, + async () => { + createRuntimeCalls += 1; + throw new Error("replacement factory must not run"); + }, + ); + runtime.setBeforeSessionInvalidate(() => { + beforeSessionInvalidateCalls += 1; + }); + runtime.setRebindSession(async () => { + rebindCalls += 1; + }); + const liveContextCwd = oldSession.extensionRunner.createContext().cwd; - await assert.rejects(runtime.newSession(), /permanent hidden reconciliation write failure/); + await assert.rejects(runtime.newSession(), /permanent hidden reconciliation write failure/); - assert.equal(hiddenPersistenceAttempts >= 2, true, "host teardown must make the final persistence attempt"); - assert.equal(beforeSessionInvalidateCalls, 0); - assert.equal(createRuntimeCalls, 0); - assert.equal(rebindCalls, 0); - assert.equal(runtime.session, oldSession, "the host must retain the recoverable session"); - assert.equal(oldSession.extensionRunner.createContext().cwd, liveContextCwd, "extensions must remain valid"); - assert.equal(protectedEntries.length, 1, "protected recovery state must not be discarded"); - assert.equal(protectedEntries[0], protectedEntry); - assert.equal(protectedEntry.phase, "persistence-failed"); - } finally { - runtime?.setBeforeSessionInvalidate(undefined); - runtime?.setRebindSession(undefined); - oldSession.sessionManager.appendCustomMessageEntry = appendCustomMessageEntry; - } - }); + assert.equal(hiddenPersistenceAttempts >= 2, true, "host teardown must make the final persistence attempt"); + assert.equal(beforeSessionInvalidateCalls, 0); + assert.equal(createRuntimeCalls, 0); + assert.equal(rebindCalls, 0); + assert.equal(runtime.session, oldSession, "the host must retain the recoverable session"); + assert.equal(oldSession.extensionRunner.createContext().cwd, liveContextCwd, "extensions must remain valid"); + assert.equal(protectedEntries.length, 1, "protected recovery state must not be discarded"); + assert.equal(protectedEntries[0], protectedEntry); + assert.equal(protectedEntry.phase, "persistence-failed"); + } finally { + runtime?.setBeforeSessionInvalidate(undefined); + runtime?.setRebindSession(undefined); + oldSession.sessionManager.appendCustomMessageEntry = appendCustomMessageEntry; + } + }); }); diff --git a/test/unit/workflow-lifecycle-parent-reconciliation.test.ts b/test/unit/workflow-lifecycle-parent-reconciliation.test.ts index d6ff28b86..6880bb80c 100644 --- a/test/unit/workflow-lifecycle-parent-reconciliation.test.ts +++ b/test/unit/workflow-lifecycle-parent-reconciliation.test.ts @@ -1,14 +1,14 @@ -import { afterEach, describe, test } from "bun:test"; import assert from "node:assert/strict"; import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import type { AgentTool } from "@earendil-works/pi-agent-core"; -import { fauxAssistantMessage, fauxToolCall, type Context } from "@earendil-works/pi-ai/compat"; +import { type Context, fauxAssistantMessage, fauxToolCall } from "@earendil-works/pi-ai/compat"; import { Type } from "typebox"; -import { createHarness, getMessageText, type Harness } from "../../packages/coding-agent/test/suite/harness.js"; -import { SessionManager } from "../../packages/coding-agent/src/core/session-manager.js"; +import { afterEach, describe, test } from "vitest"; import { PROTECTED_RECONCILIATION_CUSTOM_TYPE } from "../../packages/coding-agent/src/core/agent-session-persistent-custom-messages.js"; +import { SessionManager } from "../../packages/coding-agent/src/core/session-manager.js"; +import { createHarness, getMessageText, type Harness } from "../../packages/coding-agent/test/suite/harness.js"; import { installWorkflowLifecycleNotifications, LIFECYCLE_NOTICE_CUSTOM_TYPE, @@ -44,18 +44,22 @@ describe("workflow lifecycle parent reconciliation", () => { description: "Launch a named workflow", parameters: Type.Object({}), execute: async () => ({ - content: [{ type: "text", text: "Workflow fast-final started in background (run-fast-final). Status: running" }], + content: [ + { type: "text", text: "Workflow fast-final started in background (run-fast-final). Status: running" }, + ], details: { action: "run", runId: "run-fast-final", status: "running" }, }), }; const harness = await createHarness({ tools: [workflowTool] }); harnesses.push(harness); - unsubscriptions.push(installWorkflowLifecycleNotifications({ - store, - config: lifecycleConfig, - seedExisting: false, - sendMessage: (message, options) => harness.session.sendCustomMessage(message, options), - })); + unsubscriptions.push( + installWorkflowLifecycleNotifications({ + store, + config: lifecycleConfig, + seedExisting: false, + sendMessage: (message, options) => harness.session.sendCustomMessage(message, options), + }), + ); let terminalized = false; let reconciliationContext: Context | undefined; @@ -74,7 +78,9 @@ describe("workflow lifecycle parent reconciliation", () => { }); unsubscriptions.push(unsubscribeEvents); harness.setResponses([ - fauxAssistantMessage(fauxToolCall("workflow", {}, { id: "workflow-call-fast-final" }), { stopReason: "toolUse" }), + fauxAssistantMessage(fauxToolCall("workflow", {}, { id: "workflow-call-fast-final" }), { + stopReason: "toolUse", + }), fauxAssistantMessage("The workflow is still proceeding; I will keep monitoring it."), (context) => { reconciliationContext = context; @@ -102,7 +108,10 @@ describe("workflow lifecycle parent reconciliation", () => { assert.equal(card?.role, "custom"); if (card?.role !== "custom") throw new Error("missing lifecycle custom card"); assert.equal(card.display, true); - assert.equal(card.content, '✓ Workflow "fast-final" completed (run run-fast-final). Inspect: /workflow status run-fast-final'); + assert.equal( + card.content, + '✓ Workflow "fast-final" completed (run run-fast-final). Inspect: /workflow status run-fast-final', + ); const cardDetails = card.details as WorkflowLifecycleNoticeDetails | undefined; assert.equal(cardDetails?.kind, "completed"); assert.equal(cardDetails?.scope, "run"); @@ -112,9 +121,9 @@ describe("workflow lifecycle parent reconciliation", () => { const terminalRun = store.runs().find((run) => run.id === "run-fast-final"); assert.equal(cardDetails?.durationMs, terminalRun?.durationMs); assert.equal(cardDetails?.createdAt, terminalRun?.endedAt); - const persistedCards = harness.sessionManager.getEntries().filter( - (entry) => entry.type === "custom_message" && entry.customType === LIFECYCLE_NOTICE_CUSTOM_TYPE, - ); + const persistedCards = harness.sessionManager + .getEntries() + .filter((entry) => entry.type === "custom_message" && entry.customType === LIFECYCLE_NOTICE_CUSTOM_TYPE); assert.equal(persistedCards.length, 1, "terminal card must be durable exactly once"); assert.equal(persistedCards[0]?.type, "custom_message"); if (persistedCards[0]?.type === "custom_message") { @@ -125,11 +134,9 @@ describe("workflow lifecycle parent reconciliation", () => { (message) => message.role === "assistant" && getMessageText(message).includes("still proceeding"), ); assert.equal(staleFinal?.role, "assistant"); - if (staleFinal?.role === "assistant") assert.equal(staleFinal.stopReason, "stop", "lifecycle delivery must not interrupt unrelated final text"); - assert.equal( - harness.session.getLastAssistantText(), - "Correction: fast-final already completed successfully.", - ); + if (staleFinal?.role === "assistant") + assert.equal(staleFinal.stopReason, "stop", "lifecycle delivery must not interrupt unrelated final text"); + assert.equal(harness.session.getLastAssistantText(), "Correction: fast-final already completed successfully."); }); test("clearQueue at the core-local in-flight boundary does not restore a duplicate notice alias", async () => { @@ -144,27 +151,33 @@ describe("workflow lifecycle parent reconciliation", () => { }); const harness = await createHarness(); harnesses.push(harness); - unsubscriptions.push(installWorkflowLifecycleNotifications({ - store, - config: lifecycleConfig, - seedExisting: false, - sendMessage: (message, options) => harness.session.sendCustomMessage(message, options), - })); + unsubscriptions.push( + installWorkflowLifecycleNotifications({ + store, + config: lifecycleConfig, + seedExisting: false, + sendMessage: (message, options) => harness.session.sendCustomMessage(message, options), + }), + ); let terminalized = false; let clearedInFlight = false; let reconciliationContext: Context | undefined; - unsubscriptions.push(harness.session.subscribe((event) => { - if (!terminalized && event.type === "message_update" && event.assistantMessageEvent.type === "text_delta") { - terminalized = true; - assert.equal(store.recordRunEnd("run-clear-in-flight", "completed", {}), true); - } - })); - unsubscriptions.push(harness.session.agent.subscribe((event) => { - if (terminalized && !clearedInFlight && event.type === "turn_start") { - clearedInFlight = true; - harness.session.clearQueue(); - } - })); + unsubscriptions.push( + harness.session.subscribe((event) => { + if (!terminalized && event.type === "message_update" && event.assistantMessageEvent.type === "text_delta") { + terminalized = true; + assert.equal(store.recordRunEnd("run-clear-in-flight", "completed", {}), true); + } + }), + ); + unsubscriptions.push( + harness.session.agent.subscribe((event) => { + if (terminalized && !clearedInFlight && event.type === "turn_start") { + clearedInFlight = true; + harness.session.clearQueue(); + } + }), + ); harness.setResponses([ fauxAssistantMessage("This stale response finishes without lifecycle interruption."), (context) => { @@ -180,7 +193,8 @@ describe("workflow lifecycle parent reconciliation", () => { assert.ok(reconciliationContext); assert.equal( reconciliationContext.messages.filter( - (message) => message.role === "user" && getMessageText(message).includes('Workflow "clear-in-flight" completed'), + (message) => + message.role === "user" && getMessageText(message).includes('Workflow "clear-in-flight" completed'), ).length, 1, ); @@ -191,15 +205,16 @@ describe("workflow lifecycle parent reconciliation", () => { 1, ); assert.equal( - harness.sessionManager.getEntries().filter( - (entry) => entry.type === "custom_message" && entry.customType === LIFECYCLE_NOTICE_CUSTOM_TYPE, - ).length, + harness.sessionManager + .getEntries() + .filter((entry) => entry.type === "custom_message" && entry.customType === LIFECYCLE_NOTICE_CUSTOM_TYPE) + .length, 1, ); assert.equal( - harness.sessionManager.getEntries().filter( - (entry) => entry.type === "custom_message" && entry.display === false, - ).length, + harness.sessionManager + .getEntries() + .filter((entry) => entry.type === "custom_message" && entry.display === false).length, 1, "clear at the in-flight boundary must persist one hidden reconciliation entry", ); @@ -218,25 +233,35 @@ describe("workflow lifecycle parent reconciliation", () => { harnesses.push(harness); const appendCustomMessageEntry = harness.sessionManager.appendCustomMessageEntry.bind(harness.sessionManager); let hiddenPersistenceAttempts = 0; - harness.sessionManager.appendCustomMessageEntry = ((customType, content, display, details, excludeFromContext) => { + harness.sessionManager.appendCustomMessageEntry = (( + customType, + content, + display, + details, + excludeFromContext, + ) => { if (display === false && hiddenPersistenceAttempts++ === 0) { throw new Error("transient hidden reconciliation write failure"); } return appendCustomMessageEntry(customType, content, display, details, excludeFromContext); }) as typeof harness.sessionManager.appendCustomMessageEntry; - unsubscriptions.push(installWorkflowLifecycleNotifications({ - store, - config: lifecycleConfig, - seedExisting: false, - sendMessage: (message, options) => harness.session.sendCustomMessage(message, options), - })); + unsubscriptions.push( + installWorkflowLifecycleNotifications({ + store, + config: lifecycleConfig, + seedExisting: false, + sendMessage: (message, options) => harness.session.sendCustomMessage(message, options), + }), + ); let terminalized = false; - unsubscriptions.push(harness.session.subscribe((event) => { - if (!terminalized && event.type === "message_update" && event.assistantMessageEvent.type === "text_delta") { - terminalized = true; - assert.equal(store.recordRunEnd("run-persist-retry", "completed", {}), true); - } - })); + unsubscriptions.push( + harness.session.subscribe((event) => { + if (!terminalized && event.type === "message_update" && event.assistantMessageEvent.type === "text_delta") { + terminalized = true; + assert.equal(store.recordRunEnd("run-persist-retry", "completed", {}), true); + } + }), + ); harness.setResponses([ fauxAssistantMessage("Stale until persistence retry."), fauxAssistantMessage("persist-retry corrected."), @@ -254,7 +279,11 @@ describe("workflow lifecycle parent reconciliation", () => { ); const customEntries = harness.sessionManager.getEntries().filter((entry) => entry.type === "custom_message"); assert.equal(customEntries.filter((entry) => entry.customType === LIFECYCLE_NOTICE_CUSTOM_TYPE).length, 1); - assert.equal(customEntries.filter((entry) => entry.display === false).length, 1, "one hidden reconciliation is durable after retry"); + assert.equal( + customEntries.filter((entry) => entry.display === false).length, + 1, + "one hidden reconciliation is durable after retry", + ); }); test("session disposal flushes a consumed reconciliation after repeated transient write failures", async () => { const store = createStore(); @@ -273,50 +302,63 @@ describe("workflow lifecycle parent reconciliation", () => { harnesses.push(harness); const appendCustomMessageEntry = harness.sessionManager.appendCustomMessageEntry.bind(harness.sessionManager); let hiddenPersistenceAttempts = 0; - harness.sessionManager.appendCustomMessageEntry = ((customType, content, display, details, excludeFromContext) => { + harness.sessionManager.appendCustomMessageEntry = (( + customType, + content, + display, + details, + excludeFromContext, + ) => { if (customType === PROTECTED_RECONCILIATION_CUSTOM_TYPE && hiddenPersistenceAttempts++ < 2) { throw new Error("repeated transient hidden reconciliation write failure"); } return appendCustomMessageEntry(customType, content, display, details, excludeFromContext); }) as typeof harness.sessionManager.appendCustomMessageEntry; - unsubscriptions.push(installWorkflowLifecycleNotifications({ - store, - config: lifecycleConfig, - seedExisting: false, - sendMessage: (message, options) => harness.session.sendCustomMessage(message, options), - })); + unsubscriptions.push( + installWorkflowLifecycleNotifications({ + store, + config: lifecycleConfig, + seedExisting: false, + sendMessage: (message, options) => harness.session.sendCustomMessage(message, options), + }), + ); let terminalized = false; let disposeScheduled = false; let resolveDisposed!: () => void; const disposed = new Promise((resolve) => { resolveDisposed = resolve; }); - unsubscriptions.push(harness.session.subscribe((event) => { - if (!terminalized && event.type === "message_update" && event.assistantMessageEvent.type === "text_delta") { - terminalized = true; - assert.equal(store.recordRunEnd("run-dispose-retry", "failed", { error: "boom" }), true); - return; - } - if ( - !disposeScheduled && - event.type === "message_end" && - event.message.role === "custom" && - event.message.customType === PROTECTED_RECONCILIATION_CUSTOM_TYPE - ) { - disposeScheduled = true; - queueMicrotask(() => { - harness.session.dispose(); - resolveDisposed(); - }); - throw new Error("listener failure before session replacement"); - } - })); + unsubscriptions.push( + harness.session.subscribe((event) => { + if (!terminalized && event.type === "message_update" && event.assistantMessageEvent.type === "text_delta") { + terminalized = true; + assert.equal(store.recordRunEnd("run-dispose-retry", "failed", { error: "boom" }), true); + return; + } + if ( + !disposeScheduled && + event.type === "message_end" && + event.message.role === "custom" && + event.message.customType === PROTECTED_RECONCILIATION_CUSTOM_TYPE + ) { + disposeScheduled = true; + queueMicrotask(() => { + harness.session.dispose(); + resolveDisposed(); + }); + throw new Error("listener failure before session replacement"); + } + }), + ); harness.setResponses([ fauxAssistantMessage("This stale response is still proceeding."), fauxAssistantMessage("dispose-retry failed and was reconciled."), ]); - await assert.rejects(harness.session.prompt("Wait for dispose-retry."), /listener failure before session replacement/); + await assert.rejects( + harness.session.prompt("Wait for dispose-retry."), + /listener failure before session replacement/, + ); await disposed; assert.equal(disposeScheduled, true); @@ -326,41 +368,54 @@ describe("workflow lifecycle parent reconciliation", () => { const reopened = SessionManager.open(sessionFile, harness.sessionManager.getSessionDir()); const customEntries = reopened.getEntries().filter((entry) => entry.type === "custom_message"); assert.equal(customEntries.filter((entry) => entry.customType === LIFECYCLE_NOTICE_CUSTOM_TYPE).length, 1); - assert.equal(customEntries.filter((entry) => entry.customType === PROTECTED_RECONCILIATION_CUSTOM_TYPE).length, 1); + assert.equal( + customEntries.filter((entry) => entry.customType === PROTECTED_RECONCILIATION_CUSTOM_TYPE).length, + 1, + ); }); test("stage delivery transfer moves queued notice protection while source keeps its core-local in-flight notice", async () => { const store = createStore(); - for (const [id, name] of [["run-transfer-a", "transfer-a"], ["run-transfer-b", "transfer-b"]] as const) { + for (const [id, name] of [ + ["run-transfer-a", "transfer-a"], + ["run-transfer-b", "transfer-b"], + ] as const) { store.recordRunStart({ id, name, inputs: {}, status: "running", stages: [], startedAt: 1 }); } const source = await createHarness(); const target = await createHarness(); harnesses.push(source, target); - unsubscriptions.push(installWorkflowLifecycleNotifications({ - store, - config: lifecycleConfig, - seedExisting: false, - sendMessage: (message, options) => source.session.sendCustomMessage(message, options), - })); + unsubscriptions.push( + installWorkflowLifecycleNotifications({ + store, + config: lifecycleConfig, + seedExisting: false, + sendMessage: (message, options) => source.session.sendCustomMessage(message, options), + }), + ); let terminalized = false; let transferred = false; let sourceContext: Context | undefined; let targetContext: Context | undefined; - unsubscriptions.push(source.session.subscribe((event) => { - if (!terminalized && event.type === "message_update" && event.assistantMessageEvent.type === "text_delta") { - terminalized = true; - assert.equal(store.recordRunEnd("run-transfer-a", "completed", {}), true); - assert.equal(store.recordRunEnd("run-transfer-b", "completed", {}), true); - } - })); - unsubscriptions.push(source.session.agent.subscribe((event) => { - if (terminalized && !transferred && event.type === "turn_start") { - transferred = true; - (source.session as typeof source.session & { transferWorkflowStageDeliveriesTo(target: object): void }) - .transferWorkflowStageDeliveriesTo(target.session); - target.session.clearQueue(); - } - })); + unsubscriptions.push( + source.session.subscribe((event) => { + if (!terminalized && event.type === "message_update" && event.assistantMessageEvent.type === "text_delta") { + terminalized = true; + assert.equal(store.recordRunEnd("run-transfer-a", "completed", {}), true); + assert.equal(store.recordRunEnd("run-transfer-b", "completed", {}), true); + } + }), + ); + unsubscriptions.push( + source.session.agent.subscribe((event) => { + if (terminalized && !transferred && event.type === "turn_start") { + transferred = true; + ( + source.session as typeof source.session & { transferWorkflowStageDeliveriesTo(target: object): void } + ).transferWorkflowStageDeliveriesTo(target.session); + target.session.clearQueue(); + } + }), + ); source.setResponses([ fauxAssistantMessage("A stale source response finishes before reconciliation."), (context) => { @@ -410,22 +465,23 @@ describe("workflow lifecycle parent reconciliation", () => { 2, ); assert.equal( - source.sessionManager.getEntries().filter( - (entry) => entry.type === "custom_message" && entry.customType === LIFECYCLE_NOTICE_CUSTOM_TYPE, - ).length, + source.sessionManager + .getEntries() + .filter((entry) => entry.type === "custom_message" && entry.customType === LIFECYCLE_NOTICE_CUSTOM_TYPE) + .length, 2, ); assert.equal( - source.sessionManager.getEntries().filter( - (entry) => entry.type === "custom_message" && entry.display === false, - ).length, + source.sessionManager + .getEntries() + .filter((entry) => entry.type === "custom_message" && entry.display === false).length, 1, "the core-local in-flight reconciliation persists only at source", ); assert.equal( - target.sessionManager.getEntries().filter( - (entry) => entry.type === "custom_message" && entry.display === false, - ).length, + target.sessionManager + .getEntries() + .filter((entry) => entry.type === "custom_message" && entry.display === false).length, 1, "the transferred queued reconciliation persists only at target", ); diff --git a/test/unit/workflow-list-render.test.ts b/test/unit/workflow-list-render.test.ts index c4074d58f..a30a0a51c 100644 --- a/test/unit/workflow-list-render.test.ts +++ b/test/unit/workflow-list-render.test.ts @@ -12,149 +12,157 @@ * cross-ref: src/tui/workflow-list.ts · src/tui/chat-surface.ts */ -import { describe, test } from "bun:test"; import assert from "node:assert/strict"; -import { renderWorkflowList } from "../../packages/workflows/src/tui/workflow-list.js"; +import { describe, test } from "vitest"; import { deriveGraphTheme } from "../../packages/workflows/src/tui/graph-theme.js"; import { visibleWidth } from "../../packages/workflows/src/tui/text-helpers.js"; +import { renderWorkflowList } from "../../packages/workflows/src/tui/workflow-list.js"; const ANSI_RE = /\x1b\[[0-9;]*m/g; const stripAnsi = (s: string) => s.replace(ANSI_RE, ""); describe("renderWorkflowList — empty", () => { - test("themed: emits the rounded panel header + empty-state copy when no workflows", () => { - const out = renderWorkflowList([], { theme: deriveGraphTheme({}), width: 100 }); - const plain = stripAnsi(out); - assert.match(plain, /╭ WORKFLOWS 0 registered /); - assert.match(plain, /0 registered/); - assert.match(plain, /no workflows registered/); - }); + test("themed: emits the rounded panel header + empty-state copy when no workflows", () => { + const out = renderWorkflowList([], { theme: deriveGraphTheme({}), width: 100 }); + const plain = stripAnsi(out); + assert.match(plain, /╭ WORKFLOWS {2}0 registered /); + assert.match(plain, /0 registered/); + assert.match(plain, /no workflows registered/); + }); - test("plain: same shape without ANSI escapes", () => { - const out = renderWorkflowList([], { width: 100 }); - assert.doesNotMatch(out, /\x1b\[/); - assert.match(out, /^╭ WORKFLOWS 0 registered /); - assert.doesNotMatch(out, /\u258e/); - assert.match(out, /0 registered/); - }); + test("plain: same shape without ANSI escapes", () => { + const out = renderWorkflowList([], { width: 100 }); + assert.doesNotMatch(out, /\x1b\[/); + assert.match(out, /^╭ WORKFLOWS {2}0 registered /); + assert.doesNotMatch(out, /\u258e/); + assert.match(out, /0 registered/); + }); }); describe("renderWorkflowList — populated", () => { - test("renders one card per workflow with description and input signature", () => { - const out = renderWorkflowList( - [ - { - name: "fan-out-and-synthesize", - description: "Partition independent work and synthesize artifact-backed results.", - inputs: [ - { name: "prompt", required: true }, - { name: "max_branches", required: false }, - ], - }, - { - name: "open-claude-design", - description: "Open Claude Code primed with the impeccable design skill.", - inputs: [{ name: "target", required: true }], - }, - { - name: "tournament", - description: "Compare independent attempts through balanced pairwise judging.", - inputs: [ - { name: "prompt", required: true }, - { name: "num_attempts", required: false }, - ], - }, - ], - { theme: deriveGraphTheme({}), width: 110 }, - ); - const plain = stripAnsi(out); + test("renders one card per workflow with description and input signature", () => { + const out = renderWorkflowList( + [ + { + name: "fan-out-and-synthesize", + description: "Partition independent work and synthesize artifact-backed results.", + inputs: [ + { name: "prompt", required: true }, + { name: "max_branches", required: false }, + ], + }, + { + name: "open-claude-design", + description: "Open Claude Code primed with the impeccable design skill.", + inputs: [{ name: "target", required: true }], + }, + { + name: "tournament", + description: "Compare independent attempts through balanced pairwise judging.", + inputs: [ + { name: "prompt", required: true }, + { name: "num_attempts", required: false }, + ], + }, + ], + { theme: deriveGraphTheme({}), width: 110 }, + ); + const plain = stripAnsi(out); - assert.match(plain, /╭ WORKFLOWS 3 registered /); - assert.match(plain, /3 registered/); + assert.match(plain, /╭ WORKFLOWS {2}3 registered /); + assert.match(plain, /3 registered/); - // Tag + description per workflow. - for (const name of ["fan-out-and-synthesize", "open-claude-design", "tournament"]) { - assert.ok(plain.includes(name), `tag missing for ${name}`); - } - assert.match(plain, /Partition independent work/); - assert.match(plain, /impeccable design skill/); - assert.match(plain, /balanced pairwise judging/); + // Tag + description per workflow. + for (const name of ["fan-out-and-synthesize", "open-claude-design", "tournament"]) { + assert.ok(plain.includes(name), `tag missing for ${name}`); + } + assert.match(plain, /Partition independent work/); + assert.match(plain, /impeccable design skill/); + assert.match(plain, /balanced pairwise judging/); - // Inputs row: required and optional names; optional carries `?`. - assert.match(plain, /inputs\s+prompt/); - assert.match(plain, /max_branches\?/); - assert.match(plain, /num_attempts\?/); + // Inputs row: required and optional names; optional carries `?`. + assert.match(plain, /inputs\s+prompt/); + assert.match(plain, /max_branches\?/); + assert.match(plain, /num_attempts\?/); - // Hint rows. - assert.match(plain, /▸ \/workflow …/); - assert.match(plain, /▸ \/workflow inputs /); - }); + // Hint rows. + assert.match(plain, /▸ \/workflow …/); + assert.match(plain, /▸ \/workflow inputs /); + }); - test("required-only signature has no `?` marker", () => { - const out = renderWorkflowList( - [{ name: "x", description: "X.", inputs: [{ name: "a", required: true }, { name: "b", required: true }] }], - { theme: deriveGraphTheme({}), width: 100 }, - ); - const plain = stripAnsi(out); - assert.doesNotMatch(plain, /\?/); - }); + test("required-only signature has no `?` marker", () => { + const out = renderWorkflowList( + [ + { + name: "x", + description: "X.", + inputs: [ + { name: "a", required: true }, + { name: "b", required: true }, + ], + }, + ], + { theme: deriveGraphTheme({}), width: 100 }, + ); + const plain = stripAnsi(out); + assert.doesNotMatch(plain, /\?/); + }); - test("collapses >3 inputs to +N more", () => { - const out = renderWorkflowList( - [ - { - name: "big", - description: "Many inputs.", - inputs: [ - { name: "a", required: true }, - { name: "b", required: false }, - { name: "c", required: false }, - { name: "d", required: false }, - { name: "e", required: false }, - ], - }, - ], - { theme: deriveGraphTheme({}), width: 120 }, - ); - const plain = stripAnsi(out); - assert.match(plain, /\+2 more/); - assert.match(plain, /a/); - assert.match(plain, /b\?/); - assert.match(plain, /c\?/); - }); + test("collapses >3 inputs to +N more", () => { + const out = renderWorkflowList( + [ + { + name: "big", + description: "Many inputs.", + inputs: [ + { name: "a", required: true }, + { name: "b", required: false }, + { name: "c", required: false }, + { name: "d", required: false }, + { name: "e", required: false }, + ], + }, + ], + { theme: deriveGraphTheme({}), width: 120 }, + ); + const plain = stripAnsi(out); + assert.match(plain, /\+2 more/); + assert.match(plain, /a/); + assert.match(plain, /b\?/); + assert.match(plain, /c\?/); + }); - test("plain mode preserves card shape without ANSI", () => { - const out = renderWorkflowList( - [{ name: "wf", description: "desc.", inputs: [{ name: "p", required: true }] }], - { width: 80 }, - ); - assert.doesNotMatch(out, /\x1b\[/); - assert.match(out, /^╭ WORKFLOWS 1 registered /); - assert.doesNotMatch(out, /\u258e/); - assert.match(out, /│ wf\s+│/); - assert.match(out, /desc\./); - assert.match(out, /inputs\s+p/); - }); + test("plain mode preserves card shape without ANSI", () => { + const out = renderWorkflowList([{ name: "wf", description: "desc.", inputs: [{ name: "p", required: true }] }], { + width: 80, + }); + assert.doesNotMatch(out, /\x1b\[/); + assert.match(out, /^╭ WORKFLOWS {2}1 registered /); + assert.doesNotMatch(out, /\u258e/); + assert.match(out, /│ wf\s+│/); + assert.match(out, /desc\./); + assert.match(out, /inputs\s+p/); + }); - test("long and wide workflow names and inputs stay within width", () => { - const width = 58; - const out = renderWorkflowList( - [ - { - name: "研究".repeat(18) + "-catalogue-entry", - description: "説明".repeat(30), - inputs: [ - { name: "検索".repeat(8), required: true }, - { name: "emoji_🚀_field".repeat(3), required: false }, - { name: "tail", required: false }, - ], - }, - ], - { theme: deriveGraphTheme({}), width }, - ); - for (const line of out.split("\n")) { - assert.ok(visibleWidth(line) <= width, `line exceeds ${width}: ${visibleWidth(line)} ${JSON.stringify(line)}`); - } - assert.match(stripAnsi(out), /…/); - }); + test("long and wide workflow names and inputs stay within width", () => { + const width = 58; + const out = renderWorkflowList( + [ + { + name: `${"研究".repeat(18)}-catalogue-entry`, + description: "説明".repeat(30), + inputs: [ + { name: "検索".repeat(8), required: true }, + { name: "emoji_🚀_field".repeat(3), required: false }, + { name: "tail", required: false }, + ], + }, + ], + { theme: deriveGraphTheme({}), width }, + ); + for (const line of out.split("\n")) { + assert.ok(visibleWidth(line) <= width, `line exceeds ${width}: ${visibleWidth(line)} ${JSON.stringify(line)}`); + } + assert.match(stripAnsi(out), /…/); + }); }); diff --git a/test/unit/workflow-model-catalog-context.test.ts b/test/unit/workflow-model-catalog-context.test.ts index 71ec90101..ef3ba6a79 100644 --- a/test/unit/workflow-model-catalog-context.test.ts +++ b/test/unit/workflow-model-catalog-context.test.ts @@ -1,67 +1,70 @@ -import { test } from "bun:test"; import assert from "node:assert/strict"; -import type { Api, Model } from "@earendil-works/pi-ai/compat"; import { WORKFLOW_STAGE_SUBAGENT_GUARD_ENV } from "@bastani/atomic"; +import type { Api, Model } from "@earendil-works/pi-ai/compat"; +import { test } from "vitest"; +import type { PiExecuteContext } from "../../packages/workflows/src/extension/public-types.js"; import type { ExtensionRuntime } from "../../packages/workflows/src/extension/runtime.js"; import { workflowModelCatalogFromContext } from "../../packages/workflows/src/extension/workflow-model-catalog.js"; -import type { PiExecuteContext } from "../../packages/workflows/src/extension/public-types.js"; import { makeExecuteWorkflowTool } from "../../packages/workflows/src/extension/workflow-tool.js"; function model(provider: string, id: string): Model { - return { - provider, - id, - name: id, - api: "anthropic-messages", - baseUrl: "https://example.invalid", - reasoning: false, - input: ["text"], - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - contextWindow: 8_192, - maxTokens: 1_024, - }; + return { + provider, + id, + name: id, + api: "anthropic-messages", + baseUrl: "https://example.invalid", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 8_192, + maxTokens: 1_024, + }; } test("workflow models action lists the host model registry catalog", async () => { - const previousGuard = process.env[WORKFLOW_STAGE_SUBAGENT_GUARD_ENV]; - delete process.env[WORKFLOW_STAGE_SUBAGENT_GUARD_ENV]; - try { - const current = model("provider-a", "current"); - const alternate = model("provider-b", "alternate"); - const execute = makeExecuteWorkflowTool({} as ExtensionRuntime, () => undefined); - const context = { - model: current, - modelRegistry: { getAvailable: () => [current, alternate] }, - } as unknown as PiExecuteContext; + const previousGuard = process.env[WORKFLOW_STAGE_SUBAGENT_GUARD_ENV]; + delete process.env[WORKFLOW_STAGE_SUBAGENT_GUARD_ENV]; + try { + const current = model("provider-a", "current"); + const alternate = model("provider-b", "alternate"); + const execute = makeExecuteWorkflowTool({} as ExtensionRuntime, () => undefined); + const context = { + model: current, + modelRegistry: { getAvailable: () => [current, alternate] }, + } as unknown as PiExecuteContext; - const result = await execute({ action: "models" }, context); + const result = await execute({ action: "models" }, context); - assert.equal(result.action, "models"); - if (result.action !== "models") return; - assert.deepEqual(result.models.map(({ fullId }) => fullId), [ - "provider-a/current", - "provider-b/alternate", - ]); - assert.deepEqual(result.models.map(({ isCurrent }) => isCurrent), [true, false]); - } finally { - if (previousGuard === undefined) delete process.env[WORKFLOW_STAGE_SUBAGENT_GUARD_ENV]; - else process.env[WORKFLOW_STAGE_SUBAGENT_GUARD_ENV] = previousGuard; - } + assert.equal(result.action, "models"); + if (result.action !== "models") return; + assert.deepEqual( + result.models.map(({ fullId }) => fullId), + ["provider-a/current", "provider-b/alternate"], + ); + assert.deepEqual( + result.models.map(({ isCurrent }) => isCurrent), + [true, false], + ); + } finally { + if (previousGuard === undefined) delete process.env[WORKFLOW_STAGE_SUBAGENT_GUARD_ENV]; + else process.env[WORKFLOW_STAGE_SUBAGENT_GUARD_ENV] = previousGuard; + } }); test("workflow stage model catalog includes alternatives beyond the current model", async () => { - const current = model("provider-a", "current"); - const alternate = model("provider-b", "alternate"); - const catalog = workflowModelCatalogFromContext({ - model: current, - modelRegistry: { getAvailable: () => [current, alternate] }, - }); + const current = model("provider-a", "current"); + const alternate = model("provider-b", "alternate"); + const catalog = workflowModelCatalogFromContext({ + model: current, + modelRegistry: { getAvailable: () => [current, alternate] }, + }); - assert.ok(catalog); - const available = await catalog.listModels(); - assert.deepEqual(available.map(({ fullId }) => fullId), [ - "provider-a/current", - "provider-b/alternate", - ]); - assert.equal(catalog.currentModel, current); + assert.ok(catalog); + const available = await catalog.listModels(); + assert.deepEqual( + available.map(({ fullId }) => fullId), + ["provider-a/current", "provider-b/alternate"], + ); + assert.equal(catalog.currentModel, current); }); diff --git a/test/unit/workflow-mouse-input.test.ts b/test/unit/workflow-mouse-input.test.ts index 1558d4583..83a74ca61 100644 --- a/test/unit/workflow-mouse-input.test.ts +++ b/test/unit/workflow-mouse-input.test.ts @@ -1,77 +1,77 @@ -import { describe, test } from "bun:test"; import assert from "node:assert/strict"; +import { describe, test } from "vitest"; import { - isTerminalLeftMousePress, - parseTerminalMouseInput, - terminalMouseWheelDirection, + isTerminalLeftMousePress, + parseTerminalMouseInput, + terminalMouseWheelDirection, } from "../../packages/workflows/src/tui/mouse-input.js"; function x10(buttonCode: number, col: number, row: number): string { - return `\x1b[M${String.fromCharCode(buttonCode + 32)}${String.fromCharCode(col + 33)}${String.fromCharCode(row + 33)}`; + return `\x1b[M${String.fromCharCode(buttonCode + 32)}${String.fromCharCode(col + 33)}${String.fromCharCode(row + 33)}`; } describe("shared workflow terminal mouse parser", () => { - test("parses zero-based SGR press and release coordinates", () => { - assert.deepEqual(parseTerminalMouseInput("\x1b[<0;5;9M"), { - protocol: "sgr", - action: "press", - buttonCode: 0, - col: 4, - row: 8, - }); - assert.deepEqual(parseTerminalMouseInput("\x1b[<0;5;9m"), { - protocol: "sgr", - action: "release", - buttonCode: 0, - col: 4, - row: 8, - }); - }); + test("parses zero-based SGR press and release coordinates", () => { + assert.deepEqual(parseTerminalMouseInput("\x1b[<0;5;9M"), { + protocol: "sgr", + action: "press", + buttonCode: 0, + col: 4, + row: 8, + }); + assert.deepEqual(parseTerminalMouseInput("\x1b[<0;5;9m"), { + protocol: "sgr", + action: "release", + buttonCode: 0, + col: 4, + row: 8, + }); + }); - test("parses complete legacy X10 input", () => { - assert.deepEqual(parseTerminalMouseInput(x10(64, 9, 4)), { - protocol: "x10", - action: "press", - buttonCode: 64, - col: 9, - row: 4, - }); - }); + test("parses complete legacy X10 input", () => { + assert.deepEqual(parseTerminalMouseInput(x10(64, 9, 4)), { + protocol: "x10", + action: "press", + buttonCode: 64, + col: 9, + row: 4, + }); + }); - test("normalizes vertical and horizontal wheel directions", () => { - const inputs = [ - ["\x1b[<64;1;1M", "up"], - ["\x1b[<65;1;1M", "down"], - [x10(66, 1, 1), "left"], - [x10(67, 1, 1), "right"], - ] as const; - for (const [input, direction] of inputs) { - const event = parseTerminalMouseInput(input); - assert.ok(event); - assert.equal(terminalMouseWheelDirection(event), direction); - } - }); + test("normalizes vertical and horizontal wheel directions", () => { + const inputs = [ + ["\x1b[<64;1;1M", "up"], + ["\x1b[<65;1;1M", "down"], + [x10(66, 1, 1), "left"], + [x10(67, 1, 1), "right"], + ] as const; + for (const [input, direction] of inputs) { + const event = parseTerminalMouseInput(input); + assert.ok(event); + assert.equal(terminalMouseWheelDirection(event), direction); + } + }); - test("classifies only an unmodified primary-button press as a left press", () => { - const press = parseTerminalMouseInput("\x1b[<0;2;3M"); - const release = parseTerminalMouseInput("\x1b[<0;2;3m"); - const motion = parseTerminalMouseInput("\x1b[<32;2;3M"); - assert.ok(press && release && motion); - assert.equal(isTerminalLeftMousePress(press), true); - assert.equal(isTerminalLeftMousePress(release), false); - assert.equal(isTerminalLeftMousePress(motion), false); - }); + test("classifies only an unmodified primary-button press as a left press", () => { + const press = parseTerminalMouseInput("\x1b[<0;2;3M"); + const release = parseTerminalMouseInput("\x1b[<0;2;3m"); + const motion = parseTerminalMouseInput("\x1b[<32;2;3M"); + assert.ok(press && release && motion); + assert.equal(isTerminalLeftMousePress(press), true); + assert.equal(isTerminalLeftMousePress(release), false); + assert.equal(isTerminalLeftMousePress(motion), false); + }); - test("rejects partial, malformed, and concatenated sequences", () => { - for (const input of [ - "\x1b", - "\x1b[<64;1;1", - "\x1b[<64;0;1M", - "\x1b[ { + for (const input of [ + "\x1b", + "\x1b[<64;1;1", + "\x1b[<64;0;1M", + "\x1b[ store.clear()); afterEach(() => store.clear()); describe("nested ctx.tool status replay", () => { - test("live no-target status exposes a running tool-only child", async () => { - const entered = Promise.withResolvers(); - const release = Promise.withResolvers(); - const child = workflow({ - name: "live-status-child", description: "", inputs: {}, outputs: {}, - run: async (ctx) => { - await ctx.tool("live-child-tool", {}, async () => { entered.resolve(); await release.promise; return "done"; }); - return {}; - }, - }); - const parent = workflow({ - name: "live-status-root", description: "", inputs: {}, outputs: {}, - run: async (ctx) => { await ctx.workflow(child, { stageName: "live-child" }); return {}; }, - }); - const pending = run(parent, {}, { runId: "live-nested-status-root", store, durableBackend: new InMemoryDurableBackend() }); + test("live no-target status exposes a running tool-only child", async () => { + const entered = Promise.withResolvers(); + const release = Promise.withResolvers(); + const child = workflow({ + name: "live-status-child", + description: "", + inputs: {}, + outputs: {}, + run: async (ctx) => { + await ctx.tool("live-child-tool", {}, async () => { + entered.resolve(); + await release.promise; + return "done"; + }); + return {}; + }, + }); + const parent = workflow({ + name: "live-status-root", + description: "", + inputs: {}, + outputs: {}, + run: async (ctx) => { + await ctx.workflow(child, { stageName: "live-child" }); + return {}; + }, + }); + const pending = run( + parent, + {}, + { runId: "live-nested-status-root", store, durableBackend: new InMemoryDurableBackend() }, + ); - await entered.promise; - const listing = buildWorkflowStatusListing(topLevelExpandedSnapshots(), "all"); - assert.equal(listing.snapshots.length, 1); - assert.deepEqual(listing.snapshots[0]?.stages, []); - assert.deepEqual(listing.runs[0]?.tools?.map((tool) => ({ - name: tool.name, status: tool.status, runName: tool.runName, depth: tool.depth, - })), [{ name: "live-child-tool", status: "running", runName: "live-status-child", depth: 1 }]); - release.resolve(); - assert.equal((await pending).status, "completed"); - }); + await entered.promise; + const listing = buildWorkflowStatusListing(topLevelExpandedSnapshots(), "all"); + assert.equal(listing.snapshots.length, 1); + assert.deepEqual(listing.snapshots[0]?.stages, []); + assert.deepEqual( + listing.runs[0]?.tools?.map((tool) => ({ + name: tool.name, + status: tool.status, + runName: tool.runName, + depth: tool.depth, + })), + [{ name: "live-child-tool", status: "running", runName: "live-status-child", depth: 1 }], + ); + release.resolve(); + assert.equal((await pending).status, "completed"); + }); - test("replay and completed restoration expose one cached child tool without rerunning its callback", async () => { - const runId = "nested-status-replay-root"; - const backend = new InMemoryDurableBackend(); - let callbackCalls = 0; - const child = workflow({ - name: "nested-status-child", description: "", inputs: {}, outputs: {}, - run: async (ctx) => { - await ctx.tool("nested-publish", {}, async () => { callbackCalls += 1; return "published"; }); - return {}; - }, - }); - const parent = workflow({ - name: "nested-status-root", description: "", inputs: {}, outputs: {}, - run: async (ctx) => { await ctx.workflow(child, { stageName: "nested-child" }); return {}; }, - }); + test("replay and completed restoration expose one cached child tool without rerunning its callback", async () => { + const runId = "nested-status-replay-root"; + const backend = new InMemoryDurableBackend(); + let callbackCalls = 0; + const child = workflow({ + name: "nested-status-child", + description: "", + inputs: {}, + outputs: {}, + run: async (ctx) => { + await ctx.tool("nested-publish", {}, async () => { + callbackCalls += 1; + return "published"; + }); + return {}; + }, + }); + const parent = workflow({ + name: "nested-status-root", + description: "", + inputs: {}, + outputs: {}, + run: async (ctx) => { + await ctx.workflow(child, { stageName: "nested-child" }); + return {}; + }, + }); - const first = await run(parent, {}, { runId, store, durableBackend: backend }); - assert.equal(first.status, "completed"); - assert.equal(callbackCalls, 1); - let listing = buildWorkflowStatusListing(topLevelExpandedSnapshots(), "all"); - assert.deepEqual(listing.runs[0]?.tools?.map((tool) => [tool.name, tool.status, tool.depth]), [ - ["nested-publish", "completed", 1], - ]); + const first = await run(parent, {}, { runId, store, durableBackend: backend }); + assert.equal(first.status, "completed"); + assert.equal(callbackCalls, 1); + let listing = buildWorkflowStatusListing(topLevelExpandedSnapshots(), "all"); + assert.deepEqual( + listing.runs[0]?.tools?.map((tool) => [tool.name, tool.status, tool.depth]), + [["nested-publish", "completed", 1]], + ); - store.clear(); - const replay = await run(parent, {}, { runId, store, durableBackend: backend }); - assert.equal(replay.status, "completed"); - assert.equal(callbackCalls, 1, "durable replay must not repeat the child callback"); - listing = buildWorkflowStatusListing(topLevelExpandedSnapshots(), "all"); - const replayTools = listing.runs[0]?.tools ?? []; - assert.equal(replayTools.length, 1); - assert.equal(replayTools[0]?.name, "nested-publish"); - assert.equal(replayTools[0]?.status, "cached"); - assert.equal(replayTools[0]?.depth, 1); - assert.equal(replayTools[0]?.runId, store.runs().find((entry) => entry.parentRunId === runId)?.id); - assert.equal(listing.snapshots[0]?.toolNodes?.length, 1); + store.clear(); + const replay = await run(parent, {}, { runId, store, durableBackend: backend }); + assert.equal(replay.status, "completed"); + assert.equal(callbackCalls, 1, "durable replay must not repeat the child callback"); + listing = buildWorkflowStatusListing(topLevelExpandedSnapshots(), "all"); + const replayTools = listing.runs[0]?.tools ?? []; + assert.equal(replayTools.length, 1); + assert.equal(replayTools[0]?.name, "nested-publish"); + assert.equal(replayTools[0]?.status, "cached"); + assert.equal(replayTools[0]?.depth, 1); + assert.equal(replayTools[0]?.runId, store.runs().find((entry) => entry.parentRunId === runId)?.id); + assert.equal(listing.snapshots[0]?.toolNodes?.length, 1); - const entry = backend.listCompletedWorkflows().find((candidate) => candidate.workflowId === runId); - assert.ok(entry !== undefined); - const restored = completedWorkflowRunSnapshots(backend, entry); - store.clear(); - for (const snapshot of restored) store.recordRunStart(snapshot); - listing = buildWorkflowStatusListing(topLevelExpandedSnapshots(), "all"); - assert.deepEqual(listing.runs[0]?.tools?.map((tool) => ({ - name: tool.name, - status: tool.status, - runId: tool.runId, - runName: tool.runName, - depth: tool.depth, - attachable: tool.attachable, - })), [{ - name: "nested-publish", - status: "cached", - runId: restored.find((candidate) => candidate.parentRunId === runId)?.id, - runName: "nested-status-child", - depth: 1, - attachable: false, - }]); - assert.equal(callbackCalls, 1); - }); + const entry = backend.listCompletedWorkflows().find((candidate) => candidate.workflowId === runId); + assert.ok(entry !== undefined); + const restored = completedWorkflowRunSnapshots(backend, entry); + store.clear(); + for (const snapshot of restored) store.recordRunStart(snapshot); + listing = buildWorkflowStatusListing(topLevelExpandedSnapshots(), "all"); + assert.deepEqual( + listing.runs[0]?.tools?.map((tool) => ({ + name: tool.name, + status: tool.status, + runId: tool.runId, + runName: tool.runName, + depth: tool.depth, + attachable: tool.attachable, + })), + [ + { + name: "nested-publish", + status: "cached", + runId: restored.find((candidate) => candidate.parentRunId === runId)?.id, + runName: "nested-status-child", + depth: 1, + attachable: false, + }, + ], + ); + assert.equal(callbackCalls, 1); + }); }); diff --git a/test/unit/workflow-nested-tool-status.test.ts b/test/unit/workflow-nested-tool-status.test.ts index bfb552e77..6c1bfde8d 100644 --- a/test/unit/workflow-nested-tool-status.test.ts +++ b/test/unit/workflow-nested-tool-status.test.ts @@ -1,8 +1,8 @@ -import { afterEach, beforeEach, describe, test } from "bun:test"; import assert from "node:assert/strict"; -import { renderWorkflowToolContent } from "../../packages/workflows/src/extension/workflow-tool-content.js"; +import { afterEach, beforeEach, describe, test } from "vitest"; import { buildWorkflowStatusListing } from "../../packages/workflows/src/extension/workflow-status-summary.js"; import { topLevelExpandedSnapshots } from "../../packages/workflows/src/extension/workflow-targets.js"; +import { renderWorkflowToolContent } from "../../packages/workflows/src/extension/workflow-tool-content.js"; import { expandWorkflowGraph } from "../../packages/workflows/src/shared/expanded-workflow-graph.js"; import { store } from "../../packages/workflows/src/shared/store.js"; @@ -10,118 +10,281 @@ beforeEach(() => store.clear()); afterEach(() => store.clear()); describe("nested ctx.tool status projection", () => { - test("no-target status includes a tool-only child with explicit ownership", () => { - store.recordRunStart({ - id: "status-root", name: "root workflow", inputs: {}, status: "completed", startedAt: 1, endedAt: 5, - stages: [{ - id: "child-boundary", name: "child-boundary", status: "completed", parentIds: [], toolEvents: [], - workflowChild: { runId: "status-child", alias: "child", workflow: "child workflow", status: "completed", outputs: {} }, - }], - toolNodes: [], - }); - store.recordRunStart({ - id: "status-child", name: "child workflow", inputs: {}, status: "completed", startedAt: 2, endedAt: 4, - parentRunId: "status-root", rootRunId: "status-root", parentStageId: "child-boundary", stages: [], - toolNodes: [{ - kind: "tool", id: "tool:publish", name: "publish", argsHash: "hash", ordinal: 1, - parentIds: [], status: "cached", replayed: true, executionOrder: 1, attachable: false, - }], - }); + test("no-target status includes a tool-only child with explicit ownership", () => { + store.recordRunStart({ + id: "status-root", + name: "root workflow", + inputs: {}, + status: "completed", + startedAt: 1, + endedAt: 5, + stages: [ + { + id: "child-boundary", + name: "child-boundary", + status: "completed", + parentIds: [], + toolEvents: [], + workflowChild: { + runId: "status-child", + alias: "child", + workflow: "child workflow", + status: "completed", + outputs: {}, + }, + }, + ], + toolNodes: [], + }); + store.recordRunStart({ + id: "status-child", + name: "child workflow", + inputs: {}, + status: "completed", + startedAt: 2, + endedAt: 4, + parentRunId: "status-root", + rootRunId: "status-root", + parentStageId: "child-boundary", + stages: [], + toolNodes: [ + { + kind: "tool", + id: "tool:publish", + name: "publish", + argsHash: "hash", + ordinal: 1, + parentIds: [], + status: "cached", + replayed: true, + executionOrder: 1, + attachable: false, + }, + ], + }); - const snapshots = topLevelExpandedSnapshots(); - const listing = buildWorkflowStatusListing(snapshots, "all", 10); - const tool = listing.runs[0]?.tools?.[0]; - assert.equal(snapshots.length, 1); - assert.deepEqual(snapshots[0]?.stages, []); - assert.deepEqual(snapshots[0]?.toolNodes?.map((node) => node.name), ["publish"]); - assert.match(snapshots[0]?.toolNodes?.[0]?.id ?? "", /^status-child:tool:publish$/); - assert.deepEqual(tool, { - id: "status-child:tool:publish", - name: "publish", - status: "cached", - ordinal: 1, - executionOrder: 1, - parentIds: [], - startedAt: undefined, - endedAt: undefined, - replayed: true, - resultSummary: undefined, - error: undefined, - attachable: false, - runId: "status-child", - runName: "child workflow", - depth: 1, - }); - assert.match(renderWorkflowToolContent({ action: "status", ...listing }, { action: "status" }), /tools: publish \(cached\)/); - }); + const snapshots = topLevelExpandedSnapshots(); + const listing = buildWorkflowStatusListing(snapshots, "all", 10); + const tool = listing.runs[0]?.tools?.[0]; + assert.equal(snapshots.length, 1); + assert.deepEqual(snapshots[0]?.stages, []); + assert.deepEqual( + snapshots[0]?.toolNodes?.map((node) => node.name), + ["publish"], + ); + assert.match(snapshots[0]?.toolNodes?.[0]?.id ?? "", /^status-child:tool:publish$/); + assert.deepEqual(tool, { + id: "status-child:tool:publish", + name: "publish", + status: "cached", + ordinal: 1, + executionOrder: 1, + parentIds: [], + startedAt: undefined, + endedAt: undefined, + replayed: true, + resultSummary: undefined, + error: undefined, + attachable: false, + runId: "status-child", + runName: "child workflow", + depth: 1, + }); + assert.match( + renderWorkflowToolContent({ action: "status", ...listing }, { action: "status" }), + /tools: publish \(cached\)/, + ); + }); - test("mixed root and child tools keep expanded order and rewritten parents", () => { - store.recordRunStart({ - id: "mixed-root", name: "mixed root", inputs: {}, status: "running", startedAt: 1, - stages: [{ - id: "mixed-boundary", name: "child", status: "completed", executionOrder: 2, - parentIds: ["tool:before"], toolEvents: [], workflowChild: { runId: "mixed-child", alias: "child", workflow: "mixed child", status: "completed", outputs: {} }, - }], - toolNodes: [ - { kind: "tool", id: "tool:before", name: "before", argsHash: "before", ordinal: 1, parentIds: [], status: "completed", executionOrder: 1, attachable: false }, - { kind: "tool", id: "tool:after", name: "after", argsHash: "after", ordinal: 1, parentIds: ["mixed-boundary"], status: "running", executionOrder: 3, attachable: false }, - ], - }); - store.recordRunStart({ - id: "mixed-child", name: "mixed child", inputs: {}, status: "completed", startedAt: 2, endedAt: 3, - parentRunId: "mixed-root", rootRunId: "mixed-root", parentStageId: "mixed-boundary", stages: [], - toolNodes: [{ - kind: "tool", id: "tool:inside", name: "inside", argsHash: "inside", ordinal: 1, - parentIds: [], status: "failed", error: "inside failed", executionOrder: 1, attachable: false, - }], - }); + test("mixed root and child tools keep expanded order and rewritten parents", () => { + store.recordRunStart({ + id: "mixed-root", + name: "mixed root", + inputs: {}, + status: "running", + startedAt: 1, + stages: [ + { + id: "mixed-boundary", + name: "child", + status: "completed", + executionOrder: 2, + parentIds: ["tool:before"], + toolEvents: [], + workflowChild: { + runId: "mixed-child", + alias: "child", + workflow: "mixed child", + status: "completed", + outputs: {}, + }, + }, + ], + toolNodes: [ + { + kind: "tool", + id: "tool:before", + name: "before", + argsHash: "before", + ordinal: 1, + parentIds: [], + status: "completed", + executionOrder: 1, + attachable: false, + }, + { + kind: "tool", + id: "tool:after", + name: "after", + argsHash: "after", + ordinal: 1, + parentIds: ["mixed-boundary"], + status: "running", + executionOrder: 3, + attachable: false, + }, + ], + }); + store.recordRunStart({ + id: "mixed-child", + name: "mixed child", + inputs: {}, + status: "completed", + startedAt: 2, + endedAt: 3, + parentRunId: "mixed-root", + rootRunId: "mixed-root", + parentStageId: "mixed-boundary", + stages: [], + toolNodes: [ + { + kind: "tool", + id: "tool:inside", + name: "inside", + argsHash: "inside", + ordinal: 1, + parentIds: [], + status: "failed", + error: "inside failed", + executionOrder: 1, + attachable: false, + }, + ], + }); - const graph = expandWorkflowGraph(store.snapshot(), "mixed-root"); - const [snapshot] = topLevelExpandedSnapshots(); - assert.deepEqual(snapshot?.toolNodes?.map((tool) => [tool.name, tool.id, tool.parentIds, tool.status]), [ - ["before", "tool:before", [], "completed"], - ["inside", "mixed-child:tool:inside", ["tool:before"], "failed"], - ["after", "tool:after", ["mixed-child:tool:inside"], "running"], - ]); - assert.deepEqual(snapshot?.toolNodes, graph.tools); - assert.equal(new Set(snapshot?.toolNodes?.map((tool) => tool.id)).size, 3); - assert.deepEqual(buildWorkflowStatusListing([snapshot!], "all", 10).runs[0]?.tools?.map((tool) => tool.name), [ - "before", "inside", "after", - ]); - }); + const graph = expandWorkflowGraph(store.snapshot(), "mixed-root"); + const [snapshot] = topLevelExpandedSnapshots(); + assert.deepEqual( + snapshot?.toolNodes?.map((tool) => [tool.name, tool.id, tool.parentIds, tool.status]), + [ + ["before", "tool:before", [], "completed"], + ["inside", "mixed-child:tool:inside", ["tool:before"], "failed"], + ["after", "tool:after", ["mixed-child:tool:inside"], "running"], + ], + ); + assert.deepEqual(snapshot?.toolNodes, graph.tools); + assert.equal(new Set(snapshot?.toolNodes?.map((tool) => tool.id)).size, 3); + assert.deepEqual( + buildWorkflowStatusListing([snapshot!], "all", 10).runs[0]?.tools?.map((tool) => tool.name), + ["before", "inside", "after"], + ); + }); - test("sibling child tools retain virtual identity and explicit ownership without chat targets", () => { - store.recordRunStart({ - id: "sibling-root", name: "sibling root", inputs: {}, status: "running", startedAt: 1, - stages: [ - { id: "left-boundary", name: "left", status: "completed", executionOrder: 1, parentIds: [], toolEvents: [], workflowChild: { runId: "left-child", alias: "left", workflow: "left workflow", status: "completed", outputs: {} } }, - { id: "right-boundary", name: "right", status: "completed", executionOrder: 2, parentIds: ["left-boundary"], toolEvents: [], workflowChild: { runId: "right-child", alias: "right", workflow: "right workflow", status: "completed", outputs: {} } }, - ], - toolNodes: [], - }); - for (const [id, name, boundary] of [ - ["left-child", "left workflow", "left-boundary"], - ["right-child", "right workflow", "right-boundary"], - ] as const) { - store.recordRunStart({ - id, name, inputs: {}, status: "completed", startedAt: 2, endedAt: 3, - parentRunId: "sibling-root", rootRunId: "sibling-root", parentStageId: boundary, stages: [], - toolNodes: [{ - kind: "tool", id: "tool:same", name: "same", argsHash: `${id}-hash`, ordinal: 1, - parentIds: [], status: "cached", replayed: true, executionOrder: 1, attachable: false, - }], - }); - } + test("sibling child tools retain virtual identity and explicit ownership without chat targets", () => { + store.recordRunStart({ + id: "sibling-root", + name: "sibling root", + inputs: {}, + status: "running", + startedAt: 1, + stages: [ + { + id: "left-boundary", + name: "left", + status: "completed", + executionOrder: 1, + parentIds: [], + toolEvents: [], + workflowChild: { + runId: "left-child", + alias: "left", + workflow: "left workflow", + status: "completed", + outputs: {}, + }, + }, + { + id: "right-boundary", + name: "right", + status: "completed", + executionOrder: 2, + parentIds: ["left-boundary"], + toolEvents: [], + workflowChild: { + runId: "right-child", + alias: "right", + workflow: "right workflow", + status: "completed", + outputs: {}, + }, + }, + ], + toolNodes: [], + }); + for (const [id, name, boundary] of [ + ["left-child", "left workflow", "left-boundary"], + ["right-child", "right workflow", "right-boundary"], + ] as const) { + store.recordRunStart({ + id, + name, + inputs: {}, + status: "completed", + startedAt: 2, + endedAt: 3, + parentRunId: "sibling-root", + rootRunId: "sibling-root", + parentStageId: boundary, + stages: [], + toolNodes: [ + { + kind: "tool", + id: "tool:same", + name: "same", + argsHash: `${id}-hash`, + ordinal: 1, + parentIds: [], + status: "cached", + replayed: true, + executionOrder: 1, + attachable: false, + }, + ], + }); + } - const graph = expandWorkflowGraph(store.snapshot(), "sibling-root"); - const listing = buildWorkflowStatusListing(topLevelExpandedSnapshots(), "all", 10); - assert.deepEqual(listing.runs[0]?.tools?.map(({ id, runId, runName, depth, attachable }) => ({ - id, runId, runName, depth, attachable, - })), [ - { id: "left-child:tool:same", runId: "left-child", runName: "left workflow", depth: 1, attachable: false }, - { id: "right-child:tool:same", runId: "right-child", runName: "right workflow", depth: 1, attachable: false }, - ]); - assert.deepEqual([...graph.targets.keys()], []); - assert.deepEqual(graph.tools[1]?.parentIds, ["left-child:tool:same"]); - }); + const graph = expandWorkflowGraph(store.snapshot(), "sibling-root"); + const listing = buildWorkflowStatusListing(topLevelExpandedSnapshots(), "all", 10); + assert.deepEqual( + listing.runs[0]?.tools?.map(({ id, runId, runName, depth, attachable }) => ({ + id, + runId, + runName, + depth, + attachable, + })), + [ + { id: "left-child:tool:same", runId: "left-child", runName: "left workflow", depth: 1, attachable: false }, + { + id: "right-child:tool:same", + runId: "right-child", + runName: "right workflow", + depth: 1, + attachable: false, + }, + ], + ); + assert.deepEqual([...graph.targets.keys()], []); + assert.deepEqual(graph.tools[1]?.parentIds, ["left-child:tool:same"]); + }); }); diff --git a/test/unit/workflow-pause-release-retry.test.ts b/test/unit/workflow-pause-release-retry.test.ts index 5c842568d..ae05ed1aa 100644 --- a/test/unit/workflow-pause-release-retry.test.ts +++ b/test/unit/workflow-pause-release-retry.test.ts @@ -1,102 +1,114 @@ import { fauxAssistantMessage } from "@earendil-works/pi-ai/compat"; -import { getMessageText } from "../../packages/coding-agent/test/suite/harness.ts"; -import { - assert, - createStageControlRegistry, - createStore, - deferred, - run, - test, - workflow, -} from "./executor-shared.js"; -import { createHarness } from "../../packages/coding-agent/test/suite/harness.ts"; +import { createHarness, getMessageText } from "../../packages/coding-agent/test/suite/harness.ts"; +import type { StageSessionRuntime } from "../../packages/workflows/src/runs/foreground/stage-runner.js"; +import { sleep } from "../helpers/runtime.js"; +import { assert, createStageControlRegistry, createStore, deferred, run, test, workflow } from "./executor-shared.js"; test("transient native release failure keeps durable workflow pause retryable", async () => { - const harness = await createHarness(); - const unhandledRejections: object[] = []; - const onUnhandledRejection: NodeJS.UnhandledRejectionListener = (reason) => { - unhandledRejections.push(reason instanceof Object ? reason : new Error(String(reason))); - }; - process.on("unhandledRejection", onUnhandledRejection); - try { - const initialProviderStarted = deferred(); - harness.setResponses([ - async (_context, options) => { - initialProviderStarted.resolve(); - await new Promise((resolve) => { - if (options?.signal?.aborted) resolve(); - else options?.signal?.addEventListener("abort", () => resolve(), { once: true }); - }); - return fauxAssistantMessage("release retry interrupted"); - }, - fauxAssistantMessage("runner-owned delivery accepted"), - fauxAssistantMessage("native held delivery accepted"), - fauxAssistantMessage("unexpected duplicate delivery"), - ]); - const registry = createStageControlRegistry(); - const store = createStore(); - const sawStage = deferred<{ runId: string; stageId: string }>(); - const definition = workflow({ - name: "pause-release-retry", - description: "", - inputs: {}, - outputs: {}, - run: async (ctx) => { - await ctx.stage("retryable-pause").prompt("start retryable pause"); - return {}; - }, - }); - const runPromise = run(definition, {}, { - adapters: { agentSession: { async create() { return harness.session; } } }, - store, - stageControlRegistry: registry, - onStageStart: (runId, stage) => sawStage.resolve({ runId, stageId: stage.id }), - }); - const [{ runId, stageId }] = await Promise.all([sawStage.promise, initialProviderStarted.promise]); - const handle = registry.get(runId, stageId); - assert.ok(handle?.sendUserMessage); - await handle.pause(); + const harness = await createHarness(); + const unhandledRejections: object[] = []; + const onUnhandledRejection: NodeJS.UnhandledRejectionListener = (reason) => { + unhandledRejections.push(reason instanceof Object ? reason : new Error(String(reason))); + }; + process.on("unhandledRejection", onUnhandledRejection); + try { + const initialProviderStarted = deferred(); + harness.setResponses([ + async (_context, options) => { + initialProviderStarted.resolve(); + await new Promise((resolve) => { + if (options?.signal?.aborted) resolve(); + else options?.signal?.addEventListener("abort", () => resolve(), { once: true }); + }); + return fauxAssistantMessage("release retry interrupted"); + }, + fauxAssistantMessage("runner-owned delivery accepted"), + fauxAssistantMessage("native held delivery accepted"), + fauxAssistantMessage("unexpected duplicate delivery"), + ]); + const registry = createStageControlRegistry(); + const store = createStore(); + const sawStage = deferred<{ runId: string; stageId: string }>(); + const definition = workflow({ + name: "pause-release-retry", + description: "", + inputs: {}, + outputs: {}, + run: async (ctx) => { + await ctx.stage("retryable-pause").prompt("start retryable pause"); + return {}; + }, + }); + const runPromise = run( + definition, + {}, + { + adapters: { + agentSession: { + async create() { + return harness.session as unknown as StageSessionRuntime; + }, + }, + }, + store, + stageControlRegistry: registry, + onStageStart: (runId, stage) => sawStage.resolve({ runId, stageId: stage.id }), + }, + ); + const [{ runId, stageId }] = await Promise.all([sawStage.promise, initialProviderStarted.promise]); + const handle = registry.get(runId, stageId); + assert.ok(handle?.sendUserMessage); + await handle.pause(); - await harness.session.steer("native work held through failed release"); - let runnerDeliverySettled = false; - const runnerDelivery = handle.sendUserMessage("runner delivery held through failed release") - .finally(() => { runnerDeliverySettled = true; }); - const releaseError = new Error("transient native release failure"); - const productionResume = harness.session.resumeQueuedMessages.bind(harness.session); - let resumeAttempts = 0; - harness.session.resumeQueuedMessages = async () => { - resumeAttempts += 1; - if (resumeAttempts === 1) throw releaseError; - return productionResume(); - }; + await harness.session.steer("native work held through failed release"); + let runnerDeliverySettled = false; + const runnerDelivery = handle.sendUserMessage("runner delivery held through failed release").finally(() => { + runnerDeliverySettled = true; + }); + const releaseError = new Error("transient native release failure"); + const productionResume = harness.session.resumeQueuedMessages.bind(harness.session); + let resumeAttempts = 0; + harness.session.resumeQueuedMessages = async () => { + resumeAttempts += 1; + if (resumeAttempts === 1) throw releaseError; + return productionResume(); + }; - await assert.rejects(handle.resume(), (error) => error === releaseError); - const pausedRun = store.runs().find((candidate) => candidate.id === runId); - assert.equal(handle.status, "paused"); - assert.equal(pausedRun?.status, "paused"); - assert.equal(pausedRun?.stages.find((stage) => stage.id === stageId)?.status, "paused"); - assert.equal(harness.session.queuedMessagesPaused, true); - assert.equal(harness.session.agent.hasQueuedMessages(), false); - assert.equal(runnerDeliverySettled, false); - assert.equal(resumeAttempts, 1); + await assert.rejects(handle.resume(), (error) => error === releaseError); + const pausedRun = store.runs().find((candidate) => candidate.id === runId); + assert.equal(handle.status, "paused"); + assert.equal(pausedRun?.status, "paused"); + assert.equal(pausedRun?.stages.find((stage) => stage.id === stageId)?.status, "paused"); + assert.equal(harness.session.queuedMessagesPaused, true); + assert.equal(harness.session.agent.hasQueuedMessages(), false); + assert.equal(runnerDeliverySettled, false); + assert.equal(resumeAttempts, 1); - await handle.resume(); - const [deliveryAction, result] = await Promise.all([runnerDelivery, runPromise]); - await Bun.sleep(10); + await handle.resume(); + const [deliveryAction, result] = await Promise.all([runnerDelivery, runPromise]); + await sleep(10); - assert.equal(deliveryAction, "prompt"); - assert.equal(result.status, "completed"); - assert.equal(harness.session.queuedMessagesPaused, false); - assert.equal(resumeAttempts, 2); - assert.equal(harness.session.messages.filter( - (message) => message.role === "user" && getMessageText(message) === "runner delivery held through failed release", - ).length, 1); - assert.equal(harness.session.messages.filter( - (message) => message.role === "user" && getMessageText(message) === "native work held through failed release", - ).length, 1); - assert.deepEqual(unhandledRejections, []); - } finally { - process.off("unhandledRejection", onUnhandledRejection); - harness.cleanup(); - } + assert.equal(deliveryAction, "prompt"); + assert.equal(result.status, "completed"); + assert.equal(harness.session.queuedMessagesPaused, false); + assert.equal(resumeAttempts, 2); + assert.equal( + harness.session.messages.filter( + (message) => + message.role === "user" && getMessageText(message) === "runner delivery held through failed release", + ).length, + 1, + ); + assert.equal( + harness.session.messages.filter( + (message) => + message.role === "user" && getMessageText(message) === "native work held through failed release", + ).length, + 1, + ); + assert.deepEqual(unhandledRejections, []); + } finally { + process.off("unhandledRejection", onUnhandledRejection); + harness.cleanup(); + } }); diff --git a/test/unit/workflow-paused-queued-messages.test.ts b/test/unit/workflow-paused-queued-messages.test.ts index 03f86049e..1f7b8b031 100644 --- a/test/unit/workflow-paused-queued-messages.test.ts +++ b/test/unit/workflow-paused-queued-messages.test.ts @@ -1,423 +1,483 @@ import type { AgentMessage } from "@earendil-works/pi-agent-core"; import { fauxAssistantMessage } from "@earendil-works/pi-ai/compat"; -import { describe } from "bun:test"; +import { describe } from "vitest"; import { createHarness, getMessageText, type Harness } from "../../packages/coding-agent/test/suite/harness.ts"; +import type { StageSessionRuntime } from "../../packages/workflows/src/runs/foreground/stage-runner.js"; +import { sleep } from "../helpers/runtime.js"; import { - assert, - createStageControlRegistry, - createStore, - deferred, - pauseRun, - resumeRun, - run, - test, - waitForMicrotasks, - RESUME_CONTINUATION_PROMPT, - workflow, + assert, + createStageControlRegistry, + createStore, + deferred, + pauseRun, + RESUME_CONTINUATION_PROMPT, + resumeRun, + run, + test, + waitForMicrotasks, + workflow, } from "./executor-shared.js"; type QueueHold = { - readonly steering: AgentMessage[]; - readonly followUp: AgentMessage[]; + readonly steering: AgentMessage[]; + readonly followUp: AgentMessage[]; }; type PauseAwareSession = Harness["session"] & { - readonly queuedMessagesPaused?: boolean; - readonly _activeInterruptQueueHold?: QueueHold; + readonly queuedMessagesPaused?: boolean; + readonly _activeInterruptQueueHold?: QueueHold; }; - function assertExactHeldQueue(session: Harness["session"]): void { - const hold = (session as PauseAwareSession)._activeInterruptQueueHold; - assert.deepEqual(hold?.steering.map(getMessageText), [ - "first workflow steering", - "second workflow steering", - ]); - assert.equal(hold?.followUp.length, 3); - const [first, second, custom] = hold?.followUp ?? []; - assert.notEqual(first, second, "duplicate entries must remain distinct queue items"); - assert.deepEqual([first, second].map(getMessageText), [ - "duplicate workflow follow-up", - "duplicate workflow follow-up", - ]); - assert.equal(custom?.role, "custom"); - if (custom?.role !== "custom") return; - assert.deepEqual( - { - role: custom.role, - customType: custom.customType, - content: custom.content, - display: custom.display, - details: custom.details, - }, - { - role: "custom", - customType: "workflow-pause-raw-custom", - content: [{ type: "text", text: "\tworkflow raw custom \n" }], - display: true, - details: { optional: { untouched: true }, sequence: 3 }, - }, - ); + const hold = (session as PauseAwareSession)._activeInterruptQueueHold; + assert.deepEqual(hold?.steering.map(getMessageText), ["first workflow steering", "second workflow steering"]); + assert.equal(hold?.followUp.length, 3); + const [first, second, custom] = hold?.followUp ?? []; + assert.notEqual(first, second, "duplicate entries must remain distinct queue items"); + assert.deepEqual([first, second].map(getMessageText), [ + "duplicate workflow follow-up", + "duplicate workflow follow-up", + ]); + assert.equal(custom?.role, "custom"); + if (custom?.role !== "custom") return; + assert.deepEqual( + { + role: custom.role, + customType: custom.customType, + content: custom.content, + display: custom.display, + details: custom.details, + }, + { + role: "custom", + customType: "workflow-pause-raw-custom", + content: [{ type: "text", text: "\tworkflow raw custom \n" }], + display: true, + details: { optional: { untouched: true }, sequence: 3 }, + }, + ); } describe("workflow paused queued messages", () => { - test("the real stage handle pause holds raw queue order until its existing resume continuation", async () => { - const harness = await createHarness(); - try { - const providerStarted = deferred(); - harness.setResponses([ - async (_context, options) => { - providerStarted.resolve(); - await new Promise((resolve) => { - if (options?.signal?.aborted) resolve(); - else options?.signal?.addEventListener("abort", () => resolve(), { once: true }); - }); - return fauxAssistantMessage("interrupted"); - }, - fauxAssistantMessage("resume continuation acknowledged"), - fauxAssistantMessage("first steering handled"), - fauxAssistantMessage("second steering handled"), - fauxAssistantMessage("first duplicate handled"), - fauxAssistantMessage("second duplicate handled"), - ]); - const registry = createStageControlRegistry(); - const store = createStore(); - const sawStage = deferred<{ runId: string; stageId: string }>(); - const definition = workflow({ - name: "paused-queued-message-regression", - description: "", - inputs: {}, - outputs: {}, - run: async (ctx) => { - await ctx.stage("paused-stage").prompt("start workflow stage"); - return {}; - }, - }); - const runPromise = run(definition, {}, { - adapters: { - agentSession: { - async create() { - return harness.session; - }, - }, - }, - store, - stageControlRegistry: registry, - onStageStart: (runId, stage) => { - if (stage.name === "paused-stage") sawStage.resolve({ runId, stageId: stage.id }); - }, - }); - let runSettled = false; - void runPromise.finally(() => { - runSettled = true; - }); + test("the real stage handle pause holds raw queue order until its existing resume continuation", async () => { + const harness = await createHarness(); + try { + const providerStarted = deferred(); + harness.setResponses([ + async (_context, options) => { + providerStarted.resolve(); + await new Promise((resolve) => { + if (options?.signal?.aborted) resolve(); + else options?.signal?.addEventListener("abort", () => resolve(), { once: true }); + }); + return fauxAssistantMessage("interrupted"); + }, + fauxAssistantMessage("resume continuation acknowledged"), + fauxAssistantMessage("first steering handled"), + fauxAssistantMessage("second steering handled"), + fauxAssistantMessage("first duplicate handled"), + fauxAssistantMessage("second duplicate handled"), + ]); + const registry = createStageControlRegistry(); + const store = createStore(); + const sawStage = deferred<{ runId: string; stageId: string }>(); + const definition = workflow({ + name: "paused-queued-message-regression", + description: "", + inputs: {}, + outputs: {}, + run: async (ctx) => { + await ctx.stage("paused-stage").prompt("start workflow stage"); + return {}; + }, + }); + const runPromise = run( + definition, + {}, + { + adapters: { + agentSession: { + async create() { + return harness.session as unknown as StageSessionRuntime; + }, + }, + }, + store, + stageControlRegistry: registry, + onStageStart: (runId, stage) => { + if (stage.name === "paused-stage") sawStage.resolve({ runId, stageId: stage.id }); + }, + }, + ); + let runSettled = false; + void runPromise.finally(() => { + runSettled = true; + }); - const [{ runId, stageId }] = await Promise.all([sawStage.promise, providerStarted.promise]); - const handle = registry.get(runId, stageId); - assert.ok(handle, "live stage handle should exist"); - await handle.steer("first workflow steering"); - await handle.steer("second workflow steering"); - await handle.followUp("duplicate workflow follow-up"); - await handle.followUp("duplicate workflow follow-up"); - await harness.session.sendCustomMessage( - { - customType: "workflow-pause-raw-custom", - content: [{ type: "text", text: "\tworkflow raw custom \n" }], - display: true, - details: { optional: { untouched: true }, sequence: 3 }, - }, - { deliverAs: "followUp" }, - ); - assert.deepEqual(harness.session.getSteeringMessages(), [ - "first workflow steering", - "second workflow steering", - ]); - assert.deepEqual(harness.session.getFollowUpMessages(), [ - "duplicate workflow follow-up", - "duplicate workflow follow-up", - ]); - assert.equal(harness.session.agent.hasQueuedMessages(), true); + const [{ runId, stageId }] = await Promise.all([sawStage.promise, providerStarted.promise]); + const handle = registry.get(runId, stageId); + assert.ok(handle, "live stage handle should exist"); + await handle.steer("first workflow steering"); + await handle.steer("second workflow steering"); + await handle.followUp("duplicate workflow follow-up"); + await handle.followUp("duplicate workflow follow-up"); + await harness.session.sendCustomMessage( + { + customType: "workflow-pause-raw-custom", + content: [{ type: "text", text: "\tworkflow raw custom \n" }], + display: true, + details: { optional: { untouched: true }, sequence: 3 }, + }, + { deliverAs: "followUp" }, + ); + assert.deepEqual(harness.session.getSteeringMessages(), [ + "first workflow steering", + "second workflow steering", + ]); + assert.deepEqual(harness.session.getFollowUpMessages(), [ + "duplicate workflow follow-up", + "duplicate workflow follow-up", + ]); + assert.equal(harness.session.agent.hasQueuedMessages(), true); - const pauseResult = await pauseRun(runId, { store, stageControlRegistry: registry }); - assert.equal(pauseResult.ok, true, "aggregate workflow pause routing should reach the live stage"); + const pauseResult = await pauseRun(runId, { store, stageControlRegistry: registry }); + assert.equal(pauseResult.ok, true, "aggregate workflow pause routing should reach the live stage"); - assert.equal(handle.status, "paused"); - assert.equal(store.runs().find((candidate) => candidate.id === runId)?.status, "paused"); - assert.equal(harness.getPendingResponseCount(), 5, "no queued model turn may start while paused"); - assert.equal((harness.session as PauseAwareSession).queuedMessagesPaused, true); - assert.equal(harness.session.isStreaming, false); - assert.equal(harness.session.agent.hasQueuedMessages(), false); - assert.deepEqual(harness.session.getSteeringMessages(), [ - "first workflow steering", - "second workflow steering", - ]); - assert.deepEqual(harness.session.getFollowUpMessages(), [ - "duplicate workflow follow-up", - "duplicate workflow follow-up", - ]); - assertExactHeldQueue(harness.session); - assert.equal(runSettled, false, "the active stage flow must remain suspended"); + assert.equal(handle.status, "paused"); + assert.equal(store.runs().find((candidate) => candidate.id === runId)?.status, "paused"); + assert.equal(harness.getPendingResponseCount(), 5, "no queued model turn may start while paused"); + assert.equal((harness.session as PauseAwareSession).queuedMessagesPaused, true); + assert.equal(harness.session.isStreaming, false); + assert.equal(harness.session.agent.hasQueuedMessages(), false); + assert.deepEqual(harness.session.getSteeringMessages(), [ + "first workflow steering", + "second workflow steering", + ]); + assert.deepEqual(harness.session.getFollowUpMessages(), [ + "duplicate workflow follow-up", + "duplicate workflow follow-up", + ]); + assertExactHeldQueue(harness.session); + assert.equal(runSettled, false, "the active stage flow must remain suspended"); - const resumeResult = await resumeRun(runId, { store, stageControlRegistry: registry }); - assert.equal(resumeResult.ok, true, "aggregate workflow resume routing should release the live stage"); - const result = await runPromise; - await waitForMicrotasks(); + const resumeResult = await resumeRun(runId, { store, stageControlRegistry: registry }); + assert.equal(resumeResult.ok, true, "aggregate workflow resume routing should release the live stage"); + const result = await runPromise; + await waitForMicrotasks(); - assert.equal(result.status, "completed"); - assert.equal((harness.session as PauseAwareSession).queuedMessagesPaused, false); - assert.equal(harness.getPendingResponseCount(), 0); - assert.deepEqual(harness.session.getSteeringMessages(), []); - assert.deepEqual(harness.session.getFollowUpMessages(), []); - const deliveredQueue = harness.session.messages - .filter((message) => - (message.role === "user" && [ - "first workflow steering", - "second workflow steering", - "duplicate workflow follow-up", - ].includes(getMessageText(message))) || - (message.role === "custom" && message.customType === "workflow-pause-raw-custom"), - ) - .map((message) => message.role === "custom" ? `custom:${message.customType}` : `user:${getMessageText(message)}`); - assert.deepEqual(deliveredQueue, [ - "user:first workflow steering", - "user:second workflow steering", - "user:duplicate workflow follow-up", - "user:duplicate workflow follow-up", - "custom:workflow-pause-raw-custom", - ]); - assert.equal( - harness.session.messages.filter( - (message) => message.role === "user" && getMessageText(message) === RESUME_CONTINUATION_PROMPT, - ).length, - 1, - "the existing resume continuation must run exactly once", - ); - const deliveredCustom = harness.session.messages.filter( - (message): message is Extract => - message.role === "custom" && message.customType === "workflow-pause-raw-custom", - ); - assert.equal(deliveredCustom.length, 1, "resume callbacks must not double-deliver held work"); - assert.deepEqual(deliveredCustom[0]?.details, { optional: { untouched: true }, sequence: 3 }); - } finally { - harness.cleanup(); - } - }); + assert.equal(result.status, "completed"); + assert.equal((harness.session as PauseAwareSession).queuedMessagesPaused, false); + assert.equal(harness.getPendingResponseCount(), 0); + assert.deepEqual(harness.session.getSteeringMessages(), []); + assert.deepEqual(harness.session.getFollowUpMessages(), []); + const deliveredQueue = harness.session.messages + .filter( + (message) => + (message.role === "user" && + ["first workflow steering", "second workflow steering", "duplicate workflow follow-up"].includes( + getMessageText(message), + )) || + (message.role === "custom" && message.customType === "workflow-pause-raw-custom"), + ) + .map((message) => + message.role === "custom" ? `custom:${message.customType}` : `user:${getMessageText(message)}`, + ); + assert.deepEqual(deliveredQueue, [ + "user:first workflow steering", + "user:second workflow steering", + "user:duplicate workflow follow-up", + "user:duplicate workflow follow-up", + "custom:workflow-pause-raw-custom", + ]); + assert.equal( + harness.session.messages.filter( + (message) => message.role === "user" && getMessageText(message) === RESUME_CONTINUATION_PROMPT, + ).length, + 1, + "the existing resume continuation must run exactly once", + ); + const deliveredCustom = harness.session.messages.filter( + (message): message is Extract => + message.role === "custom" && message.customType === "workflow-pause-raw-custom", + ); + assert.equal(deliveredCustom.length, 1, "resume callbacks must not double-deliver held work"); + assert.deepEqual(deliveredCustom[0]?.details, { optional: { untouched: true }, sequence: 3 }); + } finally { + harness.cleanup(); + } + }); - test("idle readiness-stage chat releases held raw work through one objective continuation", async () => { - const harness = await createHarness(); - try { - type SessionEvent = Parameters[0]>[0]; - const emittingSession = harness.session as Harness["session"] & { - _emit(event: SessionEvent): void; - }; - const productionPrompt = harness.session.prompt.bind(harness.session); - harness.session.prompt = async (text, options) => { - await productionPrompt(text, options); - if (text !== "enter readiness-stage chat") return; - emittingSession._emit({ - type: "tool_execution_start", - toolCallId: "readiness-pause-question", - toolName: "ask_user_question", - args: {}, - } as SessionEvent); - emittingSession._emit({ - type: "tool_execution_end", - toolCallId: "readiness-pause-question", - toolName: "ask_user_question", - result: { content: [], details: {} }, - isError: false, - } as SessionEvent); - }; - harness.setResponses([ - fauxAssistantMessage("ready to wait for stage chat"), - fauxAssistantMessage("paused objective continuation"), - fauxAssistantMessage("held readiness work consumed"), - fauxAssistantMessage("unexpected duplicate continuation"), - ]); - const registry = createStageControlRegistry(); - const store = createStore(); - const sawStage = deferred<{ runId: string; stageId: string }>(); - const enteredReadiness = deferred(); - const definition = workflow({ - name: "paused-idle-readiness-stage-chat", - description: "", - inputs: {}, - outputs: {}, - run: async (ctx) => { - await ctx.stage("readiness-chat").prompt("enter readiness-stage chat"); - return {}; - }, - }); - const runPromise = run(definition, {}, { - adapters: { agentSession: { async create() { return harness.session; } } }, - store, - stageControlRegistry: registry, - confirmStageReadiness: async () => { - enteredReadiness.resolve(); - return false; - }, - onStageStart: (runId, stage) => sawStage.resolve({ runId, stageId: stage.id }), - }); - const [{ runId, stageId }] = await Promise.all([sawStage.promise, enteredReadiness.promise]); - const handle = registry.get(runId, stageId); - assert.ok(handle); - await waitForMicrotasks(); + test("idle readiness-stage chat releases held raw work through one objective continuation", async () => { + const harness = await createHarness(); + try { + type SessionEvent = Parameters[0]>[0]; + const emittingSession = harness.session as Harness["session"] & { + _emit(event: SessionEvent): void; + }; + const productionPrompt = harness.session.prompt.bind(harness.session); + harness.session.prompt = async (text, options) => { + await productionPrompt(text, options); + if (text !== "enter readiness-stage chat") return; + emittingSession._emit({ + type: "tool_execution_start", + toolCallId: "readiness-pause-question", + toolName: "ask_user_question", + args: {}, + } as SessionEvent); + emittingSession._emit({ + type: "tool_execution_end", + toolCallId: "readiness-pause-question", + toolName: "ask_user_question", + result: { content: [], details: {} }, + isError: false, + } as SessionEvent); + }; + harness.setResponses([ + fauxAssistantMessage("ready to wait for stage chat"), + fauxAssistantMessage("paused objective continuation"), + fauxAssistantMessage("held readiness work consumed"), + fauxAssistantMessage("unexpected duplicate continuation"), + ]); + const registry = createStageControlRegistry(); + const store = createStore(); + const sawStage = deferred<{ runId: string; stageId: string }>(); + const enteredReadiness = deferred(); + const definition = workflow({ + name: "paused-idle-readiness-stage-chat", + description: "", + inputs: {}, + outputs: {}, + run: async (ctx) => { + await ctx.stage("readiness-chat").prompt("enter readiness-stage chat"); + return {}; + }, + }); + const runPromise = run( + definition, + {}, + { + adapters: { + agentSession: { + async create() { + return harness.session as unknown as StageSessionRuntime; + }, + }, + }, + store, + stageControlRegistry: registry, + confirmStageReadiness: async () => { + enteredReadiness.resolve(); + return false; + }, + onStageStart: (runId, stage) => sawStage.resolve({ runId, stageId: stage.id }), + }, + ); + const [{ runId, stageId }] = await Promise.all([sawStage.promise, enteredReadiness.promise]); + const handle = registry.get(runId, stageId); + assert.ok(handle); + await waitForMicrotasks(); - await handle.pause(); - await harness.session.steer("raw work held during readiness-stage chat"); - assert.equal(handle.status, "paused"); - assert.equal(harness.session.queuedMessagesPaused, true); - assert.equal(harness.session.agent.hasQueuedMessages(), false); + await handle.pause(); + await harness.session.steer("raw work held during readiness-stage chat"); + assert.equal(handle.status, "paused"); + assert.equal(harness.session.queuedMessagesPaused, true); + assert.equal(harness.session.agent.hasQueuedMessages(), false); - await handle.resume(); - const resumedWithoutExternalTurn = await Promise.race([ - runPromise.then(() => true), - Bun.sleep(25).then(() => false), - ]); - if (!resumedWithoutExternalTurn) { - await handle.prompt("cleanup stranded readiness-stage chat"); - } - const result = await runPromise; + await handle.resume(); + const resumedWithoutExternalTurn = await Promise.race([ + runPromise.then(() => true), + sleep(25).then(() => false), + ]); + if (!resumedWithoutExternalTurn) { + await handle.prompt("cleanup stranded readiness-stage chat"); + } + const result = await runPromise; - assert.equal(resumedWithoutExternalTurn, true, "resume must wake the idle readiness-stage continuation"); - assert.equal(result.status, "completed"); - assert.equal(harness.session.queuedMessagesPaused, false); - assert.equal(harness.session.messages.filter( - (message) => message.role === "user" && getMessageText(message) === RESUME_CONTINUATION_PROMPT, - ).length, 1); - assert.equal(harness.session.messages.filter( - (message) => message.role === "user" && getMessageText(message) === "raw work held during readiness-stage chat", - ).length, 1); - assert.equal(harness.getPendingResponseCount(), 2, "no duplicate objective continuation may consume a sentinel"); - } finally { - harness.cleanup(); - } - }); + assert.equal(resumedWithoutExternalTurn, true, "resume must wake the idle readiness-stage continuation"); + assert.equal(result.status, "completed"); + assert.equal(harness.session.queuedMessagesPaused, false); + assert.equal( + harness.session.messages.filter( + (message) => message.role === "user" && getMessageText(message) === RESUME_CONTINUATION_PROMPT, + ).length, + 1, + ); + assert.equal( + harness.session.messages.filter( + (message) => + message.role === "user" && getMessageText(message) === "raw work held during readiness-stage chat", + ).length, + 1, + ); + assert.equal( + harness.getPendingResponseCount(), + 2, + "no duplicate objective continuation may consume a sentinel", + ); + } finally { + harness.cleanup(); + } + }); - for (const readinessGateEnabled of [false, true]) { - test(`late direct AgentSession arrival schedules one continuation with readiness gate ${readinessGateEnabled ? "enabled" : "disabled"}`, async () => { - const harness = await createHarness(); - try { - const providerStarted = deferred(); - harness.setResponses([ - async (_context, options) => { - providerStarted.resolve(); - await new Promise((resolve) => { - if (options?.signal?.aborted) resolve(); - else options?.signal?.addEventListener("abort", () => resolve(), { once: true }); - }); - return fauxAssistantMessage("late-arrival interrupted"); - }, - fauxAssistantMessage("late continuation one"), - fauxAssistantMessage("late direct steer consumed"), - fauxAssistantMessage("unexpected duplicate continuation"), - ]); - const registry = createStageControlRegistry(); - const store = createStore(); - const sawStage = deferred<{ runId: string; stageId: string }>(); - const definition = workflow({ - name: `late-paused-arrival-${readinessGateEnabled ? "gate" : "no-gate"}`, - description: "", - inputs: {}, - outputs: {}, - run: async (ctx) => { - await ctx.stage("late-paused-stage").prompt("start late-arrival stage"); - return {}; - }, - }); - const runPromise = run(definition, {}, { - adapters: { agentSession: { async create() { return harness.session; } } }, - store, - stageControlRegistry: registry, - ...(readinessGateEnabled ? { confirmStageReadiness: async () => true } : {}), - onStageStart: (runId, stage) => sawStage.resolve({ runId, stageId: stage.id }), - }); - const [{ runId, stageId }] = await Promise.all([sawStage.promise, providerStarted.promise]); - const handle = registry.get(runId, stageId); - assert.ok(handle); - await handle.pause(); + for (const readinessGateEnabled of [false, true]) { + test(`late direct AgentSession arrival schedules one continuation with readiness gate ${readinessGateEnabled ? "enabled" : "disabled"}`, async () => { + const harness = await createHarness(); + try { + const providerStarted = deferred(); + harness.setResponses([ + async (_context, options) => { + providerStarted.resolve(); + await new Promise((resolve) => { + if (options?.signal?.aborted) resolve(); + else options?.signal?.addEventListener("abort", () => resolve(), { once: true }); + }); + return fauxAssistantMessage("late-arrival interrupted"); + }, + fauxAssistantMessage("late continuation one"), + fauxAssistantMessage("late direct steer consumed"), + fauxAssistantMessage("unexpected duplicate continuation"), + ]); + const registry = createStageControlRegistry(); + const store = createStore(); + const sawStage = deferred<{ runId: string; stageId: string }>(); + const definition = workflow({ + name: `late-paused-arrival-${readinessGateEnabled ? "gate" : "no-gate"}`, + description: "", + inputs: {}, + outputs: {}, + run: async (ctx) => { + await ctx.stage("late-paused-stage").prompt("start late-arrival stage"); + return {}; + }, + }); + const runPromise = run( + definition, + {}, + { + adapters: { + agentSession: { + async create() { + return harness.session as unknown as StageSessionRuntime; + }, + }, + }, + store, + stageControlRegistry: registry, + ...(readinessGateEnabled ? { confirmStageReadiness: async () => true } : {}), + onStageStart: (runId, stage) => sawStage.resolve({ runId, stageId: stage.id }), + }, + ); + const [{ runId, stageId }] = await Promise.all([sawStage.promise, providerStarted.promise]); + const handle = registry.get(runId, stageId); + assert.ok(handle); + await handle.pause(); - await harness.session.steer("late direct paused steer"); - assert.equal(harness.session.queuedMessagesPaused, true); - assert.equal(harness.session.agent.hasQueuedMessages(), false); - await handle.resume(); - const result = await runPromise; + await harness.session.steer("late direct paused steer"); + assert.equal(harness.session.queuedMessagesPaused, true); + assert.equal(harness.session.agent.hasQueuedMessages(), false); + await handle.resume(); + const result = await runPromise; - assert.equal(result.status, "completed"); - assert.equal(harness.session.messages.filter( - (message) => message.role === "user" && getMessageText(message) === "late direct paused steer", - ).length, 1); - assert.equal(harness.session.messages.filter( - (message) => message.role === "user" && getMessageText(message) === RESUME_CONTINUATION_PROMPT, - ).length, 1); - } finally { - harness.cleanup(); - } - }); - } - for (const readinessGateEnabled of [false, true]) { - test(`public stage delivery owns the post-pause turn with readiness gate ${readinessGateEnabled ? "enabled" : "disabled"}`, async () => { - const harness = await createHarness(); - try { - const providerStarted = deferred(); - harness.setResponses([ - async (_context, options) => { - providerStarted.resolve(); - await new Promise((resolve) => { - if (options?.signal?.aborted) resolve(); - else options?.signal?.addEventListener("abort", () => resolve(), { once: true }); - }); - return fauxAssistantMessage("runner-owned turn interrupted"); - }, - fauxAssistantMessage("paused public delivery handled"), - ]); - const registry = createStageControlRegistry(); - const store = createStore(); - const sawStage = deferred<{ runId: string; stageId: string }>(); - const definition = workflow({ - name: `paused-public-delivery-${readinessGateEnabled ? "gate" : "no-gate"}`, - description: "", - inputs: {}, - outputs: {}, - run: async (ctx) => { - await ctx.stage("public-delivery-stage").prompt("start public delivery race"); - return {}; - }, - }); - const runPromise = run(definition, {}, { - adapters: { agentSession: { async create() { return harness.session; } } }, - store, - stageControlRegistry: registry, - ...(readinessGateEnabled ? { confirmStageReadiness: async () => true } : {}), - onStageStart: (runId, stage) => sawStage.resolve({ runId, stageId: stage.id }), - }); - const [{ runId, stageId }] = await Promise.all([sawStage.promise, providerStarted.promise]); - const handle = registry.get(runId, stageId); - assert.ok(handle?.sendUserMessage); - await handle.pause(); + assert.equal(result.status, "completed"); + assert.equal( + harness.session.messages.filter( + (message) => message.role === "user" && getMessageText(message) === "late direct paused steer", + ).length, + 1, + ); + assert.equal( + harness.session.messages.filter( + (message) => message.role === "user" && getMessageText(message) === RESUME_CONTINUATION_PROMPT, + ).length, + 1, + ); + } finally { + harness.cleanup(); + } + }); + } + for (const readinessGateEnabled of [false, true]) { + test(`public stage delivery owns the post-pause turn with readiness gate ${readinessGateEnabled ? "enabled" : "disabled"}`, async () => { + const harness = await createHarness(); + try { + const providerStarted = deferred(); + harness.setResponses([ + async (_context, options) => { + providerStarted.resolve(); + await new Promise((resolve) => { + if (options?.signal?.aborted) resolve(); + else options?.signal?.addEventListener("abort", () => resolve(), { once: true }); + }); + return fauxAssistantMessage("runner-owned turn interrupted"); + }, + fauxAssistantMessage("paused public delivery handled"), + ]); + const registry = createStageControlRegistry(); + const store = createStore(); + const sawStage = deferred<{ runId: string; stageId: string }>(); + const definition = workflow({ + name: `paused-public-delivery-${readinessGateEnabled ? "gate" : "no-gate"}`, + description: "", + inputs: {}, + outputs: {}, + run: async (ctx) => { + await ctx.stage("public-delivery-stage").prompt("start public delivery race"); + return {}; + }, + }); + const runPromise = run( + definition, + {}, + { + adapters: { + agentSession: { + async create() { + return harness.session as unknown as StageSessionRuntime; + }, + }, + }, + store, + stageControlRegistry: registry, + ...(readinessGateEnabled ? { confirmStageReadiness: async () => true } : {}), + onStageStart: (runId, stage) => sawStage.resolve({ runId, stageId: stage.id }), + }, + ); + const [{ runId, stageId }] = await Promise.all([sawStage.promise, providerStarted.promise]); + const handle = registry.get(runId, stageId); + assert.ok(handle?.sendUserMessage); + await handle.pause(); - let deliverySettled = false; - const delivery = handle.sendUserMessage("public message accepted while paused") - .finally(() => { deliverySettled = true; }); - await waitForMicrotasks(); + let deliverySettled = false; + const delivery = handle.sendUserMessage("public message accepted while paused").finally(() => { + deliverySettled = true; + }); + await waitForMicrotasks(); - assert.equal(deliverySettled, false); - assert.equal(harness.getPendingResponseCount(), 1, "no provider turn starts before resume"); - await handle.resume(); - const [action, result] = await Promise.all([delivery, runPromise]); + assert.equal(deliverySettled, false); + assert.equal(harness.getPendingResponseCount(), 1, "no provider turn starts before resume"); + await handle.resume(); + const [action, result] = await Promise.all([delivery, runPromise]); - assert.equal(action, "prompt"); - assert.equal(result.status, "completed"); - assert.equal(harness.getPendingResponseCount(), 0); - assert.equal(harness.session.messages.filter( - (message) => message.role === "user" && getMessageText(message) === "public message accepted while paused", - ).length, 1); - assert.equal(harness.session.messages.filter( - (message) => message.role === "user" && getMessageText(message) === RESUME_CONTINUATION_PROMPT, - ).length, 0, "the accepted public turn replaces a separate objective continuation"); - } finally { - harness.cleanup(); - } - }); - } + assert.equal(action, "prompt"); + assert.equal(result.status, "completed"); + assert.equal(harness.getPendingResponseCount(), 0); + assert.equal( + harness.session.messages.filter( + (message) => + message.role === "user" && getMessageText(message) === "public message accepted while paused", + ).length, + 1, + ); + assert.equal( + harness.session.messages.filter( + (message) => message.role === "user" && getMessageText(message) === RESUME_CONTINUATION_PROMPT, + ).length, + 0, + "the accepted public turn replaces a separate objective continuation", + ); + } finally { + harness.cleanup(); + } + }); + } }); diff --git a/test/unit/workflow-queue-pause-adapter-contract.test.ts b/test/unit/workflow-queue-pause-adapter-contract.test.ts index 00f7b4a6a..92daece81 100644 --- a/test/unit/workflow-queue-pause-adapter-contract.test.ts +++ b/test/unit/workflow-queue-pause-adapter-contract.test.ts @@ -1,94 +1,108 @@ -import { describe, test } from "bun:test"; import type { AgentSession } from "@bastani/atomic"; +import { describe, test } from "vitest"; import type { StageSessionRuntime as PublicStageSessionRuntime } from "../../packages/workflows/src/authoring.ts"; import type { AgentSessionAdapter, InternalStageContext, StageSessionRuntime } from "./stage-runner-helpers.js"; import { assert, createStageContext, flushMicrotasks, makeMockSession, makeOpts } from "./stage-runner-helpers.js"; type LegacyPublicRuntime = Omit< - PublicStageSessionRuntime, - "queuedMessagesPaused" | "pauseQueuedMessages" | "resumeQueuedMessages" + PublicStageSessionRuntime, + "queuedMessagesPaused" | "pauseQueuedMessages" | "resumeQueuedMessages" >; type LegacyRuntimeRemainsCompatible = LegacyPublicRuntime extends PublicStageSessionRuntime ? true : false; type PublicAgentSessionQueuePauseContract = AgentSession extends { - readonly queuedMessagesPaused: boolean; - pauseQueuedMessages(): void; - resumeQueuedMessages(): Promise; -} ? true : false; + readonly queuedMessagesPaused: boolean; + pauseQueuedMessages(): void; + resumeQueuedMessages(): Promise; +} + ? true + : false; const LEGACY_RUNTIME_REMAINS_COMPATIBLE: LegacyRuntimeRemainsCompatible = true; const PUBLIC_AGENT_SESSION_QUEUE_PAUSE_CONTRACT_IS_EXPORTED: PublicAgentSessionQueuePauseContract = true; function omitNativeQueuePause(session: StageSessionRuntime): StageSessionRuntime { - const { - queuedMessagesPaused: _queuedMessagesPaused, - pauseQueuedMessages: _pauseQueuedMessages, - resumeQueuedMessages: _resumeQueuedMessages, - ...legacySession - } = session; - void [_queuedMessagesPaused, _pauseQueuedMessages, _resumeQueuedMessages]; - return legacySession; + const { + queuedMessagesPaused: _queuedMessagesPaused, + pauseQueuedMessages: _pauseQueuedMessages, + resumeQueuedMessages: _resumeQueuedMessages, + ...legacySession + } = session; + void [_queuedMessagesPaused, _pauseQueuedMessages, _resumeQueuedMessages]; + return legacySession; } describe("public workflow queue-pause adapter compatibility", () => { - test("legacy custom adapters may omit the native queue-pause capability", async () => { - assert.equal(LEGACY_RUNTIME_REMAINS_COMPATIBLE, true); - assert.equal(PUBLIC_AGENT_SESSION_QUEUE_PAUSE_CONTRACT_IS_EXPORTED, true); - const mock = makeMockSession(); - const legacySession = omitNativeQueuePause(mock.session); - const adapter: AgentSessionAdapter = { async create() { return legacySession; } }; - const ctx = createStageContext(makeOpts({ adapters: { agentSession: adapter } })) as InternalStageContext; - const prompt = ctx.prompt("legacy adapter prompt"); - await flushMicrotasks(); + test("legacy custom adapters may omit the native queue-pause capability", async () => { + assert.equal(LEGACY_RUNTIME_REMAINS_COMPATIBLE, true); + assert.equal(PUBLIC_AGENT_SESSION_QUEUE_PAUSE_CONTRACT_IS_EXPORTED, true); + const mock = makeMockSession(); + const legacySession = omitNativeQueuePause(mock.session); + const adapter: AgentSessionAdapter = { + async create() { + return legacySession; + }, + }; + const ctx = createStageContext(makeOpts({ adapters: { agentSession: adapter } })) as InternalStageContext; + const prompt = ctx.prompt("legacy adapter prompt"); + await flushMicrotasks(); - await ctx.__requestPause(); - assert.equal(ctx.__isPaused(), true); - assert.equal(mock.state.abortCalls, 1); - await ctx.__resume(); - assert.equal(await prompt, "ok"); - assert.equal(ctx.__isPaused(), false); - }); + await ctx.__requestPause(); + assert.equal(ctx.__isPaused(), true); + assert.equal(mock.state.abortCalls, 1); + await ctx.__resume(); + assert.equal(await prompt, "ok"); + assert.equal(ctx.__isPaused(), false); + }); - test("fallback admission preserves verbatim ordered duplicate deliveries until resume", async () => { - let rejectInitial: ((error: Error) => void) | undefined; - const promptTexts: string[] = []; - const mock = makeMockSession({ - async prompt(text) { - mock.state.promptCalls += 1; - promptTexts.push(text); - if (mock.state.promptCalls === 1) { - return new Promise((_resolve, reject) => { rejectInitial = reject; }); - } - }, - async abort() { - mock.state.abortCalls += 1; - rejectInitial?.(new Error("AbortError")); - }, - }); - const legacySession = omitNativeQueuePause(mock.session); - const adapter: AgentSessionAdapter = { async create() { return legacySession; } }; - const ctx = createStageContext(makeOpts({ adapters: { agentSession: adapter } })) as InternalStageContext; - const initial = ctx.prompt("initial custom-adapter prompt"); - await flushMicrotasks(); - await ctx.__requestPause(); + test("fallback admission preserves verbatim ordered duplicate deliveries until resume", async () => { + let rejectInitial: ((error: Error) => void) | undefined; + const promptTexts: string[] = []; + const mock = makeMockSession({ + async prompt(text) { + mock.state.promptCalls += 1; + promptTexts.push(text); + if (mock.state.promptCalls === 1) { + return new Promise((_resolve, reject) => { + rejectInitial = reject; + }); + } + return undefined; + }, + async abort() { + mock.state.abortCalls += 1; + rejectInitial?.(new Error("AbortError")); + }, + }); + const legacySession = omitNativeQueuePause(mock.session); + const adapter: AgentSessionAdapter = { + async create() { + return legacySession; + }, + }; + const ctx = createStageContext(makeOpts({ adapters: { agentSession: adapter } })) as InternalStageContext; + const initial = ctx.prompt("initial custom-adapter prompt"); + await flushMicrotasks(); + await ctx.__requestPause(); - let firstSettled = false; - const first = ctx.__sendUserMessage("\tduplicate payload \n") - .finally(() => { firstSettled = true; }); - const second = ctx.__sendUserMessage("\tduplicate payload \n"); - await flushMicrotasks(); + let firstSettled = false; + const first = ctx.__sendUserMessage("\tduplicate payload \n").finally(() => { + firstSettled = true; + }); + const second = ctx.__sendUserMessage("\tduplicate payload \n"); + await flushMicrotasks(); - assert.equal(mock.state.promptCalls, 1, "fallback prompts must not start while paused"); - assert.equal(firstSettled, false); - await ctx.__resume(); - const [firstAction, secondAction] = await Promise.all([first, second, initial]); + assert.equal(mock.state.promptCalls, 1, "fallback prompts must not start while paused"); + assert.equal(firstSettled, false); + await ctx.__resume(); + const [firstAction, secondAction] = await Promise.all([first, second, initial]); - assert.equal(firstAction, "prompt"); - assert.equal(secondAction, "prompt"); - assert.deepEqual(promptTexts, [ - "initial custom-adapter prompt", - "\tduplicate payload \n", - "\tduplicate payload \n", - ]); - assert.equal(mock.state.promptCalls, 3); - }); + assert.equal(firstAction, "prompt"); + assert.equal(secondAction, "prompt"); + assert.deepEqual(promptTexts, [ + "initial custom-adapter prompt", + "\tduplicate payload \n", + "\tduplicate payload \n", + ]); + assert.equal(mock.state.promptCalls, 3); + }); }); diff --git a/test/unit/workflow-reload-rediscovery.test.ts b/test/unit/workflow-reload-rediscovery.test.ts index 7629ff718..b63c412ba 100644 --- a/test/unit/workflow-reload-rediscovery.test.ts +++ b/test/unit/workflow-reload-rediscovery.test.ts @@ -1,23 +1,23 @@ -import { afterEach, describe, test } from "bun:test"; import assert from "node:assert/strict"; import { mkdir, mkdtemp, rename, rm, unlink, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; +import { afterEach, describe, test } from "vitest"; +import { InMemoryDurableBackend } from "../../packages/workflows/src/durable/backend.js"; +import { setDurableBackend } from "../../packages/workflows/src/durable/factory.js"; +import { createWorkflowExtensionRuntimeState } from "../../packages/workflows/src/extension/extension-runtime-state.js"; import factory, { - type ExtensionAPI, - type PiCommandOptions, - type PiExecuteContext, - type PiToolOpts, - type WorkflowToolArgs, + type ExtensionAPI, + type PiCommandOptions, + type PiExecuteContext, + type PiToolOpts, + type WorkflowToolArgs, } from "../../packages/workflows/src/extension/index.js"; import type { WorkflowToolResult } from "../../packages/workflows/src/extension/render-result.js"; -import { store } from "../../packages/workflows/src/shared/store.js"; import { cancellationRegistry } from "../../packages/workflows/src/runs/background/cancellation-registry.js"; import { killAllRuns } from "../../packages/workflows/src/runs/background/status.js"; import type { StageSessionRuntime } from "../../packages/workflows/src/runs/foreground/stage-runner-types.js"; -import { createWorkflowExtensionRuntimeState } from "../../packages/workflows/src/extension/extension-runtime-state.js"; -import { InMemoryDurableBackend } from "../../packages/workflows/src/durable/backend.js"; -import { setDurableBackend } from "../../packages/workflows/src/durable/factory.js"; +import { store } from "../../packages/workflows/src/shared/store.js"; const originalCwd = process.cwd(); const originalAgentDir = process.env.ATOMIC_CODING_AGENT_DIR; @@ -26,463 +26,532 @@ const originalUserProfile = process.env.USERPROFILE; const roots: string[] = []; afterEach(async () => { - process.chdir(originalCwd); - if (originalAgentDir === undefined) delete process.env.ATOMIC_CODING_AGENT_DIR; - else process.env.ATOMIC_CODING_AGENT_DIR = originalAgentDir; - if (originalHome === undefined) delete process.env.HOME; - else process.env.HOME = originalHome; - if (originalUserProfile === undefined) delete process.env.USERPROFILE; - else process.env.USERPROFILE = originalUserProfile; - killAllRuns({ store, cancellation: cancellationRegistry }); - store.clear(); - setDurableBackend(undefined); - await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); + process.chdir(originalCwd); + if (originalAgentDir === undefined) delete process.env.ATOMIC_CODING_AGENT_DIR; + else process.env.ATOMIC_CODING_AGENT_DIR = originalAgentDir; + if (originalHome === undefined) delete process.env.HOME; + else process.env.HOME = originalHome; + if (originalUserProfile === undefined) delete process.env.USERPROFILE; + else process.env.USERPROFILE = originalUserProfile; + killAllRuns({ store, cancellation: cancellationRegistry }); + store.clear(); + setDurableBackend(undefined); + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); }); interface Harness { - readonly commands: Map; - readonly messages: string[]; - execute(args: WorkflowToolArgs, ctx?: PiExecuteContext): Promise; + readonly commands: Map; + readonly messages: string[]; + execute(args: WorkflowToolArgs, ctx?: PiExecuteContext): Promise; } function fakeSession(prompt?: (text: string) => Promise): StageSessionRuntime { - let last: string | undefined; - return { - prompt: async (text: string) => { - last = await (prompt ?? (async (value: string) => `declared:${value}`))(text); - return last; - }, - steer: async () => undefined, - followUp: async () => undefined, - subscribe: () => () => undefined, - sessionFile: undefined, - sessionId: "reload-matrix-stage", - setModel: async () => undefined, - setThinkingLevel: () => undefined, - cycleModel: async () => undefined, - cycleThinkingLevel: () => undefined, - agent: {} as StageSessionRuntime["agent"], - model: undefined, - thinkingLevel: "medium", - messages: [], - isStreaming: false, - navigateTree: async () => ({ cancelled: true }), - compact: async () => undefined as never, - abortCompaction: () => undefined, - abort: async () => undefined, - dispose: () => undefined, - getLastAssistantText: () => last, - }; + let last: string | undefined; + return { + prompt: async (text: string) => { + last = await (prompt ?? (async (value: string) => `declared:${value}`))(text); + return last; + }, + steer: async () => undefined, + followUp: async () => undefined, + subscribe: () => () => undefined, + sessionFile: undefined, + sessionId: "reload-matrix-stage", + setModel: async () => undefined, + setThinkingLevel: () => undefined, + cycleModel: async () => undefined, + cycleThinkingLevel: () => undefined, + agent: {} as StageSessionRuntime["agent"], + model: undefined, + thinkingLevel: "medium", + messages: [], + isStreaming: false, + navigateTree: async () => ({ cancelled: true }), + compact: async () => undefined as never, + abortCompaction: () => undefined, + abort: async () => undefined, + dispose: () => undefined, + getLastAssistantText: () => last, + }; } function createHarness(overrides: Partial = {}): Harness { - const commands = new Map(); - const messages: string[] = []; - let tool: PiToolOpts | undefined; - const pi: ExtensionAPI = { - registerCommand: (name, options) => commands.set(name, options), - registerTool: (options) => { tool = options as unknown as PiToolOpts; }, - registerMessageRenderer: () => undefined, - registerFlag: () => undefined, - registerShortcut: () => undefined, - sendMessage: (message) => { if (typeof message.content === "string") messages.push(message.content); }, - on: () => undefined, - ui: { setWidget: () => undefined }, - createAgentSession: async () => ({ session: fakeSession() }), - disableAsyncDiscovery: true, - ...overrides, - }; - factory(pi); - assert.ok(tool); - return { - commands, - messages, - async execute(args, ctx = { hasUI: false } as PiExecuteContext) { - const result = await tool!.execute("reload-matrix-call", args, undefined, undefined, ctx); - return result.details; - }, - }; + const commands = new Map(); + const messages: string[] = []; + let tool: PiToolOpts | undefined; + const pi: ExtensionAPI = { + registerCommand: (name, options) => commands.set(name, options), + registerTool: (options) => { + tool = options as unknown as PiToolOpts; + }, + registerMessageRenderer: () => undefined, + registerFlag: () => undefined, + registerShortcut: () => undefined, + sendMessage: (message) => { + if (typeof message.content === "string") messages.push(message.content); + }, + on: () => undefined, + ui: { setWidget: () => undefined }, + createAgentSession: async () => ({ session: fakeSession() }), + disableAsyncDiscovery: true, + ...overrides, + }; + factory(pi); + assert.ok(tool); + return { + commands, + messages, + async execute(args, ctx = { hasUI: false } as PiExecuteContext) { + const result = await tool!.execute("reload-matrix-call", args, undefined, undefined, ctx); + return result.details; + }, + }; } async function writeJson(path: string, value: object): Promise { - await mkdir(dirname(path), { recursive: true }); - await writeFile(path, JSON.stringify(value), "utf8"); + await mkdir(dirname(path), { recursive: true }); + await writeFile(path, JSON.stringify(value), "utf8"); } async function writeWorkflow( - path: string, - options: { - name: string; - description: string; - named?: boolean; - prompt?: string; - inputName?: string; - outputName?: string; - }, + path: string, + options: { + name: string; + description: string; + named?: boolean; + prompt?: string; + inputName?: string; + outputName?: string; + }, ): Promise { - await mkdir(dirname(path), { recursive: true }); - const inputName = options.inputName ?? "message"; - const outputName = options.outputName ?? "value"; - const definition = `workflow({ + await mkdir(dirname(path), { recursive: true }); + const inputName = options.inputName ?? "message"; + const outputName = options.outputName ?? "value"; + const definition = `workflow({ name: ${JSON.stringify(options.name)}, description: ${JSON.stringify(options.description)}, inputs: { ${JSON.stringify(inputName)}: Type.String() }, outputs: { ${JSON.stringify(outputName)}: Type.String() }, run: async (ctx) => ({ ${JSON.stringify(outputName)}: await ctx.stage("emit").prompt(${JSON.stringify(options.prompt ?? options.name)} + ":" + ctx.inputs[${JSON.stringify(inputName)}]) }), })`; - await writeFile(path, [ - `import { workflow } from "@bastani/workflows";`, - `import { Type } from "typebox";`, - options.named ? `export const namedWorkflow = ${definition};` : `export default ${definition};`, - ].join("\n"), "utf8"); + await writeFile( + path, + [ + `import { workflow } from "@bastani/workflows";`, + `import { Type } from "typebox";`, + options.named ? `export const namedWorkflow = ${definition};` : `export default ${definition};`, + ].join("\n"), + "utf8", + ); } function names(result: WorkflowToolResult): string[] { - assert.equal(result.action, "list"); - return result.items.map((item) => item.name); + assert.equal(result.action, "list"); + return result.items.map((item) => item.name); } function reloadResult(result: WorkflowToolResult): Extract { - assert.equal(result.action, "reload"); - return result; + assert.equal(result.action, "reload"); + return result; } async function makeIsolatedRoots(label: string): Promise<{ root: string; project: string; agent: string }> { - const root = await mkdtemp(join(tmpdir(), `atomic-${label}-`)); - roots.push(root); - const project = join(root, "project"); - const home = join(root, "home"); - const agent = join(home, ".atomic", "agent"); - await mkdir(project, { recursive: true }); - await mkdir(agent, { recursive: true }); - process.chdir(project); - process.env.HOME = home; - process.env.USERPROFILE = home; - delete process.env.ATOMIC_CODING_AGENT_DIR; - return { root, project, agent }; + const root = await mkdtemp(join(tmpdir(), `atomic-${label}-`)); + roots.push(root); + const project = join(root, "project"); + const home = join(root, "home"); + const agent = join(home, ".atomic", "agent"); + await mkdir(project, { recursive: true }); + await mkdir(agent, { recursive: true }); + process.chdir(project); + process.env.HOME = home; + process.env.USERPROFILE = home; + delete process.env.ATOMIC_CODING_AGENT_DIR; + return { root, project, agent }; } describe("workflow reload rediscovery matrix", () => { - test.serial("reload refreshes all discovery scopes and public list/get/inputs/help/completion/invocation surfaces", async () => { - const { root, project, agent } = await makeIsolatedRoots("workflow-reload-matrix"); - const paths = { - projectAtomic: join(project, ".atomic/workflows/project-atomic.ts"), - projectLegacy: join(project, ".pi/workflows/project-legacy.ts"), - globalAtomic: join(agent, "workflows/global-atomic.ts"), - globalLegacy: join(root, "home/.pi/agent/workflows/global-legacy.ts"), - projectRelative: join(project, "configured/project-relative.ts"), - projectAbsoluteDir: join(root, "project-absolute-dir/project-absolute.ts"), - globalRelative: join(agent, "configured/global-relative.ts"), - globalAbsolute: join(root, "global-absolute.ts"), - globalRelativeDir: join(agent, "configured/global-dir/global-dir.ts"), - projectConflict: join(project, ".atomic/workflows/conflict.ts"), - globalConflict: join(agent, "workflows/conflict.ts"), - }; - await Promise.all([ - writeWorkflow(paths.projectAtomic, { name: "scope-project-atomic", description: "project atomic" }), - writeWorkflow(paths.projectLegacy, { name: "scope-project-legacy", description: "project legacy", named: true }), - writeWorkflow(paths.globalAtomic, { name: "scope-global-atomic", description: "global atomic" }), - writeWorkflow(paths.globalLegacy, { name: "scope-global-legacy", description: "global legacy", named: true }), - writeWorkflow(paths.projectRelative, { name: "scope-project-relative", description: "project relative" }), - writeWorkflow(paths.projectAbsoluteDir, { name: "scope-project-absolute", description: "project absolute", named: true }), - writeWorkflow(paths.globalRelative, { name: "scope-global-relative", description: "global relative" }), - writeWorkflow(paths.globalAbsolute, { name: "scope-global-absolute", description: "global absolute", named: true }), - writeWorkflow(paths.globalRelativeDir, { name: "scope-global-relative-dir", description: "global relative directory" }), - writeWorkflow(paths.projectConflict, { name: "scope-conflict", description: "project conflict wins" }), - writeWorkflow(paths.globalConflict, { name: "scope-conflict", description: "global conflict loses" }), - ]); - await writeJson(join(project, ".atomic/extensions/workflow/config.json"), { - workflows: { - projectRelative: { path: "configured/project-relative.ts" }, - projectAbsoluteDir: { path: dirname(paths.projectAbsoluteDir) }, - }, - }); - await writeJson(join(agent, "extensions/workflow/config.json"), { - workflows: { - globalRelative: { path: "configured/global-relative.ts" }, - globalAbsolute: { path: paths.globalAbsolute }, - globalRelativeDir: { path: "configured/global-dir" }, - }, - }); + test.sequential("reload refreshes all discovery scopes and public list/get/inputs/help/completion/invocation surfaces", async () => { + const { root, project, agent } = await makeIsolatedRoots("workflow-reload-matrix"); + const paths = { + projectAtomic: join(project, ".atomic/workflows/project-atomic.ts"), + projectLegacy: join(project, ".pi/workflows/project-legacy.ts"), + globalAtomic: join(agent, "workflows/global-atomic.ts"), + globalLegacy: join(root, "home/.pi/agent/workflows/global-legacy.ts"), + projectRelative: join(project, "configured/project-relative.ts"), + projectAbsoluteDir: join(root, "project-absolute-dir/project-absolute.ts"), + globalRelative: join(agent, "configured/global-relative.ts"), + globalAbsolute: join(root, "global-absolute.ts"), + globalRelativeDir: join(agent, "configured/global-dir/global-dir.ts"), + projectConflict: join(project, ".atomic/workflows/conflict.ts"), + globalConflict: join(agent, "workflows/conflict.ts"), + }; + await Promise.all([ + writeWorkflow(paths.projectAtomic, { name: "scope-project-atomic", description: "project atomic" }), + writeWorkflow(paths.projectLegacy, { + name: "scope-project-legacy", + description: "project legacy", + named: true, + }), + writeWorkflow(paths.globalAtomic, { name: "scope-global-atomic", description: "global atomic" }), + writeWorkflow(paths.globalLegacy, { name: "scope-global-legacy", description: "global legacy", named: true }), + writeWorkflow(paths.projectRelative, { name: "scope-project-relative", description: "project relative" }), + writeWorkflow(paths.projectAbsoluteDir, { + name: "scope-project-absolute", + description: "project absolute", + named: true, + }), + writeWorkflow(paths.globalRelative, { name: "scope-global-relative", description: "global relative" }), + writeWorkflow(paths.globalAbsolute, { + name: "scope-global-absolute", + description: "global absolute", + named: true, + }), + writeWorkflow(paths.globalRelativeDir, { + name: "scope-global-relative-dir", + description: "global relative directory", + }), + writeWorkflow(paths.projectConflict, { name: "scope-conflict", description: "project conflict wins" }), + writeWorkflow(paths.globalConflict, { name: "scope-conflict", description: "global conflict loses" }), + ]); + await writeJson(join(project, ".atomic/extensions/workflow/config.json"), { + workflows: { + projectRelative: { path: "configured/project-relative.ts" }, + projectAbsoluteDir: { path: dirname(paths.projectAbsoluteDir) }, + }, + }); + await writeJson(join(agent, "extensions/workflow/config.json"), { + workflows: { + globalRelative: { path: "configured/global-relative.ts" }, + globalAbsolute: { path: paths.globalAbsolute }, + globalRelativeDir: { path: "configured/global-dir" }, + }, + }); - const harness = createHarness(); - const reload = reloadResult(await harness.execute({ action: "reload" })); - assert.equal(reload.status, "ok"); - assert.equal(reload.outcome, "applied"); - assert.ok(reload.diagnostics.some((diagnostic) => diagnostic.code === "DUPLICATE_NAME")); - const listed = names(await harness.execute({ action: "list" })); - for (const expected of [ - "scope-project-atomic", "scope-project-legacy", "scope-global-atomic", "scope-global-legacy", - "scope-project-relative", "scope-project-absolute", "scope-global-relative", "scope-global-absolute", - "scope-global-relative-dir", "scope-conflict", - ]) assert.ok(listed.includes(expected), expected); + const harness = createHarness(); + const reload = reloadResult(await harness.execute({ action: "reload" })); + assert.equal(reload.status, "ok"); + assert.equal(reload.outcome, "applied"); + assert.ok(reload.diagnostics.some((diagnostic) => diagnostic.code === "DUPLICATE_NAME")); + const listed = names(await harness.execute({ action: "list" })); + for (const expected of [ + "scope-project-atomic", + "scope-project-legacy", + "scope-global-atomic", + "scope-global-legacy", + "scope-project-relative", + "scope-project-absolute", + "scope-global-relative", + "scope-global-absolute", + "scope-global-relative-dir", + "scope-conflict", + ]) + assert.ok(listed.includes(expected), expected); - const get = await harness.execute({ action: "get", workflow: "scope-project-atomic" }); - assert.equal(get.action, "get"); - assert.equal(get.details?.output?.description, "project atomic"); - const conflict = await harness.execute({ action: "get", workflow: "scope-conflict" }); - assert.equal(conflict.action, "get"); - assert.equal(conflict.details?.output?.description, "project conflict wins"); - const inputs = await harness.execute({ action: "inputs", workflow: "scope-project-atomic" }); - assert.equal(inputs.action, "inputs"); - assert.deepEqual(inputs.inputs.map((input) => input.name), ["message"]); - const workflowCommand = harness.commands.get("workflow"); - assert.ok(workflowCommand?.getArgumentCompletions); - const completions = await workflowCommand.getArgumentCompletions("scope-project-at"); - assert.ok(completions?.some((item) => item.label === "scope-project-atomic")); - const messageStart = harness.messages.length; + const get = await harness.execute({ action: "get", workflow: "scope-project-atomic" }); + assert.equal(get.action, "get"); + assert.equal(get.details?.output?.description, "project atomic"); + const conflict = await harness.execute({ action: "get", workflow: "scope-conflict" }); + assert.equal(conflict.action, "get"); + assert.equal(conflict.details?.output?.description, "project conflict wins"); + const inputs = await harness.execute({ action: "inputs", workflow: "scope-project-atomic" }); + assert.equal(inputs.action, "inputs"); + assert.deepEqual( + inputs.inputs.map((input) => input.name), + ["message"], + ); + const workflowCommand = harness.commands.get("workflow"); + assert.ok(workflowCommand?.getArgumentCompletions); + const completions = await workflowCommand.getArgumentCompletions("scope-project-at"); + assert.ok(completions?.some((item) => item.label === "scope-project-atomic")); + const messageStart = harness.messages.length; - await workflowCommand.handler?.("scope-project-atomic --help", { hasUI: false, ui: { notify: () => undefined } }); - assert.match(harness.messages.slice(messageStart).join("\n"), /message/); + await workflowCommand.handler?.("scope-project-atomic --help", { hasUI: false, ui: { notify: () => undefined } }); + assert.match(harness.messages.slice(messageStart).join("\n"), /message/); - const run = await harness.execute({ action: "run", workflow: "scope-project-atomic", inputs: { message: "hello" } }); - assert.equal(run.action, "run"); - assert.equal(run.status, "completed", JSON.stringify(run)); - assert.deepEqual(run.result, { value: "declared:scope-project-atomic:hello" }); - }); + const run = await harness.execute({ + action: "run", + workflow: "scope-project-atomic", + inputs: { message: "hello" }, + }); + assert.equal(run.action, "run"); + assert.equal(run.status, "completed", JSON.stringify(run)); + assert.deepEqual(run.result, { value: "declared:scope-project-atomic:hello" }); + }); - test.serial("post-start global and configured additions preserve the active registry", async () => { - const { root, project, agent } = await makeIsolatedRoots("workflow-reload-post-start"); - const existing = join(project, ".atomic/workflows/existing.ts"); - await writeWorkflow(existing, { name: "post-start-existing", description: "loaded first" }); - const harness = createHarness(); - await harness.execute({ action: "reload" }); - const before = names(await harness.execute({ action: "list" })); - assert.deepEqual(before.filter((name) => name.startsWith("post-start-")), ["post-start-existing"]); - assert.ok(before.includes("adversarial-verification"), "bundled workflows must survive reload"); + test.sequential("post-start global and configured additions preserve the active registry", async () => { + const { root, project, agent } = await makeIsolatedRoots("workflow-reload-post-start"); + const existing = join(project, ".atomic/workflows/existing.ts"); + await writeWorkflow(existing, { name: "post-start-existing", description: "loaded first" }); + const harness = createHarness(); + await harness.execute({ action: "reload" }); + const before = names(await harness.execute({ action: "list" })); + assert.deepEqual( + before.filter((name) => name.startsWith("post-start-")), + ["post-start-existing"], + ); + assert.ok(before.includes("adversarial-verification"), "bundled workflows must survive reload"); - const globalAdded = join(agent, "workflows/added.ts"); - const configuredAdded = join(root, "configured-after-start/added.ts"); - await writeWorkflow(globalAdded, { name: "post-start-global", description: "added globally" }); - await writeWorkflow(configuredAdded, { name: "post-start-configured", description: "added by config" }); - await writeJson(join(project, ".atomic/extensions/workflow/config.json"), { - workflows: { addedAfterStart: { path: configuredAdded } }, - }); + const globalAdded = join(agent, "workflows/added.ts"); + const configuredAdded = join(root, "configured-after-start/added.ts"); + await writeWorkflow(globalAdded, { name: "post-start-global", description: "added globally" }); + await writeWorkflow(configuredAdded, { name: "post-start-configured", description: "added by config" }); + await writeJson(join(project, ".atomic/extensions/workflow/config.json"), { + workflows: { addedAfterStart: { path: configuredAdded } }, + }); - const report = reloadResult(await harness.execute({ action: "reload" })); - assert.equal(report.outcome, "applied"); - const after = names(await harness.execute({ action: "list" })); - assert.ok(after.includes("post-start-existing")); - assert.ok(after.includes("post-start-global")); - assert.ok(after.includes("post-start-configured")); - assert.ok(after.includes("adversarial-verification"), "bundled workflows must remain after rediscovery"); - }); + const report = reloadResult(await harness.execute({ action: "reload" })); + assert.equal(report.outcome, "applied"); + const after = names(await harness.execute({ action: "list" })); + assert.ok(after.includes("post-start-existing")); + assert.ok(after.includes("post-start-global")); + assert.ok(after.includes("post-start-configured")); + assert.ok(after.includes("adversarial-verification"), "bundled workflows must remain after rediscovery"); + }); - test.serial("add edit rename delete and malformed siblings replace metadata while preserving valid workflows", async () => { - const { project } = await makeIsolatedRoots("workflow-reload-mutations"); - const dir = join(project, ".atomic/workflows"); - const stable = join(dir, "stable.ts"); - const changing = join(dir, "changing.ts"); - await writeWorkflow(stable, { name: "reload-stable", description: "stable" }); - const harness = createHarness(); - await harness.execute({ action: "reload" }); + test.sequential("add edit rename delete and malformed siblings replace metadata while preserving valid workflows", async () => { + const { project } = await makeIsolatedRoots("workflow-reload-mutations"); + const dir = join(project, ".atomic/workflows"); + const stable = join(dir, "stable.ts"); + const changing = join(dir, "changing.ts"); + await writeWorkflow(stable, { name: "reload-stable", description: "stable" }); + const harness = createHarness(); + await harness.execute({ action: "reload" }); - await writeWorkflow(changing, { name: "reload-changing", description: "version one" }); - await writeFile(join(dir, "invalid.ts"), "export default { broken: true };", "utf8"); - await writeFile(join(dir, "import-failed.ts"), "export default ;", "utf8"); - const added = reloadResult(await harness.execute({ action: "reload" })); - assert.equal(added.status, "ok"); - assert.ok(added.diagnostics.some((diagnostic) => diagnostic.code === "INVALID_DEFINITION")); - assert.ok(added.diagnostics.some((diagnostic) => diagnostic.code === "IMPORT_FAILED")); - assert.ok(names(await harness.execute({ action: "list" })).includes("reload-stable")); - const stableRun = await harness.execute({ action: "run", workflow: "reload-stable", inputs: { message: "still-valid" } }); - assert.equal(stableRun.action, "run"); - assert.equal(stableRun.status, "completed", JSON.stringify(stableRun)); - assert.deepEqual(stableRun.result, { value: "declared:reload-stable:still-valid" }); - const slashMessageStart = harness.messages.length; - await harness.commands.get("workflow")?.handler?.("reload", { hasUI: false, ui: { notify: () => undefined } }); - assert.match(harness.messages.slice(slashMessageStart).join("\n"), /INVALID_DEFINITION[\s\S]*IMPORT_FAILED|IMPORT_FAILED[\s\S]*INVALID_DEFINITION/); + await writeWorkflow(changing, { name: "reload-changing", description: "version one" }); + await writeFile(join(dir, "invalid.ts"), "export default { broken: true };", "utf8"); + await writeFile(join(dir, "import-failed.ts"), "export default ;", "utf8"); + const added = reloadResult(await harness.execute({ action: "reload" })); + assert.equal(added.status, "ok"); + assert.ok(added.diagnostics.some((diagnostic) => diagnostic.code === "INVALID_DEFINITION")); + assert.ok(added.diagnostics.some((diagnostic) => diagnostic.code === "IMPORT_FAILED")); + assert.ok(names(await harness.execute({ action: "list" })).includes("reload-stable")); + const stableRun = await harness.execute({ + action: "run", + workflow: "reload-stable", + inputs: { message: "still-valid" }, + }); + assert.equal(stableRun.action, "run"); + assert.equal(stableRun.status, "completed", JSON.stringify(stableRun)); + assert.deepEqual(stableRun.result, { value: "declared:reload-stable:still-valid" }); + const slashMessageStart = harness.messages.length; + await harness.commands.get("workflow")?.handler?.("reload", { hasUI: false, ui: { notify: () => undefined } }); + assert.match( + harness.messages.slice(slashMessageStart).join("\n"), + /INVALID_DEFINITION[\s\S]*IMPORT_FAILED|IMPORT_FAILED[\s\S]*INVALID_DEFINITION/, + ); - await writeWorkflow(changing, { - name: "reload-edited", - description: "version two", - named: true, - prompt: "edited", - inputName: "revisedInput", - outputName: "revisedValue", - }); - const edited = reloadResult(await harness.execute({ action: "reload" })); - assert.equal(edited.status, "ok"); - const editedGet = await harness.execute({ action: "get", workflow: "reload-edited" }); - assert.equal(editedGet.action, "get"); - assert.equal(editedGet.details?.output?.description, "version two"); - assert.ok(!names(await harness.execute({ action: "list" })).includes("reload-changing")); - const editedInputs = await harness.execute({ action: "inputs", workflow: "reload-edited" }); - assert.equal(editedInputs.action, "inputs"); - assert.deepEqual(editedInputs.inputs.map((input) => input.name), ["revisedInput"]); - const editedRun = await harness.execute({ - action: "run", - workflow: "reload-edited", - inputs: { revisedInput: "next" }, - }); - assert.equal(editedRun.action, "run"); - assert.equal(editedRun.status, "completed", JSON.stringify(editedRun)); - assert.deepEqual(editedRun.result, { revisedValue: "declared:edited:next" }); + await writeWorkflow(changing, { + name: "reload-edited", + description: "version two", + named: true, + prompt: "edited", + inputName: "revisedInput", + outputName: "revisedValue", + }); + const edited = reloadResult(await harness.execute({ action: "reload" })); + assert.equal(edited.status, "ok"); + const editedGet = await harness.execute({ action: "get", workflow: "reload-edited" }); + assert.equal(editedGet.action, "get"); + assert.equal(editedGet.details?.output?.description, "version two"); + assert.ok(!names(await harness.execute({ action: "list" })).includes("reload-changing")); + const editedInputs = await harness.execute({ action: "inputs", workflow: "reload-edited" }); + assert.equal(editedInputs.action, "inputs"); + assert.deepEqual( + editedInputs.inputs.map((input) => input.name), + ["revisedInput"], + ); + const editedRun = await harness.execute({ + action: "run", + workflow: "reload-edited", + inputs: { revisedInput: "next" }, + }); + assert.equal(editedRun.action, "run"); + assert.equal(editedRun.status, "completed", JSON.stringify(editedRun)); + assert.deepEqual(editedRun.result, { revisedValue: "declared:edited:next" }); - const renamedPath = join(dir, "renamed.ts"); - await rename(changing, renamedPath); - await writeWorkflow(renamedPath, { name: "reload-renamed", description: "renamed" }); - await harness.execute({ action: "reload" }); - assert.ok(names(await harness.execute({ action: "list" })).includes("reload-renamed")); - await unlink(renamedPath); - await harness.execute({ action: "reload" }); - const finalNames = names(await harness.execute({ action: "list" })); - assert.ok(!finalNames.includes("reload-renamed")); - assert.ok(finalNames.includes("reload-stable")); - }); + const renamedPath = join(dir, "renamed.ts"); + await rename(changing, renamedPath); + await writeWorkflow(renamedPath, { name: "reload-renamed", description: "renamed" }); + await harness.execute({ action: "reload" }); + assert.ok(names(await harness.execute({ action: "list" })).includes("reload-renamed")); + await unlink(renamedPath); + await harness.execute({ action: "reload" }); + const finalNames = names(await harness.execute({ action: "list" })); + assert.ok(!finalNames.includes("reload-renamed")); + assert.ok(finalNames.includes("reload-stable")); + }); - test.serial("fatal refresh failure retains the complete previously applied registry", async () => { - const { project } = await makeIsolatedRoots("workflow-reload-failure"); - const workflowPath = join(project, ".atomic/workflows/retained.ts"); - await writeWorkflow(workflowPath, { name: "reload-retained", description: "before failure" }); - let failRefresh = false; - const harness = createHarness({ - refreshWorkflowResources: async () => { - if (failRefresh) throw new Error("deterministic refresh failure"); - return []; - }, - }); - const applied = reloadResult(await harness.execute({ action: "reload" })); - await writeWorkflow(workflowPath, { name: "reload-retained", description: "must not publish" }); - const invalidConfig = join(project, ".atomic/extensions/workflow/config.json"); - await mkdir(dirname(invalidConfig), { recursive: true }); - await writeFile(invalidConfig, "{ invalid", "utf8"); - failRefresh = true; - const failed = reloadResult(await harness.execute({ action: "reload" })); - assert.equal(failed.status, "noop"); - assert.equal(failed.outcome, "failed"); - assert.match(failed.message, /deterministic refresh failure/); - assert.equal(failed.generation, applied.generation); - assert.equal(failed.workflowCount, applied.workflowCount); - assert.equal(failed.error, "deterministic refresh failure"); - assert.ok(failed.diagnostics.some((diagnostic) => diagnostic.code === "CONFIG_INVALID")); - assert.match(failed.message, /CONFIG_INVALID/); - const retained = await harness.execute({ action: "get", workflow: "reload-retained" }); - assert.equal(retained.action, "get"); - assert.equal(retained.details?.output?.description, "before failure"); - }); + test.sequential("fatal refresh failure retains the complete previously applied registry", async () => { + const { project } = await makeIsolatedRoots("workflow-reload-failure"); + const workflowPath = join(project, ".atomic/workflows/retained.ts"); + await writeWorkflow(workflowPath, { name: "reload-retained", description: "before failure" }); + let failRefresh = false; + const harness = createHarness({ + refreshWorkflowResources: async () => { + if (failRefresh) throw new Error("deterministic refresh failure"); + return []; + }, + }); + const applied = reloadResult(await harness.execute({ action: "reload" })); + await writeWorkflow(workflowPath, { name: "reload-retained", description: "must not publish" }); + const invalidConfig = join(project, ".atomic/extensions/workflow/config.json"); + await mkdir(dirname(invalidConfig), { recursive: true }); + await writeFile(invalidConfig, "{ invalid", "utf8"); + failRefresh = true; + const failed = reloadResult(await harness.execute({ action: "reload" })); + assert.equal(failed.status, "noop"); + assert.equal(failed.outcome, "failed"); + assert.match(failed.message, /deterministic refresh failure/); + assert.equal(failed.generation, applied.generation); + assert.equal(failed.workflowCount, applied.workflowCount); + assert.equal(failed.error, "deterministic refresh failure"); + assert.ok(failed.diagnostics.some((diagnostic) => diagnostic.code === "CONFIG_INVALID")); + assert.match(failed.message, /CONFIG_INVALID/); + const retained = await harness.execute({ action: "get", workflow: "reload-retained" }); + assert.equal(retained.action, "get"); + assert.equal(retained.details?.output?.description, "before failure"); + }); - test.serial("reload during an in-flight workflow publishes new metadata without changing the running definition", async () => { - const { project } = await makeIsolatedRoots("workflow-reload-inflight"); - const workflowPath = join(project, ".atomic/workflows/inflight.ts"); - await writeWorkflow(workflowPath, { name: "reload-inflight", description: "old metadata", prompt: "old prompt" }); - let releasePrompt: (value: string) => void = () => undefined; - let markPromptStarted: () => void = () => undefined; - const promptStarted = new Promise((resolve) => { markPromptStarted = resolve; }); - const promptResult = new Promise((resolve) => { releasePrompt = resolve; }); - const harness = createHarness({ - createAgentSession: async () => ({ - session: fakeSession(async (text) => { - if (text.endsWith(":resume")) return `resumed:${text}`; - markPromptStarted(); - return promptResult; - }), - }), - }); - const durableBackend = new InMemoryDurableBackend(); - durableBackend.registerWorkflow({ - workflowId: "durable-reload-retained", - name: "reload-inflight", - inputs: { message: "resume" }, - createdAt: 1, - status: "paused", - completedCheckpoints: 1, - }); - setDurableBackend(durableBackend); - const durableBefore = structuredClone(durableBackend.listResumableWorkflows()); - await harness.execute({ action: "reload" }); - assert.deepEqual(durableBackend.listResumableWorkflows(), durableBefore); - const resumeMessageStart = harness.messages.length; - await harness.commands.get("workflow")?.handler?.("resume durable-reload-retained", { - hasUI: false, - ui: { notify: () => undefined }, - }); - assert.match(harness.messages.slice(resumeMessageStart).join("\n"), /Resuming durable workflow[\s\S]*checkpoints will be replayed/); - const running = harness.execute({ action: "run", workflow: "reload-inflight", inputs: { message: "value" } }); - await promptStarted; - await writeWorkflow(workflowPath, { name: "reload-inflight", description: "new metadata", prompt: "new prompt" }); - const reloaded = reloadResult(await harness.execute({ action: "reload" })); - assert.equal(reloaded.status, "ok"); - assert.equal(durableBackend.isWorkflowLoadable("durable-reload-retained"), true); - const current = await harness.execute({ action: "get", workflow: "reload-inflight" }); - assert.equal(current.action, "get"); - assert.equal(current.details?.output?.description, "new metadata"); - releasePrompt("held:old prompt:value"); - const completed = await running; - assert.equal(completed.action, "run"); - assert.equal(completed.status, "completed", JSON.stringify(completed)); - assert.deepEqual(completed.result, { value: "held:old prompt:value" }); - }); + test.sequential("reload during an in-flight workflow publishes new metadata without changing the running definition", async () => { + const { project } = await makeIsolatedRoots("workflow-reload-inflight"); + const workflowPath = join(project, ".atomic/workflows/inflight.ts"); + await writeWorkflow(workflowPath, { name: "reload-inflight", description: "old metadata", prompt: "old prompt" }); + let releasePrompt: (value: string) => void = () => undefined; + let markPromptStarted: () => void = () => undefined; + const promptStarted = new Promise((resolve) => { + markPromptStarted = resolve; + }); + const promptResult = new Promise((resolve) => { + releasePrompt = resolve; + }); + const harness = createHarness({ + createAgentSession: async () => ({ + session: fakeSession(async (text) => { + if (text.endsWith(":resume")) return `resumed:${text}`; + markPromptStarted(); + return promptResult; + }), + }), + }); + const durableBackend = new InMemoryDurableBackend(); + durableBackend.registerWorkflow({ + workflowId: "durable-reload-retained", + name: "reload-inflight", + inputs: { message: "resume" }, + createdAt: 1, + status: "paused", + completedCheckpoints: 1, + }); + setDurableBackend(durableBackend); + const durableBefore = structuredClone(durableBackend.listResumableWorkflows()); + await harness.execute({ action: "reload" }); + assert.deepEqual(durableBackend.listResumableWorkflows(), durableBefore); + const resumeMessageStart = harness.messages.length; + await harness.commands.get("workflow")?.handler?.("resume durable-reload-retained", { + hasUI: false, + ui: { notify: () => undefined }, + }); + assert.match( + harness.messages.slice(resumeMessageStart).join("\n"), + /Resuming durable workflow[\s\S]*checkpoints will be replayed/, + ); + const running = harness.execute({ action: "run", workflow: "reload-inflight", inputs: { message: "value" } }); + await promptStarted; + await writeWorkflow(workflowPath, { name: "reload-inflight", description: "new metadata", prompt: "new prompt" }); + const reloaded = reloadResult(await harness.execute({ action: "reload" })); + assert.equal(reloaded.status, "ok"); + assert.equal(durableBackend.isWorkflowLoadable("durable-reload-retained"), true); + const current = await harness.execute({ action: "get", workflow: "reload-inflight" }); + assert.equal(current.action, "get"); + assert.equal(current.details?.output?.description, "new metadata"); + releasePrompt("held:old prompt:value"); + const completed = await running; + assert.equal(completed.action, "run"); + assert.equal(completed.status, "completed", JSON.stringify(completed)); + assert.deepEqual(completed.result, { value: "held:old prompt:value" }); + }); - test.serial("overlapping reload recovers from an active failure and applies the coalesced trailing pass", async () => { - await makeIsolatedRoots("workflow-reload-coalesce-failure"); - const gates: Array<() => void> = []; - const starts: Array<() => void> = []; - let refreshCalls = 0; - const started = (index: number): Promise => new Promise((resolve) => { starts[index] = resolve; }); - const start0 = started(0); - const start1 = started(1); - const harness = createHarness({ - refreshWorkflowResources: async () => { - const index = refreshCalls++; - starts[index]?.(); - await new Promise((resolve) => { gates[index] = resolve; }); - if (index === 0) throw new Error("overlapping active failure"); - return []; - }, - }); + test.sequential("overlapping reload recovers from an active failure and applies the coalesced trailing pass", async () => { + await makeIsolatedRoots("workflow-reload-coalesce-failure"); + const gates: Array<() => void> = []; + const starts: Array<() => void> = []; + let refreshCalls = 0; + const started = (index: number): Promise => + new Promise((resolve) => { + starts[index] = resolve; + }); + const start0 = started(0); + const start1 = started(1); + const harness = createHarness({ + refreshWorkflowResources: async () => { + const index = refreshCalls++; + starts[index]?.(); + await new Promise((resolve) => { + gates[index] = resolve; + }); + if (index === 0) throw new Error("overlapping active failure"); + return []; + }, + }); - const first = harness.execute({ action: "reload" }); - await start0; - const trailingA = harness.execute({ action: "reload" }); - const trailingB = harness.execute({ action: "reload" }); - assert.equal(refreshCalls, 1); - gates[0]?.(); - await start1; - assert.equal(refreshCalls, 2); - gates[1]?.(); - const [firstResult, secondResult, thirdResult] = await Promise.all([first, trailingA, trailingB]); - const failed = reloadResult(firstResult); - const applied = reloadResult(secondResult); - assert.equal(failed.outcome, "failed"); - assert.equal(failed.error, "overlapping active failure"); - assert.equal(applied.outcome, "applied"); - assert.equal(applied.coalescedRequests, 2); - assert.equal(reloadResult(thirdResult).generation, applied.generation); - assert.ok(applied.generation > failed.generation); - }); + const first = harness.execute({ action: "reload" }); + await start0; + const trailingA = harness.execute({ action: "reload" }); + const trailingB = harness.execute({ action: "reload" }); + assert.equal(refreshCalls, 1); + gates[0]?.(); + await start1; + assert.equal(refreshCalls, 2); + gates[1]?.(); + const [firstResult, secondResult, thirdResult] = await Promise.all([first, trailingA, trailingB]); + const failed = reloadResult(firstResult); + const applied = reloadResult(secondResult); + assert.equal(failed.outcome, "failed"); + assert.equal(failed.error, "overlapping active failure"); + assert.equal(applied.outcome, "applied"); + assert.equal(applied.coalescedRequests, 2); + assert.equal(reloadResult(thirdResult).generation, applied.generation); + assert.ok(applied.generation > failed.generation); + }); - test.serial("queued old-session requests cannot coalesce with or publish into a new session", async () => { - await makeIsolatedRoots("workflow-reload-session-boundary"); - const gates: Array<() => void> = []; - const starts: Array<() => void> = []; - let refreshCalls = 0; - const firstStarted = new Promise((resolve) => { starts[0] = resolve; }); - const freshStarted = new Promise((resolve) => { starts[1] = resolve; }); - const pi = { - refreshWorkflowResources: async () => { - const index = refreshCalls++; - starts[index]?.(); - await new Promise((resolve) => { gates[index] = resolve; }); - return []; - }, - } as ExtensionAPI; - const state = createWorkflowExtensionRuntimeState(pi, {} as never); + test.sequential("queued old-session requests cannot coalesce with or publish into a new session", async () => { + await makeIsolatedRoots("workflow-reload-session-boundary"); + const gates: Array<() => void> = []; + const starts: Array<() => void> = []; + let refreshCalls = 0; + const firstStarted = new Promise((resolve) => { + starts[0] = resolve; + }); + const freshStarted = new Promise((resolve) => { + starts[1] = resolve; + }); + const pi = { + refreshWorkflowResources: async () => { + const index = refreshCalls++; + starts[index]?.(); + await new Promise((resolve) => { + gates[index] = resolve; + }); + return []; + }, + } as ExtensionAPI; + const state = createWorkflowExtensionRuntimeState(pi, {} as never); - const activeOld = state.reloadWorkflowResources(); - await firstStarted; - const queuedOld = state.reloadWorkflowResources(); - state.resetWorkflowDiscoveryForSession(); - const fresh = state.reloadWorkflowResources(); - gates[0]?.(); - await freshStarted; - assert.equal(refreshCalls, 2, "stale queued generation must be rejected before refresh"); - gates[1]?.(); + const activeOld = state.reloadWorkflowResources(); + await firstStarted; + const queuedOld = state.reloadWorkflowResources(); + state.resetWorkflowDiscoveryForSession(); + const fresh = state.reloadWorkflowResources(); + gates[0]?.(); + await freshStarted; + assert.equal(refreshCalls, 2, "stale queued generation must be rejected before refresh"); + gates[1]?.(); - const [activeReport, queuedReport, freshReport] = await Promise.all([activeOld, queuedOld, fresh]); - assert.equal(activeReport.outcome, "superseded"); - assert.equal(queuedReport.outcome, "superseded"); - assert.equal(freshReport.outcome, "applied"); - assert.ok(freshReport.generation > activeReport.generation); - }); + const [activeReport, queuedReport, freshReport] = await Promise.all([activeOld, queuedOld, fresh]); + assert.equal(activeReport.outcome, "superseded"); + assert.equal(queuedReport.outcome, "superseded"); + assert.equal(freshReport.outcome, "applied"); + assert.ok(freshReport.generation > activeReport.generation); + }); }); diff --git a/test/unit/workflow-reload-render.test.ts b/test/unit/workflow-reload-render.test.ts index d5c2f3f8f..adda9fcd9 100644 --- a/test/unit/workflow-reload-render.test.ts +++ b/test/unit/workflow-reload-render.test.ts @@ -1,56 +1,58 @@ -import { test } from "bun:test"; import assert from "node:assert/strict"; +import { test } from "vitest"; import { renderResult, type WorkflowToolResult } from "../../packages/workflows/src/extension/render-result.js"; import { formatWorkflowReloadReport } from "../../packages/workflows/src/extension/workflow-command-surfaces.js"; import type { WorkflowReloadReport } from "../../packages/workflows/src/extension/workflow-reload-report.js"; test("reload result rendering wraps multiline diagnostics without losing actionable details", () => { - const report: WorkflowReloadReport = { - outcome: "applied", - generation: 2, - workflowCount: 8, - coalescedRequests: 1, - diagnostics: [{ - phase: "discovery", - level: "error", - code: "IMPORT_FAILED", - source: "/a/very/long/workflow/source/path/that/would/otherwise/consume/the/notice/width.ts", - message: "module exploded while importing the newly added workflow", - }], - }; - const result: WorkflowToolResult = { - action: "reload", - status: "ok", - message: formatWorkflowReloadReport(report), - ...report, - }; + const report: WorkflowReloadReport = { + outcome: "applied", + generation: 2, + workflowCount: 8, + coalescedRequests: 1, + diagnostics: [ + { + phase: "discovery", + level: "error", + code: "IMPORT_FAILED", + source: "/a/very/long/workflow/source/path/that/would/otherwise/consume/the/notice/width.ts", + message: "module exploded while importing the newly added workflow", + }, + ], + }; + const result: WorkflowToolResult = { + action: "reload", + status: "ok", + message: formatWorkflowReloadReport(report), + ...report, + }; - const rendered = renderResult(result, { width: 80, plain: true }); - assert.match(rendered, /Reloaded workflow resources/); - assert.match(rendered, /IMPORT_FAILED/); - assert.match(rendered, /module exploded while importing/); + const rendered = renderResult(result, { width: 80, plain: true }); + assert.match(rendered, /Reloaded workflow resources/); + assert.match(rendered, /IMPORT_FAILED/); + assert.match(rendered, /module exploded while importing/); }); test("explicit reload reports and renders every diagnostic beyond the former display cap", () => { - const diagnostics: WorkflowReloadReport["diagnostics"] = Array.from({ length: 9 }, (_, index) => ({ - phase: "discovery" as const, - level: "error" as const, - code: "IMPORT_FAILED" as const, - source: `/workflows/malformed-${index + 1}.ts`, - message: `malformed workflow ${index + 1}`, - })); - const report: WorkflowReloadReport = { - outcome: "applied", - generation: 3, - workflowCount: 7, - coalescedRequests: 1, - diagnostics, - }; - const message = formatWorkflowReloadReport(report); - const result: WorkflowToolResult = { action: "reload", status: "ok", message, ...report }; - const rendered = renderResult(result, { width: 80, plain: true }); + const diagnostics: WorkflowReloadReport["diagnostics"] = Array.from({ length: 9 }, (_, index) => ({ + phase: "discovery" as const, + level: "error" as const, + code: "IMPORT_FAILED" as const, + source: `/workflows/malformed-${index + 1}.ts`, + message: `malformed workflow ${index + 1}`, + })); + const report: WorkflowReloadReport = { + outcome: "applied", + generation: 3, + workflowCount: 7, + coalescedRequests: 1, + diagnostics, + }; + const message = formatWorkflowReloadReport(report); + const result: WorkflowToolResult = { action: "reload", status: "ok", message, ...report }; + const rendered = renderResult(result, { width: 80, plain: true }); - assert.match(message, /malformed-9\.ts: malformed workflow 9/); - assert.doesNotMatch(message, /… 1 more/); - assert.match(rendered, /malformed-9\.ts: malformed workflow 9/); + assert.match(message, /malformed-9\.ts: malformed workflow 9/); + assert.doesNotMatch(message, /… 1 more/); + assert.match(rendered, /malformed-9\.ts: malformed workflow 9/); }); diff --git a/test/unit/workflow-resume-partial-surfaces.test.ts b/test/unit/workflow-resume-partial-surfaces.test.ts index fd692b89e..cda931498 100644 --- a/test/unit/workflow-resume-partial-surfaces.test.ts +++ b/test/unit/workflow-resume-partial-surfaces.test.ts @@ -1,257 +1,300 @@ -import { afterEach, beforeEach, describe, test } from "bun:test"; import assert from "node:assert/strict"; -import { WORKFLOW_STAGE_SUBAGENT_GUARD_ENV, type AgentSession } from "@bastani/atomic"; +import { type AgentSession, WORKFLOW_STAGE_SUBAGENT_GUARD_ENV } from "@bastani/atomic"; +import { afterEach, beforeEach, describe, test } from "vitest"; import { InMemoryDurableBackend } from "../../packages/workflows/src/durable/backend.js"; import { setDurableBackend } from "../../packages/workflows/src/durable/factory.js"; import { createExtensionRuntime } from "../../packages/workflows/src/extension/runtime.js"; import { handleRunControlCommand } from "../../packages/workflows/src/extension/workflow-run-control-command.js"; import { makeExecuteWorkflowTool } from "../../packages/workflows/src/extension/workflow-tool.js"; import { - stageControlRegistry, - type StageControlHandle, - type StageControlStatus, + type StageControlHandle, + type StageControlStatus, + stageControlRegistry, } from "../../packages/workflows/src/runs/foreground/stage-control-registry.js"; import { store } from "../../packages/workflows/src/shared/store.js"; function seedPartialRun(runId: string): InMemoryDurableBackend { - const backend = new InMemoryDurableBackend(); - setDurableBackend(backend); - store.recordRunStart({ id: runId, name: "partial", inputs: {}, status: "running", stages: [], startedAt: 1 }); - for (const stageId of ["resume-ok", "resume-fail"]) { - store.recordStageStart(runId, { id: stageId, name: stageId, status: "running", parentIds: [], toolEvents: [] }); - store.recordStagePaused(runId, stageId); - } - store.recordRunPaused(runId, undefined, { resumable: true, exitReason: "quit" }); - backend.registerWorkflow({ workflowId: runId, name: "partial", inputs: {}, createdAt: 1, status: "paused" }); - backend.recordCheckpoint({ - kind: "tool", workflowId: runId, checkpointId: "progress", name: "progress", - argsHash: "progress", output: "done", completedAt: 2, - }); - registerHandle(runId, "resume-ok", async (setStatus) => { - setStatus("running"); - store.recordStageResumed(runId, "resume-ok"); - store.recordRunResumed(runId); - }); - registerHandle(runId, "resume-fail", async () => { - throw new Error("surface resume failed"); - }); - return backend; + const backend = new InMemoryDurableBackend(); + setDurableBackend(backend); + store.recordRunStart({ id: runId, name: "partial", inputs: {}, status: "running", stages: [], startedAt: 1 }); + for (const stageId of ["resume-ok", "resume-fail"]) { + store.recordStageStart(runId, { id: stageId, name: stageId, status: "running", parentIds: [], toolEvents: [] }); + store.recordStagePaused(runId, stageId); + } + store.recordRunPaused(runId, undefined, { resumable: true, exitReason: "quit" }); + backend.registerWorkflow({ workflowId: runId, name: "partial", inputs: {}, createdAt: 1, status: "paused" }); + backend.recordCheckpoint({ + kind: "tool", + workflowId: runId, + checkpointId: "progress", + name: "progress", + argsHash: "progress", + output: "done", + completedAt: 2, + }); + registerHandle(runId, "resume-ok", async (setStatus) => { + setStatus("running"); + store.recordStageResumed(runId, "resume-ok"); + store.recordRunResumed(runId); + }); + registerHandle(runId, "resume-fail", async () => { + throw new Error("surface resume failed"); + }); + return backend; } function registerHandle( - runId: string, - stageId: string, - resume: (setStatus: (status: StageControlStatus) => void) => Promise, + runId: string, + stageId: string, + resume: (setStatus: (status: StageControlStatus) => void) => Promise, ): void { - let status: StageControlStatus = "paused"; - const handle: StageControlHandle = { - runId, - stageId, - stageName: stageId, - get status() { return status; }, - sessionId: undefined, - sessionFile: undefined, - isStreaming: false, - messages: [] as AgentSession["messages"], - async ensureAttached() {}, - async prompt() {}, - async steer() {}, - async followUp() {}, - async pause() { status = "paused"; }, - async resume() { await resume((next) => { status = next; }); }, - subscribe: () => () => {}, - }; - stageControlRegistry.register(handle); + let status: StageControlStatus = "paused"; + const handle: StageControlHandle = { + runId, + stageId, + stageName: stageId, + get status() { + return status; + }, + sessionId: undefined, + sessionFile: undefined, + isStreaming: false, + messages: [] as AgentSession["messages"], + async ensureAttached() {}, + async prompt() {}, + async steer() {}, + async followUp() {}, + async pause() { + status = "paused"; + }, + async resume() { + await resume((next) => { + status = next; + }); + }, + subscribe: () => () => {}, + }; + stageControlRegistry.register(handle); } beforeEach(() => { - delete process.env[WORKFLOW_STAGE_SUBAGENT_GUARD_ENV]; + delete process.env[WORKFLOW_STAGE_SUBAGENT_GUARD_ENV]; }); afterEach(() => { - delete process.env[WORKFLOW_STAGE_SUBAGENT_GUARD_ENV]; - stageControlRegistry.clear(); - store.clear(); - setDurableBackend(undefined); + delete process.env[WORKFLOW_STAGE_SUBAGENT_GUARD_ENV]; + stageControlRegistry.clear(); + store.clear(); + setDurableBackend(undefined); }); describe("partial resume command surfaces", () => { - test.serial("workflow tool preserves result identity and reports partial failure", async () => { - const runId = "tool-partial-resume"; - const backend = seedPartialRun(runId); - const runtime = createExtensionRuntime({ definitions: [], store }); - const execute = makeExecuteWorkflowTool(runtime, () => undefined, () => undefined); + test.sequential("workflow tool preserves result identity and reports partial failure", async () => { + const runId = "tool-partial-resume"; + const backend = seedPartialRun(runId); + const runtime = createExtensionRuntime({ definitions: [], store }); + const execute = makeExecuteWorkflowTool( + runtime, + () => undefined, + () => undefined, + ); - const result = await execute({ action: "resume", runId }, {} as never); + const result = await execute({ action: "resume", runId }, {} as never); - assert.deepEqual(Object.keys(result).sort(), ["action", "message", "runId", "status"]); - assert.equal(result.action, "resume"); - assert.equal(result.runId, runId); - assert.equal(result.status, "partial"); - assert.match(result.message, /partially resumed/i); - assert.match(result.message, new RegExp(`${runId}/resume-fail.*surface resume failed`)); - assert.equal(store.runs().find((run) => run.id === runId)?.status, "running"); - assert.equal(backend.getWorkflow(runId)?.status, "running"); - }); + assert.deepEqual(Object.keys(result).sort(), ["action", "message", "runId", "status"]); + assert.equal(result.action, "resume"); + assert.equal(result.runId, runId); + assert.equal(result.status, "partial"); + assert.match(result.message, /partially resumed/i); + assert.match(result.message, new RegExp(`${runId}/resume-fail.*surface resume failed`)); + assert.equal(store.runs().find((run) => run.id === runId)?.status, "running"); + assert.equal(backend.getWorkflow(runId)?.status, "running"); + }); - test.serial("workflow tool reports durable resume failure as partial when a stage is running", async () => { - class ThrowRunningBackend extends InMemoryDurableBackend { - override setWorkflowStatus( - workflowId: string, - status: Parameters[1], - pendingPrompts?: number, - resumable?: boolean, - ): void { - if (status === "running") throw new Error("durable running write failed"); - super.setWorkflowStatus(workflowId, status, pendingPrompts, resumable); - } - } - const runId = "tool-durable-resume-failure"; - const backend = new ThrowRunningBackend(); - setDurableBackend(backend); - store.recordRunStart({ id: runId, name: "partial", inputs: {}, status: "running", stages: [], startedAt: 1 }); - store.recordStageStart(runId, { id: "only", name: "only", status: "running", parentIds: [], toolEvents: [] }); - store.recordStagePaused(runId, "only"); - store.recordRunPaused(runId, undefined, { resumable: true, exitReason: "quit" }); - backend.registerWorkflow({ workflowId: runId, name: "partial", inputs: {}, createdAt: 1, status: "paused" }); - backend.recordCheckpoint({ - kind: "tool", workflowId: runId, checkpointId: "progress", name: "progress", - argsHash: "progress", output: "done", completedAt: 2, - }); - registerHandle(runId, "only", async (setStatus) => { - setStatus("running"); - store.recordStageResumed(runId, "only"); - store.recordRunResumed(runId); - }); - const runtime = createExtensionRuntime({ definitions: [], store }); - const execute = makeExecuteWorkflowTool(runtime, () => undefined, () => undefined); + test.sequential("workflow tool reports durable resume failure as partial when a stage is running", async () => { + class ThrowRunningBackend extends InMemoryDurableBackend { + override setWorkflowStatus( + workflowId: string, + status: Parameters[1], + pendingPrompts?: number, + resumable?: boolean, + ): void { + if (status === "running") throw new Error("durable running write failed"); + super.setWorkflowStatus(workflowId, status, pendingPrompts, resumable); + } + } + const runId = "tool-durable-resume-failure"; + const backend = new ThrowRunningBackend(); + setDurableBackend(backend); + store.recordRunStart({ id: runId, name: "partial", inputs: {}, status: "running", stages: [], startedAt: 1 }); + store.recordStageStart(runId, { id: "only", name: "only", status: "running", parentIds: [], toolEvents: [] }); + store.recordStagePaused(runId, "only"); + store.recordRunPaused(runId, undefined, { resumable: true, exitReason: "quit" }); + backend.registerWorkflow({ workflowId: runId, name: "partial", inputs: {}, createdAt: 1, status: "paused" }); + backend.recordCheckpoint({ + kind: "tool", + workflowId: runId, + checkpointId: "progress", + name: "progress", + argsHash: "progress", + output: "done", + completedAt: 2, + }); + registerHandle(runId, "only", async (setStatus) => { + setStatus("running"); + store.recordStageResumed(runId, "only"); + store.recordRunResumed(runId); + }); + const runtime = createExtensionRuntime({ definitions: [], store }); + const execute = makeExecuteWorkflowTool( + runtime, + () => undefined, + () => undefined, + ); - const result = await execute({ action: "resume", runId }, {} as never); + const result = await execute({ action: "resume", runId }, {} as never); - assert.deepEqual(Object.keys(result).sort(), ["action", "message", "runId", "status"]); - assert.equal(result.action, "resume"); - assert.equal(result.status, "partial"); - assert.match(result.message, /durable running write failed/); - assert.equal(store.runs().find((run) => run.id === runId)?.stages[0]?.status, "running"); - }); + assert.deepEqual(Object.keys(result).sort(), ["action", "message", "runId", "status"]); + assert.equal(result.action, "resume"); + assert.equal(result.status, "partial"); + assert.match(result.message, /durable running write failed/); + assert.equal(store.runs().find((run) => run.id === runId)?.stages[0]?.status, "running"); + }); - test.serial("workflow tool retries transient durable reconciliation on a later resume request", async () => { - class TransientRunningBackend extends InMemoryDurableBackend { - runningAttempts = 0; - override setWorkflowStatus( - workflowId: string, - status: Parameters[1], - pendingPrompts?: number, - resumable?: boolean, - ): void { - if (status === "running" && ++this.runningAttempts === 1) throw new Error("transient public durable failure"); - super.setWorkflowStatus(workflowId, status, pendingPrompts, resumable); - } - } - const runId = "tool-durable-resume-retry"; - const backend = new TransientRunningBackend(); - setDurableBackend(backend); - store.recordRunStart({ id: runId, name: "partial", inputs: {}, status: "running", stages: [], startedAt: 1 }); - store.recordStageStart(runId, { id: "only", name: "only", status: "running", parentIds: [], toolEvents: [] }); - store.recordStagePaused(runId, "only"); - store.recordRunPaused(runId, undefined, { resumable: true, exitReason: "quit" }); - backend.registerWorkflow({ workflowId: runId, name: "partial", inputs: {}, createdAt: 1, status: "paused" }); - backend.recordCheckpoint({ - kind: "tool", workflowId: runId, checkpointId: "progress", name: "progress", - argsHash: "progress", output: "done", completedAt: 2, - }); - registerHandle(runId, "only", async (setStatus) => { - setStatus("running"); - store.recordStageResumed(runId, "only"); - store.recordRunResumed(runId); - }); - const runtime = createExtensionRuntime({ definitions: [], store }); - const execute = makeExecuteWorkflowTool(runtime, () => undefined, () => undefined); + test.sequential("workflow tool retries transient durable reconciliation on a later resume request", async () => { + class TransientRunningBackend extends InMemoryDurableBackend { + runningAttempts = 0; + override setWorkflowStatus( + workflowId: string, + status: Parameters[1], + pendingPrompts?: number, + resumable?: boolean, + ): void { + if (status === "running" && ++this.runningAttempts === 1) + throw new Error("transient public durable failure"); + super.setWorkflowStatus(workflowId, status, pendingPrompts, resumable); + } + } + const runId = "tool-durable-resume-retry"; + const backend = new TransientRunningBackend(); + setDurableBackend(backend); + store.recordRunStart({ id: runId, name: "partial", inputs: {}, status: "running", stages: [], startedAt: 1 }); + store.recordStageStart(runId, { id: "only", name: "only", status: "running", parentIds: [], toolEvents: [] }); + store.recordStagePaused(runId, "only"); + store.recordRunPaused(runId, undefined, { resumable: true, exitReason: "quit" }); + backend.registerWorkflow({ workflowId: runId, name: "partial", inputs: {}, createdAt: 1, status: "paused" }); + backend.recordCheckpoint({ + kind: "tool", + workflowId: runId, + checkpointId: "progress", + name: "progress", + argsHash: "progress", + output: "done", + completedAt: 2, + }); + registerHandle(runId, "only", async (setStatus) => { + setStatus("running"); + store.recordStageResumed(runId, "only"); + store.recordRunResumed(runId); + }); + const runtime = createExtensionRuntime({ definitions: [], store }); + const execute = makeExecuteWorkflowTool( + runtime, + () => undefined, + () => undefined, + ); - const first = await execute({ action: "resume", runId }, {} as never); - assert.equal(first.action, "resume"); - assert.equal(first.runId, runId); - assert.equal(first.status, "partial"); - assert.match(first.message, /transient public durable failure/); - assert.equal(backend.getWorkflow(runId)?.status, "paused"); + const first = await execute({ action: "resume", runId }, {} as never); + assert.equal(first.action, "resume"); + assert.equal(first.runId, runId); + assert.equal(first.status, "partial"); + assert.match(first.message, /transient public durable failure/); + assert.equal(backend.getWorkflow(runId)?.status, "paused"); - const second = await execute({ action: "resume", runId }, {} as never); - assert.equal(second.action, "resume"); - assert.equal(second.runId, runId); - assert.equal(second.status, "ok"); - assert.equal(backend.runningAttempts, 2); - assert.equal(backend.getWorkflow(runId)?.status, "running"); - }); + const second = await execute({ action: "resume", runId }, {} as never); + assert.equal(second.action, "resume"); + assert.equal(second.runId, runId); + assert.equal(second.status, "ok"); + assert.equal(backend.runningAttempts, 2); + assert.equal(backend.getWorkflow(runId)?.status, "running"); + }); - test.serial("no-target slash selector reports resume rejection through the reporter", async () => { - const runId = "slash-picker-resume-failure"; - const backend = new InMemoryDurableBackend(); - setDurableBackend(backend); - store.recordRunStart({ id: runId, name: "picker", inputs: {}, status: "running", stages: [], startedAt: 1 }); - store.recordStageStart(runId, { id: "only", name: "only", status: "running", parentIds: [], toolEvents: [] }); - store.recordStagePaused(runId, "only"); - store.recordRunPaused(runId, undefined, { resumable: true, exitReason: "quit" }); - backend.registerWorkflow({ workflowId: runId, name: "picker", inputs: {}, createdAt: 1, status: "paused" }); - backend.recordCheckpoint({ - kind: "tool", workflowId: runId, checkpointId: "progress", name: "progress", - argsHash: "progress", output: "done", completedAt: 2, - }); - registerHandle(runId, "only", async () => { throw new Error("picker resume rejected"); }); - const runtime = createExtensionRuntime({ definitions: [], store }); - const info: string[] = []; - const errors: string[] = []; - // Host session-picker seam: auto-select the first (live) row shortly - // after open, mirroring a user hitting Enter on the seeded top row. - const hostSessionPicker = (request: { sessions: Array<{ path: string }> }) => ({ - result: new Promise((resolve) => { - setTimeout(() => resolve(request.sessions[0]?.path), 10); - }), - update: () => undefined, - error: () => undefined, - close: () => undefined, - }); + test.sequential("no-target slash selector reports resume rejection through the reporter", async () => { + const runId = "slash-picker-resume-failure"; + const backend = new InMemoryDurableBackend(); + setDurableBackend(backend); + store.recordRunStart({ id: runId, name: "picker", inputs: {}, status: "running", stages: [], startedAt: 1 }); + store.recordStageStart(runId, { id: "only", name: "only", status: "running", parentIds: [], toolEvents: [] }); + store.recordStagePaused(runId, "only"); + store.recordRunPaused(runId, undefined, { resumable: true, exitReason: "quit" }); + backend.registerWorkflow({ workflowId: runId, name: "picker", inputs: {}, createdAt: 1, status: "paused" }); + backend.recordCheckpoint({ + kind: "tool", + workflowId: runId, + checkpointId: "progress", + name: "progress", + argsHash: "progress", + output: "done", + completedAt: 2, + }); + registerHandle(runId, "only", async () => { + throw new Error("picker resume rejected"); + }); + const runtime = createExtensionRuntime({ definitions: [], store }); + const info: string[] = []; + const errors: string[] = []; + // Host session-picker seam: auto-select the first (live) row shortly + // after open, mirroring a user hitting Enter on the seeded top row. + const hostSessionPicker = (request: { sessions: Array<{ path: string }> }) => ({ + result: new Promise((resolve) => { + setTimeout(() => resolve(request.sessions[0]?.path), 10); + }), + update: () => undefined, + error: () => undefined, + close: () => undefined, + }); - await handleRunControlCommand( - "resume", - [], - { hasUI: true, ui: { notify: () => undefined, custom: () => undefined, hostSessionPicker } } as never, - { info: (message) => info.push(message), error: (message) => errors.push(message) }, - { - pi: {}, - overlay: { open: () => undefined, toggle: () => undefined, close: () => undefined }, - runtimeForContext: () => runtime, - ensureWorkflowResourcesLoaded: () => undefined, - }, - ); + await handleRunControlCommand( + "resume", + [], + { hasUI: true, ui: { notify: () => undefined, custom: () => undefined, hostSessionPicker } } as never, + { info: (message) => info.push(message), error: (message) => errors.push(message) }, + { + pi: {}, + overlay: { open: () => undefined, toggle: () => undefined, close: () => undefined }, + runtimeForContext: () => runtime, + ensureWorkflowResourcesLoaded: () => undefined, + }, + ); - assert.deepEqual(info, []); - assert.match(errors.join("\n"), /Failed to resume run.*picker resume rejected/); - }); + assert.deepEqual(info, []); + assert.match(errors.join("\n"), /Failed to resume run.*picker resume rejected/); + }); - test.serial("slash resume reports the same partial failure instead of success or noop", async () => { - const runId = "slash-partial-resume"; - const backend = seedPartialRun(runId); - const runtime = createExtensionRuntime({ definitions: [], store }); - const info: string[] = []; - const errors: string[] = []; + test.sequential("slash resume reports the same partial failure instead of success or noop", async () => { + const runId = "slash-partial-resume"; + const backend = seedPartialRun(runId); + const runtime = createExtensionRuntime({ definitions: [], store }); + const info: string[] = []; + const errors: string[] = []; - await handleRunControlCommand( - "resume", - [runId], - { hasUI: false, ui: { notify: () => undefined } }, - { info: (message) => info.push(message), error: (message) => errors.push(message) }, - { - pi: {}, - overlay: { open: () => undefined, toggle: () => undefined, close: () => undefined }, - runtimeForContext: () => runtime, - ensureWorkflowResourcesLoaded: () => undefined, - }, - ); + await handleRunControlCommand( + "resume", + [runId], + { hasUI: false, ui: { notify: () => undefined } }, + { info: (message) => info.push(message), error: (message) => errors.push(message) }, + { + pi: {}, + overlay: { open: () => undefined, toggle: () => undefined, close: () => undefined }, + runtimeForContext: () => runtime, + ensureWorkflowResourcesLoaded: () => undefined, + }, + ); - assert.deepEqual(info, []); - assert.match(errors.join("\n"), /partially resumed/i); - assert.match(errors.join("\n"), new RegExp(`${runId}/resume-fail.*surface resume failed`)); - assert.equal(store.runs().find((run) => run.id === runId)?.status, "running"); - assert.equal(backend.getWorkflow(runId)?.status, "running"); - }); + assert.deepEqual(info, []); + assert.match(errors.join("\n"), /partially resumed/i); + assert.match(errors.join("\n"), new RegExp(`${runId}/resume-fail.*surface resume failed`)); + assert.equal(store.runs().find((run) => run.id === runId)?.status, "running"); + assert.equal(backend.getWorkflow(runId)?.status, "running"); + }); }); diff --git a/test/unit/workflow-resume-run-timing.test.ts b/test/unit/workflow-resume-run-timing.test.ts index a352d0811..01e969773 100644 --- a/test/unit/workflow-resume-run-timing.test.ts +++ b/test/unit/workflow-resume-run-timing.test.ts @@ -1,317 +1,345 @@ -import { describe } from "bun:test"; -import { assert, createStore, run, test, Type, workflow } from "./executor-shared.js"; +import { describe } from "vitest"; import { InMemoryDurableBackend } from "../../packages/workflows/src/durable/backend.js"; +import { classifyCheckpointPayload, encodeCheckpoint } from "../../packages/workflows/src/durable/dbos-envelope.js"; +import { getDurableBackend } from "../../packages/workflows/src/durable/factory.js"; import { - RUN_TIMING_CHECKPOINT_NAME, - inheritedRunElapsedMs, - priorRunElapsedMs, - recordRunTimingCheckpoint, + inheritedRunElapsedMs, + priorRunElapsedMs, + RUN_TIMING_CHECKPOINT_NAME, + recordRunTimingCheckpoint, } from "../../packages/workflows/src/durable/run-timing.js"; import { recordStageSessionCheckpoint } from "../../packages/workflows/src/durable/stage-primitive.js"; -import { classifyCheckpointPayload, encodeCheckpoint } from "../../packages/workflows/src/durable/dbos-envelope.js"; -import { getDurableBackend } from "../../packages/workflows/src/durable/factory.js"; import { finalizeDurableTerminalStatus } from "../../packages/workflows/src/engine/run-durable-finalize.js"; import { quitRun } from "../../packages/workflows/src/runs/background/quit.js"; -import { elapsedRunMs } from "../../packages/workflows/src/shared/timing.js"; -import { appendRunStart } from "../../packages/workflows/src/shared/persistence-session-entries.js"; import { restoreOnSessionStart, type SessionEntry } from "../../packages/workflows/src/shared/persistence-restore.js"; +import { appendRunStart } from "../../packages/workflows/src/shared/persistence-session-entries.js"; import type { RunSnapshot } from "../../packages/workflows/src/shared/store-types.js"; +import { elapsedRunMs } from "../../packages/workflows/src/shared/timing.js"; import type { WorkflowSerializableValue } from "../../packages/workflows/src/shared/types.js"; +import { assert, createStore, run, Type, test, workflow } from "./executor-shared.js"; const RUN_ID = "wf-run-timing"; function makeRun(overrides: Partial = {}): RunSnapshot { - return { - id: RUN_ID, - name: "timing", - inputs: {}, - status: "running", - stages: [], - startedAt: 0, - ...overrides, - }; + return { + id: RUN_ID, + name: "timing", + inputs: {}, + status: "running", + stages: [], + startedAt: 0, + ...overrides, + }; } function makeBackend(): InMemoryDurableBackend { - const backend = new InMemoryDurableBackend(); - backend.registerWorkflow({ workflowId: RUN_ID, name: "timing", inputs: {}, createdAt: 1, status: "running" }); - return backend; + const backend = new InMemoryDurableBackend(); + backend.registerWorkflow({ workflowId: RUN_ID, name: "timing", inputs: {}, createdAt: 1, status: "running" }); + return backend; } function recordProgressCheckpoint(backend: InMemoryDurableBackend, workflowId = RUN_ID): void { - backend.recordCheckpoint({ - kind: "stage", - workflowId, - checkpointId: "stage-session:stage:work:1:seed", - name: "work", - replayKey: "stage:work:1", - sessionFile: "/tmp/work.jsonl", - startedAt: 2, - durationMs: 700, - completedAt: 5, - }); + backend.recordCheckpoint({ + kind: "stage", + workflowId, + checkpointId: "stage-session:stage:work:1:seed", + name: "work", + replayKey: "stage:work:1", + sessionFile: "/tmp/work.jsonl", + startedAt: 2, + durationMs: 700, + completedAt: 5, + }); } describe("run timer math with inherited elapsed", () => { - test("elapsedRunMs adds accumulated elapsed from prior sessions", () => { - const resumed = makeRun({ startedAt: 1000, accumulatedDurationMs: 500 }); - assert.equal(elapsedRunMs(resumed, 1600), 1100); - }); - - test("accumulated elapsed composes with pause accounting without double counting", () => { - const resumed = makeRun({ - startedAt: 1000, - accumulatedDurationMs: 500, - pausedDurationMs: 200, - pausedAt: 1800, - }); - // 500 inherited + (2000 - 1000 - 200 completed pause - 200 active pause) - assert.equal(elapsedRunMs(resumed, 2000), 1100); - }); - - test("terminal durationMs wins over accumulated elapsed", () => { - const ended = makeRun({ startedAt: 1000, durationMs: 42, accumulatedDurationMs: 500 }); - assert.equal(elapsedRunMs(ended, 9999), 42); - }); + test("elapsedRunMs adds accumulated elapsed from prior sessions", () => { + const resumed = makeRun({ startedAt: 1000, accumulatedDurationMs: 500 }); + assert.equal(elapsedRunMs(resumed, 1600), 1100); + }); + + test("accumulated elapsed composes with pause accounting without double counting", () => { + const resumed = makeRun({ + startedAt: 1000, + accumulatedDurationMs: 500, + pausedDurationMs: 200, + pausedAt: 1800, + }); + // 500 inherited + (2000 - 1000 - 200 completed pause - 200 active pause) + assert.equal(elapsedRunMs(resumed, 2000), 1100); + }); + + test("terminal durationMs wins over accumulated elapsed", () => { + const ended = makeRun({ startedAt: 1000, durationMs: 42, accumulatedDurationMs: 500 }); + assert.equal(elapsedRunMs(ended, 9999), 42); + }); }); describe("durable run-timing checkpoints", () => { - test("records run elapsed only once the workflow has durable progress", () => { - const backend = makeBackend(); - const snapshot = makeRun({ startedAt: 0 }); - assert.equal(recordRunTimingCheckpoint(backend, snapshot, { now: 10_000 }), false); - assert.equal(priorRunElapsedMs(backend, RUN_ID), undefined); - - recordProgressCheckpoint(backend); - assert.equal(recordRunTimingCheckpoint(backend, snapshot, { now: 10_000 }), true); - assert.equal(priorRunElapsedMs(backend, RUN_ID), 10_000); - }); - - test("debounces inside a 30s bucket and refreshes across buckets", () => { - const backend = makeBackend(); - recordProgressCheckpoint(backend); - const snapshot = makeRun({ startedAt: 0 }); - assert.equal(recordRunTimingCheckpoint(backend, snapshot, { now: 10_000, debounce: true }), true); - assert.equal(recordRunTimingCheckpoint(backend, snapshot, { now: 20_000, debounce: true }), false); - assert.equal(priorRunElapsedMs(backend, RUN_ID), 10_000); - assert.equal(recordRunTimingCheckpoint(backend, snapshot, { now: 40_000, debounce: true }), true); - assert.equal(priorRunElapsedMs(backend, RUN_ID), 40_000); - }); - - test("never regresses the recorded elapsed", () => { - const backend = makeBackend(); - recordProgressCheckpoint(backend); - assert.equal(recordRunTimingCheckpoint(backend, makeRun({ startedAt: 0 }), { now: 40_000 }), true); - // A snapshot with a smaller elapsed value must not overwrite the record. - assert.equal(recordRunTimingCheckpoint(backend, makeRun({ startedAt: 39_000 }), { now: 40_000 }), false); - assert.equal(priorRunElapsedMs(backend, RUN_ID), 40_000); - }); - - test("round-trips through the DBOS checkpoint envelope into a fresh process", () => { - const backend = makeBackend(); - recordProgressCheckpoint(backend); - assert.equal(recordRunTimingCheckpoint(backend, makeRun({ startedAt: 0 }), { now: 90_000 }), true); - const checkpoint = backend.listCheckpoints(RUN_ID) - .find((candidate) => candidate.kind === "tool" && candidate.name === RUN_TIMING_CHECKPOINT_NAME); - assert.ok(checkpoint !== undefined); - - const envelope = encodeCheckpoint(checkpoint) as WorkflowSerializableValue; - const classified = classifyCheckpointPayload(RUN_ID, checkpoint.checkpointId, envelope); - assert.equal(classified.kind, "current"); - - const rehydrated = makeBackend(); - if (classified.kind === "current") rehydrated.recordCheckpoint(classified.checkpoint); - assert.equal(priorRunElapsedMs(rehydrated, RUN_ID), 90_000); - }); - - test("inheritedRunElapsedMs prefers the live continuation source snapshot", () => { - const backend = makeBackend(); - const source = makeRun({ startedAt: 1000, endedAt: 2000, durationMs: 1234, status: "failed" }); - assert.equal(inheritedRunElapsedMs({ backend, runId: RUN_ID, continuationSource: source }), 1234); - assert.equal(inheritedRunElapsedMs({ backend, runId: RUN_ID }), undefined); - - recordProgressCheckpoint(backend); - recordRunTimingCheckpoint(backend, makeRun({ startedAt: 0 }), { now: 5_000 }); - assert.equal(inheritedRunElapsedMs({ backend, runId: RUN_ID }), 5_000); - }); + test("records run elapsed only once the workflow has durable progress", () => { + const backend = makeBackend(); + const snapshot = makeRun({ startedAt: 0 }); + assert.equal(recordRunTimingCheckpoint(backend, snapshot, { now: 10_000 }), false); + assert.equal(priorRunElapsedMs(backend, RUN_ID), undefined); + + recordProgressCheckpoint(backend); + assert.equal(recordRunTimingCheckpoint(backend, snapshot, { now: 10_000 }), true); + assert.equal(priorRunElapsedMs(backend, RUN_ID), 10_000); + }); + + test("debounces inside a 30s bucket and refreshes across buckets", () => { + const backend = makeBackend(); + recordProgressCheckpoint(backend); + const snapshot = makeRun({ startedAt: 0 }); + assert.equal(recordRunTimingCheckpoint(backend, snapshot, { now: 10_000, debounce: true }), true); + assert.equal(recordRunTimingCheckpoint(backend, snapshot, { now: 20_000, debounce: true }), false); + assert.equal(priorRunElapsedMs(backend, RUN_ID), 10_000); + assert.equal(recordRunTimingCheckpoint(backend, snapshot, { now: 40_000, debounce: true }), true); + assert.equal(priorRunElapsedMs(backend, RUN_ID), 40_000); + }); + + test("never regresses the recorded elapsed", () => { + const backend = makeBackend(); + recordProgressCheckpoint(backend); + assert.equal(recordRunTimingCheckpoint(backend, makeRun({ startedAt: 0 }), { now: 40_000 }), true); + // A snapshot with a smaller elapsed value must not overwrite the record. + assert.equal(recordRunTimingCheckpoint(backend, makeRun({ startedAt: 39_000 }), { now: 40_000 }), false); + assert.equal(priorRunElapsedMs(backend, RUN_ID), 40_000); + }); + + test("round-trips through the DBOS checkpoint envelope into a fresh process", () => { + const backend = makeBackend(); + recordProgressCheckpoint(backend); + assert.equal(recordRunTimingCheckpoint(backend, makeRun({ startedAt: 0 }), { now: 90_000 }), true); + const checkpoint = backend + .listCheckpoints(RUN_ID) + .find((candidate) => candidate.kind === "tool" && candidate.name === RUN_TIMING_CHECKPOINT_NAME); + assert.ok(checkpoint !== undefined); + + const envelope = encodeCheckpoint(checkpoint) as WorkflowSerializableValue; + const classified = classifyCheckpointPayload(RUN_ID, checkpoint.checkpointId, envelope); + assert.equal(classified.kind, "current"); + + const rehydrated = makeBackend(); + if (classified.kind === "current") rehydrated.recordCheckpoint(classified.checkpoint); + assert.equal(priorRunElapsedMs(rehydrated, RUN_ID), 90_000); + }); + + test("inheritedRunElapsedMs prefers the live continuation source snapshot", () => { + const backend = makeBackend(); + const source = makeRun({ startedAt: 1000, endedAt: 2000, durationMs: 1234, status: "failed" }); + assert.equal(inheritedRunElapsedMs({ backend, runId: RUN_ID, continuationSource: source }), 1234); + assert.equal(inheritedRunElapsedMs({ backend, runId: RUN_ID }), undefined); + + recordProgressCheckpoint(backend); + recordRunTimingCheckpoint(backend, makeRun({ startedAt: 0 }), { now: 5_000 }); + assert.equal(inheritedRunElapsedMs({ backend, runId: RUN_ID }), 5_000); + }); }); - describe("stage timing durability boundaries", () => { - test("forced sub-30-second checkpoint records exact elapsed and topology", async () => { - const backend = makeBackend(); - const deps = { - workflowId: RUN_ID, - backend, - nextCheckpointId: () => "unused", - nextReplayKey: () => "stage:work:1", - now: () => 10_000, - runTopology: { runId: RUN_ID, runName: "timing" }, - }; - const stage = { - id: "work-source", name: "work", replayKey: "stage:work:1", status: "paused" as const, - parentIds: ["plan-source"], startedAt: 0, pausedAt: 10_000, - sessionFile: "/tmp/work.jsonl", toolEvents: [], - }; - assert.equal(await recordStageSessionCheckpoint(deps, stage), true); - deps.now = () => 20_000; - stage.pausedAt = 20_000; - assert.equal(await recordStageSessionCheckpoint(deps, stage), false, "ordinary updates remain bucketed"); - assert.equal(await recordStageSessionCheckpoint(deps, stage, { force: true }), true); - const restored = backend.getStageSession(RUN_ID, "stage:work:1"); - assert.equal(restored?.durationMs, 20_000); - const latest = backend.listCheckpoints(RUN_ID).filter((checkpoint) => checkpoint.kind === "stage").at(-1); - assert.deepEqual(latest?.topology, { - version: 1, - stageId: "work-source", - parentIds: ["plan-source"], - status: "paused", - run: { runId: RUN_ID, runName: "timing" }, - }); - }); + test("forced sub-30-second checkpoint records exact elapsed and topology", async () => { + const backend = makeBackend(); + const deps = { + workflowId: RUN_ID, + backend, + nextCheckpointId: () => "unused", + nextReplayKey: () => "stage:work:1", + now: () => 10_000, + runTopology: { runId: RUN_ID, runName: "timing" }, + }; + const stage = { + id: "work-source", + name: "work", + replayKey: "stage:work:1", + status: "paused" as const, + parentIds: ["plan-source"], + startedAt: 0, + pausedAt: 10_000, + sessionFile: "/tmp/work.jsonl", + toolEvents: [], + }; + assert.equal(await recordStageSessionCheckpoint(deps, stage), true); + deps.now = () => 20_000; + stage.pausedAt = 20_000; + assert.equal(await recordStageSessionCheckpoint(deps, stage), false, "ordinary updates remain bucketed"); + assert.equal(await recordStageSessionCheckpoint(deps, stage, { force: true }), true); + const restored = backend.getStageSession(RUN_ID, "stage:work:1"); + assert.equal(restored?.durationMs, 20_000); + const latest = backend + .listCheckpoints(RUN_ID) + .filter((checkpoint) => checkpoint.kind === "stage") + .at(-1); + assert.deepEqual(latest?.topology, { + version: 1, + stageId: "work-source", + parentIds: ["plan-source"], + status: "paused", + run: { runId: RUN_ID, runName: "timing" }, + }); + }); }); describe("resumed runs inherit elapsed time", () => { - test("durable resume seeds run total and mid-running stage timers", async () => { - const runId = "wf-durable-resume-timing"; - const backend = new InMemoryDurableBackend(); - backend.registerWorkflow({ workflowId: runId, name: "timing", inputs: {}, createdAt: 1, status: "paused" }); - // Mid-running stage session persisted by the prior session (700ms elapsed). - backend.recordCheckpoint({ - kind: "stage", - workflowId: runId, - checkpointId: "stage-session:stage:work:1:prior", - name: "work", - replayKey: "stage:work:1", - sessionFile: "/tmp/prior.jsonl", - startedAt: 2, - durationMs: 700, - completedAt: 5, - }); - // Total run elapsed persisted at quit time (90s). - backend.recordCheckpoint({ - kind: "tool", - workflowId: runId, - checkpointId: "run-timing:90000", - name: RUN_TIMING_CHECKPOINT_NAME, - argsHash: RUN_TIMING_CHECKPOINT_NAME, - output: { elapsedMs: 90_000 }, - completedAt: 6, - }); - - const store = createStore(); - const def = workflow({ - name: "timing", - description: "", - inputs: {}, - outputs: { result: Type.String() }, - run: async (ctx) => ({ result: await ctx.stage("work").complete("continue") }), - }); - const result = await run(def, {}, { - runId, - store, - durableBackend: backend, - adapters: { complete: { complete: async (text: string) => text } }, - }); - - assert.equal(result.status, "completed"); - const snapshot = store.runs().find((candidate) => candidate.id === runId); - assert.equal(snapshot?.accumulatedDurationMs, 90_000); - // Total workflow duration includes the prior 90s, not just this session. - assert.ok((snapshot?.durationMs ?? 0) >= 90_000); - // The resumed mid-running stage timer continues from its prior 700ms. - const stage = snapshot?.stages.find((candidate) => candidate.name === "work"); - assert.ok((stage?.durationMs ?? 0) >= 700); - }); - - test("continuation resume inherits the source run's total elapsed", async () => { - const store = createStore(); - const def = workflow({ - name: "cont-timing", - description: "", - inputs: {}, - outputs: { result: Type.String() }, - run: async (ctx) => ({ result: await ctx.stage("work").complete("go") }), - }); - - const first = await run(def, {}, { - store, - adapters: { complete: { complete: async () => { throw new Error("boom"); } } }, - }); - assert.equal(first.status, "failed"); - const source = store.runs().find((candidate) => candidate.id === first.runId)!; - // Deterministic prior total for the assertion below. - source.durationMs = 4321; - - const continued = await run(def, {}, { - store, - continuation: { source, resumeFromStageId: source.failedStageId! }, - adapters: { complete: { complete: async (text: string) => text } }, - }); - - assert.equal(continued.status, "completed"); - const snapshot = store.runs().find((candidate) => candidate.id === continued.runId); - assert.equal(snapshot?.accumulatedDurationMs, 4321); - assert.ok((snapshot?.durationMs ?? 0) >= 4321); - }); + test("durable resume seeds run total and mid-running stage timers", async () => { + const runId = "wf-durable-resume-timing"; + const backend = new InMemoryDurableBackend(); + backend.registerWorkflow({ workflowId: runId, name: "timing", inputs: {}, createdAt: 1, status: "paused" }); + // Mid-running stage session persisted by the prior session (700ms elapsed). + backend.recordCheckpoint({ + kind: "stage", + workflowId: runId, + checkpointId: "stage-session:stage:work:1:prior", + name: "work", + replayKey: "stage:work:1", + sessionFile: "/tmp/prior.jsonl", + startedAt: 2, + durationMs: 700, + completedAt: 5, + }); + // Total run elapsed persisted at quit time (90s). + backend.recordCheckpoint({ + kind: "tool", + workflowId: runId, + checkpointId: "run-timing:90000", + name: RUN_TIMING_CHECKPOINT_NAME, + argsHash: RUN_TIMING_CHECKPOINT_NAME, + output: { elapsedMs: 90_000 }, + completedAt: 6, + }); + + const store = createStore(); + const def = workflow({ + name: "timing", + description: "", + inputs: {}, + outputs: { result: Type.String() }, + run: async (ctx) => ({ result: await ctx.stage("work").complete("continue") }), + }); + const result = await run( + def, + {}, + { + runId, + store, + durableBackend: backend, + adapters: { complete: { complete: async (text: string) => text } }, + }, + ); + + assert.equal(result.status, "completed"); + const snapshot = store.runs().find((candidate) => candidate.id === runId); + assert.equal(snapshot?.accumulatedDurationMs, 90_000); + // Total workflow duration includes the prior 90s, not just this session. + assert.ok((snapshot?.durationMs ?? 0) >= 90_000); + // The resumed mid-running stage timer continues from its prior 700ms. + const stage = snapshot?.stages.find((candidate) => candidate.name === "work"); + assert.ok((stage?.durationMs ?? 0) >= 700); + }); + + test("continuation resume inherits the source run's total elapsed", async () => { + const store = createStore(); + const def = workflow({ + name: "cont-timing", + description: "", + inputs: {}, + outputs: { result: Type.String() }, + run: async (ctx) => ({ result: await ctx.stage("work").complete("go") }), + }); + + const first = await run( + def, + {}, + { + store, + adapters: { + complete: { + complete: async () => { + throw new Error("boom"); + }, + }, + }, + }, + ); + assert.equal(first.status, "failed"); + const source = store.runs().find((candidate) => candidate.id === first.runId)!; + // Deterministic prior total for the assertion below. + source.durationMs = 4321; + + const continued = await run( + def, + {}, + { + store, + continuation: { source, resumeFromStageId: source.failedStageId! }, + adapters: { complete: { complete: async (text: string) => text } }, + }, + ); + + assert.equal(continued.status, "completed"); + const snapshot = store.runs().find((candidate) => candidate.id === continued.runId); + assert.equal(snapshot?.accumulatedDurationMs, 4321); + assert.ok((snapshot?.durationMs ?? 0) >= 4321); + }); }); describe("run elapsed persistence at pause/failure boundaries", () => { - test("quitRun persists the exact accumulated run elapsed durably", async () => { - const backend = getDurableBackend(); - const runId = "wf-quit-timing"; - backend.registerWorkflow({ workflowId: runId, name: "timing", inputs: {}, createdAt: 1, status: "running" }); - recordProgressCheckpoint(backend as InMemoryDurableBackend, runId); - - const store = createStore(); - const startedAt = Date.now() - 60_000; - store.recordRunStart(makeRun({ id: runId, startedAt })); - // Freeze the live clock contribution: an already-paused run accrues - // exactly pausedAt - startedAt elapsed regardless of when quit lands. - store.recordRunPaused(runId, startedAt + 8_000); - - const result = await quitRun(runId, { store }); - assert.equal(result.ok, true); - assert.equal(priorRunElapsedMs(backend, runId), 8_000); - }); - - test("terminal failure finalize persists the exact accumulated elapsed", async () => { - const backend = makeBackend(); - recordProgressCheckpoint(backend); - const snapshot = makeRun({ - status: "failed", - startedAt: 1000, - endedAt: 2000, - durationMs: 5555, - resumable: true, - }); - - await finalizeDurableTerminalStatus({ runId: RUN_ID, runSnapshot: snapshot, isRoot: true, durableBackend: backend }); - - assert.equal(priorRunElapsedMs(backend, RUN_ID), 5555); - assert.equal(backend.getWorkflow(RUN_ID)?.status, "failed"); - }); + test("quitRun persists the exact accumulated run elapsed durably", async () => { + const backend = getDurableBackend(); + const runId = "wf-quit-timing"; + backend.registerWorkflow({ workflowId: runId, name: "timing", inputs: {}, createdAt: 1, status: "running" }); + recordProgressCheckpoint(backend as InMemoryDurableBackend, runId); + + const store = createStore(); + const startedAt = Date.now() - 60_000; + store.recordRunStart(makeRun({ id: runId, startedAt })); + // Freeze the live clock contribution: an already-paused run accrues + // exactly pausedAt - startedAt elapsed regardless of when quit lands. + store.recordRunPaused(runId, startedAt + 8_000); + + const result = await quitRun(runId, { store }); + assert.equal(result.ok, true); + assert.equal(priorRunElapsedMs(backend, runId), 8_000); + }); + + test("terminal failure finalize persists the exact accumulated elapsed", async () => { + const backend = makeBackend(); + recordProgressCheckpoint(backend); + const snapshot = makeRun({ + status: "failed", + startedAt: 1000, + endedAt: 2000, + durationMs: 5555, + resumable: true, + }); + + await finalizeDurableTerminalStatus({ + runId: RUN_ID, + runSnapshot: snapshot, + isRoot: true, + durableBackend: backend, + }); + + assert.equal(priorRunElapsedMs(backend, RUN_ID), 5555); + assert.equal(backend.getWorkflow(RUN_ID)?.status, "failed"); + }); }); describe("run.start persistence round-trip", () => { - test("restores inherited elapsed onto the rehydrated run snapshot", () => { - const entries: SessionEntry[] = []; - const api = { - appendEntry: (type: string, payload: Record): string => { - entries.push({ id: `entry-${entries.length}`, type, payload: payload as SessionEntry["payload"] }); - return `entry-${entries.length}`; - }, - }; - appendRunStart(api, { runId: "restored-run", name: "timing", inputs: {}, accumulatedDurationMs: 7777, ts: 1000 }); - - const store = createStore(); - restoreOnSessionStart( - { getEntries: () => entries }, - { resumeInFlight: "auto", persistRuns: true }, - store, - ); - - const restored = store.runs().find((candidate) => candidate.id === "restored-run"); - assert.equal(restored?.accumulatedDurationMs, 7777); - assert.equal(elapsedRunMs(restored!, 1500), 7777 + 500); - }); + test("restores inherited elapsed onto the rehydrated run snapshot", () => { + const entries: SessionEntry[] = []; + const api = { + appendEntry: (type: string, payload: Record): string => { + entries.push({ id: `entry-${entries.length}`, type, payload: payload as SessionEntry["payload"] }); + return `entry-${entries.length}`; + }, + }; + appendRunStart(api, { runId: "restored-run", name: "timing", inputs: {}, accumulatedDurationMs: 7777, ts: 1000 }); + + const store = createStore(); + restoreOnSessionStart({ getEntries: () => entries }, { resumeInFlight: "auto", persistRuns: true }, store); + + const restored = store.runs().find((candidate) => candidate.id === "restored-run"); + assert.equal(restored?.accumulatedDurationMs, 7777); + assert.equal(elapsedRunMs(restored!, 1500), 7777 + 500); + }); }); diff --git a/test/unit/workflow-resume-selector-host-picker.test.ts b/test/unit/workflow-resume-selector-host-picker.test.ts index 494a178b3..ecbf54ced 100644 --- a/test/unit/workflow-resume-selector-host-picker.test.ts +++ b/test/unit/workflow-resume-selector-host-picker.test.ts @@ -10,22 +10,23 @@ * actionable error. Row building/sorting is covered by * workflow-resume-selector.test.ts. */ -import { afterAll, beforeAll, describe, test } from "bun:test"; + import assert from "node:assert/strict"; import { getKeybindings, setKeybindings } from "@earendil-works/pi-tui"; +import { afterAll, beforeAll, describe, test } from "vitest"; +import type { ExtensionUIContext } from "../../packages/coding-agent/src/core/extensions/index.ts"; import { KeybindingsManager } from "../../packages/coding-agent/src/core/keybindings.ts"; +import type { SessionSelectorComponent } from "../../packages/coding-agent/src/modes/interactive/components/session-selector.ts"; import { initTheme } from "../../packages/coding-agent/src/modes/interactive/theme/theme.ts"; import { EngineSessionPickerService } from "../../packages/coding-agent/src/modes/interactive-engine/engine-session-picker.ts"; import type { IsolatedInteractiveRuntime } from "../../packages/coding-agent/src/modes/interactive-engine/isolated-runtime.ts"; import { - parseInteractiveEngineMessage, - serializeInteractiveEngineFrame, type InteractiveEngineCommand, type InteractiveEngineMessage, + parseInteractiveEngineMessage, + serializeInteractiveEngineFrame, } from "../../packages/coding-agent/src/modes/interactive-engine/protocol.ts"; import { SessionPickerHostController } from "../../packages/coding-agent/src/modes/interactive-engine/session-picker-host.ts"; -import type { SessionSelectorComponent } from "../../packages/coding-agent/src/modes/interactive/components/session-selector.ts"; -import type { ExtensionUIContext } from "../../packages/coding-agent/src/core/extensions/index.ts"; import type { DurableWorkflowDeleteOutcome } from "../../packages/workflows/src/durable/retention-policy.js"; import type { ResumableWorkflowEntry } from "../../packages/workflows/src/durable/types.js"; import type { @@ -46,11 +47,7 @@ async function flush(times = 6): Promise { } } -function entry( - id: string, - status: ResumableWorkflowEntry["status"], - updatedAt = 200, -): ResumableWorkflowEntry { +function entry(id: string, status: ResumableWorkflowEntry["status"], updatedAt = 200): ResumableWorkflowEntry { return { workflowId: id, name: `${status}-workflow`, @@ -99,7 +96,9 @@ function makeFakeHostPicker(): FakeHostPicker { opens.push(request); onDelete = request.onDelete; return { - result: new Promise((resolve) => { resolveResult = resolve; }), + result: new Promise((resolve) => { + resolveResult = resolve; + }), update: (sessions) => updates.push(sessions), error: (message) => errors.push(message), close: () => resolveResult?.(undefined), @@ -107,7 +106,9 @@ function makeFakeHostPicker(): FakeHostPicker { }, select: (path) => resolveResult?.(path), cancel: () => resolveResult?.(undefined), - deleteRow: async (path) => { await onDelete?.(path); }, + deleteRow: async (path) => { + await onDelete?.(path); + }, }; } @@ -129,7 +130,11 @@ describe("workflow resume selector host-picker path", () => { ); assert.equal(picker.opens.length, 1, "picker opened exactly once"); - assert.deepEqual(picker.opens[0]!.sessions.map((row) => row.id), ["live-a"], "live rows seed the open"); + assert.deepEqual( + picker.opens[0]!.sessions.map((row) => row.id), + ["live-a"], + "live rows seed the open", + ); assert.equal(picker.opens[0]!.showRenameHint, false); await flush(); @@ -145,11 +150,10 @@ describe("workflow resume selector host-picker path", () => { test("cancel resolves close and still returns the hydrated catalog", async () => { const picker = makeFakeHostPicker(); - const promise = openWorkflowResumeSelector( - { hostSessionPicker: picker.hostSessionPicker }, - [], - async () => ({ durable: [entry("durable-a", "paused")], completed: [] }), - ); + const promise = openWorkflowResumeSelector({ hostSessionPicker: picker.hostSessionPicker }, [], async () => ({ + durable: [entry("durable-a", "paused")], + completed: [], + })); await flush(); picker.cancel(); @@ -192,7 +196,9 @@ describe("workflow resume selector host-picker path", () => { refreshIntervalMs: 0, watch: (change) => { onChange = change; - return () => { unsubscribed += 1; }; + return () => { + unsubscribed += 1; + }; }, refresh: async () => { refreshCalls += 1; @@ -212,7 +218,11 @@ describe("workflow resume selector host-picker path", () => { assert.equal(refreshCalls, 1, "debounced watch refresh ran once"); assert.ok(picker.updates.length > updatesAfterHydrate, "refresh pushed a row update"); const latest = picker.updates.at(-1)!; - assert.deepEqual(latest.map((row) => row.id), ["d-now-paused"], "stale live row dropped, transitioned row appears"); + assert.deepEqual( + latest.map((row) => row.id), + ["d-now-paused"], + "stale live row dropped, transitioned row appears", + ); picker.cancel(); await promise; @@ -246,7 +256,10 @@ describe("workflow resume selector host-picker path", () => { await flush(); assert.ok(refreshCalls >= 2, `interval refresh ran (${refreshCalls})`); - assert.deepEqual(picker.updates.at(-1)!.map((row) => row.id), ["d-from-poll"]); + assert.deepEqual( + picker.updates.at(-1)!.map((row) => row.id), + ["d-from-poll"], + ); picker.cancel(); await promise; @@ -310,7 +323,11 @@ describe("workflow resume selector host-picker path", () => { await picker.deleteRow("workflow-durable:durable-a"); assert.deepEqual(deleted, ["durable-a"]); assert.equal(picker.updates.length, updatesBefore + 1, "successful delete replies with an update"); - assert.deepEqual(picker.updates.at(-1)!.map((row) => row.id), ["live-a"], "deleted row removed from the update"); + assert.deepEqual( + picker.updates.at(-1)!.map((row) => row.id), + ["live-a"], + "deleted row removed from the update", + ); picker.cancel(); await promise; @@ -348,17 +365,17 @@ describe("workflow resume selector host-picker path", () => { test("rejects with one actionable error when the capability is absent (no fallback)", async () => { let hydrateCalls = 0; await assert.rejects( - openWorkflowResumeSelector( - {}, - [pausedLiveRun()], - async () => { - hydrateCalls += 1; - return { durable: [], completed: [] }; - }, - ), + openWorkflowResumeSelector({}, [pausedLiveRun()], async () => { + hydrateCalls += 1; + return { durable: [], completed: [] }; + }), (error: Error) => { assert.equal(error.message, WORKFLOW_RESUME_PICKER_UNAVAILABLE); - assert.match(error.message, /\/workflow resume /, "error tells the user the direct-resume escape hatch"); + assert.match( + error.message, + /\/workflow resume /, + "error tells the user the direct-resume escape hatch", + ); return true; }, ); @@ -371,7 +388,10 @@ describe("workflow resume selector host-picker path", () => { const promise = openWorkflowResumeSelector( { hostSessionPicker: picker.hostSessionPicker }, [pausedLiveRun("live-a", 100)], - () => new Promise((resolve) => { resolveHydrate = resolve; }), + () => + new Promise((resolve) => { + resolveHydrate = resolve; + }), ); await flush(); @@ -431,7 +451,12 @@ describe("workflow resume selector host-picker end-to-end (real engine bridge)", requestRender: () => {}, setWidget: () => {}, custom: ( - factory: (tui: unknown, theme: unknown, keys: unknown, done: (result: unknown) => void) => SessionSelectorComponent, + factory: ( + tui: unknown, + theme: unknown, + keys: unknown, + done: (result: unknown) => void, + ) => SessionSelectorComponent, ) => new Promise((resolve) => { component = factory({ terminal: { rows: 40, columns: 120 }, requestRender: () => {} }, {}, {}, resolve); diff --git a/test/unit/workflow-resume-selector.test.ts b/test/unit/workflow-resume-selector.test.ts index c238e569e..4c0b78f54 100644 --- a/test/unit/workflow-resume-selector.test.ts +++ b/test/unit/workflow-resume-selector.test.ts @@ -5,170 +5,179 @@ * exclusively through the host session-picker capability; there is no * remote-rendered path. */ -import { describe, test } from "bun:test"; + import assert from "node:assert/strict"; -import { workflowResumeSelectorItems } from "../../packages/workflows/src/tui/workflow-resume-selector.js"; +import { describe, test } from "vitest"; import type { ResumableWorkflowEntry } from "../../packages/workflows/src/durable/types.js"; import type { RunSnapshot, StageSnapshot } from "../../packages/workflows/src/shared/store-types.js"; +import { workflowResumeSelectorItems } from "../../packages/workflows/src/tui/workflow-resume-selector.js"; function entry( - id: string, - status: ResumableWorkflowEntry["status"], - updatedAt = status === "completed" ? 300 : 200, + id: string, + status: ResumableWorkflowEntry["status"], + updatedAt = status === "completed" ? 300 : 200, ): ResumableWorkflowEntry { - return { - workflowId: id, - name: `${status}-workflow`, - status, - completedCheckpoints: 2, - pendingPrompts: 0, - createdAt: 1, - updatedAt, - }; + return { + workflowId: id, + name: `${status}-workflow`, + status, + completedCheckpoints: 2, + pendingPrompts: 0, + createdAt: 1, + updatedAt, + }; } function stage(id: string, endedAt: number): StageSnapshot { - return { - id, - name: id, - status: "completed", - parentIds: [], - startedAt: endedAt - 1, - endedAt, - toolEvents: [], - }; + return { + id, + name: id, + status: "completed", + parentIds: [], + startedAt: endedAt - 1, + endedAt, + toolEvents: [], + }; } function pausedLiveRun(id = "live-paused", activityAt = 100): RunSnapshot { - return { - id, - name: "live-workflow", - inputs: {}, - status: "paused", - stages: [], - startedAt: 1, - pausedAt: activityAt, - resumable: true, - }; + return { + id, + name: "live-workflow", + inputs: {}, + status: "paused", + stages: [], + startedAt: 1, + pausedAt: activityAt, + resumable: true, + }; } describe("workflow resume selector rows", () => { - test("globally orders mixed rows and renders completed rows with a green semantic", () => { - const items = workflowResumeSelectorItems( - [pausedLiveRun()], - [entry("durable-paused", "paused")], - [entry("durable-completed", "completed")], - ); - - assert.deepEqual(items.map((item) => item.result.kind), ["completed", "durable", "live"]); - const completed = items[0]!; - assert.match(completed.session.firstMessage, /✓ completed/); - assert.equal(completed.session.messageColor, "success"); - assert.equal(completed.session.path, "workflow-completed:durable-completed"); - }); - - test("sorts unsorted live rows by latest activity", () => { - const items = workflowResumeSelectorItems([ - pausedLiveRun("middle", 200), - pausedLiveRun("newest", 300), - pausedLiveRun("oldest", 100), - ], []); - - assert.deepEqual(items.map((item) => item.session.id), ["newest", "middle", "oldest"]); - }); - - test("sorts unsorted durable rows by durable update time", () => { - const items = workflowResumeSelectorItems([], [ - entry("oldest", "paused", 100), - entry("newest", "paused", 300), - entry("middle", "paused", 200), - ]); - - assert.deepEqual(items.map((item) => item.session.id), ["newest", "middle", "oldest"]); - }); - - test("globally interleaves live and durable rows by recency", () => { - const items = workflowResumeSelectorItems( - [pausedLiveRun("live-oldest", 100), pausedLiveRun("live-newest", 400)], - [entry("durable-middle-new", "paused", 300), entry("durable-middle-old", "paused", 200)], - ); - - assert.deepEqual(items.map((item) => item.session.id), [ - "live-newest", - "durable-middle-new", - "durable-middle-old", - "live-oldest", - ]); - }); - - test("uses latest stage activity and deterministic ids for equal-time ties", () => { - const live = pausedLiveRun("zulu-live", 50); - live.stages.push(stage("recent", 500)); - const reversed = workflowResumeSelectorItems( - [live, pausedLiveRun("alpha-live", 400)], - [entry("zulu-durable", "paused", 400), entry("alpha-durable", "paused", 400)], - [entry("middle-completed", "completed", 450)], - ); - - assert.deepEqual(reversed.map((item) => item.session.id), [ - "zulu-live", - "middle-completed", - "alpha-durable", - "alpha-live", - "zulu-durable", - ]); - assert.deepEqual( - workflowResumeSelectorItems( - [pausedLiveRun("alpha-live", 400), live], - [entry("alpha-durable", "paused", 400), entry("zulu-durable", "paused", 400)], - [entry("middle-completed", "completed", 450)], - ).map((item) => item.session.id), - reversed.map((item) => item.session.id), - ); - }); - - test("deduplicates before sorting and keeps live then durable precedence", () => { - const items = workflowResumeSelectorItems( - [pausedLiveRun()], - [entry("same-id", "paused", 500)], - [entry("same-id", "completed", 900), entry("live-paused", "completed", 1_000)], - ); - - assert.deepEqual(items.map((item) => item.session.id), ["same-id", "live-paused"]); - assert.deepEqual(items.map((item) => item.result.kind), ["durable", "live"]); - }); + test("globally orders mixed rows and renders completed rows with a green semantic", () => { + const items = workflowResumeSelectorItems( + [pausedLiveRun()], + [entry("durable-paused", "paused")], + [entry("durable-completed", "completed")], + ); + + assert.deepEqual( + items.map((item) => item.result.kind), + ["completed", "durable", "live"], + ); + const completed = items[0]!; + assert.match(completed.session.firstMessage, /✓ completed/); + assert.equal(completed.session.messageColor, "success"); + assert.equal(completed.session.path, "workflow-completed:durable-completed"); + }); + + test("sorts unsorted live rows by latest activity", () => { + const items = workflowResumeSelectorItems( + [pausedLiveRun("middle", 200), pausedLiveRun("newest", 300), pausedLiveRun("oldest", 100)], + [], + ); + + assert.deepEqual( + items.map((item) => item.session.id), + ["newest", "middle", "oldest"], + ); + }); + + test("sorts unsorted durable rows by durable update time", () => { + const items = workflowResumeSelectorItems( + [], + [entry("oldest", "paused", 100), entry("newest", "paused", 300), entry("middle", "paused", 200)], + ); + + assert.deepEqual( + items.map((item) => item.session.id), + ["newest", "middle", "oldest"], + ); + }); + + test("globally interleaves live and durable rows by recency", () => { + const items = workflowResumeSelectorItems( + [pausedLiveRun("live-oldest", 100), pausedLiveRun("live-newest", 400)], + [entry("durable-middle-new", "paused", 300), entry("durable-middle-old", "paused", 200)], + ); + + assert.deepEqual( + items.map((item) => item.session.id), + ["live-newest", "durable-middle-new", "durable-middle-old", "live-oldest"], + ); + }); + + test("uses latest stage activity and deterministic ids for equal-time ties", () => { + const live = pausedLiveRun("zulu-live", 50); + live.stages.push(stage("recent", 500)); + const reversed = workflowResumeSelectorItems( + [live, pausedLiveRun("alpha-live", 400)], + [entry("zulu-durable", "paused", 400), entry("alpha-durable", "paused", 400)], + [entry("middle-completed", "completed", 450)], + ); + + assert.deepEqual( + reversed.map((item) => item.session.id), + ["zulu-live", "middle-completed", "alpha-durable", "alpha-live", "zulu-durable"], + ); + assert.deepEqual( + workflowResumeSelectorItems( + [pausedLiveRun("alpha-live", 400), live], + [entry("alpha-durable", "paused", 400), entry("zulu-durable", "paused", 400)], + [entry("middle-completed", "completed", 450)], + ).map((item) => item.session.id), + reversed.map((item) => item.session.id), + ); + }); + + test("deduplicates before sorting and keeps live then durable precedence", () => { + const items = workflowResumeSelectorItems( + [pausedLiveRun()], + [entry("same-id", "paused", 500)], + [entry("same-id", "completed", 900), entry("live-paused", "completed", 1_000)], + ); + + assert.deepEqual( + items.map((item) => item.session.id), + ["same-id", "live-paused"], + ); + assert.deepEqual( + items.map((item) => item.result.kind), + ["durable", "live"], + ); + }); }); describe("workflow resume selector row presentation", () => { - test("colors paused yellow, failed and blocked red, completed green", () => { - const items = workflowResumeSelectorItems( - [pausedLiveRun("live-paused-run")], - [entry("d-paused", "paused"), entry("d-failed", "failed"), entry("d-blocked", "blocked")], - [entry("d-completed", "completed")], - ); - const byId = new Map(items.map((item) => [item.session.id, item.session])); - assert.equal(byId.get("d-paused")?.messageColor, "warning"); - assert.equal(byId.get("d-failed")?.messageColor, "error"); - assert.equal(byId.get("d-blocked")?.messageColor, "error"); - assert.equal(byId.get("d-completed")?.messageColor, "success"); - assert.equal(byId.get("live-paused-run")?.messageColor, "warning"); - }); - - test("omits pending prompt counts from durable and completed rows", () => { - const durable = { ...entry("prompted", "paused"), pendingPrompts: 7 }; - const completed = { ...entry("completed-prompted", "completed"), pendingPrompts: 3 }; - const items = workflowResumeSelectorItems([], [durable], [completed]); - for (const item of items) { - assert.doesNotMatch(item.session.firstMessage, /\b\d+ prompts?\b/); - assert.doesNotMatch(item.session.allMessagesText, /\b\d+ prompts?\b/); - assert.match(item.session.firstMessage, /2 checkpoints$/); - } - }); - test("presents a stale-heartbeat running durable row as crashed, never running", () => { - const [item] = workflowResumeSelectorItems([], [{ ...entry("d-crashed", "running"), name: "repro-flow" }], []); - assert.match(item!.session.firstMessage, /repro-flow {2}crashed/); - assert.doesNotMatch(item!.session.firstMessage, /running/); - assert.equal(item!.session.messageColor, "error"); - assert.match(item!.session.allMessagesText, /crashed/); - }); + test("colors paused yellow, failed and blocked red, completed green", () => { + const items = workflowResumeSelectorItems( + [pausedLiveRun("live-paused-run")], + [entry("d-paused", "paused"), entry("d-failed", "failed"), entry("d-blocked", "blocked")], + [entry("d-completed", "completed")], + ); + const byId = new Map(items.map((item) => [item.session.id, item.session])); + assert.equal(byId.get("d-paused")?.messageColor, "warning"); + assert.equal(byId.get("d-failed")?.messageColor, "error"); + assert.equal(byId.get("d-blocked")?.messageColor, "error"); + assert.equal(byId.get("d-completed")?.messageColor, "success"); + assert.equal(byId.get("live-paused-run")?.messageColor, "warning"); + }); + + test("omits pending prompt counts from durable and completed rows", () => { + const durable = { ...entry("prompted", "paused"), pendingPrompts: 7 }; + const completed = { ...entry("completed-prompted", "completed"), pendingPrompts: 3 }; + const items = workflowResumeSelectorItems([], [durable], [completed]); + for (const item of items) { + assert.doesNotMatch(item.session.firstMessage, /\b\d+ prompts?\b/); + assert.doesNotMatch(item.session.allMessagesText, /\b\d+ prompts?\b/); + assert.match(item.session.firstMessage, /2 checkpoints$/); + } + }); + test("presents a stale-heartbeat running durable row as crashed, never running", () => { + const [item] = workflowResumeSelectorItems([], [{ ...entry("d-crashed", "running"), name: "repro-flow" }], []); + assert.match(item!.session.firstMessage, /repro-flow {2}crashed/); + assert.doesNotMatch(item!.session.firstMessage, /running/); + assert.equal(item!.session.messageColor, "error"); + assert.match(item!.session.allMessagesText, /crashed/); + }); }); diff --git a/test/unit/workflow-returned-status.test.ts b/test/unit/workflow-returned-status.test.ts index 9a84c0f19..7bb901c78 100644 --- a/test/unit/workflow-returned-status.test.ts +++ b/test/unit/workflow-returned-status.test.ts @@ -1,291 +1,311 @@ -import { describe, test } from "bun:test"; import assert from "node:assert/strict"; import { Type } from "typebox"; -import { run } from "../../packages/workflows/src/runs/foreground/executor.js"; -import { createStore } from "../../packages/workflows/src/shared/store.js"; +import { describe, test } from "vitest"; import { workflow } from "../../packages/workflows/src/authoring/workflow.js"; import { InMemoryDurableBackend } from "../../packages/workflows/src/durable/backend.js"; -import { restoreOnSessionStart, type SessionEntry } from "../../packages/workflows/src/shared/persistence-restore.js"; -import { statusRuns } from "../../packages/workflows/src/runs/background/status.js"; import { classifyReturnedRunStatus } from "../../packages/workflows/src/engine/run-returned-status.js"; +import { statusRuns } from "../../packages/workflows/src/runs/background/status.js"; +import { run } from "../../packages/workflows/src/runs/foreground/executor.js"; +import { restoreOnSessionStart, type SessionEntry } from "../../packages/workflows/src/shared/persistence-restore.js"; +import { createStore } from "../../packages/workflows/src/shared/store.js"; import type { RunSnapshot } from "../../packages/workflows/src/shared/store-types.js"; describe("workflow returned status outputs", () => { - test("failed result.status makes the run fail instead of completing successfully", async () => { - const store = createStore(); - const def = workflow({ - name: "returned-failed-status", - description: "", - inputs: {}, - outputs: { - status: Type.Union([Type.Literal("completed"), Type.Literal("failed"), Type.Literal("blocked")]), - summary: Type.String(), - }, - run: async (ctx) => { - await ctx.stage("work").complete("done"); - return { status: "failed" as const, summary: "deterministic gate failed" }; - }, - }); + test("failed result.status makes the run fail instead of completing successfully", async () => { + const store = createStore(); + const def = workflow({ + name: "returned-failed-status", + description: "", + inputs: {}, + outputs: { + status: Type.Union([Type.Literal("completed"), Type.Literal("failed"), Type.Literal("blocked")]), + summary: Type.String(), + }, + run: async (ctx) => { + await ctx.stage("work").complete("done"); + return { status: "failed" as const, summary: "deterministic gate failed" }; + }, + }); - const result = await run(def, {}, { store, adapters: { complete: { complete: async (text) => text } } }); - const snapshot = store.runs().find((candidate) => candidate.id === result.runId); + const result = await run(def, {}, { store, adapters: { complete: { complete: async (text) => text } } }); + const snapshot = store.runs().find((candidate) => candidate.id === result.runId); - assert.equal(result.status, "failed"); - assert.equal(snapshot?.status, "failed"); - assert.equal(result.error, "deterministic gate failed"); - assert.deepEqual(result.result, { status: "failed", summary: "deterministic gate failed" }); - assert.deepEqual(snapshot?.result, { status: "failed", summary: "deterministic gate failed" }); - assert.equal(snapshot?.failureKind, "unknown"); - assert.equal(snapshot?.failureRecoverability, "non_recoverable"); - assert.equal(snapshot?.failureDisposition, "terminal_failed"); - assert.equal(snapshot?.failureMessage, "deterministic gate failed"); - assert.equal(snapshot?.resumable, false); - }); + assert.equal(result.status, "failed"); + assert.equal(snapshot?.status, "failed"); + assert.equal(result.error, "deterministic gate failed"); + assert.deepEqual(result.result, { status: "failed", summary: "deterministic gate failed" }); + assert.deepEqual(snapshot?.result, { status: "failed", summary: "deterministic gate failed" }); + assert.equal(snapshot?.failureKind, "unknown"); + assert.equal(snapshot?.failureRecoverability, "non_recoverable"); + assert.equal(snapshot?.failureDisposition, "terminal_failed"); + assert.equal(snapshot?.failureMessage, "deterministic gate failed"); + assert.equal(snapshot?.resumable, false); + }); - test("blocked result.status makes the run blocked instead of completing successfully", async () => { - const store = createStore(); - const def = workflow({ - name: "returned-blocked-status", - description: "", - inputs: {}, - outputs: { - status: Type.Union([Type.Literal("completed"), Type.Literal("failed"), Type.Literal("blocked")]), - summary: Type.String(), - }, - run: async (ctx) => { - await ctx.stage("work").complete("done"); - return { status: "blocked" as const, summary: "required checks are pending" }; - }, - }); + test("blocked result.status makes the run blocked instead of completing successfully", async () => { + const store = createStore(); + const def = workflow({ + name: "returned-blocked-status", + description: "", + inputs: {}, + outputs: { + status: Type.Union([Type.Literal("completed"), Type.Literal("failed"), Type.Literal("blocked")]), + summary: Type.String(), + }, + run: async (ctx) => { + await ctx.stage("work").complete("done"); + return { status: "blocked" as const, summary: "required checks are pending" }; + }, + }); - const durableBackend = new InMemoryDurableBackend(); - const calls: Array<{ type: string; payload: Record }> = []; - const persistence = { - appendEntry(type: string, payload: Record): string { - calls.push({ type, payload }); - return `entry-${calls.length}`; - }, - setLabel(_entryId: string, _label: string): void {}, - }; - const result = await run(def, {}, { - store, - durableBackend, - persistence, - adapters: { complete: { complete: async (text) => text } }, - }); - const snapshot = store.runs().find((candidate) => candidate.id === result.runId); - const durableHandle = durableBackend.getWorkflow(result.runId); - const runEnd = calls.find((call) => call.type === "workflow.run.end"); + const durableBackend = new InMemoryDurableBackend(); + const calls: Array<{ type: string; payload: Record }> = []; + const persistence = { + appendEntry(type: string, payload: Record): string { + calls.push({ type, payload }); + return `entry-${calls.length}`; + }, + setLabel(_entryId: string, _label: string): void {}, + }; + const result = await run( + def, + {}, + { + store, + durableBackend, + persistence, + adapters: { complete: { complete: async (text) => text } }, + }, + ); + const snapshot = store.runs().find((candidate) => candidate.id === result.runId); + const durableHandle = durableBackend.getWorkflow(result.runId); + const runEnd = calls.find((call) => call.type === "workflow.run.end"); - assert.equal(result.status, "blocked"); - assert.equal(snapshot?.status, "blocked"); - assert.equal(result.error, "required checks are pending"); - assert.equal(snapshot?.error, "required checks are pending"); - assert.equal(snapshot?.resumable, false); - assert.equal(durableHandle?.status, "blocked"); - assert.equal(durableHandle?.resumable, false); - assert.deepEqual(durableBackend.listResumableWorkflows(), []); - assert.equal(runEnd?.payload["resumable"], false); - assert.deepEqual(result.result, { status: "blocked", summary: "required checks are pending" }); - }); + assert.equal(result.status, "blocked"); + assert.equal(snapshot?.status, "blocked"); + assert.equal(result.error, "required checks are pending"); + assert.equal(snapshot?.error, "required checks are pending"); + assert.equal(snapshot?.resumable, false); + assert.equal(durableHandle?.status, "blocked"); + assert.equal(durableHandle?.resumable, false); + assert.deepEqual(durableBackend.listResumableWorkflows(), []); + assert.equal(runEnd?.payload.resumable, false); + assert.deepEqual(result.result, { status: "blocked", summary: "required checks are pending" }); + }); - test("blocked result.status remains non-resumable without structured failure metadata", async () => { - const store = createStore(); - const def = workflow({ - name: "returned-blocked-status-with-auth-like-text", - description: "", - inputs: {}, - outputs: { - status: Type.Literal("blocked"), - summary: Type.String(), - }, - run: async (ctx) => { - await ctx.stage("reviewers").complete("reviewers reported a login issue"); - return { status: "blocked" as const, summary: "No API key for provider: github-copilot" }; - }, - }); + test("blocked result.status remains non-resumable without structured failure metadata", async () => { + const store = createStore(); + const def = workflow({ + name: "returned-blocked-status-with-auth-like-text", + description: "", + inputs: {}, + outputs: { + status: Type.Literal("blocked"), + summary: Type.String(), + }, + run: async (ctx) => { + await ctx.stage("reviewers").complete("reviewers reported a login issue"); + return { status: "blocked" as const, summary: "No API key for provider: github-copilot" }; + }, + }); - const durableBackend = new InMemoryDurableBackend(); - const result = await run(def, {}, { - store, - durableBackend, - adapters: { complete: { complete: async (text) => text } }, - }); - const snapshot = store.runs().find((candidate) => candidate.id === result.runId); - const durableHandle = durableBackend.getWorkflow(result.runId); + const durableBackend = new InMemoryDurableBackend(); + const result = await run( + def, + {}, + { + store, + durableBackend, + adapters: { complete: { complete: async (text) => text } }, + }, + ); + const snapshot = store.runs().find((candidate) => candidate.id === result.runId); + const durableHandle = durableBackend.getWorkflow(result.runId); - assert.equal(result.status, "blocked"); - assert.equal(snapshot?.status, "blocked"); - assert.equal(snapshot?.failureKind, undefined); - assert.equal(snapshot?.failureCode, undefined); - assert.equal(snapshot?.resumable, false); - assert.equal(durableHandle?.status, "blocked"); - assert.equal(durableHandle?.resumable, false); - }); + assert.equal(result.status, "blocked"); + assert.equal(snapshot?.status, "blocked"); + assert.equal(snapshot?.failureKind, undefined); + assert.equal(snapshot?.failureCode, undefined); + assert.equal(snapshot?.resumable, false); + assert.equal(durableHandle?.status, "blocked"); + assert.equal(durableHandle?.resumable, false); + }); - test("structured recoverable stage failures block even without a returned status field", () => { - const runSnapshot: RunSnapshot = { - id: "run-structured-auth", - name: "adversarial-verification", - inputs: {}, - status: "completed", - startedAt: 1, - failedStageId: "reviewer-a", - stages: [ - { - id: "reviewer-a", - name: "reviewer-a", - status: "failed", - parentIds: [], - toolEvents: [], - error: "A required model provider API key is missing. Configure the provider credentials and resume the workflow.", - failureKind: "auth", - failureCode: "missing_api_key", - failureRecoverability: "recoverable", - failureDisposition: "active_blocked", - failureMessage: "No API key for provider: github-copilot", - }, - ], - }; + test("structured recoverable stage failures block even without a returned status field", () => { + const runSnapshot: RunSnapshot = { + id: "run-structured-auth", + name: "adversarial-verification", + inputs: {}, + status: "completed", + startedAt: 1, + failedStageId: "reviewer-a", + stages: [ + { + id: "reviewer-a", + name: "reviewer-a", + status: "failed", + parentIds: [], + toolEvents: [], + error: "A required model provider API key is missing. Configure the provider credentials and resume the workflow.", + failureKind: "auth", + failureCode: "missing_api_key", + failureRecoverability: "recoverable", + failureDisposition: "active_blocked", + failureMessage: "No API key for provider: github-copilot", + }, + ], + }; - const classified = classifyReturnedRunStatus({ remaining_work: "Reviewer execution failed" }, runSnapshot); + const classified = classifyReturnedRunStatus({ remaining_work: "Reviewer execution failed" }, runSnapshot); - assert.equal(classified.status, "blocked"); - assert.equal(classified.error, "A required model provider API key is missing. Configure the provider credentials and resume the workflow."); - assert.equal(classified.metadata?.failureKind, "auth"); - assert.equal(classified.metadata?.failureCode, "missing_api_key"); - assert.equal(classified.metadata?.failedStageId, "reviewer-a"); - assert.equal(classified.metadata?.resumable, true); - }); + assert.equal(classified.status, "blocked"); + assert.equal( + classified.error, + "A required model provider API key is missing. Configure the provider credentials and resume the workflow.", + ); + assert.equal(classified.metadata?.failureKind, "auth"); + assert.equal(classified.metadata?.failureCode, "missing_api_key"); + assert.equal(classified.metadata?.failedStageId, "reviewer-a"); + assert.equal(classified.metadata?.resumable, true); + }); - test("tolerated recoverable stage failures do not block successful completed runs", () => { - const runSnapshot: RunSnapshot = { - id: "run-tolerated-auth", - name: "tournament", - inputs: {}, - status: "completed", - startedAt: 1, - result: { result: "approved" }, - stages: [ - { - id: "reviewer-a", - name: "reviewer-a", - status: "failed", - parentIds: [], - toolEvents: [], - error: "A required model provider API key is missing. Configure the provider credentials and resume the workflow.", - failureKind: "auth", - failureCode: "missing_api_key", - failureRecoverability: "recoverable", - failureDisposition: "active_blocked", - failureMessage: "No API key for provider: github-copilot", - }, - { - id: "reviewer-b", - name: "reviewer-b", - status: "completed", - parentIds: [], - toolEvents: [], - result: "approved", - }, - ], - }; + test("tolerated recoverable stage failures do not block successful completed runs", () => { + const runSnapshot: RunSnapshot = { + id: "run-tolerated-auth", + name: "tournament", + inputs: {}, + status: "completed", + startedAt: 1, + result: { result: "approved" }, + stages: [ + { + id: "reviewer-a", + name: "reviewer-a", + status: "failed", + parentIds: [], + toolEvents: [], + error: "A required model provider API key is missing. Configure the provider credentials and resume the workflow.", + failureKind: "auth", + failureCode: "missing_api_key", + failureRecoverability: "recoverable", + failureDisposition: "active_blocked", + failureMessage: "No API key for provider: github-copilot", + }, + { + id: "reviewer-b", + name: "reviewer-b", + status: "completed", + parentIds: [], + toolEvents: [], + result: "approved", + }, + ], + }; - const classified = classifyReturnedRunStatus(runSnapshot.result, runSnapshot); + const classified = classifyReturnedRunStatus(runSnapshot.result, runSnapshot); - assert.equal(classified.status, "completed"); - assert.equal(classified.error, undefined); - assert.equal(classified.metadata, undefined); - }); + assert.equal(classified.status, "completed"); + assert.equal(classified.error, undefined); + assert.equal(classified.metadata, undefined); + }); - test("needs_human result.status blocks implementation-loop auth fallback exhaustion instead of completing", async () => { - const store = createStore(); - const def = workflow({ - name: "implementation-loop-needs-human-auth", - description: "", - inputs: {}, - outputs: { - result: Type.String(), - status: Type.Union([ - Type.Literal("complete"), - Type.Literal("blocked"), - Type.Literal("needs_human"), - Type.Literal("active"), - ]), - remaining_work: Type.String(), - }, - run: async (ctx) => { - await ctx.stage("work-turn-1").complete("worker failed before receipt"); - return { - result: "Final status needs_human\nRemaining work: Worker failed before producing a receipt: No API key for provider: github-copilot", - status: "needs_human" as const, - remaining_work: "Worker failed before producing a receipt: No API key for provider: github-copilot", - }; - }, - }); + test("needs_human result.status blocks implementation-loop auth fallback exhaustion instead of completing", async () => { + const store = createStore(); + const def = workflow({ + name: "implementation-loop-needs-human-auth", + description: "", + inputs: {}, + outputs: { + result: Type.String(), + status: Type.Union([ + Type.Literal("complete"), + Type.Literal("blocked"), + Type.Literal("needs_human"), + Type.Literal("active"), + ]), + remaining_work: Type.String(), + }, + run: async (ctx) => { + await ctx.stage("work-turn-1").complete("worker failed before receipt"); + return { + result: + "Final status needs_human\nRemaining work: Worker failed before producing a receipt: No API key for provider: github-copilot", + status: "needs_human" as const, + remaining_work: "Worker failed before producing a receipt: No API key for provider: github-copilot", + }; + }, + }); - const durableBackend = new InMemoryDurableBackend(); - const calls: Array<{ type: string; payload: Record }> = []; - const persistence = { - appendEntry(type: string, payload: Record): string { - calls.push({ type, payload }); - return `entry-${calls.length}`; - }, - setLabel(_entryId: string, _label: string): void {}, - }; + const durableBackend = new InMemoryDurableBackend(); + const calls: Array<{ type: string; payload: Record }> = []; + const persistence = { + appendEntry(type: string, payload: Record): string { + calls.push({ type, payload }); + return `entry-${calls.length}`; + }, + setLabel(_entryId: string, _label: string): void {}, + }; - const result = await run(def, {}, { - store, - durableBackend, - persistence, - adapters: { complete: { complete: async (text) => text } }, - }); - const snapshot = store.runs().find((candidate) => candidate.id === result.runId); - const durableHandle = durableBackend.getWorkflow(result.runId); - const runEnd = calls.find((call) => call.type === "workflow.run.end"); - const [statusEntry] = statusRuns({ store }); + const result = await run( + def, + {}, + { + store, + durableBackend, + persistence, + adapters: { complete: { complete: async (text) => text } }, + }, + ); + const snapshot = store.runs().find((candidate) => candidate.id === result.runId); + const durableHandle = durableBackend.getWorkflow(result.runId); + const runEnd = calls.find((call) => call.type === "workflow.run.end"); + const [statusEntry] = statusRuns({ store }); - assert.equal(result.status, "blocked"); - assert.equal(snapshot?.status, "blocked"); - assert.equal(statusEntry?.status, "blocked"); - assert.match(result.error ?? "", /No API key for provider: github-copilot/); - assert.equal(snapshot?.result?.["status"], "needs_human"); - assert.equal(snapshot?.failureKind, undefined); - assert.equal(snapshot?.failureCode, undefined); - assert.equal(snapshot?.failureRecoverability, "recoverable"); - assert.equal(snapshot?.failureDisposition, "active_blocked"); - assert.equal(snapshot?.resumable, true); - assert.equal(durableHandle?.status, "blocked"); - assert.equal(durableHandle?.resumable, true); - assert.equal(runEnd?.payload["status"], "blocked"); - assert.equal(runEnd?.payload["resumable"], true); - }); + assert.equal(result.status, "blocked"); + assert.equal(snapshot?.status, "blocked"); + assert.equal(statusEntry?.status, "blocked"); + assert.match(result.error ?? "", /No API key for provider: github-copilot/); + assert.equal(snapshot?.result?.status, "needs_human"); + assert.equal(snapshot?.failureKind, undefined); + assert.equal(snapshot?.failureCode, undefined); + assert.equal(snapshot?.failureRecoverability, "recoverable"); + assert.equal(snapshot?.failureDisposition, "active_blocked"); + assert.equal(snapshot?.resumable, true); + assert.equal(durableHandle?.status, "blocked"); + assert.equal(durableHandle?.resumable, true); + assert.equal(runEnd?.payload.status, "blocked"); + assert.equal(runEnd?.payload.resumable, true); + }); - test("restores returned blocked run metadata without marking it as ctx.exit", () => { - const store = createStore(); - const entries: SessionEntry[] = [ - { id: "e1", type: "workflow.run.start", payload: { runId: "run-returned-blocked", name: "returned-blocked", inputs: {}, ts: 1 } }, - { - id: "e2", - type: "workflow.run.end", - payload: { - runId: "run-returned-blocked", - status: "blocked", - result: { status: "blocked", summary: "required checks are pending" }, - error: "required checks are pending", - resumable: false, - ts: 2, - }, - }, - ]; + test("restores returned blocked run metadata without marking it as ctx.exit", () => { + const store = createStore(); + const entries: SessionEntry[] = [ + { + id: "e1", + type: "workflow.run.start", + payload: { runId: "run-returned-blocked", name: "returned-blocked", inputs: {}, ts: 1 }, + }, + { + id: "e2", + type: "workflow.run.end", + payload: { + runId: "run-returned-blocked", + status: "blocked", + result: { status: "blocked", summary: "required checks are pending" }, + error: "required checks are pending", + resumable: false, + ts: 2, + }, + }, + ]; - restoreOnSessionStart({ getEntries: () => entries }, { resumeInFlight: "never", persistRuns: true }, store); - const snapshot = store.runs().find((candidate) => candidate.id === "run-returned-blocked"); + restoreOnSessionStart({ getEntries: () => entries }, { resumeInFlight: "never", persistRuns: true }, store); + const snapshot = store.runs().find((candidate) => candidate.id === "run-returned-blocked"); - assert.equal(snapshot?.status, "blocked"); - assert.equal(snapshot?.error, "required checks are pending"); - assert.equal(snapshot?.resumable, false); - assert.equal(snapshot?.exited, undefined); - assert.deepEqual(snapshot?.result, { status: "blocked", summary: "required checks are pending" }); - }); + assert.equal(snapshot?.status, "blocked"); + assert.equal(snapshot?.error, "required checks are pending"); + assert.equal(snapshot?.resumable, false); + assert.equal(snapshot?.exited, undefined); + assert.deepEqual(snapshot?.result, { status: "blocked", summary: "required checks are pending" }); + }); }); diff --git a/test/unit/workflow-run-control-completed-resume.test.ts b/test/unit/workflow-run-control-completed-resume.test.ts index 41e4bc4c2..24238c0a4 100644 --- a/test/unit/workflow-run-control-completed-resume.test.ts +++ b/test/unit/workflow-run-control-completed-resume.test.ts @@ -1,437 +1,623 @@ -import { afterEach, beforeEach, describe, test } from "bun:test"; import assert from "node:assert/strict"; import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { afterEach, beforeEach, describe, test } from "vitest"; import { InMemoryDurableBackend } from "../../packages/workflows/src/durable/backend.js"; import { setDurableBackend } from "../../packages/workflows/src/durable/factory.js"; -import { createExtensionRuntime, type ExtensionRuntime } from "../../packages/workflows/src/extension/runtime.js"; import { - createWorkflowLifecycleNotificationState, - installWorkflowLifecycleNotifications, - seedWorkflowLifecycleNotificationState, + createWorkflowLifecycleNotificationState, + installWorkflowLifecycleNotifications, + seedWorkflowLifecycleNotificationState, } from "../../packages/workflows/src/extension/lifecycle-notifications.js"; +import { createExtensionRuntime, type ExtensionRuntime } from "../../packages/workflows/src/extension/runtime.js"; import { - prepareWorkflowResumeCatalog, - resolveWorkflowResumeTarget, - type WorkflowRunControlDeps, + prepareWorkflowResumeCatalog, + resolveWorkflowResumeTarget, + type WorkflowRunControlDeps, } from "../../packages/workflows/src/extension/workflow-durable-resume-command.js"; -import { handleRunControlCommand } from "../../packages/workflows/src/extension/workflow-run-control-command.js"; import { collectResumePickerLiveRuns } from "../../packages/workflows/src/extension/workflow-resume-picker-rows.js"; +import { handleRunControlCommand } from "../../packages/workflows/src/extension/workflow-run-control-command.js"; import { store } from "../../packages/workflows/src/shared/store.js"; let tempDir = ""; beforeEach(() => { - // The workflows store is a module-level singleton shared across test files in - // the same bun process; clear leftovers so index/id lookups see only this file's runs. - store.clear(); - tempDir = mkdtempSync(join(tmpdir(), "atomic-completed-command-")); + // The workflows store is a module-level singleton shared across test files in + // the same bun process; clear leftovers so index/id lookups see only this file's runs. + store.clear(); + tempDir = mkdtempSync(join(tmpdir(), "atomic-completed-command-")); }); afterEach(() => { - setDurableBackend(undefined); - store.clear(); - rmSync(tempDir, { recursive: true, force: true }); + setDurableBackend(undefined); + store.clear(); + rmSync(tempDir, { recursive: true, force: true }); }); function retainedSession(name: string): string { - const path = join(tempDir, `${name}.jsonl`); - writeFileSync(path, [ - JSON.stringify({ type: "session", version: 3, id: `${name}-session`, timestamp: new Date().toISOString(), cwd: tempDir }), - JSON.stringify({ type: "message", id: `${name}-message`, parentId: null, timestamp: new Date().toISOString(), message: { role: "user", content: `Prior context for ${name}`, timestamp: Date.now() } }), - ].join("\n") + "\n"); - return path; + const path = join(tempDir, `${name}.jsonl`); + writeFileSync( + path, + `${[ + JSON.stringify({ + type: "session", + version: 3, + id: `${name}-session`, + timestamp: new Date().toISOString(), + cwd: tempDir, + }), + JSON.stringify({ + type: "message", + id: `${name}-message`, + parentId: null, + timestamp: new Date().toISOString(), + message: { role: "user", content: `Prior context for ${name}`, timestamp: Date.now() }, + }), + ].join("\n")}\n`, + ); + return path; } function registerCompleted(backend: InMemoryDurableBackend, id: string, sessionFile = retainedSession(id)): void { - backend.registerWorkflow({ workflowId: id, name: `${id}-flow`, inputs: {}, createdAt: 1, status: "completed" }); - backend.recordCheckpoint({ - kind: "stage", workflowId: id, checkpointId: "stage:1", name: "final", - replayKey: "stage:final:1", output: "ok", sessionFile, completedAt: 2, - }); + backend.registerWorkflow({ workflowId: id, name: `${id}-flow`, inputs: {}, createdAt: 1, status: "completed" }); + backend.recordCheckpoint({ + kind: "stage", + workflowId: id, + checkpointId: "stage:1", + name: "final", + replayKey: "stage:final:1", + output: "ok", + sessionFile, + completedAt: 2, + }); } function registerCompletedTool(backend: InMemoryDurableBackend, id: string): void { - backend.registerWorkflow({ workflowId: id, name: `${id}-flow`, inputs: {}, createdAt: 1, status: "completed" }); - backend.recordCheckpoint({ - kind: "tool", workflowId: id, checkpointId: "tool:done", name: "done", - argsHash: "done-hash", output: true, completedAt: 2, - }); + backend.registerWorkflow({ workflowId: id, name: `${id}-flow`, inputs: {}, createdAt: 1, status: "completed" }); + backend.recordCheckpoint({ + kind: "tool", + workflowId: id, + checkpointId: "tool:done", + name: "done", + argsHash: "done-hash", + output: true, + completedAt: 2, + }); } function commandDeps(runtime: ExtensionRuntime, opened: string[]): WorkflowRunControlDeps { - return { - pi: {}, - overlay: { open: (runId) => { if (runId) opened.push(runId); }, toggle: () => undefined, close: () => undefined }, - runtimeForContext: () => runtime, - ensureWorkflowResourcesLoaded: () => undefined, - }; + return { + pi: {}, + overlay: { + open: (runId) => { + if (runId) opened.push(runId); + }, + toggle: () => undefined, + close: () => undefined, + }, + runtimeForContext: () => runtime, + ensureWorkflowResourcesLoaded: () => undefined, + }; } -async function resume(target: string, runtime: ExtensionRuntime, opened: string[] = []): Promise<{ messages: string[]; errors: string[] }> { - const messages: string[] = []; - const errors: string[] = []; - await handleRunControlCommand( - "resume", - [target], - { hasUI: true, ui: { notify: () => undefined } }, - { info: (message) => messages.push(message), error: (message) => errors.push(message) }, - commandDeps(runtime, opened), - ); - return { messages, errors }; +async function resume( + target: string, + runtime: ExtensionRuntime, + opened: string[] = [], +): Promise<{ messages: string[]; errors: string[] }> { + const messages: string[] = []; + const errors: string[] = []; + await handleRunControlCommand( + "resume", + [target], + { hasUI: true, ui: { notify: () => undefined } }, + { info: (message) => messages.push(message), error: (message) => errors.push(message) }, + commandDeps(runtime, opened), + ); + return { messages, errors }; } describe("/workflow resume completed target", () => { - test("opens a unique completed id prefix without invoking durable resume dispatch", async () => { - const backend = new InMemoryDurableBackend(); - setDurableBackend(backend); - registerCompleted(backend, "completed-command-target"); - const baseRuntime = createExtensionRuntime({ store }); - let resumeCalls = 0; - const runtime: ExtensionRuntime = { - ...baseRuntime, - resumeDurableWorkflow(workflowIdOrPrefix, options) { - resumeCalls += 1; - return baseRuntime.resumeDurableWorkflow(workflowIdOrPrefix, options); - }, - }; - const opened: string[] = []; - - const result = await resume("completed-command", runtime, opened); - - assert.equal(resumeCalls, 0); - assert.deepEqual(opened, ["completed-command-target"]); - assert.match(result.messages.join("\n"), /read-only inspection and follow-up chat/); - assert.equal(store.runs().find((run) => run.id === "completed-command-target")?.status, "completed"); - assert.equal(backend.getWorkflow("completed-command-target")?.status, "completed"); - }); - - test("keeps the direct completed fallback lifecycle-silent when the runtime omits the open adapter", async () => { - const backend = new InMemoryDurableBackend(); - setDurableBackend(backend); - registerCompletedTool(backend, "completed-direct-fallback"); - const lifecycleState = createWorkflowLifecycleNotificationState(); - const sends: Array<{ options: object | undefined }> = []; - const unsubscribe = installWorkflowLifecycleNotifications({ - store, - state: lifecycleState, - seedExisting: false, - config: { enabled: true, notifyOn: ["completed"] }, - sendMessage(_message, options) { sends.push({ options }); }, - }); - const runtime = { ...createExtensionRuntime({ store }) }; - delete runtime.openCompletedDurableWorkflow; - const opened: string[] = []; - const restored: string[][] = []; - const deps = commandDeps(runtime, opened); - deps.beforeRestoreCompleted = (snapshots) => { - assert.equal(store.runs().some((run) => run.id === "completed-direct-fallback"), false, - "lifecycle state must be seeded before the historical snapshot is inserted"); - restored.push(snapshots.map((snapshot) => snapshot.id)); - seedWorkflowLifecycleNotificationState(lifecycleState, { ...store.snapshot(), runs: snapshots }); - }; - - try { - const messages: string[] = []; - const errors: string[] = []; - await handleRunControlCommand( - "resume", - ["completed-direct-fallback"], - { hasUI: true, ui: { notify: () => undefined } }, - { info: (message) => messages.push(message), error: (message) => errors.push(message) }, - deps, - ); - - assert.deepEqual(errors, []); - assert.deepEqual(opened, ["completed-direct-fallback"]); - assert.deepEqual(restored, [["completed-direct-fallback"]]); - assert.match(messages.join("\n"), /read-only inspection/); - assert.equal(store.runs().find((run) => run.id === "completed-direct-fallback")?.toolNodes?.[0]?.name, "done"); - assert.deepEqual(sends, [], "historical command fallback must emit no lifecycle steer/card"); - } finally { - unsubscribe(); - } - }); - - test("opens an exact completed id and reports completed-prefix ambiguity", async () => { - const backend = new InMemoryDurableBackend(); - setDurableBackend(backend); - registerCompleted(backend, "completed-exact-alpha"); - registerCompleted(backend, "completed-exact-beta"); - const runtime = createExtensionRuntime({ store }); - const opened: string[] = []; - - const exact = await resume("completed-exact-alpha", runtime, opened); - store.clear(); - const ambiguous = await resume("completed-exact-", runtime); - - assert.deepEqual(opened, ["completed-exact-alpha"]); - assert.match(exact.messages.join("\n"), /Opened completed durable workflow/); - assert.match(ambiguous.errors.join("\n"), /Ambiguous workflow prefix/); - assert.match(ambiguous.errors.join("\n"), /completed-exact-alpha-flow/); - assert.match(ambiguous.errors.join("\n"), /completed-exact-beta-flow/); - }); - - test("reports a clear missing target without dispatching completed inspection", async () => { - const backend = new InMemoryDurableBackend(); - setDurableBackend(backend); - registerCompleted(backend, "known-completed"); - - const result = await resume("missing-workflow", createExtensionRuntime({ store })); - - assert.match(result.errors.join("\n"), /No resumable workflow found for id\/prefix: missing-workflow/); - }); - - test("reports a stale completed target instead of dispatching it", async () => { - const backend = new InMemoryDurableBackend(); - setDurableBackend(backend); - backend.registerWorkflow({ workflowId: "stale-completed-target", name: "completed-flow", inputs: {}, createdAt: 1, status: "completed", completedCheckpoints: 1 }); - const baseRuntime = createExtensionRuntime({ store }); - let resumeCalls = 0; - const runtime: ExtensionRuntime = { - ...baseRuntime, - resumeDurableWorkflow(workflowIdOrPrefix, options) { - resumeCalls += 1; - return baseRuntime.resumeDurableWorkflow(workflowIdOrPrefix, options); - }, - }; - - const result = await resume("stale-completed", runtime); - - assert.equal(resumeCalls, 0); - assert.match(result.errors.join("\n"), /stale or missing durable checkpoint\/session data/); - }); - - test("does not let a retained completed snapshot bypass authoritative stale checks", async () => { - const backend = new InMemoryDurableBackend(); - setDurableBackend(backend); - backend.registerWorkflow({ workflowId: "retained-stale", name: "completed-flow", inputs: {}, createdAt: 1, status: "completed", completedCheckpoints: 1 }); - store.recordRunStart({ id: "retained-stale", name: "completed-flow", inputs: {}, status: "completed", stages: [], startedAt: 1, endedAt: 2, resumable: false }); - const baseRuntime = createExtensionRuntime({ store }); - let resumeCalls = 0; - const runtime: ExtensionRuntime = { ...baseRuntime, resumeDurableWorkflow(target, options) { resumeCalls += 1; return baseRuntime.resumeDurableWorkflow(target, options); } }; - const opened: string[] = []; - - const result = await resume("retained-stale", runtime, opened); - - assert.equal(resumeCalls, 0); - assert.deepEqual(opened, []); - assert.match(result.errors.join("\n"), /stale or missing durable checkpoint\/session data/); - }); - - test("reports ambiguity across live and completed workflow prefixes", async () => { - const backend = new InMemoryDurableBackend(); - setDurableBackend(backend); - registerCompleted(backend, "shared-completed"); - store.recordRunStart({ id: "shared-live", name: "live-flow", inputs: {}, status: "paused", stages: [], startedAt: 1, resumable: true }); - const result = await resume("shared-", createExtensionRuntime({ store })); - - assert.match(result.errors.join("\n"), /Ambiguous workflow prefix/); - assert.match(result.errors.join("\n"), /live-flow/); - assert.match(result.errors.join("\n"), /shared-completed-flow/); - }); - - test("excludes cancelled, killed, and non-resumable failed locals from prefix resolution", async () => { - const backend = new InMemoryDurableBackend(); - setDurableBackend(backend); - registerCompleted(backend, "excluded-completed"); - store.recordRunStart({ id: "excluded-cancelled", name: "cancelled", inputs: {}, status: "cancelled", stages: [], startedAt: 1, endedAt: 2, resumable: false }); - store.recordRunStart({ id: "excluded-killed", name: "killed", inputs: {}, status: "killed", stages: [], startedAt: 1, endedAt: 2, resumable: false }); - store.recordRunStart({ id: "excluded-failed", name: "failed", inputs: {}, status: "failed", stages: [], startedAt: 1, endedAt: 2, resumable: false }); - const opened: string[] = []; - - const result = await resume("excluded-", createExtensionRuntime({ store }), opened); - - assert.equal(result.errors.length, 0); - assert.deepEqual(opened, ["excluded-completed"]); - }); - - test("keeps quit shadows on the durable resume path", async () => { - const backend = new InMemoryDurableBackend(); - setDurableBackend(backend); - backend.registerWorkflow({ workflowId: "quit-shadow", name: "quit-flow", inputs: {}, createdAt: 1, status: "paused", completedCheckpoints: 1 }); - store.recordRunStart({ id: "quit-shadow", name: "quit-flow", inputs: {}, status: "running", stages: [], startedAt: 1, endedAt: 2, exitReason: "quit", resumable: true }); - const baseRuntime = createExtensionRuntime({ store }); - let durableResumeCalls = 0; - const runtime: ExtensionRuntime = { - ...baseRuntime, - resumeDurableWorkflow() { - durableResumeCalls += 1; - return Promise.resolve({ ok: true, runId: "quit-shadow", workflowId: "quit-shadow", name: "quit-flow", message: "resumed quit shadow" }); - }, - }; - - const result = await resume("quit-shadow", runtime); - - assert.equal(durableResumeCalls, 1); - assert.match(result.messages.join("\n"), /resumed quit shadow/); - }); - - for (const status of ["running", "failed", "blocked"] as const) { - test(`keeps durable ${status} targets on the durable resume path`, async () => { - const backend = new InMemoryDurableBackend(); - setDurableBackend(backend); - const id = `durable-${status}`; - const entry = { workflowId: id, name: `${status}-flow`, status, completedCheckpoints: 1, pendingPrompts: 0, createdAt: 1, updatedAt: 2, resumable: true }; - let resumeCalls = 0; - const runtime = { - registry: { has: () => true }, - prepareDurableResumable: async () => [entry], - prepareCompletedDurable: async () => [], - resumeDurableWorkflow: () => { - resumeCalls += 1; - return { ok: true as const, runId: id, workflowId: id, name: entry.name, message: `resumed ${status}` }; - }, - } as unknown as ExtensionRuntime; - - const result = await resume(id, runtime); - - assert.equal(resumeCalls, 1); - assert.match(result.messages.join("\n"), new RegExp(`resumed ${status}`)); - }); - } - - test("routes checkpointed resumable failures through durable resume instead of completed inspection", async () => { - const backend = new InMemoryDurableBackend(); - setDurableBackend(backend); - const id = "failed-resumable-command-target"; - backend.registerWorkflow({ - workflowId: id, - name: "failed-resumable-flow", - inputs: {}, - createdAt: 1, - status: "failed", - resumable: true, - error: "durable tool failed", - }); - backend.recordCheckpoint({ - kind: "tool", - workflowId: id, - checkpointId: "tool-failure:1", - name: "failed-tool", - argsHash: "failed-tool-hash", - output: null, - throwingFailureError: "durable tool failed", - completedAt: 2, - }); - const resumableRow = backend.listResumableWorkflows()[0]!; - const completedCollision = { ...resumableRow, resumable: false }; - let resumeCalls = 0; - let completedOpenCalls = 0; - const runtime = { - registry: { has: () => true }, - prepareDurableResumable: async () => [resumableRow], - prepareCompletedDurable: async () => [completedCollision], - resumeDurableWorkflow() { - resumeCalls += 1; - return Promise.resolve({ ok: true as const, runId: id, workflowId: id, name: "failed-resumable-flow", message: "resumed failed durable run" }); - }, - openCompletedDurableWorkflow() { - completedOpenCalls += 1; - return { ok: false as const, reason: "not_found" as const, message: "must not open completed history" }; - }, - } as unknown as ExtensionRuntime; - - const catalog = await prepareWorkflowResumeCatalog(runtime, new Set(), id); - assert.deepEqual(catalog.resumable.map((entry) => entry.workflowId), [id]); - assert.deepEqual(catalog.completed.map((entry) => entry.workflowId), [id], "the collision must exist in both catalogs"); - assert.equal(resolveWorkflowResumeTarget(id, [], catalog.resumable, catalog.completed).kind, "durable"); - assert.equal(resolveWorkflowResumeTarget("failed-resumable-command", [], catalog.resumable, catalog.completed).kind, "durable"); - - const result = await resume("failed-resumable-command", runtime); - - assert.equal(resumeCalls, 1); - assert.equal(completedOpenCalls, 0); - assert.deepEqual(result.errors, []); - assert.match(result.messages.join("\n"), /resumed failed durable run/); - }); - - test("keeps exact full live ids on the existing paused resume path without listing completed durable runs", async () => { - const backend = new InMemoryDurableBackend(); - let completedCatalogReads = 0; - const listCompletedWorkflows = backend.listCompletedWorkflows.bind(backend); - backend.listCompletedWorkflows = () => { - completedCatalogReads += 1; - return listCompletedWorkflows(); - }; - setDurableBackend(backend); - registerCompleted(backend, "exact-live-other-completed"); - store.recordRunStart({ id: "exact-live", name: "live-flow", inputs: {}, status: "paused", stages: [], startedAt: 1, resumable: true }); - const opened: string[] = []; - - const result = await resume("exact-live", createExtensionRuntime({ store }), opened); - - assert.equal(result.errors.length, 0); - assert.equal(store.runs().find((run) => run.id === "exact-live")?.status, "running"); - assert.match(result.messages.join("\n"), /Resumed run exact-li/); - assert.equal(completedCatalogReads, 0, "an exact live run must bypass durable completed-catalog enumeration"); - }); - - test("includes active recoverable blocks in the no-argument resume picker", () => { - const backend = new InMemoryDurableBackend(); - setDurableBackend(backend); - backend.registerWorkflow({ - workflowId: "picker-active-block", - name: "picker-flow", - inputs: {}, - createdAt: 1, - status: "blocked", - resumable: true, - }); - store.recordRunStart({ - id: "picker-active-block", - name: "picker-flow", - inputs: {}, - status: "running", - stages: [], - startedAt: 1, - blockedAt: 2, - resumable: true, - failureRecoverability: "recoverable", - failureDisposition: "active_blocked", - }); - - const source = collectResumePickerLiveRuns(store); - - assert.deepEqual(source.liveRuns.map((run) => run.id), ["picker-active-block"]); - assert.equal(source.activeLiveIds.has("picker-active-block"), false); - }); - - test("keeps recoverable failed and active-running explicit behavior unchanged", async () => { - const backend = new InMemoryDurableBackend(); - setDurableBackend(backend); - store.recordRunStart({ id: "failed-live", name: "failed-flow", inputs: {}, status: "failed", stages: [], startedAt: 1, endedAt: 2, resumable: true }); - store.recordRunStart({ id: "running-live", name: "running-flow", inputs: {}, status: "running", stages: [], startedAt: 1 }); - store.recordRunStart({ - id: "active-blocked-live", - name: "blocked-flow", - inputs: {}, - status: "running", - stages: [], - startedAt: 1, - blockedAt: 2, - resumable: true, - failureRecoverability: "recoverable", - failureDisposition: "active_blocked", - }); - const baseRuntime = createExtensionRuntime({ store }); - let failedResumeCalls = 0; - const runtime: ExtensionRuntime = { - ...baseRuntime, - async resumeFailedRun() { - failedResumeCalls += 1; - return { ok: true, runId: "continued-run", sourceRunId: "failed-live", resumeFromStageId: "failed-stage", message: "continued failed workflow" }; - }, - }; - - const failedResult = await resume("failed-live", runtime); - const blockedResult = await resume("active-blocked-live", runtime); - const runningResult = await resume("running-live", runtime); - - assert.equal(failedResumeCalls, 2); - assert.match(failedResult.messages.join("\n"), /continued failed workflow/); - assert.match(blockedResult.messages.join("\n"), /continued failed workflow/); - assert.equal(blockedResult.errors.length, 0); - assert.match(runningResult.errors.join("\n"), /already running.*connect/i); - }); + test("opens a unique completed id prefix without invoking durable resume dispatch", async () => { + const backend = new InMemoryDurableBackend(); + setDurableBackend(backend); + registerCompleted(backend, "completed-command-target"); + const baseRuntime = createExtensionRuntime({ store }); + let resumeCalls = 0; + const runtime: ExtensionRuntime = { + ...baseRuntime, + resumeDurableWorkflow(workflowIdOrPrefix, options) { + resumeCalls += 1; + return baseRuntime.resumeDurableWorkflow(workflowIdOrPrefix, options); + }, + }; + const opened: string[] = []; + + const result = await resume("completed-command", runtime, opened); + + assert.equal(resumeCalls, 0); + assert.deepEqual(opened, ["completed-command-target"]); + assert.match(result.messages.join("\n"), /read-only inspection and follow-up chat/); + assert.equal(store.runs().find((run) => run.id === "completed-command-target")?.status, "completed"); + assert.equal(backend.getWorkflow("completed-command-target")?.status, "completed"); + }); + + test("keeps the direct completed fallback lifecycle-silent when the runtime omits the open adapter", async () => { + const backend = new InMemoryDurableBackend(); + setDurableBackend(backend); + registerCompletedTool(backend, "completed-direct-fallback"); + const lifecycleState = createWorkflowLifecycleNotificationState(); + const sends: Array<{ options: object | undefined }> = []; + const unsubscribe = installWorkflowLifecycleNotifications({ + store, + state: lifecycleState, + seedExisting: false, + config: { enabled: true, notifyOn: ["completed"] }, + sendMessage(_message, options) { + sends.push({ options }); + }, + }); + const runtime = { ...createExtensionRuntime({ store }) }; + delete runtime.openCompletedDurableWorkflow; + const opened: string[] = []; + const restored: string[][] = []; + const deps = commandDeps(runtime, opened); + deps.beforeRestoreCompleted = (snapshots) => { + assert.equal( + store.runs().some((run) => run.id === "completed-direct-fallback"), + false, + "lifecycle state must be seeded before the historical snapshot is inserted", + ); + restored.push(snapshots.map((snapshot) => snapshot.id)); + seedWorkflowLifecycleNotificationState(lifecycleState, { ...store.snapshot(), runs: snapshots }); + }; + + try { + const messages: string[] = []; + const errors: string[] = []; + await handleRunControlCommand( + "resume", + ["completed-direct-fallback"], + { hasUI: true, ui: { notify: () => undefined } }, + { info: (message) => messages.push(message), error: (message) => errors.push(message) }, + deps, + ); + + assert.deepEqual(errors, []); + assert.deepEqual(opened, ["completed-direct-fallback"]); + assert.deepEqual(restored, [["completed-direct-fallback"]]); + assert.match(messages.join("\n"), /read-only inspection/); + assert.equal(store.runs().find((run) => run.id === "completed-direct-fallback")?.toolNodes?.[0]?.name, "done"); + assert.deepEqual(sends, [], "historical command fallback must emit no lifecycle steer/card"); + } finally { + unsubscribe(); + } + }); + + test("opens an exact completed id and reports completed-prefix ambiguity", async () => { + const backend = new InMemoryDurableBackend(); + setDurableBackend(backend); + registerCompleted(backend, "completed-exact-alpha"); + registerCompleted(backend, "completed-exact-beta"); + const runtime = createExtensionRuntime({ store }); + const opened: string[] = []; + + const exact = await resume("completed-exact-alpha", runtime, opened); + store.clear(); + const ambiguous = await resume("completed-exact-", runtime); + + assert.deepEqual(opened, ["completed-exact-alpha"]); + assert.match(exact.messages.join("\n"), /Opened completed durable workflow/); + assert.match(ambiguous.errors.join("\n"), /Ambiguous workflow prefix/); + assert.match(ambiguous.errors.join("\n"), /completed-exact-alpha-flow/); + assert.match(ambiguous.errors.join("\n"), /completed-exact-beta-flow/); + }); + + test("reports a clear missing target without dispatching completed inspection", async () => { + const backend = new InMemoryDurableBackend(); + setDurableBackend(backend); + registerCompleted(backend, "known-completed"); + + const result = await resume("missing-workflow", createExtensionRuntime({ store })); + + assert.match(result.errors.join("\n"), /No resumable workflow found for id\/prefix: missing-workflow/); + }); + + test("reports a stale completed target instead of dispatching it", async () => { + const backend = new InMemoryDurableBackend(); + setDurableBackend(backend); + backend.registerWorkflow({ + workflowId: "stale-completed-target", + name: "completed-flow", + inputs: {}, + createdAt: 1, + status: "completed", + completedCheckpoints: 1, + }); + const baseRuntime = createExtensionRuntime({ store }); + let resumeCalls = 0; + const runtime: ExtensionRuntime = { + ...baseRuntime, + resumeDurableWorkflow(workflowIdOrPrefix, options) { + resumeCalls += 1; + return baseRuntime.resumeDurableWorkflow(workflowIdOrPrefix, options); + }, + }; + + const result = await resume("stale-completed", runtime); + + assert.equal(resumeCalls, 0); + assert.match(result.errors.join("\n"), /stale or missing durable checkpoint\/session data/); + }); + + test("does not let a retained completed snapshot bypass authoritative stale checks", async () => { + const backend = new InMemoryDurableBackend(); + setDurableBackend(backend); + backend.registerWorkflow({ + workflowId: "retained-stale", + name: "completed-flow", + inputs: {}, + createdAt: 1, + status: "completed", + completedCheckpoints: 1, + }); + store.recordRunStart({ + id: "retained-stale", + name: "completed-flow", + inputs: {}, + status: "completed", + stages: [], + startedAt: 1, + endedAt: 2, + resumable: false, + }); + const baseRuntime = createExtensionRuntime({ store }); + let resumeCalls = 0; + const runtime: ExtensionRuntime = { + ...baseRuntime, + resumeDurableWorkflow(target, options) { + resumeCalls += 1; + return baseRuntime.resumeDurableWorkflow(target, options); + }, + }; + const opened: string[] = []; + + const result = await resume("retained-stale", runtime, opened); + + assert.equal(resumeCalls, 0); + assert.deepEqual(opened, []); + assert.match(result.errors.join("\n"), /stale or missing durable checkpoint\/session data/); + }); + + test("reports ambiguity across live and completed workflow prefixes", async () => { + const backend = new InMemoryDurableBackend(); + setDurableBackend(backend); + registerCompleted(backend, "shared-completed"); + store.recordRunStart({ + id: "shared-live", + name: "live-flow", + inputs: {}, + status: "paused", + stages: [], + startedAt: 1, + resumable: true, + }); + const result = await resume("shared-", createExtensionRuntime({ store })); + + assert.match(result.errors.join("\n"), /Ambiguous workflow prefix/); + assert.match(result.errors.join("\n"), /live-flow/); + assert.match(result.errors.join("\n"), /shared-completed-flow/); + }); + + test("excludes cancelled, killed, and non-resumable failed locals from prefix resolution", async () => { + const backend = new InMemoryDurableBackend(); + setDurableBackend(backend); + registerCompleted(backend, "excluded-completed"); + store.recordRunStart({ + id: "excluded-cancelled", + name: "cancelled", + inputs: {}, + status: "cancelled", + stages: [], + startedAt: 1, + endedAt: 2, + resumable: false, + }); + store.recordRunStart({ + id: "excluded-killed", + name: "killed", + inputs: {}, + status: "killed", + stages: [], + startedAt: 1, + endedAt: 2, + resumable: false, + }); + store.recordRunStart({ + id: "excluded-failed", + name: "failed", + inputs: {}, + status: "failed", + stages: [], + startedAt: 1, + endedAt: 2, + resumable: false, + }); + const opened: string[] = []; + + const result = await resume("excluded-", createExtensionRuntime({ store }), opened); + + assert.equal(result.errors.length, 0); + assert.deepEqual(opened, ["excluded-completed"]); + }); + + test("keeps quit shadows on the durable resume path", async () => { + const backend = new InMemoryDurableBackend(); + setDurableBackend(backend); + backend.registerWorkflow({ + workflowId: "quit-shadow", + name: "quit-flow", + inputs: {}, + createdAt: 1, + status: "paused", + completedCheckpoints: 1, + }); + store.recordRunStart({ + id: "quit-shadow", + name: "quit-flow", + inputs: {}, + status: "running", + stages: [], + startedAt: 1, + endedAt: 2, + exitReason: "quit", + resumable: true, + }); + const baseRuntime = createExtensionRuntime({ store }); + let durableResumeCalls = 0; + const runtime: ExtensionRuntime = { + ...baseRuntime, + resumeDurableWorkflow() { + durableResumeCalls += 1; + return Promise.resolve({ + ok: true, + runId: "quit-shadow", + workflowId: "quit-shadow", + name: "quit-flow", + message: "resumed quit shadow", + }); + }, + }; + + const result = await resume("quit-shadow", runtime); + + assert.equal(durableResumeCalls, 1); + assert.match(result.messages.join("\n"), /resumed quit shadow/); + }); + + for (const status of ["running", "failed", "blocked"] as const) { + test(`keeps durable ${status} targets on the durable resume path`, async () => { + const backend = new InMemoryDurableBackend(); + setDurableBackend(backend); + const id = `durable-${status}`; + const entry = { + workflowId: id, + name: `${status}-flow`, + status, + completedCheckpoints: 1, + pendingPrompts: 0, + createdAt: 1, + updatedAt: 2, + resumable: true, + }; + let resumeCalls = 0; + const runtime = { + registry: { has: () => true }, + prepareDurableResumable: async () => [entry], + prepareCompletedDurable: async () => [], + resumeDurableWorkflow: () => { + resumeCalls += 1; + return { ok: true as const, runId: id, workflowId: id, name: entry.name, message: `resumed ${status}` }; + }, + } as unknown as ExtensionRuntime; + + const result = await resume(id, runtime); + + assert.equal(resumeCalls, 1); + assert.match(result.messages.join("\n"), new RegExp(`resumed ${status}`)); + }); + } + + test("routes checkpointed resumable failures through durable resume instead of completed inspection", async () => { + const backend = new InMemoryDurableBackend(); + setDurableBackend(backend); + const id = "failed-resumable-command-target"; + backend.registerWorkflow({ + workflowId: id, + name: "failed-resumable-flow", + inputs: {}, + createdAt: 1, + status: "failed", + resumable: true, + error: "durable tool failed", + }); + backend.recordCheckpoint({ + kind: "tool", + workflowId: id, + checkpointId: "tool-failure:1", + name: "failed-tool", + argsHash: "failed-tool-hash", + output: null, + throwingFailureError: "durable tool failed", + completedAt: 2, + }); + const resumableRow = backend.listResumableWorkflows()[0]!; + const completedCollision = { ...resumableRow, resumable: false }; + let resumeCalls = 0; + let completedOpenCalls = 0; + const runtime = { + registry: { has: () => true }, + prepareDurableResumable: async () => [resumableRow], + prepareCompletedDurable: async () => [completedCollision], + resumeDurableWorkflow() { + resumeCalls += 1; + return Promise.resolve({ + ok: true as const, + runId: id, + workflowId: id, + name: "failed-resumable-flow", + message: "resumed failed durable run", + }); + }, + openCompletedDurableWorkflow() { + completedOpenCalls += 1; + return { ok: false as const, reason: "not_found" as const, message: "must not open completed history" }; + }, + } as unknown as ExtensionRuntime; + + const catalog = await prepareWorkflowResumeCatalog(runtime, new Set(), id); + assert.deepEqual( + catalog.resumable.map((entry) => entry.workflowId), + [id], + ); + assert.deepEqual( + catalog.completed.map((entry) => entry.workflowId), + [id], + "the collision must exist in both catalogs", + ); + assert.equal(resolveWorkflowResumeTarget(id, [], catalog.resumable, catalog.completed).kind, "durable"); + assert.equal( + resolveWorkflowResumeTarget("failed-resumable-command", [], catalog.resumable, catalog.completed).kind, + "durable", + ); + + const result = await resume("failed-resumable-command", runtime); + + assert.equal(resumeCalls, 1); + assert.equal(completedOpenCalls, 0); + assert.deepEqual(result.errors, []); + assert.match(result.messages.join("\n"), /resumed failed durable run/); + }); + + test("keeps exact full live ids on the existing paused resume path without listing completed durable runs", async () => { + const backend = new InMemoryDurableBackend(); + let completedCatalogReads = 0; + const listCompletedWorkflows = backend.listCompletedWorkflows.bind(backend); + backend.listCompletedWorkflows = () => { + completedCatalogReads += 1; + return listCompletedWorkflows(); + }; + setDurableBackend(backend); + registerCompleted(backend, "exact-live-other-completed"); + store.recordRunStart({ + id: "exact-live", + name: "live-flow", + inputs: {}, + status: "paused", + stages: [], + startedAt: 1, + resumable: true, + }); + const opened: string[] = []; + + const result = await resume("exact-live", createExtensionRuntime({ store }), opened); + + assert.equal(result.errors.length, 0); + assert.equal(store.runs().find((run) => run.id === "exact-live")?.status, "running"); + assert.match(result.messages.join("\n"), /Resumed run exact-li/); + assert.equal(completedCatalogReads, 0, "an exact live run must bypass durable completed-catalog enumeration"); + }); + + test("includes active recoverable blocks in the no-argument resume picker", () => { + const backend = new InMemoryDurableBackend(); + setDurableBackend(backend); + backend.registerWorkflow({ + workflowId: "picker-active-block", + name: "picker-flow", + inputs: {}, + createdAt: 1, + status: "blocked", + resumable: true, + }); + store.recordRunStart({ + id: "picker-active-block", + name: "picker-flow", + inputs: {}, + status: "running", + stages: [], + startedAt: 1, + blockedAt: 2, + resumable: true, + failureRecoverability: "recoverable", + failureDisposition: "active_blocked", + }); + + const source = collectResumePickerLiveRuns(store); + + assert.deepEqual( + source.liveRuns.map((run) => run.id), + ["picker-active-block"], + ); + assert.equal(source.activeLiveIds.has("picker-active-block"), false); + }); + + test("keeps recoverable failed and active-running explicit behavior unchanged", async () => { + const backend = new InMemoryDurableBackend(); + setDurableBackend(backend); + store.recordRunStart({ + id: "failed-live", + name: "failed-flow", + inputs: {}, + status: "failed", + stages: [], + startedAt: 1, + endedAt: 2, + resumable: true, + }); + store.recordRunStart({ + id: "running-live", + name: "running-flow", + inputs: {}, + status: "running", + stages: [], + startedAt: 1, + }); + store.recordRunStart({ + id: "active-blocked-live", + name: "blocked-flow", + inputs: {}, + status: "running", + stages: [], + startedAt: 1, + blockedAt: 2, + resumable: true, + failureRecoverability: "recoverable", + failureDisposition: "active_blocked", + }); + const baseRuntime = createExtensionRuntime({ store }); + let failedResumeCalls = 0; + const runtime: ExtensionRuntime = { + ...baseRuntime, + async resumeFailedRun() { + failedResumeCalls += 1; + return { + ok: true, + runId: "continued-run", + sourceRunId: "failed-live", + resumeFromStageId: "failed-stage", + message: "continued failed workflow", + }; + }, + }; + + const failedResult = await resume("failed-live", runtime); + const blockedResult = await resume("active-blocked-live", runtime); + const runningResult = await resume("running-live", runtime); + + assert.equal(failedResumeCalls, 2); + assert.match(failedResult.messages.join("\n"), /continued failed workflow/); + assert.match(blockedResult.messages.join("\n"), /continued failed workflow/); + assert.equal(blockedResult.errors.length, 0); + assert.match(runningResult.errors.join("\n"), /already running.*connect/i); + }); }); diff --git a/test/unit/workflow-schema.test.ts b/test/unit/workflow-schema.test.ts index 38004cf39..80e3c4743 100644 --- a/test/unit/workflow-schema.test.ts +++ b/test/unit/workflow-schema.test.ts @@ -1,76 +1,93 @@ -import { describe, test } from "bun:test"; import assert from "node:assert/strict"; import { Value } from "typebox/value"; +import { describe, test } from "vitest"; import { WorkflowParametersSchema } from "../../packages/workflows/src/extension/workflow-schema.js"; describe("WorkflowParametersSchema", () => { - test("accepts named workflow execution, discovery, inspection, messaging, control, and reload", () => { - const calls = [ - { action: "run", workflow: "fan-out-and-synthesize", inputs: { prompt: "ship it" } }, - { action: "list" }, - { action: "get", workflow: "fan-out-and-synthesize" }, - { action: "inputs", workflow: "fan-out-and-synthesize" }, - { action: "models" }, - { action: "models", format: "json" }, - { action: "status", runId: "abc123" }, - { action: "stages", runId: "abc123", statusFilter: "running" }, - { action: "stage", runId: "abc123", stageId: "review" }, - { action: "transcript", runId: "abc123", stageId: "review", tail: 20 }, - { action: "send", runId: "abc123", stageId: "review", text: "continue" }, - { action: "pause", runId: "abc123" }, - { action: "resume", runId: "abc123" }, - { action: "interrupt", runId: "abc123" }, - { action: "quit", runId: "abc123" }, - { action: "reload", reason: "new workflow" }, - ]; - for (const call of calls) assert.equal(Value.Check(WorkflowParametersSchema, call), true, JSON.stringify(call)); - }); + test("accepts named workflow execution, discovery, inspection, messaging, control, and reload", () => { + const calls = [ + { action: "run", workflow: "fan-out-and-synthesize", inputs: { prompt: "ship it" } }, + { action: "list" }, + { action: "get", workflow: "fan-out-and-synthesize" }, + { action: "inputs", workflow: "fan-out-and-synthesize" }, + { action: "models" }, + { action: "models", format: "json" }, + { action: "status", runId: "abc123" }, + { action: "stages", runId: "abc123", statusFilter: "running" }, + { action: "stage", runId: "abc123", stageId: "review" }, + { action: "transcript", runId: "abc123", stageId: "review", tail: 20 }, + { action: "send", runId: "abc123", stageId: "review", text: "continue" }, + { action: "pause", runId: "abc123" }, + { action: "resume", runId: "abc123" }, + { action: "interrupt", runId: "abc123" }, + { action: "quit", runId: "abc123" }, + { action: "reload", reason: "new workflow" }, + ]; + for (const call of calls) assert.equal(Value.Check(WorkflowParametersSchema, call), true, JSON.stringify(call)); + }); - test("rejects every removed one-off execution argument", () => { - const removed = { - task: { name: "worker", prompt: "work" }, - tasks: [{ name: "worker", prompt: "work" }], - chain: [{ name: "worker", prompt: "work" }], - chainName: "one-off", - chainDir: ".atomic/workflows/run", - concurrency: 2, - failFast: false, - async: true, - intercom: { enabled: true }, - context: "fresh", - forkFromSessionFile: "/tmp/session.jsonl", - output: "result.md", - outputMode: "file-only", - reads: ["input.md"], - maxOutput: { lines: 20 }, - artifacts: true, - worktree: true, - gitWorktreeDir: "/tmp/worktree", - baseBranch: "main", - model: "openai/gpt-5", - fallbackModels: ["anthropic/claude-sonnet"], - tools: ["read"], - group: true, - } as const; - for (const [field, value] of Object.entries(removed)) { - assert.equal( - Value.Check(WorkflowParametersSchema, { action: "run", workflow: "fan-out-and-synthesize", [field]: value }), - false, - `expected removed field ${field} to be rejected`, - ); - } - }); + test("rejects every removed one-off execution argument", () => { + const removed = { + task: { name: "worker", prompt: "work" }, + tasks: [{ name: "worker", prompt: "work" }], + chain: [{ name: "worker", prompt: "work" }], + chainName: "one-off", + chainDir: ".atomic/workflows/run", + concurrency: 2, + failFast: false, + async: true, + intercom: { enabled: true }, + context: "fresh", + forkFromSessionFile: "/tmp/session.jsonl", + output: "result.md", + outputMode: "file-only", + reads: ["input.md"], + maxOutput: { lines: 20 }, + artifacts: true, + worktree: true, + gitWorktreeDir: "/tmp/worktree", + baseBranch: "main", + model: "openai/gpt-5", + fallbackModels: ["anthropic/claude-sonnet"], + tools: ["read"], + group: true, + } as const; + for (const [field, value] of Object.entries(removed)) { + assert.equal( + Value.Check(WorkflowParametersSchema, { + action: "run", + workflow: "fan-out-and-synthesize", + [field]: value, + }), + false, + `expected removed field ${field} to be rejected`, + ); + } + }); - test("rejects invalid action values and transcript counts", () => { - assert.equal(Value.Check(WorkflowParametersSchema, { action: "kill", runId: "abc123" }), false); - assert.equal(Value.Check(WorkflowParametersSchema, { action: "transcript", limit: -1 }), false); - assert.equal(Value.Check(WorkflowParametersSchema, { action: "transcript", tail: 1.5 }), false); - }); + test("rejects invalid action values and transcript counts", () => { + assert.equal(Value.Check(WorkflowParametersSchema, { action: "kill", runId: "abc123" }), false); + assert.equal(Value.Check(WorkflowParametersSchema, { action: "transcript", limit: -1 }), false); + assert.equal(Value.Check(WorkflowParametersSchema, { action: "transcript", tail: 1.5 }), false); + }); - test("keeps agent-facing action field descriptions", () => { - const properties = (WorkflowParametersSchema as { properties: Record }).properties; - for (const field of ["action", "statusFilter", "format", "limit", "tail", "includeToolOutput", "text", "response", "delivery", "promptId", "reason"]) { - assert.ok((properties[field]?.description ?? "").length > 0, `${field} description`); - } - }); + test("keeps agent-facing action field descriptions", () => { + const properties = (WorkflowParametersSchema as { properties: Record }) + .properties; + for (const field of [ + "action", + "statusFilter", + "format", + "limit", + "tail", + "includeToolOutput", + "text", + "response", + "delivery", + "promptId", + "reason", + ]) { + assert.ok((properties[field]?.description ?? "").length > 0, `${field} description`); + } + }); }); diff --git a/test/unit/workflow-scope-guard-guidance.test.ts b/test/unit/workflow-scope-guard-guidance.test.ts index d32ff12c5..4643823dc 100644 --- a/test/unit/workflow-scope-guard-guidance.test.ts +++ b/test/unit/workflow-scope-guard-guidance.test.ts @@ -1,281 +1,296 @@ import { mkdtemp, rm, writeFile } from "node:fs/promises"; import { resolve } from "node:path"; import { pathToFileURL } from "node:url"; -import { describe, expect, test } from "bun:test"; +import { describe, expect, test } from "vitest"; +import { fileExists, moduleDir, readText } from "../helpers/runtime.js"; import { - createStore, - mockSession, - run, - structuredOutputMockSession, - type CreateAgentSessionOptions, - type StageSessionRuntime, - type WorkflowDefinition, + type CreateAgentSessionOptions, + createStore, + mockSession, + run, + type StageSessionRuntime, + structuredOutputMockSession, + type WorkflowDefinition, } from "./executor-shared.js"; -const repositoryRoot = resolve(import.meta.dir, "../.."); +const repositoryRoot = resolve(moduleDir(import.meta.url), "../.."); const documentationPath = resolve(repositoryRoot, "packages/coding-agent/docs/workflows.md"); async function readDocumentation(): Promise { - return (await Bun.file(documentationPath).text()).replaceAll("\r\n", "\n"); + return (await readText(documentationPath)).replaceAll("\r\n", "\n"); } function extractExample(documentation: string, filename: string): string { - const marker = `// .atomic/workflows/${filename}`; - const opening = `\`\`\`ts\n${marker}\n`; - const tail = documentation.split(opening)[1]; - if (tail === undefined) throw new Error(`missing ${filename} example`); - const body = tail.split("\n```", 1)[0]; - if (body === undefined) throw new Error(`unterminated ${filename} example`); - return `${marker}\n${body}\n`; + const marker = `// .atomic/workflows/${filename}`; + const opening = `\`\`\`ts\n${marker}\n`; + const tail = documentation.split(opening)[1]; + if (tail === undefined) throw new Error(`missing ${filename} example`); + const body = tail.split("\n```", 1)[0]; + if (body === undefined) throw new Error(`unterminated ${filename} example`); + return `${marker}\n${body}\n`; } async function loadExample( - documentation: string, - filename: string, - directory: string, + documentation: string, + filename: string, + directory: string, ): Promise<{ readonly definition: WorkflowDefinition; readonly source: string }> { - const source = extractExample(documentation, filename); - const examplePath = resolve(directory, filename); - await writeFile(examplePath, source); - const loaded = (await import(`${pathToFileURL(examplePath).href}?test=${filename}-${Date.now()}`)) as { - readonly default: WorkflowDefinition; - }; - return { definition: loaded.default, source }; + const source = extractExample(documentation, filename); + const examplePath = resolve(directory, filename); + await writeFile(examplePath, source); + const loaded = (await import(`${pathToFileURL(examplePath).href}?test=${filename}-${Date.now()}`)) as { + readonly default: WorkflowDefinition; + }; + return { definition: loaded.default, source }; } function textSession(stageName: string, prompts: Map): StageSessionRuntime { - return { - ...mockSession(), - sessionFile: undefined, - async prompt(text: string) { - const stagePrompts = prompts.get(stageName) ?? []; - stagePrompts.push(text); - prompts.set(stageName, stagePrompts); - }, - getLastAssistantText() { - return `output from ${stageName}`; - }, - }; + return { + ...mockSession(), + sessionFile: undefined, + async prompt(text: string) { + const stagePrompts = prompts.get(stageName) ?? []; + stagePrompts.push(text); + prompts.set(stageName, stagePrompts); + }, + getLastAssistantText() { + return `output from ${stageName}`; + }, + }; } function testUi(onEditor: () => void = () => {}): { - input: (prompt: string) => Promise; - confirm: (message: string) => Promise; - select: (message: string, options: readonly T[]) => Promise; - editor: (initial?: string) => Promise; + input: (prompt: string) => Promise; + confirm: (message: string) => Promise; + select: (message: string, options: readonly T[]) => Promise; + editor: (initial?: string) => Promise; } { - return { - input: async () => "input", - confirm: async () => true, - select: async (_message: string, options: readonly T[]) => options[0]!, - editor: async () => { - onEditor(); - return "No unclear decisions remain."; - }, - }; + return { + input: async () => "input", + confirm: async () => true, + select: async (_message: string, options: readonly T[]) => options[0]!, + editor: async () => { + onEditor(); + return "No unclear decisions remain."; + }, + }; } describe("workflow scope-guard guidance", () => { - test("locks the scope contract, decision actions, fallback, and lifecycle rules", async () => { - const documentation = await readDocumentation(); + test("locks the scope contract, decision actions, fallback, and lifecycle rules", async () => { + const documentation = await readDocumentation(); - for (const phrase of [ - "one inspectable contract artifact", - "Treat it as immutable for that run", - "literal objective", - "required scope and allowed files or systems", - "explicit non-goals", - "stage boundaries and expected lifecycle order", - "acceptance criteria and required evidence", - "`required`", - "`dependent`", - "`follow-up`", - "`unclear`", - "one row per key", - "cap the log", - "A guard failure or missing coordination channel never means approval", - "`warn`", - "`block`", - "`off`", - "Expected lifecycle state is not a defect", - "must not reject the patch merely because it is unpushed or unpublished", - "It controls scope only", - ]) { - expect(documentation).toContain(phrase); - } - }); + for (const phrase of [ + "one inspectable contract artifact", + "Treat it as immutable for that run", + "literal objective", + "required scope and allowed files or systems", + "explicit non-goals", + "stage boundaries and expected lifecycle order", + "acceptance criteria and required evidence", + "`required`", + "`dependent`", + "`follow-up`", + "`unclear`", + "one row per key", + "cap the log", + "A guard failure or missing coordination channel never means approval", + "`warn`", + "`block`", + "`off`", + "Expected lifecycle state is not a defect", + "must not reject the patch merely because it is unpushed or unpublished", + "It controls scope only", + ]) { + expect(documentation).toContain(phrase); + } + }); - test("locks acyclic role-based context and inherited-group guidance", async () => { - const documentation = await readDocumentation(); + test("locks acyclic role-based context and inherited-group guidance", async () => { + const documentation = await readDocumentation(); - for (const phrase of [ - "A boundary guard is an ordinary downstream reviewer node", - "Live Intercom steering is activity inside already-running parallel stages, not a new graph edge", - "Never make a guard watch itself", - "Late messages do not reopen or mutate its terminal workflow state", - "releases downstream work only after all started branches settle", - "Pause/resume, model fallback, durable replay, and nested workflows", - "Omit `group` for ordinary use", - "delegated subagents inherit the top-level workflow invocation's stable Intercom group", - "Use `context: \"fresh\"` for guards, reviewers, and judges", - "Use `context: \"fork\"` plus `forkFromSessionFile` for implementation, debugging, and repair roles", - "`context: \"fork\"` alone does not name a fork source", - "use the earlier worker's `sessionFile` when available", - "Send a forked continuation only the delta after the fork point", - "Complete all turns on a retained guard before starting downstream dependency work", - ]) { - expect(documentation).toContain(phrase); - } - }); + for (const phrase of [ + "A boundary guard is an ordinary downstream reviewer node", + "Live Intercom steering is activity inside already-running parallel stages, not a new graph edge", + "Never make a guard watch itself", + "Late messages do not reopen or mutate its terminal workflow state", + "releases downstream work only after all started branches settle", + "Pause/resume, model fallback, durable replay, and nested workflows", + "Omit `group` for ordinary use", + "delegated subagents inherit the top-level workflow invocation's stable Intercom group", + 'Use `context: "fresh"` for guards, reviewers, and judges', + 'Use `context: "fork"` plus `forkFromSessionFile` for implementation, debugging, and repair roles', + '`context: "fork"` alone does not name a fork source', + "use the earlier worker's `sessionFile` when available", + "Send a forked continuation only the delta after the fork point", + "Complete all turns on a retained guard before starting downstream dependency work", + ]) { + expect(documentation).toContain(phrase); + } + }); - test("keeps all three starter examples loadable and on current APIs", async () => { - const documentation = await readDocumentation(); - const examples = [ - { - filename: "scope-guard-boundary.ts", - name: "scope-guard-boundary", - api: "ctx.task(\"scope boundary\"", - inputs: ["scope_contract", "artifact_dir"], - }, - { - filename: "scope-guard-retained.ts", - name: "scope-guard-retained", - api: "ctx.stage(\"retained scope guard\"", - inputs: ["scope_contract", "artifact_dir"], - }, - { - filename: "scope-guard-live.ts", - name: "scope-guard-live", - api: "ctx.parallel(", - inputs: ["scope_contract", "worker_session_file", "fallback_policy", "artifact_dir"], - }, - ] as const; - const tempDirectory = await mkdtemp(resolve(repositoryRoot, "test", ".scope-guard-examples-")); + test("keeps all three starter examples loadable and on current APIs", async () => { + const documentation = await readDocumentation(); + const examples = [ + { + filename: "scope-guard-boundary.ts", + name: "scope-guard-boundary", + api: 'ctx.task("scope boundary"', + inputs: ["scope_contract", "artifact_dir"], + }, + { + filename: "scope-guard-retained.ts", + name: "scope-guard-retained", + api: 'ctx.stage("retained scope guard"', + inputs: ["scope_contract", "artifact_dir"], + }, + { + filename: "scope-guard-live.ts", + name: "scope-guard-live", + api: "ctx.parallel(", + inputs: ["scope_contract", "worker_session_file", "fallback_policy", "artifact_dir"], + }, + ] as const; + const tempDirectory = await mkdtemp(resolve(repositoryRoot, "test", ".scope-guard-examples-")); - try { - for (const example of examples) { - const { definition, source } = await loadExample(documentation, example.filename, tempDirectory); - expect(source).toContain(example.api); - expect(source).toContain("context: \"fresh\""); - expect(source).toContain("context: \"fork\""); - expect(source).not.toContain("watchdog:"); - if (example.name === "scope-guard-retained") { - expect(source.match(/guard\.prompt\(/g)).toHaveLength(1); - expect(source).toContain("guard.sendUserMessage("); - } - if (example.name === "scope-guard-live") { - expect(source).toContain("forkFromSessionFile: sessionFile"); - expect(source).toContain("fallback_policy:"); - expect(source).toContain("ctx.task(\"persist scope decisions\""); - expect(source).toContain("{ concurrency: 2, failFast: true }"); - expect(source).not.toContain("group:"); - expect(source.indexOf("ctx.parallel(")).toBeLessThan(source.indexOf("persist scope decisions")); - expect(source.indexOf("persist scope decisions")).toBeLessThan(source.indexOf("independent correctness review")); - } + try { + for (const example of examples) { + const { definition, source } = await loadExample(documentation, example.filename, tempDirectory); + expect(source).toContain(example.api); + expect(source).toContain('context: "fresh"'); + expect(source).toContain('context: "fork"'); + expect(source).not.toContain("watchdog:"); + if (example.name === "scope-guard-retained") { + expect(source.match(/guard\.prompt\(/g)).toHaveLength(1); + expect(source).toContain("guard.sendUserMessage("); + } + if (example.name === "scope-guard-live") { + expect(source).toContain("forkFromSessionFile: sessionFile"); + expect(source).toContain("fallback_policy:"); + expect(source).toContain('ctx.task("persist scope decisions"'); + expect(source).toContain("{ concurrency: 2, failFast: true }"); + expect(source).not.toContain("group:"); + expect(source.indexOf("ctx.parallel(")).toBeLessThan(source.indexOf("persist scope decisions")); + expect(source.indexOf("persist scope decisions")).toBeLessThan( + source.indexOf("independent correctness review"), + ); + } - expect(definition.name).toBe(example.name); - expect(Object.keys(definition.inputs)).toEqual([...example.inputs]); - expect(definition.outputs).toHaveProperty("decision_log"); - } - } finally { - await rm(tempDirectory, { recursive: true, force: true }); - } - }); + expect(definition.name).toBe(example.name); + expect(Object.keys(definition.inputs)).toEqual([...example.inputs]); + expect(definition.outputs).toHaveProperty("decision_log"); + } + } finally { + await rm(tempDirectory, { recursive: true, force: true }); + } + }); - test("executes the retained post-prompt lifecycle", async () => { - const documentation = await readDocumentation(); - const tempDirectory = await mkdtemp(resolve(repositoryRoot, "test", ".scope-guard-retained-run-")); - const contract = resolve(tempDirectory, "scope.md"); - const prompts = new Map(); + test("executes the retained post-prompt lifecycle", async () => { + const documentation = await readDocumentation(); + const tempDirectory = await mkdtemp(resolve(repositoryRoot, "test", ".scope-guard-retained-run-")); + const contract = resolve(tempDirectory, "scope.md"); + const prompts = new Map(); - try { - await writeFile(contract, "# Immutable scope\nOnly edit docs.\n"); - const { definition } = await loadExample(documentation, "scope-guard-retained.ts", tempDirectory); - const result = await run(definition, { scope_contract: contract, artifact_dir: tempDirectory }, { - cwd: tempDirectory, - store: createStore(), - ui: testUi(), - adapters: { - agentSession: { - async create(_options, meta) { - return textSession(meta?.stageName ?? "unknown", prompts); - }, - }, - }, - }); + try { + await writeFile(contract, "# Immutable scope\nOnly edit docs.\n"); + const { definition } = await loadExample(documentation, "scope-guard-retained.ts", tempDirectory); + const result = await run( + definition, + { scope_contract: contract, artifact_dir: tempDirectory }, + { + cwd: tempDirectory, + store: createStore(), + ui: testUi(), + adapters: { + agentSession: { + async create(_options, meta) { + return textSession(meta?.stageName ?? "unknown", prompts); + }, + }, + }, + }, + ); - expect(result.status).toBe("completed"); - expect(prompts.get("retained scope guard")).toHaveLength(2); - expect(prompts.get("retained scope guard")?.[1]).toContain("Recheck the complete candidate"); - expect(await Bun.file(resolve(tempDirectory, "scope-decisions.md")).exists()).toBe(true); - } finally { - await rm(tempDirectory, { recursive: true, force: true }); - } - }); + expect(result.status).toBe("completed"); + expect(prompts.get("retained scope guard")).toHaveLength(2); + expect(prompts.get("retained scope guard")?.[1]).toContain("Recheck the complete candidate"); + expect(await fileExists(resolve(tempDirectory, "scope-decisions.md"))).toBe(true); + } finally { + await rm(tempDirectory, { recursive: true, force: true }); + } + }); - test("executes live persistence and the block fallback", async () => { - const documentation = await readDocumentation(); - const tempDirectory = await mkdtemp(resolve(repositoryRoot, "test", ".scope-guard-live-run-")); - const contract = resolve(tempDirectory, "scope.md"); - const guardTranscript = resolve(tempDirectory, "guard.jsonl"); + test("executes live persistence and the block fallback", async () => { + const documentation = await readDocumentation(); + const tempDirectory = await mkdtemp(resolve(repositoryRoot, "test", ".scope-guard-live-run-")); + const contract = resolve(tempDirectory, "scope.md"); + const guardTranscript = resolve(tempDirectory, "guard.jsonl"); - try { - await writeFile(contract, "# Immutable scope\nOnly edit docs.\n"); - await writeFile(guardTranscript, "guard classification transcript\n"); - const { definition } = await loadExample(documentation, "scope-guard-live.ts", tempDirectory); - const execute = async ( - status: "available" | "unavailable", - fallbackPolicy: "warn" | "block", - artifactDir: string, - onEditor: () => void, - ) => { - const stageNames: string[] = []; - return await run(definition, { - scope_contract: contract, - fallback_policy: fallbackPolicy, - artifact_dir: artifactDir, - }, { - cwd: tempDirectory, - store: createStore(), - ui: testUi(onEditor), - adapters: { - agentSession: { - async create(options: CreateAgentSessionOptions, meta) { - const stageName = meta?.stageName ?? "unknown"; - stageNames.push(stageName); - if (stageName === "scope guard") { - return { - ...structuredOutputMockSession(options, { status, evidence: `intercom ${status}` }), - // A normal stage transcript can exist even when Intercom is unavailable. - sessionFile: guardTranscript, - }; - } - return textSession(stageName, new Map()); - }, - }, - }, - onRunEnd: () => { - expect(stageNames.indexOf("persist scope decisions")).toBeLessThan( - stageNames.indexOf("independent correctness review"), - ); - }, - }); - }; + try { + await writeFile(contract, "# Immutable scope\nOnly edit docs.\n"); + await writeFile(guardTranscript, "guard classification transcript\n"); + const { definition } = await loadExample(documentation, "scope-guard-live.ts", tempDirectory); + const execute = async ( + status: "available" | "unavailable", + fallbackPolicy: "warn" | "block", + artifactDir: string, + onEditor: () => void, + ) => { + const stageNames: string[] = []; + return await run( + definition, + { + scope_contract: contract, + fallback_policy: fallbackPolicy, + artifact_dir: artifactDir, + }, + { + cwd: tempDirectory, + store: createStore(), + ui: testUi(onEditor), + adapters: { + agentSession: { + async create(options: CreateAgentSessionOptions, meta) { + const stageName = meta?.stageName ?? "unknown"; + stageNames.push(stageName); + if (stageName === "scope guard") { + return { + ...structuredOutputMockSession(options, { status, evidence: `intercom ${status}` }), + // A normal stage transcript can exist even when Intercom is unavailable. + sessionFile: guardTranscript, + }; + } + return textSession(stageName, new Map()); + }, + }, + }, + onRunEnd: () => { + expect(stageNames.indexOf("persist scope decisions")).toBeLessThan( + stageNames.indexOf("independent correctness review"), + ); + }, + }, + ); + }; - const availableDir = resolve(tempDirectory, "available"); - let editorCalls = 0; - const available = await execute("available", "warn", availableDir, () => { editorCalls += 1; }); - expect(available.status).toBe("completed"); - expect(editorCalls).toBe(0); - expect(await Bun.file(resolve(availableDir, "scope-decisions.md")).exists()).toBe(true); + const availableDir = resolve(tempDirectory, "available"); + let editorCalls = 0; + const available = await execute("available", "warn", availableDir, () => { + editorCalls += 1; + }); + expect(available.status).toBe("completed"); + expect(editorCalls).toBe(0); + expect(await fileExists(resolve(availableDir, "scope-decisions.md"))).toBe(true); - const blockedDir = resolve(tempDirectory, "blocked"); - const blocked = await execute("unavailable", "block", blockedDir, () => { editorCalls += 1; }); - expect(blocked.status).toBe("completed"); - expect(editorCalls).toBe(1); - expect(await Bun.file(resolve(blockedDir, "scope-decisions.md")).exists()).toBe(true); - } finally { - await rm(tempDirectory, { recursive: true, force: true }); - } - }); + const blockedDir = resolve(tempDirectory, "blocked"); + const blocked = await execute("unavailable", "block", blockedDir, () => { + editorCalls += 1; + }); + expect(blocked.status).toBe("completed"); + expect(editorCalls).toBe(1); + expect(await fileExists(resolve(blockedDir, "scope-decisions.md"))).toBe(true); + } finally { + await rm(tempDirectory, { recursive: true, force: true }); + } + }); }); diff --git a/test/unit/workflow-stage-admission.test.ts b/test/unit/workflow-stage-admission.test.ts index 467c3247f..0dc3970f8 100644 --- a/test/unit/workflow-stage-admission.test.ts +++ b/test/unit/workflow-stage-admission.test.ts @@ -1,18 +1,28 @@ -import { describe, test } from "bun:test"; import assert from "node:assert/strict"; +import { describe, test } from "vitest"; import { WorkflowStageAdmissionBoundary } from "../../packages/coding-agent/src/core/workflow-stage-admission.js"; describe("WorkflowStageAdmissionBoundary", () => { test("admission before close is delivered to the stage and drained before close resolves", async () => { const events: string[] = []; - const boundary = new WorkflowStageAdmissionBoundary(async () => { events.push("drained"); }); + const boundary = new WorkflowStageAdmissionBoundary(async () => { + events.push("drained"); + }); const delivery = Promise.withResolvers(); - const admitted = boundary.admit("message-1", async () => { - events.push("stage"); - await delivery.promise; - }, () => { events.push("external"); }); + const admitted = boundary.admit( + "message-1", + async () => { + events.push("stage"); + await delivery.promise; + }, + () => { + events.push("external"); + }, + ); let closed = false; - const close = boundary.close().then(() => { closed = true; }); + const close = boundary.close().then(() => { + closed = true; + }); await Promise.resolve(); assert.equal(admitted.decision, "admitted"); @@ -26,7 +36,9 @@ describe("WorkflowStageAdmissionBoundary", () => { test("concurrent close calls share one drain", async () => { let drains = 0; - const boundary = new WorkflowStageAdmissionBoundary(async () => { drains += 1; }); + const boundary = new WorkflowStageAdmissionBoundary(async () => { + drains += 1; + }); await Promise.all([boundary.close(), boundary.close()]); assert.equal(drains, 1); @@ -36,7 +48,15 @@ describe("WorkflowStageAdmissionBoundary", () => { const boundary = new WorkflowStageAdmissionBoundary(); await boundary.close(); const events: string[] = []; - const result = boundary.admit("message-1", () => { events.push("stage"); }, () => { events.push("external"); }); + const result = boundary.admit( + "message-1", + () => { + events.push("stage"); + }, + () => { + events.push("external"); + }, + ); await result.completion; assert.equal(result.decision, "late"); @@ -62,8 +82,21 @@ describe("WorkflowStageAdmissionBoundary", () => { await boundary.close(); const route = Promise.withResolvers(); let attempts = 0; - const first = boundary.admit("completion-1", () => {}, () => { attempts += 1; return route.promise; }); - const duplicate = boundary.admit("completion-1", () => {}, () => { attempts += 1; }); + const first = boundary.admit( + "completion-1", + () => {}, + () => { + attempts += 1; + return route.promise; + }, + ); + const duplicate = boundary.admit( + "completion-1", + () => {}, + () => { + attempts += 1; + }, + ); assert.equal(duplicate.decision, "duplicate"); route.reject(new Error("temporary route failure")); @@ -75,12 +108,22 @@ describe("WorkflowStageAdmissionBoundary", () => { test("a synchronous reentrant duplicate joins the installed owner", async () => { const boundary = new WorkflowStageAdmissionBoundary(); const decisions: string[] = []; - const owner = boundary.admit("message-1", async () => { - await Promise.resolve(); - const duplicate = boundary.admit("message-1", () => { decisions.push("duplicate-delivered"); }, () => {}); - decisions.push(duplicate.decision); - return duplicate.completion; - }, () => {}); + const owner = boundary.admit( + "message-1", + async () => { + await Promise.resolve(); + const duplicate = boundary.admit( + "message-1", + () => { + decisions.push("duplicate-delivered"); + }, + () => {}, + ); + decisions.push(duplicate.decision); + return duplicate.completion; + }, + () => {}, + ); await owner.completion; assert.deepEqual(decisions, ["duplicate"]); @@ -90,11 +133,18 @@ describe("WorkflowStageAdmissionBoundary", () => { const boundary = new WorkflowStageAdmissionBoundary(); const producer = Promise.withResolvers(); const events: string[] = []; - const eventualDelivery = producer.promise.then(() => boundary.admit( - "completion-1", - () => { events.push("stage"); }, - () => { events.push("external"); }, - ).completion); + const eventualDelivery = producer.promise.then( + () => + boundary.admit( + "completion-1", + () => { + events.push("stage"); + }, + () => { + events.push("external"); + }, + ).completion, + ); await boundary.close(); assert.deepEqual(events, []); @@ -106,9 +156,25 @@ describe("WorkflowStageAdmissionBoundary", () => { test("a stable key is delivered exactly once across the close boundary", async () => { const boundary = new WorkflowStageAdmissionBoundary(); const events: string[] = []; - await boundary.admit("message-1", () => { events.push("stage"); }, () => { events.push("external"); }).completion; + await boundary.admit( + "message-1", + () => { + events.push("stage"); + }, + () => { + events.push("external"); + }, + ).completion; await boundary.close(); - const duplicate = boundary.admit("message-1", () => { events.push("stage"); }, () => { events.push("external"); }); + const duplicate = boundary.admit( + "message-1", + () => { + events.push("stage"); + }, + () => { + events.push("external"); + }, + ); await duplicate.completion; assert.equal(duplicate.decision, "duplicate"); diff --git a/test/unit/workflow-stage-bundled-resources.test.ts b/test/unit/workflow-stage-bundled-resources.test.ts index f614f0099..8b7d24028 100644 --- a/test/unit/workflow-stage-bundled-resources.test.ts +++ b/test/unit/workflow-stage-bundled-resources.test.ts @@ -1,303 +1,315 @@ /// -import { afterEach, describe, setDefaultTimeout, test } from "bun:test"; import assert from "node:assert/strict"; import { existsSync, mkdirSync, mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { getModel } from "@earendil-works/pi-ai/compat"; +import { afterEach, describe, test } from "vitest"; import { getBuiltinPackagePaths } from "../../packages/coding-agent/src/core/builtin-packages.js"; import { DefaultResourceLoader } from "../../packages/coding-agent/src/core/resource-loader.js"; -import { createAgentSession, type CreateAgentSessionOptions } from "../../packages/coding-agent/src/core/sdk.js"; +import { type CreateAgentSessionOptions, createAgentSession } from "../../packages/coding-agent/src/core/sdk.js"; import { SessionManager } from "../../packages/coding-agent/src/core/session-manager.js"; -import { SettingsManager, type PackageSource } from "../../packages/coding-agent/src/core/settings-manager.js"; +import { type PackageSource, SettingsManager } from "../../packages/coding-agent/src/core/settings-manager.js"; import { discoverAgentsAll } from "../../packages/subagents/src/agents/agents.js"; import { MAX_SUBAGENT_NESTING_DEPTH } from "../../packages/subagents/src/shared/types.js"; import { - prepareAtomicStageSessionOptions, - type PiCodingAgentSdk, - type PiSdkResourceLoader, - type PiSdkSettingsManager, + type PiCodingAgentSdk, + type PiSdkResourceLoader, + type PiSdkSettingsManager, + prepareAtomicStageSessionOptions, } from "../../packages/workflows/src/extension/wiring.js"; +import type { StageSessionRuntime } from "../../packages/workflows/src/runs/foreground/stage-runner.js"; -setDefaultTimeout(30_000); const tempDirs: string[] = []; const ENV_KEYS = [ - "ATOMIC_SUBAGENT_CHILD", - "ATOMIC_SUBAGENT_FANOUT_CHILD", - "PI_SUBAGENT_CHILD", - "PI_SUBAGENT_FANOUT_CHILD", - "ATOMIC_CODING_AGENT_DIR", - "PI_CODING_AGENT_DIR", + "ATOMIC_SUBAGENT_CHILD", + "ATOMIC_SUBAGENT_FANOUT_CHILD", + "PI_SUBAGENT_CHILD", + "PI_SUBAGENT_FANOUT_CHILD", + "ATOMIC_CODING_AGENT_DIR", + "PI_CODING_AGENT_DIR", ] as const; afterEach(() => { - for (const dir of tempDirs.splice(0)) { - if (existsSync(dir)) rmSync(dir, { recursive: true, force: true }); - } + for (const dir of tempDirs.splice(0)) { + if (existsSync(dir)) rmSync(dir, { recursive: true, force: true }); + } }); function tempDir(prefix: string): string { - const dir = mkdtempSync(join(tmpdir(), prefix)); - tempDirs.push(dir); - return dir; + const dir = mkdtempSync(join(tmpdir(), prefix)); + tempDirs.push(dir); + return dir; } function snapshotEnv(): Map { - return new Map(ENV_KEYS.map((key) => [key, process.env[key]])); + return new Map(ENV_KEYS.map((key) => [key, process.env[key]])); } function restoreEnv(snapshot: ReadonlyMap): void { - for (const key of ENV_KEYS) { - const value = snapshot.get(key); - if (value === undefined) delete process.env[key]; - else process.env[key] = value; - } + for (const key of ENV_KEYS) { + const value = snapshot.get(key); + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } } class StageDefaultResourceLoader extends DefaultResourceLoader implements PiSdkResourceLoader { - constructor(options: { - readonly cwd: string; - readonly agentDir: string; - readonly settingsManager?: PiSdkSettingsManager; - readonly builtinPackagePaths?: PackageSource[]; - }) { - super({ - cwd: options.cwd, - agentDir: options.agentDir, - settingsManager: options.settingsManager as SettingsManager | undefined, - builtinPackagePaths: options.builtinPackagePaths, - }); - } + constructor(options: { + readonly cwd: string; + readonly agentDir: string; + readonly settingsManager?: PiSdkSettingsManager; + readonly builtinPackagePaths?: PackageSource[]; + }) { + super({ + cwd: options.cwd, + agentDir: options.agentDir, + settingsManager: options.settingsManager as SettingsManager | undefined, + builtinPackagePaths: options.builtinPackagePaths, + }); + } } function makeSdk(agentDir: string): PiCodingAgentSdk { - return { - getAgentDir: () => agentDir, - getBuiltinPackagePaths, - SettingsManager, - DefaultResourceLoader: StageDefaultResourceLoader, - async createAgentSession(options) { - const result = await createAgentSession(options as CreateAgentSessionOptions); - return { session: result.session }; - }, - }; + return { + getAgentDir: () => agentDir, + getBuiltinPackagePaths, + SettingsManager, + DefaultResourceLoader: StageDefaultResourceLoader, + async createAgentSession(options) { + const result = await createAgentSession(options as CreateAgentSessionOptions); + return { session: result.session as unknown as StageSessionRuntime }; + }, + }; } async function createWorkflowStageSession(options: { - readonly cwd: string; - readonly agentDir: string; - readonly tools?: readonly string[]; - readonly noTools?: CreateAgentSessionOptions["noTools"]; - readonly excludedTools?: readonly string[]; + readonly cwd: string; + readonly agentDir: string; + readonly tools?: readonly string[]; + readonly noTools?: CreateAgentSessionOptions["noTools"]; + readonly excludedTools?: readonly string[]; }) { - const model = getModel("anthropic", "claude-sonnet-4-5"); - assert.notEqual(model, undefined); - const settingsManager = SettingsManager.create(options.cwd, options.agentDir); - const orchestrationContext = { - kind: "workflow-stage", - workflowRunId: "run-test", - workflowStageId: "stage-test", - workflowStageName: "Stage Test", - constraints: { - disableWorkflowTool: true, - maxSubagentDepth: MAX_SUBAGENT_NESTING_DEPTH, - }, - } satisfies CreateAgentSessionOptions["orchestrationContext"]; - const excludedTools = Array.from(new Set([...(options.excludedTools ?? []), "workflow"])); - const sessionOptions = await prepareAtomicStageSessionOptions( - { - cwd: options.cwd, - agentDir: options.agentDir, - settingsManager, - ...(options.tools === undefined ? {} : { tools: [...options.tools] }), - ...(options.noTools === undefined ? {} : { noTools: options.noTools }), - excludedTools, - model: model!, - orchestrationContext, - }, - makeSdk(options.agentDir), - ); - if (sessionOptions === undefined) { - throw new Error("prepareAtomicStageSessionOptions returned undefined."); - } - if (sessionOptions.resourceLoader === undefined) { - throw new Error("prepareAtomicStageSessionOptions did not create a resource loader."); - } + const model = getModel("anthropic", "claude-sonnet-4-5"); + assert.notEqual(model, undefined); + const settingsManager = SettingsManager.create(options.cwd, options.agentDir); + const orchestrationContext = { + kind: "workflow-stage", + workflowRunId: "run-test", + workflowStageId: "stage-test", + workflowStageName: "Stage Test", + constraints: { + disableWorkflowTool: true, + maxSubagentDepth: MAX_SUBAGENT_NESTING_DEPTH, + }, + } satisfies CreateAgentSessionOptions["orchestrationContext"]; + const excludedTools = Array.from(new Set([...(options.excludedTools ?? []), "workflow"])); + const sessionOptions = await prepareAtomicStageSessionOptions( + { + cwd: options.cwd, + agentDir: options.agentDir, + settingsManager, + ...(options.tools === undefined ? {} : { tools: [...options.tools] }), + ...(options.noTools === undefined ? {} : { noTools: options.noTools }), + excludedTools, + model: model!, + orchestrationContext, + }, + makeSdk(options.agentDir), + ); + if (sessionOptions === undefined) { + throw new Error("prepareAtomicStageSessionOptions returned undefined."); + } + if (sessionOptions.resourceLoader === undefined) { + throw new Error("prepareAtomicStageSessionOptions did not create a resource loader."); + } - return createAgentSession({ - cwd: options.cwd, - agentDir: options.agentDir, - settingsManager, - resourceLoader: sessionOptions.resourceLoader as DefaultResourceLoader, - ...(options.tools === undefined ? {} : { tools: [...options.tools] }), - ...(options.noTools === undefined ? {} : { noTools: options.noTools }), - excludedTools, - orchestrationContext, - sessionManager: SessionManager.inMemory(options.cwd), - model: model!, - }); + return createAgentSession({ + cwd: options.cwd, + agentDir: options.agentDir, + settingsManager, + resourceLoader: sessionOptions.resourceLoader as DefaultResourceLoader, + ...(options.tools === undefined ? {} : { tools: [...options.tools] }), + ...(options.noTools === undefined ? {} : { noTools: options.noTools }), + excludedTools, + orchestrationContext, + sessionManager: SessionManager.inMemory(options.cwd), + model: model!, + }); } describe("workflow stage bundled resources", () => { - test("discovers bundled subagent definitions from the packaged repo", () => { - const snapshot = snapshotEnv(); - const cwd = tempDir("atomic-workflow-stage-agents-cwd-"); - const agentDir = join(cwd, "agent"); - mkdirSync(agentDir, { recursive: true }); - try { - process.env.ATOMIC_CODING_AGENT_DIR = agentDir; - delete process.env.PI_CODING_AGENT_DIR; + test("discovers bundled subagent definitions from the packaged repo", () => { + const snapshot = snapshotEnv(); + const cwd = tempDir("atomic-workflow-stage-agents-cwd-"); + const agentDir = join(cwd, "agent"); + mkdirSync(agentDir, { recursive: true }); + try { + process.env.ATOMIC_CODING_AGENT_DIR = agentDir; + delete process.env.PI_CODING_AGENT_DIR; - const builtinAgents = discoverAgentsAll(cwd).builtin; - const builtinNames = new Set(builtinAgents.map((agent) => agent.name)); - for (const name of [ - "code-simplifier", - "codebase-analyzer", - "codebase-locator", - "codebase-online-researcher", - "codebase-pattern-finder", - "codebase-research-analyzer", - "codebase-research-locator", - "debugger", - "worker", - ]) { - assert.ok(builtinNames.has(name), `expected bundled subagent ${name}`); - } - const debuggerAgent = builtinAgents.find((agent) => agent.name === "debugger"); - const workerAgent = builtinAgents.find((agent) => agent.name === "worker"); - assert.ok(debuggerAgent, "expected bundled debugger definition"); - assert.ok(workerAgent, "expected bundled worker definition"); - assert.deepEqual(debuggerAgent.tools, workerAgent.tools); - assert.ok(debuggerAgent.tools?.includes("edit")); - assert.ok(debuggerAgent.tools?.includes("write")); - assert.match(debuggerAgent.systemPrompt, /apply the smallest in-scope fix with `edit` or `write`/i); - } finally { - restoreEnv(snapshot); - } - }); + const builtinAgents = discoverAgentsAll(cwd).builtin; + const builtinNames = new Set(builtinAgents.map((agent) => agent.name)); + for (const name of [ + "code-simplifier", + "codebase-analyzer", + "codebase-locator", + "codebase-online-researcher", + "codebase-pattern-finder", + "codebase-research-analyzer", + "codebase-research-locator", + "debugger", + "worker", + ]) { + assert.ok(builtinNames.has(name), `expected bundled subagent ${name}`); + } + const debuggerAgent = builtinAgents.find((agent) => agent.name === "debugger"); + const workerAgent = builtinAgents.find((agent) => agent.name === "worker"); + assert.ok(debuggerAgent, "expected bundled debugger definition"); + assert.ok(workerAgent, "expected bundled worker definition"); + assert.deepEqual(debuggerAgent.tools, workerAgent.tools); + assert.ok(debuggerAgent.tools?.includes("edit")); + assert.ok(debuggerAgent.tools?.includes("write")); + assert.match(debuggerAgent.systemPrompt, /apply the smallest in-scope fix with `edit` or `write`/i); + } finally { + restoreEnv(snapshot); + } + }); - test("keeps bundled subagent active by default in workflow stages", async () => { - const snapshot = snapshotEnv(); - const cwd = tempDir("atomic-workflow-stage-default-subagent-cwd-"); - const agentDir = join(cwd, "agent"); - mkdirSync(agentDir, { recursive: true }); - try { - process.env.ATOMIC_SUBAGENT_CHILD = "1"; - process.env.ATOMIC_SUBAGENT_FANOUT_CHILD = "0"; + test("keeps bundled subagent active by default in workflow stages", async () => { + const snapshot = snapshotEnv(); + const cwd = tempDir("atomic-workflow-stage-default-subagent-cwd-"); + const agentDir = join(cwd, "agent"); + mkdirSync(agentDir, { recursive: true }); + try { + process.env.ATOMIC_SUBAGENT_CHILD = "1"; + process.env.ATOMIC_SUBAGENT_FANOUT_CHILD = "0"; - const { session } = await createWorkflowStageSession({ cwd, agentDir }); - try { - const allToolNames = session.getAllTools().map((tool) => tool.name); - const activeToolNames = session.getActiveToolNames(); - assert.ok(allToolNames.includes("subagent"), "expected subagent in all workflow stage tools"); - assert.ok(activeToolNames.includes("subagent"), "expected subagent to be active by default"); - } finally { - session.dispose(); - } - } finally { - restoreEnv(snapshot); - } - }); + const { session } = await createWorkflowStageSession({ cwd, agentDir }); + try { + const allToolNames = session.getAllTools().map((tool) => tool.name); + const activeToolNames = session.getActiveToolNames(); + assert.ok(allToolNames.includes("subagent"), "expected subagent in all workflow stage tools"); + assert.ok(activeToolNames.includes("subagent"), "expected subagent to be active by default"); + } finally { + session.dispose(); + } + } finally { + restoreEnv(snapshot); + } + }); - test("keeps explicit workflow stage tool allowlists authoritative", async () => { - const cwd = tempDir("atomic-workflow-stage-explicit-tools-cwd-"); - const agentDir = join(cwd, "agent"); - mkdirSync(agentDir, { recursive: true }); + test("keeps explicit workflow stage tool allowlists authoritative", async () => { + const cwd = tempDir("atomic-workflow-stage-explicit-tools-cwd-"); + const agentDir = join(cwd, "agent"); + mkdirSync(agentDir, { recursive: true }); - const { session } = await createWorkflowStageSession({ - cwd, - agentDir, - tools: ["read"], - }); - try { - assert.deepEqual(session.getAllTools().map((tool) => tool.name), ["read"]); - assert.deepEqual(session.getActiveToolNames(), ["read"]); - } finally { - session.dispose(); - } - }); + const { session } = await createWorkflowStageSession({ + cwd, + agentDir, + tools: ["read"], + }); + try { + assert.deepEqual( + session.getAllTools().map((tool) => tool.name), + ["read"], + ); + assert.deepEqual(session.getActiveToolNames(), ["read"]); + } finally { + session.dispose(); + } + }); - test("keeps excluded subagent unavailable even though it is a workflow default", async () => { - const cwd = tempDir("atomic-workflow-stage-exclude-subagent-cwd-"); - const agentDir = join(cwd, "agent"); - mkdirSync(agentDir, { recursive: true }); + test("keeps excluded subagent unavailable even though it is a workflow default", async () => { + const cwd = tempDir("atomic-workflow-stage-exclude-subagent-cwd-"); + const agentDir = join(cwd, "agent"); + mkdirSync(agentDir, { recursive: true }); - const { session } = await createWorkflowStageSession({ - cwd, - agentDir, - excludedTools: ["subagent"], - }); - try { - const allToolNames = session.getAllTools().map((tool) => tool.name); - const activeToolNames = session.getActiveToolNames(); - assert.equal(allToolNames.includes("subagent"), false); - assert.equal(activeToolNames.includes("subagent"), false); - } finally { - session.dispose(); - } - }); + const { session } = await createWorkflowStageSession({ + cwd, + agentDir, + excludedTools: ["subagent"], + }); + try { + const allToolNames = session.getAllTools().map((tool) => tool.name); + const activeToolNames = session.getActiveToolNames(); + assert.equal(allToolNames.includes("subagent"), false); + assert.equal(activeToolNames.includes("subagent"), false); + } finally { + session.dispose(); + } + }); - test("honors noTools all over workflow default subagent", async () => { - const cwd = tempDir("atomic-workflow-stage-no-tools-cwd-"); - const agentDir = join(cwd, "agent"); - mkdirSync(agentDir, { recursive: true }); + test("honors noTools all over workflow default subagent", async () => { + const cwd = tempDir("atomic-workflow-stage-no-tools-cwd-"); + const agentDir = join(cwd, "agent"); + mkdirSync(agentDir, { recursive: true }); - const { session } = await createWorkflowStageSession({ - cwd, - agentDir, - noTools: "all", - }); - try { - assert.deepEqual(session.getAllTools().map((tool) => tool.name), []); - assert.deepEqual(session.getActiveToolNames(), []); - } finally { - session.dispose(); - } - }); + const { session } = await createWorkflowStageSession({ + cwd, + agentDir, + noTools: "all", + }); + try { + assert.deepEqual( + session.getAllTools().map((tool) => tool.name), + [], + ); + assert.deepEqual(session.getActiveToolNames(), []); + } finally { + session.dispose(); + } + }); - test("keeps explicitly allowlisted bundled subagent tool in workflow stages launched by subagents", async () => { - const snapshot = snapshotEnv(); - const cwd = tempDir("atomic-workflow-stage-subagent-tool-cwd-"); - const agentDir = join(cwd, "agent"); - mkdirSync(agentDir, { recursive: true }); - try { - process.env.ATOMIC_SUBAGENT_CHILD = "1"; - process.env.ATOMIC_SUBAGENT_FANOUT_CHILD = "0"; + test("keeps explicitly allowlisted bundled subagent tool in workflow stages launched by subagents", async () => { + const snapshot = snapshotEnv(); + const cwd = tempDir("atomic-workflow-stage-subagent-tool-cwd-"); + const agentDir = join(cwd, "agent"); + mkdirSync(agentDir, { recursive: true }); + try { + process.env.ATOMIC_SUBAGENT_CHILD = "1"; + process.env.ATOMIC_SUBAGENT_FANOUT_CHILD = "0"; - const { session } = await createWorkflowStageSession({ - cwd, - agentDir, - tools: ["subagent"], - }); - try { - assert.deepEqual(session.getAllTools().map((tool) => tool.name), ["subagent"]); - assert.deepEqual(session.getActiveToolNames(), ["subagent"]); - } finally { - session.dispose(); - } - } finally { - restoreEnv(snapshot); - } - }); + const { session } = await createWorkflowStageSession({ + cwd, + agentDir, + tools: ["subagent"], + }); + try { + assert.deepEqual( + session.getAllTools().map((tool) => tool.name), + ["subagent"], + ); + assert.deepEqual(session.getActiveToolNames(), ["subagent"]); + } finally { + session.dispose(); + } + } finally { + restoreEnv(snapshot); + } + }); - test("keeps explicitly allowlisted bundled extension tools visible", async () => { - const cwd = tempDir("atomic-workflow-stage-extension-tools-cwd-"); - const agentDir = join(cwd, "agent"); - mkdirSync(agentDir, { recursive: true }); + test("keeps explicitly allowlisted bundled extension tools visible", async () => { + const cwd = tempDir("atomic-workflow-stage-extension-tools-cwd-"); + const agentDir = join(cwd, "agent"); + mkdirSync(agentDir, { recursive: true }); - const { session } = await createWorkflowStageSession({ - cwd, - agentDir, - tools: ["web_search", "fetch_content", "intercom"], - }); - try { - const allToolNames = session.getAllTools().map((tool) => tool.name).sort(); - const activeToolNames = session.getActiveToolNames().sort(); - assert.deepEqual(allToolNames, ["fetch_content", "intercom", "web_search"]); - assert.deepEqual(activeToolNames, ["fetch_content", "intercom", "web_search"]); - } finally { - session.dispose(); - } - }); + const { session } = await createWorkflowStageSession({ + cwd, + agentDir, + tools: ["web_search", "fetch_content", "intercom"], + }); + try { + const allToolNames = session + .getAllTools() + .map((tool) => tool.name) + .sort(); + const activeToolNames = session.getActiveToolNames().sort(); + assert.deepEqual(allToolNames, ["fetch_content", "intercom", "web_search"]); + assert.deepEqual(activeToolNames, ["fetch_content", "intercom", "web_search"]); + } finally { + session.dispose(); + } + }); }); diff --git a/test/unit/workflow-status-listing.test.ts b/test/unit/workflow-status-listing.test.ts index 524bc6d53..32f86007a 100644 --- a/test/unit/workflow-status-listing.test.ts +++ b/test/unit/workflow-status-listing.test.ts @@ -5,212 +5,207 @@ * (status, timing, active stages, awaiting-input prompts), statusFilter * support for the run listing, and the agent-visible text/json output. */ -import { beforeEach, describe, test } from "bun:test"; +import { beforeEach, describe, test } from "vitest"; +import type { WorkflowRunStatusSummary } from "../../packages/workflows/src/extension/workflow-status-summary.js"; +import { renderWorkflowToolContent } from "../../packages/workflows/src/extension/workflow-tool-content.js"; import { - installSlashDispatchTestHooks, - assert, - createRegistry, - createExtensionRuntime, - makeExecuteWorkflowTool, - makeInflightRun, - recordTerminalRun, - store, + assert, + createExtensionRuntime, + createRegistry, + installSlashDispatchTestHooks, + makeExecuteWorkflowTool, + makeInflightRun, + recordTerminalRun, + store, } from "./slash-dispatch-utils.js"; -import { renderWorkflowToolContent } from "../../packages/workflows/src/extension/workflow-tool-content.js"; -import type { WorkflowRunStatusSummary } from "../../packages/workflows/src/extension/workflow-status-summary.js"; installSlashDispatchTestHooks(); // Other test files in the same bun process may leave retained terminal runs // in the module-singleton store; start each listing test from a clean slate. beforeEach(() => { - store.clear(); + store.clear(); }); type StatusListing = { - action: "status"; - filter: string; - runs: WorkflowRunStatusSummary[]; - snapshots: Array<{ id: string }>; + action: "status"; + filter: string; + runs: WorkflowRunStatusSummary[]; + snapshots: Array<{ id: string }>; }; function makeToolHandler() { - const registry = createRegistry([]); - const runtime = createExtensionRuntime({ registry }); - return makeExecuteWorkflowTool(runtime, () => undefined); + const registry = createRegistry([]); + const runtime = createExtensionRuntime({ registry }); + return makeExecuteWorkflowTool(runtime, () => undefined); } function recordRunningRunWithStages(runId: string): void { - store.recordRunStart({ - ...makeInflightRun(runId), - name: "release-docs", - startedAt: Date.now() - 5_000, - }); - store.recordStageStart(runId, { - id: `${runId}-stage-verify`, - name: "verify", - status: "running", - parentIds: [], - toolEvents: [], - startedAt: Date.now() - 4_000, - }); - store.recordStageStart(runId, { - id: `${runId}-stage-approve`, - name: "approve", - status: "awaiting_input", - parentIds: [], - toolEvents: [], - awaitingInputSince: Date.now() - 1_000, - pendingPrompt: { - id: "prompt-1", - kind: "confirm", - message: "Approve the release plan?", - createdAt: Date.now() - 1_000, - }, - }); + store.recordRunStart({ + ...makeInflightRun(runId), + name: "release-docs", + startedAt: Date.now() - 5_000, + }); + store.recordStageStart(runId, { + id: `${runId}-stage-verify`, + name: "verify", + status: "running", + parentIds: [], + toolEvents: [], + startedAt: Date.now() - 4_000, + }); + store.recordStageStart(runId, { + id: `${runId}-stage-approve`, + name: "approve", + status: "awaiting_input", + parentIds: [], + toolEvents: [], + awaitingInputSince: Date.now() - 1_000, + pendingPrompt: { + id: "prompt-1", + kind: "confirm", + message: "Approve the release plan?", + createdAt: Date.now() - 1_000, + }, + }); } describe("workflow tool status run listing", () => { - test.serial("status without runId lists session runs with concise summaries, in-flight first", async () => { - const activeId = `status-listing-active-${Date.now()}`; - recordRunningRunWithStages(activeId); - recordTerminalRun(`status-listing-done-${Date.now()}`, "completed", { - startedAt: Date.now() - 60_000, - }); - const handler = makeToolHandler(); - - const result = (await handler({ action: "status" }, {} as never)) as StatusListing; - - assert.equal(result.action, "status"); - assert.equal(result.filter, "all"); - assert.equal(result.runs.length, 2); - // In-flight run sorts before the ended run despite starting later. - const active = result.runs[0]!; - assert.equal(active.runId, activeId); - assert.equal(active.runIdPrefix, activeId.slice(0, 8)); - assert.equal(active.name, "release-docs"); - assert.equal(active.status, "running"); - assert.equal(active.endedAt, undefined); - assert.ok(active.elapsedMs >= 0); - assert.deepEqual( - active.activeStages.map((stage) => stage.name).sort(), - ["approve", "verify"], - ); - assert.equal(active.awaitingInputCount, 1); - assert.equal(active.awaitingInput.length, 1); - const awaiting = active.awaitingInput[0]!; - assert.equal(awaiting.stageId, `${activeId}-stage-approve`); - assert.equal(awaiting.stageName, "approve"); - assert.equal(awaiting.promptId, "prompt-1"); - assert.equal(awaiting.promptKind, "confirm"); - assert.equal(awaiting.message, "Approve the release plan?"); - - const terminal = result.runs[1]!; - assert.equal(terminal.status, "completed"); - assert.notEqual(terminal.endedAt, undefined); - assert.equal(terminal.awaitingInputCount, 0); - - // Snapshots stay aligned with the summaries (same runs, same order). - assert.deepEqual( - result.snapshots.map((snapshot) => snapshot.id), - result.runs.map((run) => run.runId), - ); - }); - - test.serial("statusFilter filters the run listing by run status", async () => { - const activeId = `status-filter-active-${Date.now()}`; - const doneId = `status-filter-done-${Date.now()}`; - recordRunningRunWithStages(activeId); - recordTerminalRun(doneId, "completed", { startedAt: Date.now() - 60_000 }); - const handler = makeToolHandler(); - - const running = (await handler( - { action: "status", statusFilter: "running" }, - {} as never, - )) as StatusListing; - assert.equal(running.filter, "running"); - assert.deepEqual(running.runs.map((run) => run.runId), [activeId]); - assert.deepEqual(running.snapshots.map((snapshot) => snapshot.id), [activeId]); - - const completed = (await handler( - { action: "status", statusFilter: "completed" }, - {} as never, - )) as StatusListing; - assert.equal(completed.filter, "completed"); - assert.deepEqual(completed.runs.map((run) => run.runId), [doneId]); - - const failed = (await handler( - { action: "status", statusFilter: "failed" }, - {} as never, - )) as StatusListing; - assert.deepEqual(failed.runs, []); - assert.deepEqual(failed.snapshots, []); - }); - - test.serial("statusFilter awaiting_input selects runs with a pending stage prompt", async () => { - const awaitingId = `status-filter-awaiting-${Date.now()}`; - const plainId = `status-filter-plain-${Date.now()}`; - recordRunningRunWithStages(awaitingId); - store.recordRunStart(makeInflightRun(plainId)); - const handler = makeToolHandler(); - - const result = (await handler( - { action: "status", statusFilter: "awaiting_input" }, - {} as never, - )) as StatusListing; - - assert.equal(result.filter, "awaiting_input"); - assert.deepEqual(result.runs.map((run) => run.runId), [awaitingId]); - assert.equal(result.runs[0]!.awaitingInputCount, 1); - }); - - test.serial("status text output is a concise per-run listing; json format returns structured data", async () => { - const activeId = `status-content-active-${Date.now()}`; - recordRunningRunWithStages(activeId); - const handler = makeToolHandler(); - const result = await handler({ action: "status" }, {} as never); - - const text = renderWorkflowToolContent(result, { action: "status" }); - assert.match(text, /action: status/); - assert.match(text, /filter: all/); - assert.match(text, /runs: 1 \(1 in flight\)/); - // Concise summary line: [n] . - const summaryLine = text - .split("\n") - .find((line) => line.startsWith("[1]")); - assert.notEqual(summaryLine, undefined); - assert.match(summaryLine!, new RegExp(activeId.slice(0, 8))); - assert.match(summaryLine!, /release-docs/); - assert.match(summaryLine!, /running/); - assert.match(summaryLine!, /awaiting input \(1\): approve/); - // Full identifiers for pause/resume/interrupt/quit/send follow-ups. - assert.match(text, new RegExp(`runId: ${activeId}`)); - assert.match(text, new RegExp(`${activeId}-stage-approve`)); - assert.match(text, /promptId: prompt-1/); - - const json = renderWorkflowToolContent(result, { - action: "status", - format: "json", - }); - const parsed = JSON.parse(json) as StatusListing; - assert.equal(parsed.action, "status"); - assert.equal(parsed.filter, "all"); - assert.equal(parsed.runs.length, 1); - assert.equal(parsed.runs[0]!.runId, activeId); - assert.equal(parsed.runs[0]!.awaitingInput[0]!.promptId, "prompt-1"); - assert.equal(parsed.snapshots.length, 1); - }); - - test.serial("status text output reports an empty filtered listing", async () => { - const handler = makeToolHandler(); - const result = await handler( - { action: "status", statusFilter: "paused" }, - {} as never, - ); - const text = renderWorkflowToolContent(result, { - action: "status", - statusFilter: "paused", - }); - assert.match(text, /runs: none \(statusFilter: paused\)/); - }); + test.sequential("status without runId lists session runs with concise summaries, in-flight first", async () => { + const activeId = `status-listing-active-${Date.now()}`; + recordRunningRunWithStages(activeId); + recordTerminalRun(`status-listing-done-${Date.now()}`, "completed", { + startedAt: Date.now() - 60_000, + }); + const handler = makeToolHandler(); + + const result = (await handler({ action: "status" }, {} as never)) as StatusListing; + + assert.equal(result.action, "status"); + assert.equal(result.filter, "all"); + assert.equal(result.runs.length, 2); + // In-flight run sorts before the ended run despite starting later. + const active = result.runs[0]!; + assert.equal(active.runId, activeId); + assert.equal(active.runIdPrefix, activeId.slice(0, 8)); + assert.equal(active.name, "release-docs"); + assert.equal(active.status, "running"); + assert.equal(active.endedAt, undefined); + assert.ok(active.elapsedMs >= 0); + assert.deepEqual(active.activeStages.map((stage) => stage.name).sort(), ["approve", "verify"]); + assert.equal(active.awaitingInputCount, 1); + assert.equal(active.awaitingInput.length, 1); + const awaiting = active.awaitingInput[0]!; + assert.equal(awaiting.stageId, `${activeId}-stage-approve`); + assert.equal(awaiting.stageName, "approve"); + assert.equal(awaiting.promptId, "prompt-1"); + assert.equal(awaiting.promptKind, "confirm"); + assert.equal(awaiting.message, "Approve the release plan?"); + + const terminal = result.runs[1]!; + assert.equal(terminal.status, "completed"); + assert.notEqual(terminal.endedAt, undefined); + assert.equal(terminal.awaitingInputCount, 0); + + // Snapshots stay aligned with the summaries (same runs, same order). + assert.deepEqual( + result.snapshots.map((snapshot) => snapshot.id), + result.runs.map((run) => run.runId), + ); + }); + + test.sequential("statusFilter filters the run listing by run status", async () => { + const activeId = `status-filter-active-${Date.now()}`; + const doneId = `status-filter-done-${Date.now()}`; + recordRunningRunWithStages(activeId); + recordTerminalRun(doneId, "completed", { startedAt: Date.now() - 60_000 }); + const handler = makeToolHandler(); + + const running = (await handler({ action: "status", statusFilter: "running" }, {} as never)) as StatusListing; + assert.equal(running.filter, "running"); + assert.deepEqual( + running.runs.map((run) => run.runId), + [activeId], + ); + assert.deepEqual( + running.snapshots.map((snapshot) => snapshot.id), + [activeId], + ); + + const completed = (await handler({ action: "status", statusFilter: "completed" }, {} as never)) as StatusListing; + assert.equal(completed.filter, "completed"); + assert.deepEqual( + completed.runs.map((run) => run.runId), + [doneId], + ); + + const failed = (await handler({ action: "status", statusFilter: "failed" }, {} as never)) as StatusListing; + assert.deepEqual(failed.runs, []); + assert.deepEqual(failed.snapshots, []); + }); + + test.sequential("statusFilter awaiting_input selects runs with a pending stage prompt", async () => { + const awaitingId = `status-filter-awaiting-${Date.now()}`; + const plainId = `status-filter-plain-${Date.now()}`; + recordRunningRunWithStages(awaitingId); + store.recordRunStart(makeInflightRun(plainId)); + const handler = makeToolHandler(); + + const result = (await handler( + { action: "status", statusFilter: "awaiting_input" }, + {} as never, + )) as StatusListing; + + assert.equal(result.filter, "awaiting_input"); + assert.deepEqual( + result.runs.map((run) => run.runId), + [awaitingId], + ); + assert.equal(result.runs[0]!.awaitingInputCount, 1); + }); + + test.sequential("status text output is a concise per-run listing; json format returns structured data", async () => { + const activeId = `status-content-active-${Date.now()}`; + recordRunningRunWithStages(activeId); + const handler = makeToolHandler(); + const result = await handler({ action: "status" }, {} as never); + + const text = renderWorkflowToolContent(result, { action: "status" }); + assert.match(text, /action: status/); + assert.match(text, /filter: all/); + assert.match(text, /runs: 1 \(1 in flight\)/); + // Concise summary line: [n] . + const summaryLine = text.split("\n").find((line) => line.startsWith("[1]")); + assert.notEqual(summaryLine, undefined); + assert.match(summaryLine!, new RegExp(activeId.slice(0, 8))); + assert.match(summaryLine!, /release-docs/); + assert.match(summaryLine!, /running/); + assert.match(summaryLine!, /awaiting input \(1\): approve/); + // Full identifiers for pause/resume/interrupt/quit/send follow-ups. + assert.match(text, new RegExp(`runId: ${activeId}`)); + assert.match(text, new RegExp(`${activeId}-stage-approve`)); + assert.match(text, /promptId: prompt-1/); + + const json = renderWorkflowToolContent(result, { + action: "status", + format: "json", + }); + const parsed = JSON.parse(json) as StatusListing; + assert.equal(parsed.action, "status"); + assert.equal(parsed.filter, "all"); + assert.equal(parsed.runs.length, 1); + assert.equal(parsed.runs[0]!.runId, activeId); + assert.equal(parsed.runs[0]!.awaitingInput[0]!.promptId, "prompt-1"); + assert.equal(parsed.snapshots.length, 1); + }); + + test.sequential("status text output reports an empty filtered listing", async () => { + const handler = makeToolHandler(); + const result = await handler({ action: "status", statusFilter: "paused" }, {} as never); + const text = renderWorkflowToolContent(result, { + action: "status", + statusFilter: "paused", + }); + assert.match(text, /runs: none \(statusFilter: paused\)/); + }); }); diff --git a/test/unit/workflow-tool-admission-barrier.test.ts b/test/unit/workflow-tool-admission-barrier.test.ts index 06810fde0..c591399fe 100644 --- a/test/unit/workflow-tool-admission-barrier.test.ts +++ b/test/unit/workflow-tool-admission-barrier.test.ts @@ -1,370 +1,514 @@ -import { describe, test } from "bun:test"; import assert from "node:assert/strict"; -import { createToolPrimitive } from "../../packages/workflows/src/durable/tool-primitive.js"; +import { describe, test } from "vitest"; import { workflow } from "../../packages/workflows/src/authoring/workflow.js"; import { InMemoryDurableBackend } from "../../packages/workflows/src/durable/backend.js"; +import { createToolPrimitive } from "../../packages/workflows/src/durable/tool-primitive.js"; import { run } from "../../packages/workflows/src/engine/run.js"; import { createAdmittedToolExecutionTracker } from "../../packages/workflows/src/engine/run-tool-execution-tracker.js"; import { createStore } from "../../packages/workflows/src/shared/store.js"; +import { sleep } from "../helpers/runtime.js"; describe("ctx.tool admitted execution barrier", () => { - test("tracking returns the exact execution promise and callback value", async () => { - const backend = new InMemoryDurableBackend(); - let admitted: Promise | undefined; - let trackedBeforeCallback = false; - let boundNodeId: string | undefined; - const exactValue = { names: ["same", "same"], raw: " value " } as const; - const tool = createToolPrimitive({ - workflowId: "promise-identity", - backend, - nextCheckpointId: () => "unused", - throwIfCancelled: () => undefined, - trackExecution(execution: Promise) { - admitted = execution; - trackedBeforeCallback = true; - return { bindNode(nodeId: string): void { boundNodeId = nodeId; } }; - }, - }); - - const returned = tool("identity", {}, async () => { - assert.equal(trackedBeforeCallback, true, "rejection observation is installed before the callback starts"); - assert.match(boundNodeId ?? "", /^tool:/, "node identity is bound before the callback starts"); - return exactValue; - }); - assert.equal(returned, admitted); - assert.equal(await returned, exactValue); - }); - + test("tracking returns the exact execution promise and callback value", async () => { + const backend = new InMemoryDurableBackend(); + let admitted: Promise | undefined; + let trackedBeforeCallback = false; + let boundNodeId: string | undefined; + const exactValue = { names: ["same", "same"], raw: " value " } as const; + const tool = createToolPrimitive({ + workflowId: "promise-identity", + backend, + nextCheckpointId: () => "unused", + throwIfCancelled: () => undefined, + trackExecution(execution: Promise) { + admitted = execution; + trackedBeforeCallback = true; + return { + bindNode(nodeId: string): void { + boundNodeId = nodeId; + }, + }; + }, + }); - test("refused tracking rejects the exact native execution promise before invocation", async () => { - const backend = new InMemoryDurableBackend(); - const refusal = new Error("deterministic refusal"); - let admitted: Promise | undefined; - let callbacks = 0; - const tool = createToolPrimitive({ - workflowId: "refused-promise-identity", - backend, - nextCheckpointId: () => "unused", - throwIfCancelled: () => undefined, - trackExecution(execution: Promise) { - admitted = execution; - return { accepted: false, error: refusal, bindNode(): void {} }; - }, - }); + const returned = tool("identity", {}, async () => { + assert.equal(trackedBeforeCallback, true, "rejection observation is installed before the callback starts"); + assert.match(boundNodeId ?? "", /^tool:/, "node identity is bound before the callback starts"); + return exactValue; + }); + assert.equal(returned, admitted); + assert.equal(await returned, exactValue); + }); - const returned = tool("refused", {}, async () => { callbacks += 1; return "never"; }); - assert.equal(returned, admitted); - assert.equal(returned instanceof Promise, true); - await assert.rejects(returned, (error: unknown) => error === refusal); - assert.equal(callbacks, 0); - assert.deepEqual(backend.listCheckpoints("refused-promise-identity"), []); - }); - test("unawaited admitted success delays root completion until checkpointed", async () => { - const store = createStore(); - const backend = new InMemoryDurableBackend(); - const entered = Promise.withResolvers(); - const release = Promise.withResolvers(); - let rootSettled = false; + test("refused tracking rejects the exact native execution promise before invocation", async () => { + const backend = new InMemoryDurableBackend(); + const refusal = new Error("deterministic refusal"); + let admitted: Promise | undefined; + let callbacks = 0; + const tool = createToolPrimitive({ + workflowId: "refused-promise-identity", + backend, + nextCheckpointId: () => "unused", + throwIfCancelled: () => undefined, + trackExecution(execution: Promise) { + admitted = execution; + return { accepted: false, error: refusal, bindNode(): void {} }; + }, + }); - const pending = run(workflow({ - name: "unawaited-tool-success", - description: "", - inputs: {}, - outputs: {}, - run: async (ctx) => { - void ctx.tool("delayed-write", {}, async () => { - entered.resolve(); - await release.promise; - return "written"; - }); - return {}; - }, - }), {}, { store, durableBackend: backend }); - void pending.then(() => { rootSettled = true; }); + const returned = tool("refused", {}, async () => { + callbacks += 1; + return "never"; + }); + assert.equal(returned, admitted); + assert.equal(returned instanceof Promise, true); + await assert.rejects(returned, (error: unknown) => error === refusal); + assert.equal(callbacks, 0); + assert.deepEqual(backend.listCheckpoints("refused-promise-identity"), []); + }); + test("unawaited admitted success delays root completion until checkpointed", async () => { + const store = createStore(); + const backend = new InMemoryDurableBackend(); + const entered = Promise.withResolvers(); + const release = Promise.withResolvers(); + let rootSettled = false; - await entered.promise; - await Bun.sleep(0); - assert.equal(rootSettled, false, "admitted tools are part of root completion"); - assert.equal(store.runs()[0]?.status, "running"); - assert.equal(store.runs()[0]?.toolNodes?.[0]?.status, "running"); + const pending = run( + workflow({ + name: "unawaited-tool-success", + description: "", + inputs: {}, + outputs: {}, + run: async (ctx) => { + void ctx.tool("delayed-write", {}, async () => { + entered.resolve(); + await release.promise; + return "written"; + }); + return {}; + }, + }), + {}, + { store, durableBackend: backend }, + ); + void pending.then(() => { + rootSettled = true; + }); - release.resolve(); - const result = await pending; - assert.equal(result.status, "completed"); - assert.equal(result.toolNodes?.[0]?.status, "completed"); - assert.equal(backend.listCheckpoints(result.runId).filter((checkpoint) => checkpoint.kind === "tool" && checkpoint.name === "delayed-write").length, 1); - }); + await entered.promise; + await sleep(0); + assert.equal(rootSettled, false, "admitted tools are part of root completion"); + assert.equal(store.runs()[0]?.status, "running"); + assert.equal(store.runs()[0]?.toolNodes?.[0]?.status, "running"); - test("caught tool rejection preserves identity and still fails the root", async () => { - const store = createStore(); - const backend = new InMemoryDurableBackend(); - const original = new Error("caught original tool failure"); - let caught: unknown; + release.resolve(); + const result = await pending; + assert.equal(result.status, "completed"); + assert.equal(result.toolNodes?.[0]?.status, "completed"); + assert.equal( + backend + .listCheckpoints(result.runId) + .filter((checkpoint) => checkpoint.kind === "tool" && checkpoint.name === "delayed-write").length, + 1, + ); + }); - const result = await run(workflow({ - name: "caught-tool-failure", - description: "", - inputs: {}, - outputs: {}, - run: async (ctx) => { - try { - await ctx.tool("caught-failure", {}, async () => { throw original; }); - } catch (error) { - caught = error; - } - return {}; - }, - }), {}, { store, durableBackend: backend }); + test("caught tool rejection preserves identity and still fails the root", async () => { + const store = createStore(); + const backend = new InMemoryDurableBackend(); + const original = new Error("caught original tool failure"); + let caught: unknown; - assert.equal(caught, original, "workflow code receives the original rejection object"); - assert.equal(result.status, "failed"); - assert.match(result.error ?? "", /caught original tool failure/); - assert.equal(result.toolNodes?.[0]?.status, "failed"); - assert.equal(store.runs()[0]?.failedStageId, undefined); - assert.equal(backend.listCheckpoints(result.runId).some((checkpoint) => checkpoint.kind === "tool" && checkpoint.name === "caught-failure" && checkpoint.throwingFailureError === "caught original tool failure"), true); - assert.equal(backend.getToolCheckpoint(result.runId, result.toolNodes![0]!.argsHash), undefined); - }); + const result = await run( + workflow({ + name: "caught-tool-failure", + description: "", + inputs: {}, + outputs: {}, + run: async (ctx) => { + try { + await ctx.tool("caught-failure", {}, async () => { + throw original; + }); + } catch (error) { + caught = error; + } + return {}; + }, + }), + {}, + { store, durableBackend: backend }, + ); - test("unawaited rejection is observed and fails root without an unhandled rejection", async () => { - const store = createStore(); - const backend = new InMemoryDurableBackend(); - const entered = Promise.withResolvers(); - const release = Promise.withResolvers(); - const unhandled: unknown[] = []; - const onUnhandled = (error: unknown): void => { unhandled.push(error); }; - process.on("unhandledRejection", onUnhandled); - try { - const pending = run(workflow({ - name: "unawaited-tool-failure", description: "", inputs: {}, outputs: {}, - run: async (ctx) => { - void ctx.tool("unawaited-failure", {}, async () => { - entered.resolve(); - await release.promise; - throw new Error("unawaited original failure"); - }); - return {}; - }, - }), {}, { store, durableBackend: backend }); + assert.equal(caught, original, "workflow code receives the original rejection object"); + assert.equal(result.status, "failed"); + assert.match(result.error ?? "", /caught original tool failure/); + assert.equal(result.toolNodes?.[0]?.status, "failed"); + assert.equal(store.runs()[0]?.failedStageId, undefined); + assert.equal( + backend + .listCheckpoints(result.runId) + .some( + (checkpoint) => + checkpoint.kind === "tool" && + checkpoint.name === "caught-failure" && + checkpoint.throwingFailureError === "caught original tool failure", + ), + true, + ); + assert.equal(backend.getToolCheckpoint(result.runId, result.toolNodes![0]!.argsHash), undefined); + }); - await entered.promise; - release.resolve(); - const result = await pending; - await Bun.sleep(0); - assert.equal(result.status, "failed"); - assert.match(result.error ?? "", /unawaited original failure/); - assert.equal(result.toolNodes?.[0]?.status, "failed"); - assert.deepEqual(unhandled, []); - assert.equal(backend.listCheckpoints(result.runId).some((checkpoint) => checkpoint.kind === "tool" && checkpoint.name === "unawaited-failure" && checkpoint.throwingFailureError === "unawaited original failure"), true); - assert.equal(backend.getToolCheckpoint(result.runId, result.toolNodes![0]!.argsHash), undefined); - } finally { - process.off("unhandledRejection", onUnhandled); - } - }); + test("unawaited rejection is observed and fails root without an unhandled rejection", async () => { + const store = createStore(); + const backend = new InMemoryDurableBackend(); + const entered = Promise.withResolvers(); + const release = Promise.withResolvers(); + const unhandled: unknown[] = []; + const onUnhandled = (error: unknown): void => { + unhandled.push(error); + }; + process.on("unhandledRejection", onUnhandled); + try { + const pending = run( + workflow({ + name: "unawaited-tool-failure", + description: "", + inputs: {}, + outputs: {}, + run: async (ctx) => { + void ctx.tool("unawaited-failure", {}, async () => { + entered.resolve(); + await release.promise; + throw new Error("unawaited original failure"); + }); + return {}; + }, + }), + {}, + { store, durableBackend: backend }, + ); - test("drains tools admitted by an earlier tool settlement continuation", async () => { - const store = createStore(); - const firstRelease = Promise.withResolvers(); - const secondEntered = Promise.withResolvers(); - const secondRelease = Promise.withResolvers(); - let rootSettled = false; - const pending = run(workflow({ - name: "fixed-point-tools", description: "", inputs: {}, outputs: {}, - run: async (ctx) => { - const first = ctx.tool("first", {}, async () => { await firstRelease.promise; return "first"; }); - void first.then(() => ctx.tool("second", {}, async () => { - secondEntered.resolve(); - await secondRelease.promise; - return "second"; - })); - return {}; - }, - }), {}, { store }); - void pending.then(() => { rootSettled = true; }); + await entered.promise; + release.resolve(); + const result = await pending; + await sleep(0); + assert.equal(result.status, "failed"); + assert.match(result.error ?? "", /unawaited original failure/); + assert.equal(result.toolNodes?.[0]?.status, "failed"); + assert.deepEqual(unhandled, []); + assert.equal( + backend + .listCheckpoints(result.runId) + .some( + (checkpoint) => + checkpoint.kind === "tool" && + checkpoint.name === "unawaited-failure" && + checkpoint.throwingFailureError === "unawaited original failure", + ), + true, + ); + assert.equal(backend.getToolCheckpoint(result.runId, result.toolNodes![0]!.argsHash), undefined); + } finally { + process.off("unhandledRejection", onUnhandled); + } + }); - firstRelease.resolve(); - await secondEntered.promise; - await Bun.sleep(0); - assert.equal(rootSettled, false); - assert.deepEqual(store.runs()[0]?.toolNodes?.map((node) => [node.name, node.status]), [ - ["first", "completed"], - ["second", "running"], - ]); - secondRelease.resolve(); - const result = await pending; - assert.equal(result.status, "completed"); - assert.deepEqual(result.toolNodes?.map((node) => [node.name, node.status, node.executionOrder]), [ - ["first", "completed", 1], - ["second", "completed", 2], - ]); - assert.deepEqual(result.toolNodes?.[1]?.parentIds, [result.toolNodes?.[0]?.id]); - }); + test("drains tools admitted by an earlier tool settlement continuation", async () => { + const store = createStore(); + const firstRelease = Promise.withResolvers(); + const secondEntered = Promise.withResolvers(); + const secondRelease = Promise.withResolvers(); + let rootSettled = false; + const pending = run( + workflow({ + name: "fixed-point-tools", + description: "", + inputs: {}, + outputs: {}, + run: async (ctx) => { + const first = ctx.tool("first", {}, async () => { + await firstRelease.promise; + return "first"; + }); + void first.then(() => + ctx.tool("second", {}, async () => { + secondEntered.resolve(); + await secondRelease.promise; + return "second"; + }), + ); + return {}; + }, + }), + {}, + { store }, + ); + void pending.then(() => { + rootSettled = true; + }); - test("uncaught workflow error wins after admitted tools become terminal", async () => { - const store = createStore(); - const entered = Promise.withResolvers(); - const release = Promise.withResolvers(); - const pending = run(workflow({ - name: "outer-error-precedence", description: "", inputs: {}, outputs: {}, - run: async (ctx) => { - void ctx.tool("also-fails", {}, async () => { - entered.resolve(); - await release.promise; - throw new Error("tool failure loses precedence"); - }); - throw new Error("outer domain failure"); - }, - }), {}, { store }); + firstRelease.resolve(); + await secondEntered.promise; + await sleep(0); + assert.equal(rootSettled, false); + assert.deepEqual( + store.runs()[0]?.toolNodes?.map((node) => [node.name, node.status]), + [ + ["first", "completed"], + ["second", "running"], + ], + ); + secondRelease.resolve(); + const result = await pending; + assert.equal(result.status, "completed"); + assert.deepEqual( + result.toolNodes?.map((node) => [node.name, node.status, node.executionOrder]), + [ + ["first", "completed", 1], + ["second", "completed", 2], + ], + ); + assert.deepEqual(result.toolNodes?.[1]?.parentIds, [result.toolNodes?.[0]?.id]); + }); - await entered.promise; - release.resolve(); - const result = await pending; - assert.equal(result.status, "failed"); - assert.match(result.error ?? "", /outer domain failure/); - assert.equal(result.toolNodes?.[0]?.status, "failed"); - }); + test("uncaught workflow error wins after admitted tools become terminal", async () => { + const store = createStore(); + const entered = Promise.withResolvers(); + const release = Promise.withResolvers(); + const pending = run( + workflow({ + name: "outer-error-precedence", + description: "", + inputs: {}, + outputs: {}, + run: async (ctx) => { + void ctx.tool("also-fails", {}, async () => { + entered.resolve(); + await release.promise; + throw new Error("tool failure loses precedence"); + }); + throw new Error("outer domain failure"); + }, + }), + {}, + { store }, + ); - test("selected ctx.exit remains authoritative after admitted tool cancellation", async () => { - const store = createStore(); - const backend = new InMemoryDurableBackend(); - const entered = Promise.withResolvers(); - const release = Promise.withResolvers(); - const pending = run(workflow({ - name: "exit-precedence", description: "", inputs: {}, outputs: {}, - run: async (ctx) => { - void ctx.tool("cancelled-by-exit", {}, async () => { - entered.resolve(); - await release.promise; - return "too-late"; - }); - ctx.exit({ status: "completed" }); - }, - }), {}, { store, durableBackend: backend }); + await entered.promise; + release.resolve(); + const result = await pending; + assert.equal(result.status, "failed"); + assert.match(result.error ?? "", /outer domain failure/); + assert.equal(result.toolNodes?.[0]?.status, "failed"); + }); - await entered.promise; - release.resolve(); - const result = await pending; - assert.equal(result.status, "completed"); - assert.equal(result.exited, true); - assert.equal(result.toolNodes?.[0]?.status, "cancelled"); - assert.equal(backend.listCheckpoints(result.runId).some((checkpoint) => checkpoint.kind === "tool" && checkpoint.name === "cancelled-by-exit"), false); - }); + test("selected ctx.exit remains authoritative after admitted tool cancellation", async () => { + const store = createStore(); + const backend = new InMemoryDurableBackend(); + const entered = Promise.withResolvers(); + const release = Promise.withResolvers(); + const pending = run( + workflow({ + name: "exit-precedence", + description: "", + inputs: {}, + outputs: {}, + run: async (ctx) => { + void ctx.tool("cancelled-by-exit", {}, async () => { + entered.resolve(); + await release.promise; + return "too-late"; + }); + ctx.exit({ status: "completed" }); + }, + }), + {}, + { store, durableBackend: backend }, + ); - test("first observed failure cancels remaining admitted work", async () => { - const firstRelease = Promise.withResolvers(); - const secondRelease = Promise.withResolvers(); - const firstError = new Error("first admitted failure"); - const secondError = new Error("second admitted failure"); - const pending = run(workflow({ - name: "multiple-tool-failures", description: "", inputs: {}, outputs: {}, - run: async (ctx) => { - void ctx.tool("first-failure", {}, async () => { await firstRelease.promise; throw firstError; }); - void ctx.tool("second-failure", {}, async () => { await secondRelease.promise; throw secondError; }); - return {}; - }, - }), {}); + await entered.promise; + release.resolve(); + const result = await pending; + assert.equal(result.status, "completed"); + assert.equal(result.exited, true); + assert.equal(result.toolNodes?.[0]?.status, "cancelled"); + assert.equal( + backend + .listCheckpoints(result.runId) + .some((checkpoint) => checkpoint.kind === "tool" && checkpoint.name === "cancelled-by-exit"), + false, + ); + }); - secondRelease.resolve(); - await Bun.sleep(0); - firstRelease.resolve(); - const result = await pending; - assert.equal(result.status, "failed"); - assert.match(result.error ?? "", /second admitted failure/); - assert.deepEqual(result.toolNodes?.map((node) => [node.name, node.status]), [ - ["first-failure", "cancelled"], - ["second-failure", "failed"], - ]); - }); + test("first observed failure cancels remaining admitted work", async () => { + const firstRelease = Promise.withResolvers(); + const secondRelease = Promise.withResolvers(); + const firstError = new Error("first admitted failure"); + const secondError = new Error("second admitted failure"); + const pending = run( + workflow({ + name: "multiple-tool-failures", + description: "", + inputs: {}, + outputs: {}, + run: async (ctx) => { + void ctx.tool("first-failure", {}, async () => { + await firstRelease.promise; + throw firstError; + }); + void ctx.tool("second-failure", {}, async () => { + await secondRelease.promise; + throw secondError; + }); + return {}; + }, + }), + {}, + ); + secondRelease.resolve(); + await sleep(0); + firstRelease.resolve(); + const result = await pending; + assert.equal(result.status, "failed"); + assert.match(result.error ?? "", /second admitted failure/); + assert.deepEqual( + result.toolNodes?.map((node) => [node.name, node.status]), + [ + ["first-failure", "cancelled"], + ["second-failure", "failed"], + ], + ); + }); - test("unawaited pre-node validation rejection fails the root", async () => { - let callbackCalls = 0; - const result = await run(workflow({ - name: "unawaited-pre-node-validation", description: "", inputs: {}, outputs: {}, - run: async (ctx) => { - void ctx.tool("invalid-attempts", {}, async () => { - callbackCalls += 1; - return "must not run"; - }, { retriesAllowed: true, maxAttempts: 0 }); - return {}; - }, - }), {}); + test("unawaited pre-node validation rejection fails the root", async () => { + let callbackCalls = 0; + const result = await run( + workflow({ + name: "unawaited-pre-node-validation", + description: "", + inputs: {}, + outputs: {}, + run: async (ctx) => { + void ctx.tool( + "invalid-attempts", + {}, + async () => { + callbackCalls += 1; + return "must not run"; + }, + { retriesAllowed: true, maxAttempts: 0 }, + ); + return {}; + }, + }), + {}, + ); - assert.equal(result.status, "failed"); - assert.match(result.error ?? "", /maxAttempts.*positive integer/); - assert.equal(callbackCalls, 0); - assert.equal(result.toolNodes?.length ?? 0, 0); - }); - test("unawaited retry exhaustion fails with the final original retry error", async () => { - const errors = [new Error("retry one"), new Error("retry final")]; - let attempts = 0; - const result = await run(workflow({ - name: "unawaited-retry-exhaustion", description: "", inputs: {}, outputs: {}, - run: async (ctx) => { - void ctx.tool("retrying", {}, async () => { throw errors[attempts++]!; }, { - retriesAllowed: true, - maxAttempts: 2, - intervalMs: 0, - }); - return {}; - }, - }), {}); + assert.equal(result.status, "failed"); + assert.match(result.error ?? "", /maxAttempts.*positive integer/); + assert.equal(callbackCalls, 0); + assert.equal(result.toolNodes?.length ?? 0, 0); + }); + test("unawaited retry exhaustion fails with the final original retry error", async () => { + const errors = [new Error("retry one"), new Error("retry final")]; + let attempts = 0; + const result = await run( + workflow({ + name: "unawaited-retry-exhaustion", + description: "", + inputs: {}, + outputs: {}, + run: async (ctx) => { + void ctx.tool( + "retrying", + {}, + async () => { + throw errors[attempts++]!; + }, + { + retriesAllowed: true, + maxAttempts: 2, + intervalMs: 0, + }, + ); + return {}; + }, + }), + {}, + ); - assert.equal(attempts, 2); - assert.equal(result.status, "failed"); - assert.match(result.error ?? "", /retry final/); - assert.equal(result.toolNodes?.[0]?.status, "failed"); - }); + assert.equal(attempts, 2); + assert.equal(result.status, "failed"); + assert.match(result.error ?? "", /retry final/); + assert.equal(result.toolNodes?.[0]?.status, "failed"); + }); - test("external cancellation wins after an unawaited admitted tool settles", async () => { - const store = createStore(); - const backend = new InMemoryDurableBackend(); - const controller = new AbortController(); - const entered = Promise.withResolvers(); - const release = Promise.withResolvers(); - const pending = run(workflow({ - name: "unawaited-cancel", description: "", inputs: {}, outputs: {}, - run: async (ctx) => { - void ctx.tool("cancelled-write", {}, async () => { - entered.resolve(); - await release.promise; - return "late"; - }); - return {}; - }, - }), {}, { store, durableBackend: backend, signal: controller.signal }); + test("external cancellation wins after an unawaited admitted tool settles", async () => { + const store = createStore(); + const backend = new InMemoryDurableBackend(); + const controller = new AbortController(); + const entered = Promise.withResolvers(); + const release = Promise.withResolvers(); + const pending = run( + workflow({ + name: "unawaited-cancel", + description: "", + inputs: {}, + outputs: {}, + run: async (ctx) => { + void ctx.tool("cancelled-write", {}, async () => { + entered.resolve(); + await release.promise; + return "late"; + }); + return {}; + }, + }), + {}, + { store, durableBackend: backend, signal: controller.signal }, + ); - await entered.promise; - controller.abort(new Error("operator cancelled")); - release.resolve(); - const result = await pending; - assert.equal(result.status, "killed"); - assert.equal(result.toolNodes?.[0]?.status, "cancelled"); - assert.equal(result.failedToolNodeId, undefined); - assert.equal(store.runs()[0]?.failedToolNodeId, undefined); - assert.equal(backend.listCheckpoints(result.runId).some((checkpoint) => checkpoint.kind === "tool" && checkpoint.name === "cancelled-write"), false); - }); + await entered.promise; + controller.abort(new Error("operator cancelled")); + release.resolve(); + const result = await pending; + assert.equal(result.status, "killed"); + assert.equal(result.toolNodes?.[0]?.status, "cancelled"); + assert.equal(result.failedToolNodeId, undefined); + assert.equal(store.runs()[0]?.failedToolNodeId, undefined); + assert.equal( + backend + .listCheckpoints(result.runId) + .some((checkpoint) => checkpoint.kind === "tool" && checkpoint.name === "cancelled-write"), + false, + ); + }); - test("closeAndDrain shares one close, admits while draining, then refuses atomically", async () => { - const tracker = createAdmittedToolExecutionTracker(); - const first = Promise.withResolvers(); - const second = Promise.withResolvers(); - assert.equal(tracker.track(first.promise).accepted, true); - const close = tracker.closeAndDrain(); - assert.equal(tracker.closeAndDrain(), close, "concurrent close calls share the close promise"); - assert.equal(tracker.track(second.promise).accepted, true, "DRAINING remains open to settlement continuations"); + test("closeAndDrain shares one close, admits while draining, then refuses atomically", async () => { + const tracker = createAdmittedToolExecutionTracker(); + const first = Promise.withResolvers(); + const second = Promise.withResolvers(); + assert.equal(tracker.track(first.promise).accepted, true); + const close = tracker.closeAndDrain(); + assert.equal(tracker.closeAndDrain(), close, "concurrent close calls share the close promise"); + assert.equal(tracker.track(second.promise).accepted, true, "DRAINING remains open to settlement continuations"); - let closed = false; - void close.then(() => { closed = true; }); - first.resolve(); - await Bun.sleep(0); - assert.equal(closed, false); - second.resolve(); - await close; - assert.equal(tracker.closeAndDrain(), close, "completed close remains idempotent"); + let closed = false; + void close.then(() => { + closed = true; + }); + first.resolve(); + await sleep(0); + assert.equal(closed, false); + second.resolve(); + await close; + assert.equal(tracker.closeAndDrain(), close, "completed close remains idempotent"); - const refusedExecution = Promise.resolve(); - const refused = tracker.track(refusedExecution); - assert.equal(refused.accepted, false); - if (!refused.accepted) assert.match(refused.error.message, /ctx\.tool admission is closed/); - }); + const refusedExecution = Promise.resolve(); + const refused = tracker.track(refusedExecution); + assert.equal(refused.accepted, false); + if (!refused.accepted) assert.match(refused.error.message, /ctx\.tool admission is closed/); + }); }); diff --git a/test/unit/workflow-tool-chat-hints.test.ts b/test/unit/workflow-tool-chat-hints.test.ts index cce6d7606..28c80f55c 100644 --- a/test/unit/workflow-tool-chat-hints.test.ts +++ b/test/unit/workflow-tool-chat-hints.test.ts @@ -1,6 +1,7 @@ // @ts-nocheck -- focused GraphView rendering/input contract coverage -import { describe, test } from "bun:test"; + import assert from "node:assert/strict"; +import { describe, test } from "vitest"; import { createStore } from "../../packages/workflows/src/shared/store.js"; import type { StageSnapshot, ToolNodeSnapshot } from "../../packages/workflows/src/shared/store-types.js"; import { GraphView } from "../../packages/workflows/src/tui/graph-view.js"; @@ -11,130 +12,128 @@ const TOOL_FOOTER = "ctrl+x return to main chat · ↑↓←→ navigate · const STAGE_SWITCHER = "↑↓ select · ↵ open stage chat · esc close"; const TOOL_SWITCHER = "↑↓ select · esc close"; -function stage( - id: string, - parentIds: readonly string[] = [], - overrides: Partial = {}, -): StageSnapshot { - return { - id, - name: id, - status: "running", - parentIds, - toolEvents: [], - attachable: true, - ...overrides, - }; +function stage(id: string, parentIds: readonly string[] = [], overrides: Partial = {}): StageSnapshot { + return { + id, + name: id, + status: "running", + parentIds, + toolEvents: [], + attachable: true, + ...overrides, + }; } function tool(id = "tool:publish", name = "publish-api"): ToolNodeSnapshot { - return { - kind: "tool", - id, - name, - argsHash: "hash", - ordinal: 1, - parentIds: [], - status: "running", - executionOrder: 1, - attachable: false, - }; + return { + kind: "tool", + id, + name, + argsHash: "hash", + ordinal: 1, + parentIds: [], + status: "running", + executionOrder: 1, + attachable: false, + }; } function viewFor(stages: StageSnapshot[], tools: ToolNodeSnapshot[] = []): GraphView { - const localStore = createStore(); - localStore.recordRunStart({ - id: "hint-run", - name: "hint run", - inputs: {}, - status: "running", - stages, - toolNodes: tools, - startedAt: 1, - }); - return new GraphView({ - mode: "overlay", - runId: "hint-run", - store: localStore, - graphTheme: defaultTheme, - getViewportRows: () => 32, - onStageAttach() {}, - }); + const localStore = createStore(); + localStore.recordRunStart({ + id: "hint-run", + name: "hint run", + inputs: {}, + status: "running", + stages, + toolNodes: tools, + startedAt: 1, + }); + return new GraphView({ + mode: "overlay", + runId: "hint-run", + store: localStore, + graphTheme: defaultTheme, + getViewportRows: () => 32, + onStageAttach() {}, + }); } describe("tool graph chat hints", () => { - test("wide footer advertises chat for stage targets, including non-live completed stages", () => { - const stageView = viewFor([stage("stage")]); - const stageText = visibleText(stageView.render(120)); - assert.ok(stageText.includes(STAGE_FOOTER)); - - stageView.expandedGraph.targets.clear(); - const missingTargetText = visibleText(stageView.render(120)); - assert.ok(missingTargetText.includes(TOOL_FOOTER)); - assert.doesNotMatch(missingTargetText, /↵ (?:open )?stage chat/); - - const toolView = viewFor([], [tool()]); - const toolText = visibleText(toolView.render(120)); - assert.ok(toolText.includes(TOOL_FOOTER)); - assert.doesNotMatch(toolText, /↵ (?:open )?stage chat/); - - const nonAttachableView = viewFor([stage("summary", [], { status: "completed", attachable: false })]); - const nonAttachableText = visibleText(nonAttachableView.render(120)); - assert.ok(nonAttachableText.includes(STAGE_FOOTER)); - - const compactToolText = visibleText(toolView.render(40)); - assert.match(compactToolText, /ctrl\+x\s+return to main chat/i); - assert.doesNotMatch(compactToolText, /stage chat/); - stageView.dispose(); - toolView.dispose(); - nonAttachableView.dispose(); - }); - - test("mixed graph footer updates when focus moves between a tool and stage", () => { - const toolNode = tool(); - const stageNode = stage("review", [toolNode.id], { executionOrder: 2 }); - const view = viewFor([stageNode], [toolNode]); - - const toolFocused = visibleText(view.render(120)); - assert.ok(toolFocused.includes(TOOL_FOOTER)); - assert.doesNotMatch(toolFocused, /↵ open stage chat/); - - view.handleInput("\x1b[B"); - const stageFocused = visibleText(view.render(120)); - assert.ok(stageFocused.includes(STAGE_FOOTER)); - view.dispose(); - }); - - test("switcher hints update for the selected tool or stage at wide and compact widths", () => { - const toolNode = tool(); - const stageNode = stage("review", [toolNode.id], { executionOrder: 2 }); - const view = viewFor([stageNode], [toolNode]); - view.handleInput("/"); - - const wideTool = visibleText(view.render(120)); - assert.ok(wideTool.includes(TOOL_SWITCHER)); - assert.doesNotMatch(wideTool, /↵ open stage chat/); - const compactTool = visibleText(view.render(40)); - assert.match(compactTool, /esc close/); - assert.doesNotMatch(compactTool, /↵ stage chat/); - - view.handleInput("\x1b[B"); - const wideStage = visibleText(view.render(120)); - assert.ok(wideStage.includes(STAGE_SWITCHER)); - const compactStage = visibleText(view.render(40)); - assert.match(compactStage, /↵ stage chat · esc close/); - - view.handleInput("\x1b[A"); - assert.ok(visibleText(view.render(120)).includes(TOOL_SWITCHER)); - view.dispose(); - }); - - test("awaiting-stage card keeps its own response hint", () => { - const view = viewFor([stage("answer", [], { - status: "awaiting_input", - awaitingInputSince: 2, - })]); - assert.match(visibleText(view.render(120)), /↵ enter to respond/); - view.dispose(); - }); + test("wide footer advertises chat for stage targets, including non-live completed stages", () => { + const stageView = viewFor([stage("stage")]); + const stageText = visibleText(stageView.render(120)); + assert.ok(stageText.includes(STAGE_FOOTER)); + + stageView.expandedGraph.targets.clear(); + const missingTargetText = visibleText(stageView.render(120)); + assert.ok(missingTargetText.includes(TOOL_FOOTER)); + assert.doesNotMatch(missingTargetText, /↵ (?:open )?stage chat/); + + const toolView = viewFor([], [tool()]); + const toolText = visibleText(toolView.render(120)); + assert.ok(toolText.includes(TOOL_FOOTER)); + assert.doesNotMatch(toolText, /↵ (?:open )?stage chat/); + + const nonAttachableView = viewFor([stage("summary", [], { status: "completed", attachable: false })]); + const nonAttachableText = visibleText(nonAttachableView.render(120)); + assert.ok(nonAttachableText.includes(STAGE_FOOTER)); + + const compactToolText = visibleText(toolView.render(40)); + assert.match(compactToolText, /ctrl\+x\s+return to main chat/i); + assert.doesNotMatch(compactToolText, /stage chat/); + stageView.dispose(); + toolView.dispose(); + nonAttachableView.dispose(); + }); + + test("mixed graph footer updates when focus moves between a tool and stage", () => { + const toolNode = tool(); + const stageNode = stage("review", [toolNode.id], { executionOrder: 2 }); + const view = viewFor([stageNode], [toolNode]); + + const toolFocused = visibleText(view.render(120)); + assert.ok(toolFocused.includes(TOOL_FOOTER)); + assert.doesNotMatch(toolFocused, /↵ open stage chat/); + + view.handleInput("\x1b[B"); + const stageFocused = visibleText(view.render(120)); + assert.ok(stageFocused.includes(STAGE_FOOTER)); + view.dispose(); + }); + + test("switcher hints update for the selected tool or stage at wide and compact widths", () => { + const toolNode = tool(); + const stageNode = stage("review", [toolNode.id], { executionOrder: 2 }); + const view = viewFor([stageNode], [toolNode]); + view.handleInput("/"); + + const wideTool = visibleText(view.render(120)); + assert.ok(wideTool.includes(TOOL_SWITCHER)); + assert.doesNotMatch(wideTool, /↵ open stage chat/); + const compactTool = visibleText(view.render(40)); + assert.match(compactTool, /esc close/); + assert.doesNotMatch(compactTool, /↵ stage chat/); + + view.handleInput("\x1b[B"); + const wideStage = visibleText(view.render(120)); + assert.ok(wideStage.includes(STAGE_SWITCHER)); + const compactStage = visibleText(view.render(40)); + assert.match(compactStage, /↵ stage chat · esc close/); + + view.handleInput("\x1b[A"); + assert.ok(visibleText(view.render(120)).includes(TOOL_SWITCHER)); + view.dispose(); + }); + + test("awaiting-stage card keeps its own response hint", () => { + const view = viewFor([ + stage("answer", [], { + status: "awaiting_input", + awaitingInputSince: 2, + }), + ]); + assert.match(visibleText(view.render(120)), /↵ enter to respond/); + view.dispose(); + }); }); diff --git a/test/unit/workflow-tool-durable-replay.test.ts b/test/unit/workflow-tool-durable-replay.test.ts index 6dc083453..0591a95e3 100644 --- a/test/unit/workflow-tool-durable-replay.test.ts +++ b/test/unit/workflow-tool-durable-replay.test.ts @@ -1,6 +1,6 @@ -import { afterEach, beforeEach, describe, test } from "bun:test"; import assert from "node:assert/strict"; import { Type } from "typebox"; +import { afterEach, beforeEach, describe, test } from "vitest"; import { workflow } from "../../packages/workflows/src/authoring/workflow.js"; import { durableHash, InMemoryDurableBackend } from "../../packages/workflows/src/durable/backend.js"; import { setDurableBackend } from "../../packages/workflows/src/durable/factory.js"; @@ -11,161 +11,165 @@ import { stageControlRegistry } from "../../packages/workflows/src/runs/foregrou import { store } from "../../packages/workflows/src/shared/store.js"; class FailingHydrationBackend extends InMemoryDurableBackend { - override async hydrateResumableWorkflows(): Promise { - throw new Error("durable hydration exploded"); - } + override async hydrateResumableWorkflows(): Promise { + throw new Error("durable hydration exploded"); + } } class TrackingHydrationBackend extends InMemoryDurableBackend { - hydrationCalls = 0; + hydrationCalls = 0; - override async hydrateResumableWorkflows(): Promise { - this.hydrationCalls += 1; - } + override async hydrateResumableWorkflows(): Promise { + this.hydrationCalls += 1; + } } beforeEach(() => { - // Other test files may run workflows against the shared singleton store; - // clear it so the durable-only replay assertions below start from a clean - // slate regardless of cross-file execution order. - store.clear(); + // Other test files may run workflows against the shared singleton store; + // clear it so the durable-only replay assertions below start from a clean + // slate regardless of cross-file execution order. + store.clear(); }); afterEach(async () => { - stageControlRegistry.clear(); - for (const runId of jobTracker.runIds()) { - const entry = jobTracker.get(runId); - entry?.controller.abort(); - await entry?.promise; - jobTracker.unregister(runId); - } - store.clear(); - setDurableBackend(undefined); + stageControlRegistry.clear(); + for (const runId of jobTracker.runIds()) { + const entry = jobTracker.get(runId); + entry?.controller.abort(); + await entry?.promise; + jobTracker.unregister(runId); + } + store.clear(); + setDurableBackend(undefined); }); describe("workflow tool durable-only checkpoint replay", () => { - test.serial("resume keeps the original id and does not repeat a completed ctx.tool side effect", async () => { - const workflowId = "tool-durable-replay-original-id"; - const backend = new InMemoryDurableBackend(); - setDurableBackend(backend); - backend.registerWorkflow({ - workflowId, - name: "tool-durable-replay", - inputs: {}, - createdAt: 1, - status: "paused", - resumable: true, - }); - const args = { path: "artifact.txt" }; - const argsHash = durableHash({ name: "write-once", args, ordinal: 1 }); - backend.recordCheckpoint({ - kind: "tool", - workflowId, - checkpointId: `tool:${argsHash}`, - name: "write-once", - argsHash, - output: "cached-output", - completedAt: 2, - }); - assert.deepEqual(store.runs(), []); + test.sequential("resume keeps the original id and does not repeat a completed ctx.tool side effect", async () => { + const workflowId = "tool-durable-replay-original-id"; + const backend = new InMemoryDurableBackend(); + setDurableBackend(backend); + backend.registerWorkflow({ + workflowId, + name: "tool-durable-replay", + inputs: {}, + createdAt: 1, + status: "paused", + resumable: true, + }); + const args = { path: "artifact.txt" }; + const argsHash = durableHash({ name: "write-once", args, ordinal: 1 }); + backend.recordCheckpoint({ + kind: "tool", + workflowId, + checkpointId: `tool:${argsHash}`, + name: "write-once", + argsHash, + output: "cached-output", + completedAt: 2, + }); + assert.deepEqual(store.runs(), []); - let sideEffectCalls = 0; - const promptStarted = Promise.withResolvers(); - const releasePrompt = Promise.withResolvers(); - const definition = workflow({ - name: "tool-durable-replay", - description: "", - inputs: {}, - outputs: { done: Type.Boolean() }, - run: async (ctx) => { - const output = await ctx.tool("write-once", args, async () => { - sideEffectCalls += 1; - return "new-output"; - }); - assert.equal(output, "cached-output"); - await ctx.stage("continue").prompt("continue from checkpoint"); - return { done: true }; - }, - }); - const runtime = createExtensionRuntime({ - definitions: [definition], - store, - adapters: { - prompt: { - prompt: async () => { - promptStarted.resolve(); - await releasePrompt.promise; - return "continued"; - }, - }, - }, - }); - const execute = makeExecuteWorkflowTool(runtime, () => undefined, () => undefined); + let sideEffectCalls = 0; + const promptStarted = Promise.withResolvers(); + const releasePrompt = Promise.withResolvers(); + const definition = workflow({ + name: "tool-durable-replay", + description: "", + inputs: {}, + outputs: { done: Type.Boolean() }, + run: async (ctx) => { + const output = await ctx.tool("write-once", args, async () => { + sideEffectCalls += 1; + return "new-output"; + }); + assert.equal(output, "cached-output"); + await ctx.stage("continue").prompt("continue from checkpoint"); + return { done: true }; + }, + }); + const runtime = createExtensionRuntime({ + definitions: [definition], + store, + adapters: { + prompt: { + prompt: async () => { + promptStarted.resolve(); + await releasePrompt.promise; + return "continued"; + }, + }, + }, + }); + const execute = makeExecuteWorkflowTool( + runtime, + () => undefined, + () => undefined, + ); - const result = await execute({ action: "resume", runId: workflowId }, {} as never); - await promptStarted.promise; + const result = await execute({ action: "resume", runId: workflowId }, {} as never); + await promptStarted.promise; - assert.equal(result.action, "resume"); - assert.equal(result.status, "running"); - assert.equal(result.runId, workflowId); - assert.equal(sideEffectCalls, 0, "replayed ctx.tool must not execute its side-effect callback"); - assert.equal(backend.listCheckpoints(workflowId).length, 1, "replay must not duplicate the checkpoint"); - const resumedJob = jobTracker.get(workflowId); - assert.ok(resumedJob); - releasePrompt.resolve(); - await resumedJob.promise; - assert.equal(store.runs().find((run) => run.id === workflowId)?.status, "completed"); - }); - test.serial("unknown resume hydrates durability while ordinary status remains local", async () => { - const backend = new TrackingHydrationBackend(); - setDurableBackend(backend); - const definition = workflow({ name: "lookup", description: "", inputs: {}, outputs: {}, run: () => ({}) }); - const execute = makeExecuteWorkflowTool( - createExtensionRuntime({ definitions: [definition], store }), - () => undefined, - () => undefined, - ); - const target = "durable-only-unknown-id"; + assert.equal(result.action, "resume"); + assert.equal(result.status, "running"); + assert.equal(result.runId, workflowId); + assert.equal(sideEffectCalls, 0, "replayed ctx.tool must not execute its side-effect callback"); + assert.equal(backend.listCheckpoints(workflowId).length, 1, "replay must not duplicate the checkpoint"); + const resumedJob = jobTracker.get(workflowId); + assert.ok(resumedJob); + releasePrompt.resolve(); + await resumedJob.promise; + assert.equal(store.runs().find((run) => run.id === workflowId)?.status, "completed"); + }); + test.sequential("unknown resume hydrates durability while ordinary status remains local", async () => { + const backend = new TrackingHydrationBackend(); + setDurableBackend(backend); + const definition = workflow({ name: "lookup", description: "", inputs: {}, outputs: {}, run: () => ({}) }); + const execute = makeExecuteWorkflowTool( + createExtensionRuntime({ definitions: [definition], store }), + () => undefined, + () => undefined, + ); + const target = "durable-only-unknown-id"; - const status = await execute({ action: "status" }, {} as never); - assert.equal(status.action, "status"); - assert.equal(backend.hydrationCalls, 0, "status must not eagerly hydrate durable history"); + const status = await execute({ action: "status" }, {} as never); + assert.equal(status.action, "status"); + assert.equal(backend.hydrationCalls, 0, "status must not eagerly hydrate durable history"); - const result = await execute({ action: "resume", runId: target }, {} as never); - assert.equal(result.action, "resume"); - assert.equal(result.status, "noop"); - assert.equal(result.message, `Run not found: ${target}`); - assert.ok(backend.hydrationCalls > 0, "not-found must follow authoritative hydration"); - }); + const result = await execute({ action: "resume", runId: target }, {} as never); + assert.equal(result.action, "resume"); + assert.equal(result.status, "noop"); + assert.equal(result.message, `Run not found: ${target}`); + assert.ok(backend.hydrationCalls > 0, "not-found must follow authoritative hydration"); + }); - test.serial("resume surfaces durable hydration failures and keeps --all unsupported", async () => { - const backend = new FailingHydrationBackend(); - setDurableBackend(backend); - const definition = workflow({ - name: "tool-durable-failure", - description: "", - inputs: {}, - outputs: {}, - run: () => ({}), - }); - const execute = makeExecuteWorkflowTool( - createExtensionRuntime({ definitions: [definition], store }), - () => undefined, - () => undefined, - ); + test.sequential("resume surfaces durable hydration failures and keeps --all unsupported", async () => { + const backend = new FailingHydrationBackend(); + setDurableBackend(backend); + const definition = workflow({ + name: "tool-durable-failure", + description: "", + inputs: {}, + outputs: {}, + run: () => ({}), + }); + const execute = makeExecuteWorkflowTool( + createExtensionRuntime({ definitions: [definition], store }), + () => undefined, + () => undefined, + ); - const failed = await execute({ action: "resume", runId: "durable-failure" }, {} as never); - assert.equal(failed.action, "resume"); - assert.equal(failed.status, "noop"); - assert.match(failed.message, /durable hydration exploded/); - assert.doesNotMatch(failed.message, /Run not found/); + const failed = await execute({ action: "resume", runId: "durable-failure" }, {} as never); + assert.equal(failed.action, "resume"); + assert.equal(failed.status, "noop"); + assert.match(failed.message, /durable hydration exploded/); + assert.doesNotMatch(failed.message, /Run not found/); - const all = await execute({ action: "resume", all: true }, {} as never); - assert.deepEqual(all, { - action: "resume", - runId: "--all", - status: "noop", - message: "Resume does not support --all.", - }); - }); + const all = await execute({ action: "resume", all: true }, {} as never); + assert.deepEqual(all, { + action: "resume", + runId: "--all", + status: "noop", + message: "Resume does not support --all.", + }); + }); }); diff --git a/test/unit/workflow-tool-failure-origin.test.ts b/test/unit/workflow-tool-failure-origin.test.ts index 903f60607..206edd26f 100644 --- a/test/unit/workflow-tool-failure-origin.test.ts +++ b/test/unit/workflow-tool-failure-origin.test.ts @@ -1,471 +1,618 @@ -import { describe, test } from "bun:test"; import assert from "node:assert/strict"; +import { describe, test } from "vitest"; import { workflow } from "../../packages/workflows/src/authoring/workflow.js"; import { run } from "../../packages/workflows/src/engine/run.js"; import { - createWorkflowLifecycleNotificationState, - installWorkflowLifecycleNotifications, - registerLifecycleNoticeRenderer, - type WorkflowLifecycleNoticeDetails, + createWorkflowLifecycleNotificationState, + installWorkflowLifecycleNotifications, + registerLifecycleNoticeRenderer, + type WorkflowLifecycleNoticeDetails, } from "../../packages/workflows/src/extension/lifecycle-notifications.js"; import { inspectRun } from "../../packages/workflows/src/runs/background/run-inspect.js"; import { createStore } from "../../packages/workflows/src/shared/store.js"; +import { sleep } from "../helpers/runtime.js"; interface SentNotice { - readonly content?: string; - readonly details?: WorkflowLifecycleNoticeDetails; + readonly content?: string; + readonly details?: WorkflowLifecycleNoticeDetails; } interface CardComponent { - render(width: number): string[]; + render(width: number): string[]; } function renderLifecycleCard(details: WorkflowLifecycleNoticeDetails): string { - let renderer: ((payload: unknown) => unknown) | undefined; - registerLifecycleNoticeRenderer({ - rendererHost: {}, - registerMessageRenderer(_event, registered) { renderer = registered as (payload: unknown) => unknown; }, - }); - assert.notEqual(renderer, undefined); - return (renderer?.({ details }) as CardComponent).render(80).join("\n"); + let renderer: ((payload: unknown) => unknown) | undefined; + registerLifecycleNoticeRenderer({ + rendererHost: {}, + registerMessageRenderer(_event, registered) { + renderer = registered as (payload: unknown) => unknown; + }, + }); + const render = renderer as ((payload: unknown) => unknown) | undefined; + assert.ok(render); + return (render({ details }) as CardComponent).render(80).join("\n"); } function installFailureNotices(store: ReturnType): { sent: SentNotice[]; unsubscribe: () => void } { - const sent: SentNotice[] = []; - const unsubscribe = installWorkflowLifecycleNotifications({ - store, - config: { enabled: true, notifyOn: ["failed"] }, - state: createWorkflowLifecycleNotificationState(), - seedExisting: false, - sendMessage(message) { sent.push(message as SentNotice); }, - }); - return { sent, unsubscribe }; + const sent: SentNotice[] = []; + const unsubscribe = installWorkflowLifecycleNotifications({ + store, + config: { enabled: true, notifyOn: ["failed"] }, + state: createWorkflowLifecycleNotificationState(), + seedExisting: false, + sendMessage(message) { + sent.push(message as SentNotice); + }, + }); + return { sent, unsubscribe }; } async function runImmediateFailures(firstError: unknown, secondError: unknown) { - const store = createStore(); - const { sent, unsubscribe } = installFailureNotices(store); - const result = await run(workflow({ - name: "ambiguous tool errors", description: "", inputs: {}, outputs: {}, - run: async (ctx) => { - void ctx.tool("first-ambiguous", {}, async () => { throw firstError; }); - void ctx.tool("second-ambiguous", {}, async () => { throw secondError; }); - return {}; - }, - }), {}, { store }); - unsubscribe(); - return { result, sent, store }; + const store = createStore(); + const { sent, unsubscribe } = installFailureNotices(store); + const result = await run( + workflow({ + name: "ambiguous tool errors", + description: "", + inputs: {}, + outputs: {}, + run: async (ctx) => { + void ctx.tool("first-ambiguous", {}, async () => { + throw firstError; + }); + void ctx.tool("second-ambiguous", {}, async () => { + throw secondError; + }); + return {}; + }, + }), + {}, + { store }, + ); + unsubscribe(); + return { result, sent, store }; } async function runSimultaneousSameValueFailures(shared: unknown) { - const awaitedEntered = Promise.withResolvers(); - const laterEntered = Promise.withResolvers(); - const awaitedRelease = Promise.withResolvers(); - const laterRelease = Promise.withResolvers(); - const store = createStore(); - const { sent, unsubscribe } = installFailureNotices(store); - const pending = run(workflow({ - name: "simultaneous same-value failures", description: "", inputs: {}, outputs: {}, - run: async (ctx) => { - const awaited = ctx.tool("awaited-first", {}, async () => { - awaitedEntered.resolve(); - await awaitedRelease.promise; - throw shared; - }); - void ctx.tool("later-unawaited", {}, async () => { - laterEntered.resolve(); - await laterRelease.promise; - throw shared; - }); - await awaited; - return {}; - }, - }), {}, { store }); - - await Promise.all([awaitedEntered.promise, laterEntered.promise]); - awaitedRelease.resolve(); - laterRelease.resolve(); - const result = await pending; - unsubscribe(); - return { result, sent, store }; + const awaitedEntered = Promise.withResolvers(); + const laterEntered = Promise.withResolvers(); + const awaitedRelease = Promise.withResolvers(); + const laterRelease = Promise.withResolvers(); + const store = createStore(); + const { sent, unsubscribe } = installFailureNotices(store); + const pending = run( + workflow({ + name: "simultaneous same-value failures", + description: "", + inputs: {}, + outputs: {}, + run: async (ctx) => { + const awaited = ctx.tool("awaited-first", {}, async () => { + awaitedEntered.resolve(); + await awaitedRelease.promise; + throw shared; + }); + void ctx.tool("later-unawaited", {}, async () => { + laterEntered.resolve(); + await laterRelease.promise; + throw shared; + }); + await awaited; + return {}; + }, + }), + {}, + { store }, + ); + + await Promise.all([awaitedEntered.promise, laterEntered.promise]); + awaitedRelease.resolve(); + laterRelease.resolve(); + const result = await pending; + unsubscribe(); + return { result, sent, store }; } - async function runCaughtThenThrowSameValue(shared: unknown) { - const store = createStore(); - const { sent, unsubscribe } = installFailureNotices(store); - const result = await run(workflow({ - name: "independent same-value body failure", description: "", inputs: {}, outputs: {}, - run: async (ctx) => { - try { - await ctx.tool("caught-same-value", {}, async () => { throw shared; }); - } catch {} - throw shared; - }, - }), {}, { store }); - unsubscribe(); - return { result, sent, store }; + const store = createStore(); + const { sent, unsubscribe } = installFailureNotices(store); + const result = await run( + workflow({ + name: "independent same-value body failure", + description: "", + inputs: {}, + outputs: {}, + run: async (ctx) => { + try { + await ctx.tool("caught-same-value", {}, async () => { + throw shared; + }); + } catch {} + throw shared; + }, + }), + {}, + { store }, + ); + unsubscribe(); + return { result, sent, store }; } - describe("ctx.tool failure origin", () => { - test("multiple failures attribute the first observed failure independent of admission order", async () => { - const store = createStore(); - const { sent, unsubscribe } = installFailureNotices(store); - const persisted: Array<{ type: string; payload: Record }> = []; - const persistence = { - appendEntry(type: string, payload: Record): string { - persisted.push({ type, payload }); - return `entry-${persisted.length}`; - }, - setLabel(_entryId: string, _label: string): void {}, - }; - const firstRelease = Promise.withResolvers(); - const secondRelease = Promise.withResolvers(); - const pending = run(workflow({ - name: "selected tool origin", description: "", inputs: {}, outputs: {}, - run: async (ctx) => { - void ctx.tool("first-admitted", {}, async () => { await firstRelease.promise; throw new Error("FIRST_ERROR"); }); - void ctx.tool("second-admitted", {}, async () => { await secondRelease.promise; throw new Error("SECOND_ERROR"); }); - return {}; - }, - }), {}, { store, persistence }); - - secondRelease.resolve(); - await Bun.sleep(0); - firstRelease.resolve(); - const result = await pending; - unsubscribe(); - - const observedNodeId = result.toolNodes?.[1]?.id; - assert.equal(result.status, "failed"); - assert.match(result.error ?? "", /SECOND_ERROR/); - assert.equal(store.runs()[0]?.failedToolNodeId, observedNodeId); - const inspection = inspectRun(result.runId, { store }); - assert.equal(inspection.ok && inspection.detail.failedToolNodeId, observedNodeId); - assert.equal(result.failedToolNodeId, observedNodeId); - const runEnd = persisted.find((entry) => entry.type === "workflow.run.end"); - assert.equal(runEnd?.payload["failedToolNodeId"], observedNodeId); - assert.equal(sent.length, 1); - assert.equal(sent[0]?.details?.toolNodeId, observedNodeId); - assert.equal(sent[0]?.details?.toolName, "second-admitted"); - assert.match(sent[0]?.content ?? "", /tool second-admitted.*SECOND_ERROR/); - assert.doesNotMatch(sent[0]?.content ?? "", /first-admitted/); - const card = renderLifecycleCard(sent[0]!.details!); - assert.match(card, /tool\s+second-admitted/); - assert.match(card, /SECOND_ERROR/); - assert.doesNotMatch(card, /first-admitted|FIRST_ERROR/); - }); - - test("duplicate error messages do not confuse selected tool identity", async () => { - const { result, sent, store } = await runImmediateFailures(new Error("duplicate"), new Error("duplicate")); - assert.equal(store.runs()[0]?.failedToolNodeId, result.toolNodes?.[0]?.id); - assert.equal(sent[0]?.details?.toolName, "first-ambiguous"); - assert.match(sent[0]?.content ?? "", /tool first-ambiguous.*duplicate/); - }); - - test("a reused Error object does not confuse selected tool identity", async () => { - const shared = new Error("shared rejection"); - const { result, sent, store } = await runImmediateFailures(shared, shared); - assert.equal(store.runs()[0]?.failedToolNodeId, result.toolNodes?.[0]?.id); - assert.equal(sent[0]?.details?.toolName, "first-ambiguous"); - }); - - test("a non-Error rejection retains the selected tool identity", async () => { - const { result, sent, store } = await runImmediateFailures("raw rejection", "later raw rejection"); - assert.equal(store.runs()[0]?.failedToolNodeId, result.toolNodes?.[0]?.id); - assert.equal(sent[0]?.details?.toolName, "first-ambiguous"); - assert.match(sent[0]?.content ?? "", /tool first-ambiguous.*raw rejection/); - }); - - - test("reused Error failures retain the first selected tool origin", async () => { - const shared = new Error("shared awaited rejection"); - const store = createStore(); - const { sent, unsubscribe } = installFailureNotices(store); - const result = await run(workflow({ - name: "reused awaited error", description: "", inputs: {}, outputs: {}, - run: async (ctx) => { - try { - await ctx.tool("first-caught", {}, async () => { throw shared; }); - } catch {} - await ctx.tool("second-uncaught", {}, async () => { throw shared; }); - return {}; - }, - }), {}, { store }); - unsubscribe(); - - const selectedNode = result.toolNodes?.find((node) => node.name === "first-caught"); - assert.equal(result.failedToolNodeId, selectedNode?.id); - assert.equal(store.runs()[0]?.failedToolNodeId, selectedNode?.id); - assert.equal(sent[0]?.details?.toolName, "first-caught"); - assert.equal(sent[0]?.details?.toolNodeId, selectedNode?.id); - assert.match(sent[0]?.content ?? "", /tool first-caught.*shared awaited rejection/); - }); - - test("reused primitive failures retain the first selected tool origin", async () => { - const shared = "shared primitive rejection"; - const store = createStore(); - const { sent, unsubscribe } = installFailureNotices(store); - const result = await run(workflow({ - name: "reused awaited primitive", description: "", inputs: {}, outputs: {}, - run: async (ctx) => { - try { - await ctx.tool("first-primitive", {}, async () => { throw shared; }); - } catch {} - await ctx.tool("second-primitive", {}, async () => { throw shared; }); - return {}; - }, - }), {}, { store }); - unsubscribe(); - - const selectedNode = result.toolNodes?.find((node) => node.name === "first-primitive"); - assert.equal(result.failedToolNodeId, selectedNode?.id); - assert.equal(store.runs()[0]?.failedToolNodeId, selectedNode?.id); - assert.equal(sent[0]?.details?.toolName, "first-primitive"); - assert.equal(sent[0]?.details?.toolNodeId, selectedNode?.id); - assert.match(sent[0]?.content ?? "", /tool first-primitive.*shared primitive rejection/); - }); - - test("an ordinary awaited tool rejection retains its selected terminal tool origin", async () => { - const shared = new Error("shared concurrent rejection"); - const firstRelease = Promise.withResolvers(); - const secondEntered = Promise.withResolvers(); - const store = createStore(); - const pending = run(workflow({ - name: "reused concurrent error", description: "", inputs: {}, outputs: {}, - run: async (ctx) => { - void ctx.tool("first-delayed", {}, async () => { - await firstRelease.promise; - throw shared; - }); - await ctx.tool("second-awaited", {}, async () => { - secondEntered.resolve(); - throw shared; - }); - return {}; - }, - }), {}, { store }); - - await secondEntered.promise; - await Bun.sleep(0); - assert.deepEqual(store.runs()[0]?.toolNodes?.map((node) => [node.name, node.status]), [ - ["first-delayed", "cancelled"], - ["second-awaited", "failed"], - ]); - firstRelease.resolve(); - const result = await pending; - - const selectedNode = result.toolNodes?.find((node) => node.name === "second-awaited"); - assert.equal(result.failedToolNodeId, selectedNode?.id); - assert.equal(store.runs()[0]?.failedToolNodeId, selectedNode?.id); - }); - - - test("already-observed same-value failures retain the first observed tool origin", async () => { - const shared = new Error("shared observation order"); - const awaitedEntered = Promise.withResolvers(); - const awaitedRelease = Promise.withResolvers(); - const store = createStore(); - const { sent, unsubscribe } = installFailureNotices(store); - const pending = run(workflow({ - name: "reused observation order", description: "", inputs: {}, outputs: {}, - run: async (ctx) => { - const awaited = ctx.tool("awaited-first", {}, async () => { - awaitedEntered.resolve(); - await awaitedRelease.promise; - throw shared; - }); - void ctx.tool("later-unawaited", {}, async () => { throw shared; }); - await awaited; - return {}; - }, - }), {}, { store }); - - await awaitedEntered.promise; - await Bun.sleep(0); - assert.deepEqual(store.runs()[0]?.toolNodes?.map((node) => [node.name, node.status]), [ - ["awaited-first", "running"], - ["later-unawaited", "failed"], - ]); - awaitedRelease.resolve(); - const result = await pending; - unsubscribe(); - - const selectedNode = result.toolNodes?.find((node) => node.name === "later-unawaited"); - assert.equal(result.failedToolNodeId, selectedNode?.id); - assert.equal(store.runs()[0]?.failedToolNodeId, selectedNode?.id); - assert.equal(sent[0]?.details?.toolName, "later-unawaited"); - assert.equal(sent[0]?.details?.toolNodeId, selectedNode?.id); - assert.match(sent[0]?.content ?? "", /tool later-unawaited.*shared observation order/); - }); - - for (const [label, shared] of [ - ["Error", new Error("same-turn shared rejection")], - ["primitive", "same-turn shared rejection"], - ] as const) { - test(`same-turn ${label} failures retain the first selected tool origin`, async () => { - const { result, sent, store } = await runSimultaneousSameValueFailures(shared); - - assert.equal(result.status, "failed"); - assert.match(result.error ?? "", /same-turn shared rejection/); - assert.deepEqual(result.toolNodes?.map((node) => [node.name, node.status]), [ - ["awaited-first", "failed"], - ["later-unawaited", "failed"], - ]); - const selectedNode = result.toolNodes?.find((node) => node.name === "awaited-first"); - assert.equal(result.failedToolNodeId, selectedNode?.id); - assert.equal(store.runs()[0]?.failedToolNodeId, selectedNode?.id); - assert.equal(sent[0]?.details?.toolNodeId, selectedNode?.id); - assert.equal(sent[0]?.details?.toolName, "awaited-first"); - assert.match(sent[0]?.content ?? "", /tool awaited-first.*same-turn shared rejection/); - }); - } - test("catching and rethrowing one matching rejection retains terminal tool origin", async () => { - const shared = new Error("rethrow same rejection"); - const store = createStore(); - const result = await run(workflow({ - name: "same rejection rethrow", description: "", inputs: {}, outputs: {}, - run: async (ctx) => { - try { - await ctx.tool("rethrow-origin", {}, async () => { throw shared; }); - } catch (error) { - throw error; - } - return {}; - }, - }), {}, { store }); - - const failedNodeId = result.toolNodes?.[0]?.id; - assert.match(result.error ?? "", /rethrow same rejection/); - assert.equal(result.failedToolNodeId, failedNodeId); - assert.equal(store.runs()[0]?.failedToolNodeId, failedNodeId); - }); - test("a unique same-value body throw retains the only matching tool origin", async () => { - const { result, sent, store } = await runCaughtThenThrowSameValue(new Error("independent shared Error")); - - const failedNodeId = result.toolNodes?.[0]?.id; - assert.match(result.error ?? "", /independent shared Error/); - assert.deepEqual(result.toolNodes?.map((node) => [node.name, node.status]), [["caught-same-value", "failed"]]); - assert.equal(result.failedToolNodeId, failedNodeId); - assert.equal(store.runs()[0]?.failedToolNodeId, failedNodeId); - assert.equal(sent[0]?.details?.toolNodeId, failedNodeId); - assert.equal(sent[0]?.details?.toolName, "caught-same-value"); - assert.match(sent[0]?.content ?? "", /tool caught-same-value/); - }); - test("a unique same-value primitive throw retains the only matching tool origin", async () => { - const { result, sent, store } = await runCaughtThenThrowSameValue("independent shared primitive"); - - const failedNodeId = result.toolNodes?.[0]?.id; - assert.match(result.error ?? "", /independent shared primitive/); - assert.equal(result.toolNodes?.[0]?.status, "failed"); - assert.equal(result.failedToolNodeId, failedNodeId); - assert.equal(store.runs()[0]?.failedToolNodeId, failedNodeId); - assert.equal(sent[0]?.details?.toolNodeId, failedNodeId); - assert.equal(sent[0]?.details?.toolName, "caught-same-value"); - assert.match(sent[0]?.content ?? "", /tool caught-same-value/); - }); - test("an uncaught body error has no false origin from a caught failed tool", async () => { - const store = createStore(); - const { sent, unsubscribe } = installFailureNotices(store); - const result = await run(workflow({ - name: "body precedence", description: "", inputs: {}, outputs: {}, - run: async (ctx) => { - try { - await ctx.tool("caught-unrelated", {}, async () => { throw new Error("tool loses"); }); - } catch {} - throw new Error("body wins"); - }, - }), {}, { store }); - unsubscribe(); - - assert.match(result.error ?? "", /body wins/); - assert.equal(store.runs()[0]?.failedToolNodeId, undefined); - assert.equal(sent[0]?.details?.toolNodeId, undefined); - assert.equal(sent[0]?.details?.toolName, undefined); - assert.doesNotMatch(sent[0]?.content ?? "", /caught-unrelated/); - }); - - test("a selected stage failure keeps stage origin and excludes an unrelated failed tool", async () => { - const store = createStore(); - const { sent, unsubscribe } = installFailureNotices(store); - const result = await run(workflow({ - name: "stage precedence", description: "", inputs: {}, outputs: {}, - run: async (ctx) => { - try { - await ctx.tool("caught-before-stage", {}, async () => { throw new Error("tool loses"); }); - } catch {} - await ctx.stage("selected-stage").prompt("fail"); - return {}; - }, - }), {}, { - store, - adapters: { prompt: { prompt: async () => { throw new Error("stage wins"); } } }, - }); - unsubscribe(); - - const snapshot = store.runs()[0]; - assert.match(result.error ?? "", /stage wins/); - assert.equal(snapshot?.failedStageId, snapshot?.stages[0]?.id); - assert.equal(snapshot?.failedToolNodeId, undefined); - assert.equal(sent[0]?.details?.toolNodeId, undefined); - assert.equal(sent[0]?.details?.stageName, "selected-stage"); - assert.match(sent[0]?.content ?? "", /stage selected-stage.*stage wins/); - }); - - test("workflow API validation failure has no false origin from a caught failed tool", async () => { - const store = createStore(); - const result = await run(workflow({ - name: "validation precedence", description: "", inputs: {}, outputs: {}, - run: async (ctx) => { - try { - await ctx.tool("caught-before-validation", {}, async () => { throw new Error("tool loses"); }); - } catch {} - await ctx.workflow({} as never); - return {}; - }, - }), {}, { store }); - - assert.match(result.error ?? "", /requires a workflow definition/i); - assert.equal(result.failedToolNodeId, undefined); - assert.equal(store.runs()[0]?.failedToolNodeId, undefined); - }); - - - test("child ordinary tool rejection stays visible while the parent uses its failed boundary", async () => { - const store = createStore(); - const child = workflow({ - name: "tool-failed-child", description: "", inputs: {}, outputs: {}, - run: async (ctx) => { - await ctx.tool("child-failure", {}, async () => { throw new Error("child publish failed"); }); - return {}; - }, - }); - const parent = workflow({ - name: "tool-failed-parent", description: "", inputs: {}, outputs: {}, - run: async (ctx) => { await ctx.workflow(child, { stageName: "child-boundary" }); return {}; }, - }); - const result = await run(parent, {}, { store }); - - const parentSnapshot = store.runs().find((candidate) => candidate.id === result.runId); - const childSnapshot = store.runs().find((candidate) => candidate.parentRunId === result.runId); - assert.equal(childSnapshot?.failedToolNodeId, childSnapshot?.toolNodes?.[0]?.id); - assert.equal(parentSnapshot?.failedStageId, parentSnapshot?.stages[0]?.id); - assert.equal(parentSnapshot?.failedToolNodeId, undefined); - assert.equal(result.failedToolNodeId, undefined); - }); - test("persisted tool identity renders by node id when failed topology is unavailable", () => { - const store = createStore(); - const { sent, unsubscribe } = installFailureNotices(store); - store.recordRunStart({ id: "restored-failure", name: "restored", inputs: {}, status: "running", stages: [], startedAt: 1 }); - store.recordRunEnd("restored-failure", "failed", undefined, "restored error", { - failedToolNodeId: "tool:restored", - }); - unsubscribe(); - - assert.equal(sent.length, 1); - assert.equal(sent[0]?.details?.toolNodeId, "tool:restored"); - assert.equal(sent[0]?.details?.toolName, undefined); - assert.match(sent[0]?.content ?? "", /tool tool:restored.*restored error/); - }); + test("multiple failures attribute the first observed failure independent of admission order", async () => { + const store = createStore(); + const { sent, unsubscribe } = installFailureNotices(store); + const persisted: Array<{ type: string; payload: Record }> = []; + const persistence = { + appendEntry(type: string, payload: Record): string { + persisted.push({ type, payload }); + return `entry-${persisted.length}`; + }, + setLabel(_entryId: string, _label: string): void {}, + }; + const firstRelease = Promise.withResolvers(); + const secondRelease = Promise.withResolvers(); + const pending = run( + workflow({ + name: "selected tool origin", + description: "", + inputs: {}, + outputs: {}, + run: async (ctx) => { + void ctx.tool("first-admitted", {}, async () => { + await firstRelease.promise; + throw new Error("FIRST_ERROR"); + }); + void ctx.tool("second-admitted", {}, async () => { + await secondRelease.promise; + throw new Error("SECOND_ERROR"); + }); + return {}; + }, + }), + {}, + { store, persistence }, + ); + + secondRelease.resolve(); + await sleep(0); + firstRelease.resolve(); + const result = await pending; + unsubscribe(); + + const observedNodeId = result.toolNodes?.[1]?.id; + assert.equal(result.status, "failed"); + assert.match(result.error ?? "", /SECOND_ERROR/); + assert.equal(store.runs()[0]?.failedToolNodeId, observedNodeId); + const inspection = inspectRun(result.runId, { store }); + assert.equal(inspection.ok && inspection.detail.failedToolNodeId, observedNodeId); + assert.equal(result.failedToolNodeId, observedNodeId); + const runEnd = persisted.find((entry) => entry.type === "workflow.run.end"); + assert.equal(runEnd?.payload.failedToolNodeId, observedNodeId); + assert.equal(sent.length, 1); + assert.equal(sent[0]?.details?.toolNodeId, observedNodeId); + assert.equal(sent[0]?.details?.toolName, "second-admitted"); + assert.match(sent[0]?.content ?? "", /tool second-admitted.*SECOND_ERROR/); + assert.doesNotMatch(sent[0]?.content ?? "", /first-admitted/); + const card = renderLifecycleCard(sent[0]!.details!); + assert.match(card, /tool\s+second-admitted/); + assert.match(card, /SECOND_ERROR/); + assert.doesNotMatch(card, /first-admitted|FIRST_ERROR/); + }); + + test("duplicate error messages do not confuse selected tool identity", async () => { + const { result, sent, store } = await runImmediateFailures(new Error("duplicate"), new Error("duplicate")); + assert.equal(store.runs()[0]?.failedToolNodeId, result.toolNodes?.[0]?.id); + assert.equal(sent[0]?.details?.toolName, "first-ambiguous"); + assert.match(sent[0]?.content ?? "", /tool first-ambiguous.*duplicate/); + }); + + test("a reused Error object does not confuse selected tool identity", async () => { + const shared = new Error("shared rejection"); + const { result, sent, store } = await runImmediateFailures(shared, shared); + assert.equal(store.runs()[0]?.failedToolNodeId, result.toolNodes?.[0]?.id); + assert.equal(sent[0]?.details?.toolName, "first-ambiguous"); + }); + + test("a non-Error rejection retains the selected tool identity", async () => { + const { result, sent, store } = await runImmediateFailures("raw rejection", "later raw rejection"); + assert.equal(store.runs()[0]?.failedToolNodeId, result.toolNodes?.[0]?.id); + assert.equal(sent[0]?.details?.toolName, "first-ambiguous"); + assert.match(sent[0]?.content ?? "", /tool first-ambiguous.*raw rejection/); + }); + + test("reused Error failures retain the first selected tool origin", async () => { + const shared = new Error("shared awaited rejection"); + const store = createStore(); + const { sent, unsubscribe } = installFailureNotices(store); + const result = await run( + workflow({ + name: "reused awaited error", + description: "", + inputs: {}, + outputs: {}, + run: async (ctx) => { + try { + await ctx.tool("first-caught", {}, async () => { + throw shared; + }); + } catch {} + await ctx.tool("second-uncaught", {}, async () => { + throw shared; + }); + return {}; + }, + }), + {}, + { store }, + ); + unsubscribe(); + + const selectedNode = result.toolNodes?.find((node) => node.name === "first-caught"); + assert.equal(result.failedToolNodeId, selectedNode?.id); + assert.equal(store.runs()[0]?.failedToolNodeId, selectedNode?.id); + assert.equal(sent[0]?.details?.toolName, "first-caught"); + assert.equal(sent[0]?.details?.toolNodeId, selectedNode?.id); + assert.match(sent[0]?.content ?? "", /tool first-caught.*shared awaited rejection/); + }); + + test("reused primitive failures retain the first selected tool origin", async () => { + const shared = "shared primitive rejection"; + const store = createStore(); + const { sent, unsubscribe } = installFailureNotices(store); + const result = await run( + workflow({ + name: "reused awaited primitive", + description: "", + inputs: {}, + outputs: {}, + run: async (ctx) => { + try { + await ctx.tool("first-primitive", {}, async () => { + throw shared; + }); + } catch {} + await ctx.tool("second-primitive", {}, async () => { + throw shared; + }); + return {}; + }, + }), + {}, + { store }, + ); + unsubscribe(); + + const selectedNode = result.toolNodes?.find((node) => node.name === "first-primitive"); + assert.equal(result.failedToolNodeId, selectedNode?.id); + assert.equal(store.runs()[0]?.failedToolNodeId, selectedNode?.id); + assert.equal(sent[0]?.details?.toolName, "first-primitive"); + assert.equal(sent[0]?.details?.toolNodeId, selectedNode?.id); + assert.match(sent[0]?.content ?? "", /tool first-primitive.*shared primitive rejection/); + }); + + test("an ordinary awaited tool rejection retains its selected terminal tool origin", async () => { + const shared = new Error("shared concurrent rejection"); + const firstRelease = Promise.withResolvers(); + const secondEntered = Promise.withResolvers(); + const store = createStore(); + const pending = run( + workflow({ + name: "reused concurrent error", + description: "", + inputs: {}, + outputs: {}, + run: async (ctx) => { + void ctx.tool("first-delayed", {}, async () => { + await firstRelease.promise; + throw shared; + }); + await ctx.tool("second-awaited", {}, async () => { + secondEntered.resolve(); + throw shared; + }); + return {}; + }, + }), + {}, + { store }, + ); + + await secondEntered.promise; + await sleep(0); + assert.deepEqual( + store.runs()[0]?.toolNodes?.map((node) => [node.name, node.status]), + [ + ["first-delayed", "cancelled"], + ["second-awaited", "failed"], + ], + ); + firstRelease.resolve(); + const result = await pending; + + const selectedNode = result.toolNodes?.find((node) => node.name === "second-awaited"); + assert.equal(result.failedToolNodeId, selectedNode?.id); + assert.equal(store.runs()[0]?.failedToolNodeId, selectedNode?.id); + }); + + test("already-observed same-value failures retain the first observed tool origin", async () => { + const shared = new Error("shared observation order"); + const awaitedEntered = Promise.withResolvers(); + const awaitedRelease = Promise.withResolvers(); + const store = createStore(); + const { sent, unsubscribe } = installFailureNotices(store); + const pending = run( + workflow({ + name: "reused observation order", + description: "", + inputs: {}, + outputs: {}, + run: async (ctx) => { + const awaited = ctx.tool("awaited-first", {}, async () => { + awaitedEntered.resolve(); + await awaitedRelease.promise; + throw shared; + }); + void ctx.tool("later-unawaited", {}, async () => { + throw shared; + }); + await awaited; + return {}; + }, + }), + {}, + { store }, + ); + + await awaitedEntered.promise; + await sleep(0); + assert.deepEqual( + store.runs()[0]?.toolNodes?.map((node) => [node.name, node.status]), + [ + ["awaited-first", "running"], + ["later-unawaited", "failed"], + ], + ); + awaitedRelease.resolve(); + const result = await pending; + unsubscribe(); + + const selectedNode = result.toolNodes?.find((node) => node.name === "later-unawaited"); + assert.equal(result.failedToolNodeId, selectedNode?.id); + assert.equal(store.runs()[0]?.failedToolNodeId, selectedNode?.id); + assert.equal(sent[0]?.details?.toolName, "later-unawaited"); + assert.equal(sent[0]?.details?.toolNodeId, selectedNode?.id); + assert.match(sent[0]?.content ?? "", /tool later-unawaited.*shared observation order/); + }); + + for (const [label, shared] of [ + ["Error", new Error("same-turn shared rejection")], + ["primitive", "same-turn shared rejection"], + ] as const) { + test(`same-turn ${label} failures retain the first selected tool origin`, async () => { + const { result, sent, store } = await runSimultaneousSameValueFailures(shared); + + assert.equal(result.status, "failed"); + assert.match(result.error ?? "", /same-turn shared rejection/); + assert.deepEqual( + result.toolNodes?.map((node) => [node.name, node.status]), + [ + ["awaited-first", "failed"], + ["later-unawaited", "failed"], + ], + ); + const selectedNode = result.toolNodes?.find((node) => node.name === "awaited-first"); + assert.equal(result.failedToolNodeId, selectedNode?.id); + assert.equal(store.runs()[0]?.failedToolNodeId, selectedNode?.id); + assert.equal(sent[0]?.details?.toolNodeId, selectedNode?.id); + assert.equal(sent[0]?.details?.toolName, "awaited-first"); + assert.match(sent[0]?.content ?? "", /tool awaited-first.*same-turn shared rejection/); + }); + } + test("catching and rethrowing one matching rejection retains terminal tool origin", async () => { + const shared = new Error("rethrow same rejection"); + const store = createStore(); + const result = await run( + workflow({ + name: "same rejection rethrow", + description: "", + inputs: {}, + outputs: {}, + run: async (ctx) => { + await ctx.tool("rethrow-origin", {}, async () => { + throw shared; + }); + return {}; + }, + }), + {}, + { store }, + ); + + const failedNodeId = result.toolNodes?.[0]?.id; + assert.match(result.error ?? "", /rethrow same rejection/); + assert.equal(result.failedToolNodeId, failedNodeId); + assert.equal(store.runs()[0]?.failedToolNodeId, failedNodeId); + }); + test("a unique same-value body throw retains the only matching tool origin", async () => { + const { result, sent, store } = await runCaughtThenThrowSameValue(new Error("independent shared Error")); + + const failedNodeId = result.toolNodes?.[0]?.id; + assert.match(result.error ?? "", /independent shared Error/); + assert.deepEqual( + result.toolNodes?.map((node) => [node.name, node.status]), + [["caught-same-value", "failed"]], + ); + assert.equal(result.failedToolNodeId, failedNodeId); + assert.equal(store.runs()[0]?.failedToolNodeId, failedNodeId); + assert.equal(sent[0]?.details?.toolNodeId, failedNodeId); + assert.equal(sent[0]?.details?.toolName, "caught-same-value"); + assert.match(sent[0]?.content ?? "", /tool caught-same-value/); + }); + test("a unique same-value primitive throw retains the only matching tool origin", async () => { + const { result, sent, store } = await runCaughtThenThrowSameValue("independent shared primitive"); + + const failedNodeId = result.toolNodes?.[0]?.id; + assert.match(result.error ?? "", /independent shared primitive/); + assert.equal(result.toolNodes?.[0]?.status, "failed"); + assert.equal(result.failedToolNodeId, failedNodeId); + assert.equal(store.runs()[0]?.failedToolNodeId, failedNodeId); + assert.equal(sent[0]?.details?.toolNodeId, failedNodeId); + assert.equal(sent[0]?.details?.toolName, "caught-same-value"); + assert.match(sent[0]?.content ?? "", /tool caught-same-value/); + }); + test("an uncaught body error has no false origin from a caught failed tool", async () => { + const store = createStore(); + const { sent, unsubscribe } = installFailureNotices(store); + const result = await run( + workflow({ + name: "body precedence", + description: "", + inputs: {}, + outputs: {}, + run: async (ctx) => { + try { + await ctx.tool("caught-unrelated", {}, async () => { + throw new Error("tool loses"); + }); + } catch {} + throw new Error("body wins"); + }, + }), + {}, + { store }, + ); + unsubscribe(); + + assert.match(result.error ?? "", /body wins/); + assert.equal(store.runs()[0]?.failedToolNodeId, undefined); + assert.equal(sent[0]?.details?.toolNodeId, undefined); + assert.equal(sent[0]?.details?.toolName, undefined); + assert.doesNotMatch(sent[0]?.content ?? "", /caught-unrelated/); + }); + + test("a selected stage failure keeps stage origin and excludes an unrelated failed tool", async () => { + const store = createStore(); + const { sent, unsubscribe } = installFailureNotices(store); + const result = await run( + workflow({ + name: "stage precedence", + description: "", + inputs: {}, + outputs: {}, + run: async (ctx) => { + try { + await ctx.tool("caught-before-stage", {}, async () => { + throw new Error("tool loses"); + }); + } catch {} + await ctx.stage("selected-stage").prompt("fail"); + return {}; + }, + }), + {}, + { + store, + adapters: { + prompt: { + prompt: async () => { + throw new Error("stage wins"); + }, + }, + }, + }, + ); + unsubscribe(); + + const snapshot = store.runs()[0]; + assert.match(result.error ?? "", /stage wins/); + assert.equal(snapshot?.failedStageId, snapshot?.stages[0]?.id); + assert.equal(snapshot?.failedToolNodeId, undefined); + assert.equal(sent[0]?.details?.toolNodeId, undefined); + assert.equal(sent[0]?.details?.stageName, "selected-stage"); + assert.match(sent[0]?.content ?? "", /stage selected-stage.*stage wins/); + }); + + test("workflow API validation failure has no false origin from a caught failed tool", async () => { + const store = createStore(); + const result = await run( + workflow({ + name: "validation precedence", + description: "", + inputs: {}, + outputs: {}, + run: async (ctx) => { + try { + await ctx.tool("caught-before-validation", {}, async () => { + throw new Error("tool loses"); + }); + } catch {} + await ctx.workflow({} as never); + return {}; + }, + }), + {}, + { store }, + ); + + assert.match(result.error ?? "", /requires a workflow definition/i); + assert.equal(result.failedToolNodeId, undefined); + assert.equal(store.runs()[0]?.failedToolNodeId, undefined); + }); + + test("child ordinary tool rejection stays visible while the parent uses its failed boundary", async () => { + const store = createStore(); + const child = workflow({ + name: "tool-failed-child", + description: "", + inputs: {}, + outputs: {}, + run: async (ctx) => { + await ctx.tool("child-failure", {}, async () => { + throw new Error("child publish failed"); + }); + return {}; + }, + }); + const parent = workflow({ + name: "tool-failed-parent", + description: "", + inputs: {}, + outputs: {}, + run: async (ctx) => { + await ctx.workflow(child, { stageName: "child-boundary" }); + return {}; + }, + }); + const result = await run(parent, {}, { store }); + + const parentSnapshot = store.runs().find((candidate) => candidate.id === result.runId); + const childSnapshot = store.runs().find((candidate) => candidate.parentRunId === result.runId); + assert.equal(childSnapshot?.failedToolNodeId, childSnapshot?.toolNodes?.[0]?.id); + assert.equal(parentSnapshot?.failedStageId, parentSnapshot?.stages[0]?.id); + assert.equal(parentSnapshot?.failedToolNodeId, undefined); + assert.equal(result.failedToolNodeId, undefined); + }); + test("persisted tool identity renders by node id when failed topology is unavailable", () => { + const store = createStore(); + const { sent, unsubscribe } = installFailureNotices(store); + store.recordRunStart({ + id: "restored-failure", + name: "restored", + inputs: {}, + status: "running", + stages: [], + startedAt: 1, + }); + store.recordRunEnd("restored-failure", "failed", undefined, "restored error", { + failedToolNodeId: "tool:restored", + }); + unsubscribe(); + + assert.equal(sent.length, 1); + assert.equal(sent[0]?.details?.toolNodeId, "tool:restored"); + assert.equal(sent[0]?.details?.toolName, undefined); + assert.match(sent[0]?.content ?? "", /tool tool:restored.*restored error/); + }); }); diff --git a/test/unit/workflow-tool-graph-replay.test.ts b/test/unit/workflow-tool-graph-replay.test.ts index 9c1728665..a0bb7c233 100644 --- a/test/unit/workflow-tool-graph-replay.test.ts +++ b/test/unit/workflow-tool-graph-replay.test.ts @@ -1,276 +1,330 @@ -import { describe, test } from "bun:test"; import assert from "node:assert/strict"; import { Type } from "typebox"; +import { describe, test } from "vitest"; import { workflow } from "../../packages/workflows/src/authoring/workflow.js"; import { InMemoryDurableBackend } from "../../packages/workflows/src/durable/backend.js"; import { completedWorkflowRunSnapshots } from "../../packages/workflows/src/durable/completed-catalog.js"; import { DbosDurableBackend } from "../../packages/workflows/src/durable/dbos-backend.js"; import { run } from "../../packages/workflows/src/engine/run.js"; -import { createStore } from "../../packages/workflows/src/shared/store.js"; import { expandWorkflowGraph } from "../../packages/workflows/src/shared/expanded-workflow-graph.js"; +import { createStore } from "../../packages/workflows/src/shared/store.js"; import type { RunSnapshot } from "../../packages/workflows/src/shared/store-types.js"; import { createMockSdk } from "./durable-dbos-backend-helpers.js"; function orderedNames(snapshot: RunSnapshot): string[] { - return [ - ...snapshot.stages.map((stage) => ({ name: stage.name, order: stage.executionOrder })), - ...(snapshot.toolNodes ?? []).map((tool) => ({ name: tool.name, order: tool.executionOrder })), - ].sort((left, right) => (left.order ?? 0) - (right.order ?? 0)).map((item) => item.name); + return [ + ...snapshot.stages.map((stage) => ({ name: stage.name, order: stage.executionOrder })), + ...(snapshot.toolNodes ?? []).map((tool) => ({ name: tool.name, order: tool.executionOrder })), + ] + .sort((left, right) => (left.order ?? 0) - (right.order ?? 0)) + .map((item) => item.name); } describe("ctx.tool durable graph replay", () => { - test("fresh-store replay preserves stage → tool → stage topology and exact cached output", async () => { - const backend = new InMemoryDurableBackend(); - const runId = "tool-mixed-replay"; - let toolCalls = 0; - const definition = workflow({ - name: "tool-mixed-replay", - description: "", - inputs: {}, - outputs: { value: Type.Number(), tags: Type.Array(Type.String()) }, - run: async (ctx) => { - await ctx.stage("before").prompt("before"); - const output = await ctx.tool("middle", {}, async () => { - toolCalls += 1; - return { value: 7, tags: ["raw", "raw"] }; - }); - await ctx.stage("after").prompt("after"); - return output; - }, - }); - const adapters = { prompt: { prompt: async (text: string) => text } }; + test("fresh-store replay preserves stage → tool → stage topology and exact cached output", async () => { + const backend = new InMemoryDurableBackend(); + const runId = "tool-mixed-replay"; + let toolCalls = 0; + const definition = workflow({ + name: "tool-mixed-replay", + description: "", + inputs: {}, + outputs: { value: Type.Number(), tags: Type.Array(Type.String()) }, + run: async (ctx) => { + await ctx.stage("before").prompt("before"); + const output = await ctx.tool("middle", {}, async () => { + toolCalls += 1; + return { value: 7, tags: ["raw", "raw"] }; + }); + await ctx.stage("after").prompt("after"); + return output; + }, + }); + const adapters = { prompt: { prompt: async (text: string) => text } }; - const firstStore = createStore(); - const first = await run(definition, {}, { runId, store: firstStore, durableBackend: backend, adapters }); - assert.equal(first.status, "completed"); - assert.deepEqual(first.result, { value: 7, tags: ["raw", "raw"] }); - assert.equal(toolCalls, 1); - const firstTool = firstStore.runs()[0]?.toolNodes?.[0]; - assert.ok(firstTool?.startedAt !== undefined); - assert.ok(firstTool.endedAt !== undefined); + const firstStore = createStore(); + const first = await run(definition, {}, { runId, store: firstStore, durableBackend: backend, adapters }); + assert.equal(first.status, "completed"); + assert.deepEqual(first.result, { value: 7, tags: ["raw", "raw"] }); + assert.equal(toolCalls, 1); + const firstTool = firstStore.runs()[0]?.toolNodes?.[0]; + assert.ok(firstTool?.startedAt !== undefined); + assert.ok(firstTool.endedAt !== undefined); - const replayStore = createStore(); - const replay = await run(definition, {}, { runId, store: replayStore, durableBackend: backend, adapters }); - assert.equal(replay.status, "completed"); - assert.deepEqual(replay.result, { value: 7, tags: ["raw", "raw"] }); - assert.equal(toolCalls, 1, "the durable callback must not rerun"); + const replayStore = createStore(); + const replay = await run(definition, {}, { runId, store: replayStore, durableBackend: backend, adapters }); + assert.equal(replay.status, "completed"); + assert.deepEqual(replay.result, { value: 7, tags: ["raw", "raw"] }); + assert.equal(toolCalls, 1, "the durable callback must not rerun"); - const replayRun = replayStore.runs()[0]!; - const [before, after] = replayRun.stages; - const tool = replayRun.toolNodes?.[0]; - assert.deepEqual(orderedNames(replayRun), ["before", "middle", "after"]); - assert.equal(tool?.status, "cached"); - assert.equal(tool?.id, firstTool.id); - assert.equal(tool?.executionOrder, firstTool.executionOrder); - assert.equal(tool?.startedAt, firstTool.startedAt); - assert.equal(tool?.endedAt, firstTool.endedAt); - assert.deepEqual(tool?.parentIds, [before?.id]); - assert.deepEqual(after?.parentIds, [tool?.id]); - const currentIds = new Set([before?.id, tool?.id, after?.id]); - for (const node of [before, tool, after]) { - for (const parentId of node?.parentIds ?? []) assert.equal(currentIds.has(parentId), true, `dangling parent ${parentId}`); - } - const entry = backend.listCompletedWorkflows().find((candidate) => candidate.workflowId === runId)!; - const catalogRun = completedWorkflowRunSnapshots(backend, entry).find((candidate) => candidate.id === runId)!; - const catalogTool = catalogRun.toolNodes?.[0]; - assert.deepEqual(orderedNames(catalogRun), ["before", "middle", "after"]); - assert.deepEqual(catalogTool?.parentIds, [catalogRun.stages[0]?.id]); - assert.deepEqual(catalogRun.stages[1]?.parentIds, [catalogTool?.id]); - assert.equal(catalogTool?.startedAt, firstTool.startedAt); - assert.equal(catalogTool?.endedAt, firstTool.endedAt); - }); + const replayRun = replayStore.runs()[0]!; + const [before, after] = replayRun.stages; + const tool = replayRun.toolNodes?.[0]; + assert.deepEqual(orderedNames(replayRun), ["before", "middle", "after"]); + assert.equal(tool?.status, "cached"); + assert.equal(tool?.id, firstTool.id); + assert.equal(tool?.executionOrder, firstTool.executionOrder); + assert.equal(tool?.startedAt, firstTool.startedAt); + assert.equal(tool?.endedAt, firstTool.endedAt); + assert.deepEqual(tool?.parentIds, [before?.id]); + assert.deepEqual(after?.parentIds, [tool?.id]); + const currentIds = new Set([before?.id, tool?.id, after?.id]); + for (const node of [before, tool, after]) { + for (const parentId of node?.parentIds ?? []) + assert.equal(currentIds.has(parentId), true, `dangling parent ${parentId}`); + } + const entry = backend.listCompletedWorkflows().find((candidate) => candidate.workflowId === runId)!; + const catalogRun = completedWorkflowRunSnapshots(backend, entry).find((candidate) => candidate.id === runId)!; + const catalogTool = catalogRun.toolNodes?.[0]; + assert.deepEqual(orderedNames(catalogRun), ["before", "middle", "after"]); + assert.deepEqual(catalogTool?.parentIds, [catalogRun.stages[0]?.id]); + assert.deepEqual(catalogRun.stages[1]?.parentIds, [catalogTool?.id]); + assert.equal(catalogTool?.startedAt, firstTool.startedAt); + assert.equal(catalogTool?.endedAt, firstTool.endedAt); + }); - test("fresh-store replay preserves concurrent tool siblings and fan-in", async () => { - const backend = new InMemoryDurableBackend(); - const runId = "tool-concurrent-replay"; - let toolCalls = 0; - const siblingGate = Promise.withResolvers(); - const definition = workflow({ - name: "tool-concurrent-replay", - description: "", - inputs: {}, - outputs: {}, - run: async (ctx) => { - await ctx.stage("seed").prompt("seed"); - const left = ctx.tool("left", {}, async () => { - toolCalls += 1; - await siblingGate.promise; - return "left"; - }); - // The authored siblings are admitted across a microtask while the live - // left callback is still pending. Replay must use persisted topology, - // not synchronous cache-hit timing, to retain the sibling relation. - await Promise.resolve(); - const right = ctx.tool("right", {}, async () => { - toolCalls += 1; - await siblingGate.promise; - return "right"; - }); - siblingGate.resolve(); - await Promise.all([left, right]); - await ctx.stage("join").prompt("join"); - return {}; - }, - }); - const adapters = { prompt: { prompt: async (text: string) => text } }; + test("fresh-store replay preserves concurrent tool siblings and fan-in", async () => { + const backend = new InMemoryDurableBackend(); + const runId = "tool-concurrent-replay"; + let toolCalls = 0; + const siblingGate = Promise.withResolvers(); + const definition = workflow({ + name: "tool-concurrent-replay", + description: "", + inputs: {}, + outputs: {}, + run: async (ctx) => { + await ctx.stage("seed").prompt("seed"); + const left = ctx.tool("left", {}, async () => { + toolCalls += 1; + await siblingGate.promise; + return "left"; + }); + // The authored siblings are admitted across a microtask while the live + // left callback is still pending. Replay must use persisted topology, + // not synchronous cache-hit timing, to retain the sibling relation. + await Promise.resolve(); + const right = ctx.tool("right", {}, async () => { + toolCalls += 1; + await siblingGate.promise; + return "right"; + }); + siblingGate.resolve(); + await Promise.all([left, right]); + await ctx.stage("join").prompt("join"); + return {}; + }, + }); + const adapters = { prompt: { prompt: async (text: string) => text } }; - const first = await run(definition, {}, { - runId, store: createStore(), durableBackend: backend, adapters, - }); - assert.equal(first.status, "completed"); - assert.equal(toolCalls, 2); + const first = await run( + definition, + {}, + { + runId, + store: createStore(), + durableBackend: backend, + adapters, + }, + ); + assert.equal(first.status, "completed"); + assert.equal(toolCalls, 2); - const replayStore = createStore(); - const replay = await run(definition, {}, { - runId, store: replayStore, durableBackend: backend, adapters, - }); - assert.equal(replay.status, "completed"); - assert.equal(toolCalls, 2); - const replayRun = replayStore.runs()[0]!; - const seed = replayRun.stages.find((stage) => stage.name === "seed")!; - const join = replayRun.stages.find((stage) => stage.name === "join")!; - const tools = replayRun.toolNodes ?? []; - assert.deepEqual(orderedNames(replayRun), ["seed", "left", "right", "join"]); - assert.equal(tools.length, 2); - assert.deepEqual(tools.map((tool) => tool.parentIds), [[seed.id], [seed.id]]); - assert.deepEqual(new Set(join.parentIds), new Set(tools.map((tool) => tool.id))); - }); + const replayStore = createStore(); + const replay = await run( + definition, + {}, + { + runId, + store: replayStore, + durableBackend: backend, + adapters, + }, + ); + assert.equal(replay.status, "completed"); + assert.equal(toolCalls, 2); + const replayRun = replayStore.runs()[0]!; + const seed = replayRun.stages.find((stage) => stage.name === "seed")!; + const join = replayRun.stages.find((stage) => stage.name === "join")!; + const tools = replayRun.toolNodes ?? []; + assert.deepEqual(orderedNames(replayRun), ["seed", "left", "right", "join"]); + assert.equal(tools.length, 2); + assert.deepEqual( + tools.map((tool) => tool.parentIds), + [[seed.id], [seed.id]], + ); + assert.deepEqual(new Set(join.parentIds), new Set(tools.map((tool) => tool.id))); + }); - test("tool-only child topology survives completed catalog and cached-boundary replay", async () => { - const backend = new InMemoryDurableBackend(); - const runId = "tool-only-child-root"; - let toolCalls = 0; - const child = workflow({ - name: "tool-only-child", - description: "", - inputs: {}, - outputs: { value: Type.Number() }, - run: async (ctx) => ({ - value: await ctx.tool("child-write", {}, async () => { - toolCalls += 1; - return 11; - }), - }), - }); - const parent = workflow({ - name: "tool-only-child-root", - description: "", - inputs: {}, - outputs: { value: Type.Number() }, - run: async (ctx) => { - const result = await ctx.workflow(child, { stageName: "child-boundary" }); - if (result.exited) throw new Error("child exited unexpectedly"); - return result.outputs; - }, - }); + test("tool-only child topology survives completed catalog and cached-boundary replay", async () => { + const backend = new InMemoryDurableBackend(); + const runId = "tool-only-child-root"; + let toolCalls = 0; + const child = workflow({ + name: "tool-only-child", + description: "", + inputs: {}, + outputs: { value: Type.Number() }, + run: async (ctx) => ({ + value: await ctx.tool("child-write", {}, async () => { + toolCalls += 1; + return 11; + }), + }), + }); + const parent = workflow({ + name: "tool-only-child-root", + description: "", + inputs: {}, + outputs: { value: Type.Number() }, + run: async (ctx) => { + const result = await ctx.workflow(child, { stageName: "child-boundary" }); + if (result.exited) throw new Error("child exited unexpectedly"); + return result.outputs; + }, + }); - const liveStore = createStore(); - const live = await run(parent, {}, { runId, store: liveStore, durableBackend: backend }); - assert.equal(live.status, "completed"); - assert.deepEqual(live.result, { value: 11 }); - assert.equal(toolCalls, 1); - const liveGraph = expandWorkflowGraph(liveStore.snapshot(), runId); - assert.deepEqual(liveGraph.tools.map((tool) => tool.name), ["child-write"]); - assert.deepEqual(liveGraph.stages, [], "the populated child boundary is flattened"); + const liveStore = createStore(); + const live = await run(parent, {}, { runId, store: liveStore, durableBackend: backend }); + assert.equal(live.status, "completed"); + assert.deepEqual(live.result, { value: 11 }); + assert.equal(toolCalls, 1); + const liveGraph = expandWorkflowGraph(liveStore.snapshot(), runId); + assert.deepEqual( + liveGraph.tools.map((tool) => tool.name), + ["child-write"], + ); + assert.deepEqual(liveGraph.stages, [], "the populated child boundary is flattened"); - const entry = backend.listCompletedWorkflows().find((candidate) => candidate.workflowId === runId)!; - const catalogRuns = completedWorkflowRunSnapshots(backend, entry); - const catalogChild = catalogRuns.find((candidate) => candidate.parentRunId === runId); - assert.ok(catalogChild !== undefined); - assert.equal(catalogChild.rootRunId, runId); - assert.deepEqual(catalogChild.toolNodes?.map((tool) => tool.name), ["child-write"]); - const catalogGraph = expandWorkflowGraph({ runs: catalogRuns, notices: [], version: 1 }, runId); - assert.deepEqual(catalogGraph.tools.map((tool) => tool.name), ["child-write"]); - assert.deepEqual(catalogGraph.stages, []); + const entry = backend.listCompletedWorkflows().find((candidate) => candidate.workflowId === runId)!; + const catalogRuns = completedWorkflowRunSnapshots(backend, entry); + const catalogChild = catalogRuns.find((candidate) => candidate.parentRunId === runId); + assert.ok(catalogChild !== undefined); + assert.equal(catalogChild.rootRunId, runId); + assert.deepEqual( + catalogChild.toolNodes?.map((tool) => tool.name), + ["child-write"], + ); + const catalogGraph = expandWorkflowGraph({ runs: catalogRuns, notices: [], version: 1 }, runId); + assert.deepEqual( + catalogGraph.tools.map((tool) => tool.name), + ["child-write"], + ); + assert.deepEqual(catalogGraph.stages, []); - const replayStore = createStore(); - const replay = await run(parent, {}, { runId, store: replayStore, durableBackend: backend }); - assert.equal(replay.status, "completed"); - assert.deepEqual(replay.result, { value: 11 }); - assert.equal(toolCalls, 1, "cached child boundary and tool must not rerun"); - const replayGraph = expandWorkflowGraph(replayStore.snapshot(), runId); - assert.deepEqual(replayGraph.tools.map((tool) => ({ name: tool.name, status: tool.status, attachable: tool.attachable })), [ - { name: "child-write", status: "cached", attachable: false }, - ]); - assert.deepEqual(replayGraph.stages, []); + const replayStore = createStore(); + const replay = await run(parent, {}, { runId, store: replayStore, durableBackend: backend }); + assert.equal(replay.status, "completed"); + assert.deepEqual(replay.result, { value: 11 }); + assert.equal(toolCalls, 1, "cached child boundary and tool must not rerun"); + const replayGraph = expandWorkflowGraph(replayStore.snapshot(), runId); + assert.deepEqual( + replayGraph.tools.map((tool) => ({ name: tool.name, status: tool.status, attachable: tool.attachable })), + [{ name: "child-write", status: "cached", attachable: false }], + ); + assert.deepEqual(replayGraph.stages, []); - const cachedChildTool = backend.listCheckpoints(runId).find( - (checkpoint) => checkpoint.kind === "tool" && checkpoint.name === "child-write", - ); - assert.equal(cachedChildTool?.kind, "tool"); - if (cachedChildTool?.kind !== "tool") return; - const partialBackend = new InMemoryDurableBackend(); - partialBackend.registerWorkflow({ - workflowId: runId, name: parent.name, inputs: {}, createdAt: 1, status: "paused", resumable: true, - }); - partialBackend.recordCheckpoint(cachedChildTool); - const partialStore = createStore(); - const partial = await run(parent, {}, { runId, store: partialStore, durableBackend: partialBackend }); - assert.equal(partial.status, "completed"); - assert.deepEqual(partial.result, { value: 11 }); - assert.equal(toolCalls, 1, "cached child tool must survive a live child/boundary continuation"); - const partialRoot = partialStore.runs().find((candidate) => candidate.id === runId)!; - const partialBoundary = partialRoot.stages.find((stage) => stage.name === "child-boundary")!; - const partialChild = partialStore.runs().find((candidate) => candidate.id === partialBoundary.workflowChild?.runId)!; - assert.deepEqual(partialChild.toolNodes?.map((tool) => tool.name), ["child-write"]); - assert.equal(partialChild.parentRunId, runId); - assert.equal(partialChild.rootRunId, runId); - const partialEntry = partialBackend.listCompletedWorkflows().find((candidate) => candidate.workflowId === runId)!; - const partialCatalogRuns = completedWorkflowRunSnapshots(partialBackend, partialEntry); - const partialCatalogChild = partialCatalogRuns.find((candidate) => candidate.parentRunId === runId); - assert.equal(partialCatalogChild?.id, partialBoundary.workflowChild?.runId); - assert.deepEqual(partialCatalogChild?.toolNodes?.map((tool) => tool.name), ["child-write"]); - assert.deepEqual( - expandWorkflowGraph({ runs: partialCatalogRuns, notices: [], version: 1 }, runId).tools.map((tool) => tool.name), - ["child-write"], - ); - }); + const cachedChildTool = backend + .listCheckpoints(runId) + .find((checkpoint) => checkpoint.kind === "tool" && checkpoint.name === "child-write"); + assert.equal(cachedChildTool?.kind, "tool"); + if (cachedChildTool?.kind !== "tool") return; + const partialBackend = new InMemoryDurableBackend(); + partialBackend.registerWorkflow({ + workflowId: runId, + name: parent.name, + inputs: {}, + createdAt: 1, + status: "paused", + resumable: true, + }); + partialBackend.recordCheckpoint(cachedChildTool); + const partialStore = createStore(); + const partial = await run(parent, {}, { runId, store: partialStore, durableBackend: partialBackend }); + assert.equal(partial.status, "completed"); + assert.deepEqual(partial.result, { value: 11 }); + assert.equal(toolCalls, 1, "cached child tool must survive a live child/boundary continuation"); + const partialRoot = partialStore.runs().find((candidate) => candidate.id === runId)!; + const partialBoundary = partialRoot.stages.find((stage) => stage.name === "child-boundary")!; + const partialChild = partialStore + .runs() + .find((candidate) => candidate.id === partialBoundary.workflowChild?.runId)!; + assert.deepEqual( + partialChild.toolNodes?.map((tool) => tool.name), + ["child-write"], + ); + assert.equal(partialChild.parentRunId, runId); + assert.equal(partialChild.rootRunId, runId); + const partialEntry = partialBackend.listCompletedWorkflows().find((candidate) => candidate.workflowId === runId)!; + const partialCatalogRuns = completedWorkflowRunSnapshots(partialBackend, partialEntry); + const partialCatalogChild = partialCatalogRuns.find((candidate) => candidate.parentRunId === runId); + assert.equal(partialCatalogChild?.id, partialBoundary.workflowChild?.runId); + assert.deepEqual( + partialCatalogChild?.toolNodes?.map((tool) => tool.name), + ["child-write"], + ); + assert.deepEqual( + expandWorkflowGraph({ runs: partialCatalogRuns, notices: [], version: 1 }, runId).tools.map( + (tool) => tool.name, + ), + ["child-write"], + ); + }); - test("new tool topology survives DBOS flush, fresh hydration, and replay", async () => { - const sdk = createMockSdk(); - const runId = "tool-dbos-restart"; - let toolCalls = 0; - const definition = workflow({ - name: "tool-dbos-restart", - description: "", - inputs: {}, - outputs: { value: Type.Number(), labels: Type.Array(Type.String()) }, - run: async (ctx) => ctx.tool("persist-shape", { key: "same" }, async () => { - toolCalls += 1; - return { value: 42, labels: ["a", "a", "b"] }; - }), - }); - const firstBackend = new DbosDurableBackend(sdk); - const firstStore = createStore(); - const first = await run(definition, {}, { runId, store: firstStore, durableBackend: firstBackend }); - await firstBackend.flush(); - assert.equal(first.status, "completed"); - assert.equal(toolCalls, 1); - const firstTool = firstStore.runs()[0]?.toolNodes?.[0]!; + test("new tool topology survives DBOS flush, fresh hydration, and replay", async () => { + const sdk = createMockSdk(); + const runId = "tool-dbos-restart"; + let toolCalls = 0; + const definition = workflow({ + name: "tool-dbos-restart", + description: "", + inputs: {}, + outputs: { value: Type.Number(), labels: Type.Array(Type.String()) }, + run: async (ctx) => + ctx.tool("persist-shape", { key: "same" }, async () => { + toolCalls += 1; + return { value: 42, labels: ["a", "a", "b"] }; + }), + }); + const firstBackend = new DbosDurableBackend(sdk); + const firstStore = createStore(); + const first = await run(definition, {}, { runId, store: firstStore, durableBackend: firstBackend }); + await firstBackend.flush(); + assert.equal(first.status, "completed"); + assert.equal(toolCalls, 1); + const firstTool = firstStore.runs()[0]?.toolNodes?.[0]; + assert.ok(firstTool); - const freshBackend = new DbosDurableBackend(sdk); - await freshBackend.hydrateWorkflow(runId); - const hydrated = freshBackend.listCheckpoints(runId).find((checkpoint) => checkpoint.kind === "tool"); - assert.equal(hydrated?.kind, "tool"); - if (hydrated?.kind === "tool") { - assert.equal(hydrated.topology?.nodeId, firstTool.id); - assert.equal(hydrated.topology?.order, firstTool.executionOrder); - assert.equal(hydrated.topology?.startedAt, firstTool.startedAt); - } + const freshBackend = new DbosDurableBackend(sdk); + await freshBackend.hydrateWorkflow(runId); + const hydrated = freshBackend.listCheckpoints(runId).find((checkpoint) => checkpoint.kind === "tool"); + assert.equal(hydrated?.kind, "tool"); + if (hydrated?.kind === "tool") { + assert.equal(hydrated.topology?.nodeId, firstTool.id); + assert.equal(hydrated.topology?.order, firstTool.executionOrder); + assert.equal(hydrated.topology?.startedAt, firstTool.startedAt); + } - const replayStore = createStore(); - const replay = await run(definition, {}, { runId, store: replayStore, durableBackend: freshBackend }); - assert.equal(replay.status, "completed"); - assert.deepEqual(replay.result, { value: 42, labels: ["a", "a", "b"] }); - assert.equal(toolCalls, 1); - const replayTool = replayStore.runs()[0]?.toolNodes?.[0]; - assert.deepEqual({ - id: replayTool?.id, order: replayTool?.executionOrder, - startedAt: replayTool?.startedAt, endedAt: replayTool?.endedAt, - }, { - id: firstTool.id, order: firstTool.executionOrder, - startedAt: firstTool.startedAt, endedAt: firstTool.endedAt, - }); - assert.equal(replayTool?.status, "cached"); - }); + const replayStore = createStore(); + const replay = await run(definition, {}, { runId, store: replayStore, durableBackend: freshBackend }); + assert.equal(replay.status, "completed"); + assert.deepEqual(replay.result, { value: 42, labels: ["a", "a", "b"] }); + assert.equal(toolCalls, 1); + const replayTool = replayStore.runs()[0]?.toolNodes?.[0]; + assert.deepEqual( + { + id: replayTool?.id, + order: replayTool?.executionOrder, + startedAt: replayTool?.startedAt, + endedAt: replayTool?.endedAt, + }, + { + id: firstTool.id, + order: firstTool.executionOrder, + startedAt: firstTool.startedAt, + endedAt: firstTool.endedAt, + }, + ); + assert.equal(replayTool?.status, "cached"); + }); }); diff --git a/test/unit/workflow-tool-graph.test.ts b/test/unit/workflow-tool-graph.test.ts index 6c08d670d..4beaa58ca 100644 --- a/test/unit/workflow-tool-graph.test.ts +++ b/test/unit/workflow-tool-graph.test.ts @@ -1,220 +1,266 @@ -import { describe, test } from "bun:test"; import assert from "node:assert/strict"; import { Type } from "typebox"; +import { describe, test } from "vitest"; import { workflow } from "../../packages/workflows/src/authoring/workflow.js"; import { InMemoryDurableBackend } from "../../packages/workflows/src/durable/backend.js"; import { run } from "../../packages/workflows/src/engine/run.js"; -import { createStore } from "../../packages/workflows/src/shared/store.js"; -import { expandWorkflowGraph } from "../../packages/workflows/src/shared/expanded-workflow-graph.js"; import { inspectRun, statusRuns } from "../../packages/workflows/src/runs/background/status.js"; +import { expandWorkflowGraph } from "../../packages/workflows/src/shared/expanded-workflow-graph.js"; +import { createStore } from "../../packages/workflows/src/shared/store.js"; describe("ctx.tool workflow graph execution", () => { - test("tool-only workflow completes with its declared output and one durable side effect", async () => { - const store = createStore(); - const backend = new InMemoryDurableBackend(); - let calls = 0; - const definition = workflow({ - name: "tool-only-graph", - description: "", - inputs: {}, - outputs: { done: Type.Boolean() }, - run: async (ctx) => { - await ctx.tool("irreversible", {}, async () => { - calls += 1; - return true; - }); - return { done: true }; - }, - }); + test("tool-only workflow completes with its declared output and one durable side effect", async () => { + const store = createStore(); + const backend = new InMemoryDurableBackend(); + let calls = 0; + const definition = workflow({ + name: "tool-only-graph", + description: "", + inputs: {}, + outputs: { done: Type.Boolean() }, + run: async (ctx) => { + await ctx.tool("irreversible", {}, async () => { + calls += 1; + return true; + }); + return { done: true }; + }, + }); - const result = await run(definition, {}, { store, durableBackend: backend }); + const result = await run(definition, {}, { store, durableBackend: backend }); - assert.equal(result.status, "completed"); - assert.deepEqual(result.result, { done: true }); - assert.equal(calls, 1); - const checkpoint = backend.listCheckpoints(result.runId).find((entry) => entry.kind === "tool" && entry.name === "irreversible"); - assert.equal(checkpoint?.kind, "tool"); - if (checkpoint?.kind === "tool") { - assert.equal(checkpoint.topology?.nodeId, result.toolNodes?.[0]?.id); - assert.equal(checkpoint.topology?.order, result.toolNodes?.[0]?.executionOrder); - assert.deepEqual(checkpoint.topology?.parentIds, []); - } - assert.equal(backend.listCheckpoints(result.runId).filter((entry) => entry.kind === "tool" && entry.name === "irreversible").length, 1); - assert.deepEqual(result.toolNodes?.map((node) => ({ name: node.name, status: node.status, attachable: node.attachable })), [ - { name: "irreversible", status: "completed", attachable: false }, - ]); - const graph = expandWorkflowGraph(store.snapshot(), result.runId); - assert.equal(graph.stages.length, 0, "stage inspection remains stage-only"); - assert.equal(graph.tools.length, 1); - assert.equal(graph.renderStages[0]?.nodeKind, "tool"); - assert.equal(graph.renderStages[0]?.attachable, false); - assert.equal(graph.targets.has(graph.renderStages[0]!.id), false, "tool nodes have no stage chat target"); - assert.equal(statusRuns({ store })[0]?.toolCount, 1); - const inspected = inspectRun(result.runId, { store }); - assert.equal(inspected.ok, true); - if (inspected.ok) { - assert.equal(inspected.detail.stages.length, 0); - assert.equal(inspected.detail.tools?.[0]?.status, "completed"); - } - }); + assert.equal(result.status, "completed"); + assert.deepEqual(result.result, { done: true }); + assert.equal(calls, 1); + const checkpoint = backend + .listCheckpoints(result.runId) + .find((entry) => entry.kind === "tool" && entry.name === "irreversible"); + assert.equal(checkpoint?.kind, "tool"); + if (checkpoint?.kind === "tool") { + assert.equal(checkpoint.topology?.nodeId, result.toolNodes?.[0]?.id); + assert.equal(checkpoint.topology?.order, result.toolNodes?.[0]?.executionOrder); + assert.deepEqual(checkpoint.topology?.parentIds, []); + } + assert.equal( + backend.listCheckpoints(result.runId).filter((entry) => entry.kind === "tool" && entry.name === "irreversible") + .length, + 1, + ); + assert.deepEqual( + result.toolNodes?.map((node) => ({ name: node.name, status: node.status, attachable: node.attachable })), + [{ name: "irreversible", status: "completed", attachable: false }], + ); + const graph = expandWorkflowGraph(store.snapshot(), result.runId); + assert.equal(graph.stages.length, 0, "stage inspection remains stage-only"); + assert.equal(graph.tools.length, 1); + assert.equal(graph.renderStages[0]?.nodeKind, "tool"); + assert.equal(graph.renderStages[0]?.attachable, false); + assert.equal(graph.targets.has(graph.renderStages[0]!.id), false, "tool nodes have no stage chat target"); + assert.equal(statusRuns({ store })[0]?.toolCount, 1); + const inspected = inspectRun(result.runId, { store }); + assert.equal(inspected.ok, true); + if (inspected.ok) { + assert.equal(inspected.detail.stages.length, 0); + assert.equal(inspected.detail.tools?.[0]?.status, "completed"); + } + }); - test("admits a running tool node before invoking its callback and records failures", async () => { - const store = createStore(); - const backend = new InMemoryDurableBackend(); - const observedStatuses: string[] = []; - const unsubscribe = store.subscribe((snapshot) => { - const status = snapshot.runs[0]?.toolNodes?.[0]?.status; - if (status !== undefined) observedStatuses.push(status); - }); - const entered = Promise.withResolvers(); - const release = Promise.withResolvers(); - const definition = workflow({ - name: "tool-live-node", - description: "", - inputs: {}, - outputs: {}, - run: async (ctx) => { - await ctx.tool("fails-late", {}, async () => { - entered.resolve(); - await release.promise; - throw new Error("original tool failure"); - }); - return {}; - }, - }); + test("admits a running tool node before invoking its callback and records failures", async () => { + const store = createStore(); + const backend = new InMemoryDurableBackend(); + const observedStatuses: string[] = []; + const unsubscribe = store.subscribe((snapshot) => { + const status = snapshot.runs[0]?.toolNodes?.[0]?.status; + if (status !== undefined) observedStatuses.push(status); + }); + const entered = Promise.withResolvers(); + const release = Promise.withResolvers(); + const definition = workflow({ + name: "tool-live-node", + description: "", + inputs: {}, + outputs: {}, + run: async (ctx) => { + await ctx.tool("fails-late", {}, async () => { + entered.resolve(); + await release.promise; + throw new Error("original tool failure"); + }); + return {}; + }, + }); - const pending = run(definition, {}, { store, durableBackend: backend }); - await entered.promise; - const live = store.runs()[0]?.toolNodes?.[0]; - assert.equal(live?.status, "running"); - assert.equal(live?.name, "fails-late"); - assert.equal(live?.attachable, false); - assert.equal(typeof live?.startedAt, "number"); - unsubscribe(); - assert.deepEqual(observedStatuses.slice(0, 2), ["pending", "running"]); - assert.equal(live?.endedAt, undefined); - release.resolve(); + const pending = run(definition, {}, { store, durableBackend: backend }); + await entered.promise; + const live = store.runs()[0]?.toolNodes?.[0]; + assert.equal(live?.status, "running"); + assert.equal(live?.name, "fails-late"); + assert.equal(live?.attachable, false); + assert.equal(typeof live?.startedAt, "number"); + unsubscribe(); + assert.deepEqual(observedStatuses.slice(0, 2), ["pending", "running"]); + assert.equal(live?.endedAt, undefined); + release.resolve(); - const result = await pending; - assert.equal(result.status, "failed"); - assert.match(result.error ?? "", /original tool failure/); - assert.equal(store.runs()[0]?.failedStageId, undefined); - assert.equal(result.toolNodes?.[0]?.status, "failed"); - assert.match(result.toolNodes?.[0]?.error ?? "", /original tool failure/); - assert.equal(backend.listCheckpoints(result.runId).some((entry) => entry.kind === "tool" && entry.name === "fails-late" && entry.throwingFailureError === "original tool failure"), true); - assert.equal(backend.getToolCheckpoint(result.runId, result.toolNodes![0]!.argsHash), undefined); - }); + const result = await pending; + assert.equal(result.status, "failed"); + assert.match(result.error ?? "", /original tool failure/); + assert.equal(store.runs()[0]?.failedStageId, undefined); + assert.equal(result.toolNodes?.[0]?.status, "failed"); + assert.match(result.toolNodes?.[0]?.error ?? "", /original tool failure/); + assert.equal( + backend + .listCheckpoints(result.runId) + .some( + (entry) => + entry.kind === "tool" && + entry.name === "fails-late" && + entry.throwingFailureError === "original tool failure", + ), + true, + ); + assert.equal(backend.getToolCheckpoint(result.runId, result.toolNodes![0]!.argsHash), undefined); + }); - test("preserves tool-before, between, after, duplicate order with stage topology", async () => { - const store = createStore(); - const definition = workflow({ - name: "mixed-tool-order", - description: "", - inputs: {}, - outputs: {}, - run: async (ctx) => { - await ctx.tool("first", {}, async () => "first"); - await ctx.stage("stage-one").prompt("one"); - await ctx.tool("middle", {}, async () => "middle"); - await ctx.stage("stage-two").prompt("two"); - await ctx.tool("last", {}, async () => "last"); - await ctx.tool("last", {}, async () => "last-again"); - return {}; - }, - }); + test("preserves tool-before, between, after, duplicate order with stage topology", async () => { + const store = createStore(); + const definition = workflow({ + name: "mixed-tool-order", + description: "", + inputs: {}, + outputs: {}, + run: async (ctx) => { + await ctx.tool("first", {}, async () => "first"); + await ctx.stage("stage-one").prompt("one"); + await ctx.tool("middle", {}, async () => "middle"); + await ctx.stage("stage-two").prompt("two"); + await ctx.tool("last", {}, async () => "last"); + await ctx.tool("last", {}, async () => "last-again"); + return {}; + }, + }); - const result = await run(definition, {}, { - store, - durableBackend: new InMemoryDurableBackend(), - adapters: { prompt: { prompt: async (text) => text } }, - }); - assert.equal(result.status, "completed"); - const snapshot = store.runs()[0]!; - const ordered = [ - ...snapshot.stages.map((stage) => ({ name: stage.name, order: stage.executionOrder })), - ...(snapshot.toolNodes ?? []).map((node) => ({ name: node.name, order: node.executionOrder })), - ].sort((left, right) => (left.order ?? 0) - (right.order ?? 0)); - assert.deepEqual(ordered.map((item) => item.name), ["first", "stage-one", "middle", "stage-two", "last", "last"]); - assert.equal(snapshot.toolNodes?.[2]?.ordinal, 1); - assert.equal(snapshot.toolNodes?.[3]?.ordinal, 2); - assert.deepEqual(snapshot.stages[0]?.parentIds, [snapshot.toolNodes?.[0]?.id]); - assert.deepEqual(snapshot.toolNodes?.[1]?.parentIds, [snapshot.stages[0]?.id]); - }); + const result = await run( + definition, + {}, + { + store, + durableBackend: new InMemoryDurableBackend(), + adapters: { prompt: { prompt: async (text) => text } }, + }, + ); + assert.equal(result.status, "completed"); + const snapshot = store.runs()[0]!; + const ordered = [ + ...snapshot.stages.map((stage) => ({ name: stage.name, order: stage.executionOrder })), + ...(snapshot.toolNodes ?? []).map((node) => ({ name: node.name, order: node.executionOrder })), + ].sort((left, right) => (left.order ?? 0) - (right.order ?? 0)); + assert.deepEqual( + ordered.map((item) => item.name), + ["first", "stage-one", "middle", "stage-two", "last", "last"], + ); + assert.equal(snapshot.toolNodes?.[2]?.ordinal, 1); + assert.equal(snapshot.toolNodes?.[3]?.ordinal, 2); + assert.deepEqual(snapshot.stages[0]?.parentIds, [snapshot.toolNodes?.[0]?.id]); + assert.deepEqual(snapshot.toolNodes?.[1]?.parentIds, [snapshot.stages[0]?.id]); + }); - test("replays a legacy tool checkpoint without rerunning and reconstructs cached topology", async () => { - const workflowId = "legacy-tool-only"; - const backend = new InMemoryDurableBackend(); - backend.registerWorkflow({ workflowId, name: "legacy-tool-only", inputs: {}, createdAt: 1, status: "paused", resumable: true }); - const { durableHash } = await import("../../packages/workflows/src/durable/backend.js"); - const argsHash = durableHash({ name: "legacy", args: {}, ordinal: 1 }); - backend.recordCheckpoint({ kind: "tool", workflowId, checkpointId: `tool:${argsHash}`, name: "legacy", argsHash, output: { raw: true }, completedAt: 2 }); - let calls = 0; - const definition = workflow({ - name: "legacy-tool-only", - description: "", - inputs: {}, - outputs: { raw: Type.Boolean() }, - run: async (ctx) => { - const value = await ctx.tool("legacy", {}, async () => { - calls += 1; - return { raw: false }; - }); - return value; - }, - }); + test("replays a legacy tool checkpoint without rerunning and reconstructs cached topology", async () => { + const workflowId = "legacy-tool-only"; + const backend = new InMemoryDurableBackend(); + backend.registerWorkflow({ + workflowId, + name: "legacy-tool-only", + inputs: {}, + createdAt: 1, + status: "paused", + resumable: true, + }); + const { durableHash } = await import("../../packages/workflows/src/durable/backend.js"); + const argsHash = durableHash({ name: "legacy", args: {}, ordinal: 1 }); + backend.recordCheckpoint({ + kind: "tool", + workflowId, + checkpointId: `tool:${argsHash}`, + name: "legacy", + argsHash, + output: { raw: true }, + completedAt: 2, + }); + let calls = 0; + const definition = workflow({ + name: "legacy-tool-only", + description: "", + inputs: {}, + outputs: { raw: Type.Boolean() }, + run: async (ctx) => { + const value = await ctx.tool("legacy", {}, async () => { + calls += 1; + return { raw: false }; + }); + return value; + }, + }); - const result = await run(definition, {}, { runId: workflowId, store: createStore(), durableBackend: backend }); - assert.equal(result.status, "completed"); - assert.deepEqual(result.result, { raw: true }); - assert.equal(calls, 0); - assert.equal(result.toolNodes?.[0]?.status, "cached"); - assert.equal(result.toolNodes?.[0]?.id, `tool:${argsHash}`); - assert.equal(result.toolNodes?.[0]?.executionOrder, 1); - }); + const result = await run(definition, {}, { runId: workflowId, store: createStore(), durableBackend: backend }); + assert.equal(result.status, "completed"); + assert.deepEqual(result.result, { raw: true }); + assert.equal(calls, 0); + assert.equal(result.toolNodes?.[0]?.status, "cached"); + assert.equal(result.toolNodes?.[0]?.id, `tool:${argsHash}`); + assert.equal(result.toolNodes?.[0]?.executionOrder, 1); + }); - test("accepts a conditional branch whose only graph execution is ctx.tool", async () => { - const definition = workflow({ - name: "conditional-tool-only", - description: "", - inputs: { mutate: Type.Boolean() }, - outputs: { done: Type.Boolean() }, - run: async (ctx) => { - if (ctx.inputs.mutate) await ctx.tool("conditional", {}, async () => true); - return { done: true }; - }, - }); - const result = await run(definition, { mutate: true }, { store: createStore(), durableBackend: new InMemoryDurableBackend() }); - assert.equal(result.status, "completed"); - assert.equal(result.toolNodes?.[0]?.name, "conditional"); - }); + test("accepts a conditional branch whose only graph execution is ctx.tool", async () => { + const definition = workflow({ + name: "conditional-tool-only", + description: "", + inputs: { mutate: Type.Boolean() }, + outputs: { done: Type.Boolean() }, + run: async (ctx) => { + if (ctx.inputs.mutate) await ctx.tool("conditional", {}, async () => true); + return { done: true }; + }, + }); + const result = await run( + definition, + { mutate: true }, + { store: createStore(), durableBackend: new InMemoryDurableBackend() }, + ); + assert.equal(result.status, "completed"); + assert.equal(result.toolNodes?.[0]?.name, "conditional"); + }); - test("cancellation leaves a cancelled node and no completed tool checkpoint", async () => { - const store = createStore(); - const backend = new InMemoryDurableBackend(); - const controller = new AbortController(); - const entered = Promise.withResolvers(); - const release = Promise.withResolvers(); - const definition = workflow({ - name: "cancelled-tool", - description: "", - inputs: {}, - outputs: {}, - run: async (ctx) => { - await ctx.tool("cancel-me", {}, async () => { - entered.resolve(); - await release.promise; - return "late"; - }); - return {}; - }, - }); - const pending = run(definition, {}, { store, durableBackend: backend, signal: controller.signal }); - await entered.promise; - controller.abort(new Error("stop")); - release.resolve(); - const result = await pending; - assert.equal(result.status, "killed"); - assert.equal(result.toolNodes?.[0]?.status, "cancelled"); - assert.equal(backend.listCheckpoints(result.runId).some((entry) => entry.kind === "tool" && entry.name === "cancel-me"), false); - }); + test("cancellation leaves a cancelled node and no completed tool checkpoint", async () => { + const store = createStore(); + const backend = new InMemoryDurableBackend(); + const controller = new AbortController(); + const entered = Promise.withResolvers(); + const release = Promise.withResolvers(); + const definition = workflow({ + name: "cancelled-tool", + description: "", + inputs: {}, + outputs: {}, + run: async (ctx) => { + await ctx.tool("cancel-me", {}, async () => { + entered.resolve(); + await release.promise; + return "late"; + }); + return {}; + }, + }); + const pending = run(definition, {}, { store, durableBackend: backend, signal: controller.signal }); + await entered.promise; + controller.abort(new Error("stop")); + release.resolve(); + const result = await pending; + assert.equal(result.status, "killed"); + assert.equal(result.toolNodes?.[0]?.status, "cancelled"); + assert.equal( + backend.listCheckpoints(result.runId).some((entry) => entry.kind === "tool" && entry.name === "cancel-me"), + false, + ); + }); }); diff --git a/test/unit/workflow-tool-interactions.test.ts b/test/unit/workflow-tool-interactions.test.ts index 40bf1359c..a7b3f20df 100644 --- a/test/unit/workflow-tool-interactions.test.ts +++ b/test/unit/workflow-tool-interactions.test.ts @@ -1,151 +1,215 @@ // @ts-nocheck -- intentional white-box GraphView input coverage -import { afterEach, beforeEach, describe, test } from "bun:test"; + import assert from "node:assert/strict"; -import { workflowInterruptAction, workflowPauseAction, workflowResumeAction } from "../../packages/workflows/src/extension/workflow-tool-control.js"; +import { afterEach, beforeEach, describe, test } from "vitest"; import { handleRunControlCommand } from "../../packages/workflows/src/extension/workflow-run-control-command.js"; -import { workflowSendAction } from "../../packages/workflows/src/extension/workflow-tool-send.js"; import { resolveStageTarget } from "../../packages/workflows/src/extension/workflow-targets.js"; +import { + workflowInterruptAction, + workflowPauseAction, + workflowResumeAction, +} from "../../packages/workflows/src/extension/workflow-tool-control.js"; +import { workflowSendAction } from "../../packages/workflows/src/extension/workflow-tool-send.js"; import { stageControlRegistry } from "../../packages/workflows/src/runs/foreground/stage-control-registry.js"; -import { createStore, store } from "../../packages/workflows/src/shared/store.js"; import { expandWorkflowGraph } from "../../packages/workflows/src/shared/expanded-workflow-graph.js"; +import { createStore, store } from "../../packages/workflows/src/shared/store.js"; import { GraphView } from "../../packages/workflows/src/tui/graph-view.js"; import { computeLayout, NODE_H, NODE_W } from "../../packages/workflows/src/tui/layout.js"; import { defaultTheme } from "./overlay-graph-helpers.js"; function recordToolOnly(target = createStore(), status: "running" | "completed" = "running") { - target.recordRunStart({ - id: "tool-interaction-run", name: "tool interaction", inputs: {}, status, - stages: [], toolNodes: [{ - kind: "tool", id: "tool:publish", name: "publish-api", argsHash: "hash", ordinal: 1, - parentIds: [], status: status === "running" ? "running" : "completed", attachable: false, - }], - startedAt: 1, - ...(status === "completed" ? { endedAt: 2 } : {}), - }); - return target; + target.recordRunStart({ + id: "tool-interaction-run", + name: "tool interaction", + inputs: {}, + status, + stages: [], + toolNodes: [ + { + kind: "tool", + id: "tool:publish", + name: "publish-api", + argsHash: "hash", + ordinal: 1, + parentIds: [], + status: status === "running" ? "running" : "completed", + attachable: false, + }, + ], + startedAt: 1, + ...(status === "completed" ? { endedAt: 2 } : {}), + }); + return target; } function clickForSingleNode(stage, width = 96, rows = 32): string { - const [node] = computeLayout([stage], { orientation: "vertical" }); - const marginRows = 1; - const panelRows = rows - marginRows * 2; - const bodyRows = panelRows - 6; - const totalGraphRows = node.y + NODE_H; - const topPad = totalGraphRows <= bodyRows ? Math.min(3, Math.max(0, Math.floor((bodyRows - totalGraphRows) / 2))) : 0; - const graphInner = Math.max(1, Math.max(40, width) - 4); - const canvasWidth = node.x + NODE_W; - const leftMargin = Math.max(2, canvasWidth <= graphInner ? Math.floor((graphInner - canvasWidth) / 2) : 2); - const col = leftMargin + node.x + 2; - const row = marginRows + 3 + topPad + node.y + 2; - return `\x1b[<0;${col + 1};${row + 1}M`; + const [node] = computeLayout([stage], { orientation: "vertical" }); + const marginRows = 1; + const panelRows = rows - marginRows * 2; + const bodyRows = panelRows - 6; + const totalGraphRows = node.y + NODE_H; + const topPad = + totalGraphRows <= bodyRows ? Math.min(3, Math.max(0, Math.floor((bodyRows - totalGraphRows) / 2))) : 0; + const graphInner = Math.max(1, Math.max(40, width) - 4); + const canvasWidth = node.x + NODE_W; + const leftMargin = Math.max(2, canvasWidth <= graphInner ? Math.floor((graphInner - canvasWidth) / 2) : 2); + const col = leftMargin + node.x + 2; + const row = marginRows + 3 + topPad + node.y + 2; + return `\x1b[<0;${col + 1};${row + 1}M`; } -beforeEach(() => { store.clear(); stageControlRegistry.clear(); }); -afterEach(() => { store.clear(); stageControlRegistry.clear(); }); +beforeEach(() => { + store.clear(); + stageControlRegistry.clear(); +}); +afterEach(() => { + store.clear(); + stageControlRegistry.clear(); +}); describe("non-attachable tool interactions", () => { - test("keyboard, direct mouse, and switcher activation never attach a tool", () => { - const localStore = recordToolOnly(); - const graph = expandWorkflowGraph(localStore.snapshot(), "tool-interaction-run"); - const attached: string[] = []; - const view = new GraphView({ - mode: "overlay", runId: "tool-interaction-run", store: localStore, graphTheme: defaultTheme, - getViewportRows: () => 32, - onStageAttach: (_runId, stageId) => attached.push(stageId), - }); + test("keyboard, direct mouse, and switcher activation never attach a tool", () => { + const localStore = recordToolOnly(); + const graph = expandWorkflowGraph(localStore.snapshot(), "tool-interaction-run"); + const attached: string[] = []; + const view = new GraphView({ + mode: "overlay", + runId: "tool-interaction-run", + store: localStore, + graphTheme: defaultTheme, + getViewportRows: () => 32, + onStageAttach: (_runId, stageId) => attached.push(stageId), + }); - view.render(96); - assert.equal(view.handleInput("\r"), true, "keyboard activation"); - assert.equal(view.handleInput(clickForSingleNode(graph.renderStages[0]!)), true, "direct mouse activation"); - assert.equal(view.handleInput("/"), true); - for (const char of "publish-api") view.handleInput(char); - assert.equal(view.handleInput("\r"), true, "switcher activation"); - assert.deepEqual(attached, []); - view.dispose(); - }); + view.render(96); + assert.equal(view.handleInput("\r"), true, "keyboard activation"); + assert.equal(view.handleInput(clickForSingleNode(graph.renderStages[0]!)), true, "direct mouse activation"); + assert.equal(view.handleInput("/"), true); + for (const char of "publish-api") view.handleInput(char); + assert.equal(view.handleInput("\r"), true, "switcher activation"); + assert.deepEqual(attached, []); + view.dispose(); + }); - test("keyboard, mouse, and switcher activation open a retained completed stage", () => { - const localStore = createStore(); - localStore.recordRunStart({ - id: "postmortem-run", name: "postmortem", inputs: {}, status: "completed", startedAt: 1, endedAt: 2, - stages: [{ - id: "retained-stage", name: "retained-stage", status: "completed", parentIds: [], - toolEvents: [], attachable: false, sessionFile: "/tmp/retained-session.jsonl", - }], - }); - const graph = expandWorkflowGraph(localStore.snapshot(), "postmortem-run"); - const attached: string[] = []; - const view = new GraphView({ - mode: "overlay", runId: "postmortem-run", store: localStore, graphTheme: defaultTheme, - getViewportRows: () => 32, - onStageAttach: (runId, stageId) => attached.push(`${runId}/${stageId}`), - }); + test("keyboard, mouse, and switcher activation open a retained completed stage", () => { + const localStore = createStore(); + localStore.recordRunStart({ + id: "postmortem-run", + name: "postmortem", + inputs: {}, + status: "completed", + startedAt: 1, + endedAt: 2, + stages: [ + { + id: "retained-stage", + name: "retained-stage", + status: "completed", + parentIds: [], + toolEvents: [], + attachable: false, + sessionFile: "/tmp/retained-session.jsonl", + }, + ], + }); + const graph = expandWorkflowGraph(localStore.snapshot(), "postmortem-run"); + const attached: string[] = []; + const view = new GraphView({ + mode: "overlay", + runId: "postmortem-run", + store: localStore, + graphTheme: defaultTheme, + getViewportRows: () => 32, + onStageAttach: (runId, stageId) => attached.push(`${runId}/${stageId}`), + }); - view.render(96); - view.handleInput("\r"); - view.handleInput(clickForSingleNode(graph.renderStages[0]!)); - view.handleInput("/"); - for (const char of "retained-stage") view.handleInput(char); - view.handleInput("\r"); - assert.deepEqual(attached, [ - "postmortem-run/retained-stage", - "postmortem-run/retained-stage", - "postmortem-run/retained-stage", - ]); - view.dispose(); - }); + view.render(96); + view.handleInput("\r"); + view.handleInput(clickForSingleNode(graph.renderStages[0]!)); + view.handleInput("/"); + for (const char of "retained-stage") view.handleInput(char); + view.handleInput("\r"); + assert.deepEqual(attached, [ + "postmortem-run/retained-stage", + "postmortem-run/retained-stage", + "postmortem-run/retained-stage", + ]); + view.dispose(); + }); - test("terminal sends reject before textual tool targeting can create a handle", async () => { - recordToolOnly(store, "completed"); - for (const target of ["tool:publish", "publish-api"]) { - const resolved = resolveStageTarget("tool-interaction-run", target); - assert.equal(resolved.ok, false, `${target} must not resolve as a stage`); - } + test("terminal sends reject before textual tool targeting can create a handle", async () => { + recordToolOnly(store, "completed"); + for (const target of ["tool:publish", "publish-api"]) { + const resolved = resolveStageTarget("tool-interaction-run", target); + assert.equal(resolved.ok, false, `${target} must not resolve as a stage`); + } - let postMortemCreates = 0; - const sent = await workflowSendAction( - { action: "send", runId: "tool-interaction-run", stageId: "tool:publish", text: "chat" }, - { resolvePostMortemDeps: () => { postMortemCreates += 1; throw new Error("must not create"); } }, - ); - const paused = await workflowPauseAction({ action: "pause", runId: "tool-interaction-run", stageId: "tool:publish" }); - let overlayOpens = 0; - const commandErrors: string[] = []; - await handleRunControlCommand( - "attach", - ["tool-interaction-run", "tool:publish"], - {}, - { info() {}, error(message) { commandErrors.push(message); } }, - { - pi: {}, - overlay: { open() { overlayOpens += 1; } }, - runtimeForContext: () => ({ prepareDurableResumable: async () => [] }), - ensureWorkflowResourcesLoaded() {}, - }, - ); - const interrupted = await workflowInterruptAction({ action: "interrupt", runId: "tool-interaction-run", stageId: "tool:publish" }); + let postMortemCreates = 0; + const sent = await workflowSendAction( + { action: "send", runId: "tool-interaction-run", stageId: "tool:publish", text: "chat" }, + { + resolvePostMortemDeps: () => { + postMortemCreates += 1; + throw new Error("must not create"); + }, + }, + ); + const paused = await workflowPauseAction({ + action: "pause", + runId: "tool-interaction-run", + stageId: "tool:publish", + }); + let overlayOpens = 0; + const commandErrors: string[] = []; + await handleRunControlCommand( + "attach", + ["tool-interaction-run", "tool:publish"], + {}, + { + info() {}, + error(message) { + commandErrors.push(message); + }, + }, + { + pi: {}, + overlay: { + open() { + overlayOpens += 1; + }, + }, + runtimeForContext: () => ({ prepareDurableResumable: async () => [] }), + ensureWorkflowResourcesLoaded() {}, + }, + ); + const interrupted = await workflowInterruptAction({ + action: "interrupt", + runId: "tool-interaction-run", + stageId: "tool:publish", + }); - assert.equal(sent.status, "failed"); - if (sent.status === "failed") { - assert.equal(sent.code, "WORKFLOW_TERMINAL"); - assert.equal(sent.workflowStatus, "completed"); - } - assert.equal(sent.delivery, "rejected"); - assert.equal(paused.status, "noop"); - assert.equal(interrupted.status, "noop"); - assert.match(sent.message, /workflow tool-interaction-run has terminated with status completed/); - assert.match(`${paused.message}\n${interrupted.message}`, /Stage not found/); - assert.equal(postMortemCreates, 0); - const resumed = await workflowResumeAction( - { action: "resume", runId: "tool-interaction-run", stageId: "tool:publish" }, - { - getRuntime: () => ({ prepareDurableResumable: async () => [] }), - policy: {}, - ensureWorkflowResourcesLoaded() {}, - }, - ); - assert.equal(resumed.status, "noop"); - assert.match(commandErrors.join("\n"), /Stage not found/); - assert.equal(overlayOpens, 0); - assert.deepEqual(stageControlRegistry.forRun("tool-interaction-run"), []); - }); -}); \ No newline at end of file + assert.equal(sent.status, "failed"); + if (sent.status === "failed") { + assert.equal(sent.code, "WORKFLOW_TERMINAL"); + assert.equal(sent.workflowStatus, "completed"); + } + assert.equal(sent.delivery, "rejected"); + assert.equal(paused.status, "noop"); + assert.equal(interrupted.status, "noop"); + assert.match(sent.message, /workflow tool-interaction-run has terminated with status completed/); + assert.match(`${paused.message}\n${interrupted.message}`, /Stage not found/); + assert.equal(postMortemCreates, 0); + const resumed = await workflowResumeAction( + { action: "resume", runId: "tool-interaction-run", stageId: "tool:publish" }, + { + getRuntime: () => ({ prepareDurableResumable: async () => [] }), + policy: {}, + ensureWorkflowResourcesLoaded() {}, + }, + ); + assert.equal(resumed.status, "noop"); + assert.match(commandErrors.join("\n"), /Stage not found/); + assert.equal(overlayOpens, 0); + assert.deepEqual(stageControlRegistry.forRun("tool-interaction-run"), []); + }); +}); diff --git a/test/unit/workflow-tool-legacy-child-migration.test.ts b/test/unit/workflow-tool-legacy-child-migration.test.ts index ed78a2ed9..dc1a47d79 100644 --- a/test/unit/workflow-tool-legacy-child-migration.test.ts +++ b/test/unit/workflow-tool-legacy-child-migration.test.ts @@ -1,362 +1,586 @@ -import { describe, test } from "bun:test"; import assert from "node:assert/strict"; import { Type } from "typebox"; +import { describe, test } from "vitest"; import { workflow } from "../../packages/workflows/src/authoring/workflow.js"; import { InMemoryDurableBackend } from "../../packages/workflows/src/durable/backend.js"; +import { completedWorkflowRunSnapshots } from "../../packages/workflows/src/durable/completed-catalog.js"; import { DbosDurableBackend } from "../../packages/workflows/src/durable/dbos-backend.js"; import type { DurableToolCheckpoint } from "../../packages/workflows/src/durable/types.js"; -import { completedWorkflowRunSnapshots } from "../../packages/workflows/src/durable/completed-catalog.js"; import { run } from "../../packages/workflows/src/engine/run.js"; -import { createStore } from "../../packages/workflows/src/shared/store.js"; import { expandWorkflowGraph } from "../../packages/workflows/src/shared/expanded-workflow-graph.js"; +import { createStore } from "../../packages/workflows/src/shared/store.js"; import { createMockSdk } from "./durable-dbos-backend-helpers.js"; describe("legacy scoped child tool topology migration", () => { - test("replays without callback and appends current child ownership", async () => { - const runId = "legacy-child-tool-root"; - let callbackCalls = 0; - const child = workflow({ - name: "legacy-child-tool", description: "", inputs: {}, outputs: { value: Type.Number() }, - run: async (ctx) => ({ - value: await ctx.tool("legacy-child-write", {}, async () => { callbackCalls += 1; return 17; }), - }), - }); - const parent = workflow({ - name: "legacy-child-tool-root", description: "", inputs: {}, outputs: { value: Type.Number() }, - run: async (ctx) => { - const result = await ctx.workflow(child, { stageName: "child-boundary" }); - if (result.exited) throw new Error("unexpected child exit"); - return result.outputs; - }, - }); + test("replays without callback and appends current child ownership", async () => { + const runId = "legacy-child-tool-root"; + let callbackCalls = 0; + const child = workflow({ + name: "legacy-child-tool", + description: "", + inputs: {}, + outputs: { value: Type.Number() }, + run: async (ctx) => ({ + value: await ctx.tool("legacy-child-write", {}, async () => { + callbackCalls += 1; + return 17; + }), + }), + }); + const parent = workflow({ + name: "legacy-child-tool-root", + description: "", + inputs: {}, + outputs: { value: Type.Number() }, + run: async (ctx) => { + const result = await ctx.workflow(child, { stageName: "child-boundary" }); + if (result.exited) throw new Error("unexpected child exit"); + return result.outputs; + }, + }); - const seedBackend = new InMemoryDurableBackend(); - await run(parent, {}, { runId, store: createStore(), durableBackend: seedBackend }); - const seeded = seedBackend.listCheckpoints(runId).find( - (checkpoint): checkpoint is DurableToolCheckpoint => checkpoint.kind === "tool" && checkpoint.name === "legacy-child-write", - ); - callbackCalls = 0; - assert.ok(seeded !== undefined); - const legacy: DurableToolCheckpoint = { - kind: "tool", - workflowId: runId, - checkpointId: seeded.checkpointId, - name: seeded.name, - argsHash: seeded.argsHash, - output: seeded.output, - completedAt: 1, - }; + const seedBackend = new InMemoryDurableBackend(); + await run(parent, {}, { runId, store: createStore(), durableBackend: seedBackend }); + const seeded = seedBackend + .listCheckpoints(runId) + .find( + (checkpoint): checkpoint is DurableToolCheckpoint => + checkpoint.kind === "tool" && checkpoint.name === "legacy-child-write", + ); + callbackCalls = 0; + assert.ok(seeded !== undefined); + const legacy: DurableToolCheckpoint = { + kind: "tool", + workflowId: runId, + checkpointId: seeded.checkpointId, + name: seeded.name, + argsHash: seeded.argsHash, + output: seeded.output, + completedAt: 1, + }; - const backend = new InMemoryDurableBackend(); - backend.registerWorkflow({ - workflowId: runId, name: parent.name, inputs: {}, createdAt: 1, status: "paused", resumable: true, - }); - backend.recordCheckpoint(legacy); - const store = createStore(); - const result = await run(parent, {}, { runId, store, durableBackend: backend }); + const backend = new InMemoryDurableBackend(); + backend.registerWorkflow({ + workflowId: runId, + name: parent.name, + inputs: {}, + createdAt: 1, + status: "paused", + resumable: true, + }); + backend.recordCheckpoint(legacy); + const store = createStore(); + const result = await run(parent, {}, { runId, store, durableBackend: backend }); - assert.equal(result.status, "completed"); - assert.deepEqual(result.result, { value: 17 }); - assert.equal(callbackCalls, 0, "legacy replay must not call the child tool again"); - const boundary = store.runs().find((candidate) => candidate.id === runId)?.stages[0]; - const childRun = store.runs().find((candidate) => candidate.id === boundary?.workflowChild?.runId); - assert.equal(childRun?.toolNodes?.[0]?.status, "cached"); - const logicalRecords = backend.listCheckpoints(runId).filter( - (checkpoint): checkpoint is DurableToolCheckpoint => checkpoint.kind === "tool" && checkpoint.argsHash === legacy.argsHash, - ); - assert.equal(logicalRecords.length, 2, "migration appends instead of replacing the legacy checkpoint"); - const migrated = logicalRecords.at(-1); - assert.equal(migrated?.topology?.run?.runId, childRun?.id); - assert.equal(migrated?.name, "legacy-child-write"); - assert.equal(migrated?.topology?.endedAt, legacy.completedAt); - assert.equal(migrated?.topology?.run?.parentRunId, runId); - assert.equal(migrated?.topology?.run?.parentStageId, boundary?.id); - assert.equal(migrated?.output, legacy.output); + assert.equal(result.status, "completed"); + assert.deepEqual(result.result, { value: 17 }); + assert.equal(callbackCalls, 0, "legacy replay must not call the child tool again"); + const boundary = store.runs().find((candidate) => candidate.id === runId)?.stages[0]; + const childRun = store.runs().find((candidate) => candidate.id === boundary?.workflowChild?.runId); + assert.equal(childRun?.toolNodes?.[0]?.status, "cached"); + const logicalRecords = backend + .listCheckpoints(runId) + .filter( + (checkpoint): checkpoint is DurableToolCheckpoint => + checkpoint.kind === "tool" && checkpoint.argsHash === legacy.argsHash, + ); + assert.equal(logicalRecords.length, 2, "migration appends instead of replacing the legacy checkpoint"); + const migrated = logicalRecords.at(-1); + assert.equal(migrated?.topology?.run?.runId, childRun?.id); + assert.equal(migrated?.name, "legacy-child-write"); + assert.equal(migrated?.topology?.endedAt, legacy.completedAt); + assert.equal(migrated?.topology?.run?.parentRunId, runId); + assert.equal(migrated?.topology?.run?.parentStageId, boundary?.id); + assert.equal(migrated?.output, legacy.output); - const entry = backend.listCompletedWorkflows().find((candidate) => candidate.workflowId === runId)!; - const catalogRuns = completedWorkflowRunSnapshots(backend, entry); - const catalogChild = catalogRuns.find((candidate) => candidate.parentRunId === runId); - assert.equal(catalogChild?.id, childRun?.id); - assert.deepEqual(catalogChild?.toolNodes?.map((node) => node.name), ["legacy-child-write"]); - assert.deepEqual( - expandWorkflowGraph({ runs: catalogRuns, notices: [], version: 1 }, runId).tools.map((node) => node.name), - ["legacy-child-write"], - ); - }); + const entry = backend.listCompletedWorkflows().find((candidate) => candidate.workflowId === runId)!; + const catalogRuns = completedWorkflowRunSnapshots(backend, entry); + const catalogChild = catalogRuns.find((candidate) => candidate.parentRunId === runId); + assert.equal(catalogChild?.id, childRun?.id); + assert.deepEqual( + catalogChild?.toolNodes?.map((node) => node.name), + ["legacy-child-write"], + ); + assert.deepEqual( + expandWorkflowGraph({ runs: catalogRuns, notices: [], version: 1 }, runId).tools.map((node) => node.name), + ["legacy-child-write"], + ); + }); - test("migrated child tool remains the parent of a following stage", async () => { - const runId = "legacy-child-tool-stage-root"; - let callbackCalls = 0; - const seedChild = workflow({ - name: "legacy-mixed-child", description: "", inputs: {}, outputs: {}, - run: async (ctx) => { await ctx.tool("legacy-before-stage", {}, async () => { callbackCalls += 1; return "cached"; }); return {}; }, - }); - const makeParent = (child: typeof seedChild) => workflow({ - name: "legacy-child-tool-stage-root", description: "", inputs: {}, outputs: {}, - run: async (ctx) => { await ctx.workflow(child, { stageName: "child-boundary" }); return {}; }, - }); - const seedBackend = new InMemoryDurableBackend(); - await run(makeParent(seedChild), {}, { runId, store: createStore(), durableBackend: seedBackend }); - const seeded = seedBackend.listCheckpoints(runId).find( - (checkpoint): checkpoint is DurableToolCheckpoint => checkpoint.kind === "tool" && checkpoint.name === "legacy-before-stage", - )!; - callbackCalls = 0; - const backend = new InMemoryDurableBackend(); - backend.registerWorkflow({ workflowId: runId, name: "legacy-child-tool-stage-root", inputs: {}, createdAt: 1, status: "paused", resumable: true }); - backend.recordCheckpoint({ - kind: "tool", workflowId: runId, checkpointId: seeded.checkpointId, name: seeded.name, - argsHash: seeded.argsHash, output: seeded.output, completedAt: 1, - }); - const replayChild = workflow({ - name: "legacy-mixed-child", description: "", inputs: {}, outputs: {}, - run: async (ctx) => { - await ctx.tool("legacy-before-stage", {}, async () => { callbackCalls += 1; return "unexpected"; }); - await ctx.stage("after-legacy-tool").prompt("continue"); - return {}; - }, - }); - const store = createStore(); - const result = await run(makeParent(replayChild as typeof seedChild), {}, { - runId, store, durableBackend: backend, adapters: { prompt: { prompt: async () => "done" } }, - }); + test("migrated child tool remains the parent of a following stage", async () => { + const runId = "legacy-child-tool-stage-root"; + let callbackCalls = 0; + const seedChild = workflow({ + name: "legacy-mixed-child", + description: "", + inputs: {}, + outputs: {}, + run: async (ctx) => { + await ctx.tool("legacy-before-stage", {}, async () => { + callbackCalls += 1; + return "cached"; + }); + return {}; + }, + }); + const makeParent = (child: typeof seedChild) => + workflow({ + name: "legacy-child-tool-stage-root", + description: "", + inputs: {}, + outputs: {}, + run: async (ctx) => { + await ctx.workflow(child, { stageName: "child-boundary" }); + return {}; + }, + }); + const seedBackend = new InMemoryDurableBackend(); + await run(makeParent(seedChild), {}, { runId, store: createStore(), durableBackend: seedBackend }); + const seeded = seedBackend + .listCheckpoints(runId) + .find( + (checkpoint): checkpoint is DurableToolCheckpoint => + checkpoint.kind === "tool" && checkpoint.name === "legacy-before-stage", + )!; + callbackCalls = 0; + const backend = new InMemoryDurableBackend(); + backend.registerWorkflow({ + workflowId: runId, + name: "legacy-child-tool-stage-root", + inputs: {}, + createdAt: 1, + status: "paused", + resumable: true, + }); + backend.recordCheckpoint({ + kind: "tool", + workflowId: runId, + checkpointId: seeded.checkpointId, + name: seeded.name, + argsHash: seeded.argsHash, + output: seeded.output, + completedAt: 1, + }); + const replayChild = workflow({ + name: "legacy-mixed-child", + description: "", + inputs: {}, + outputs: {}, + run: async (ctx) => { + await ctx.tool("legacy-before-stage", {}, async () => { + callbackCalls += 1; + return "unexpected"; + }); + await ctx.stage("after-legacy-tool").prompt("continue"); + return {}; + }, + }); + const store = createStore(); + const result = await run( + makeParent(replayChild as typeof seedChild), + {}, + { + runId, + store, + durableBackend: backend, + adapters: { prompt: { prompt: async () => "done" } }, + }, + ); - assert.equal(result.status, "completed"); - assert.equal(callbackCalls, 0); - const childRun = store.runs().find((candidate) => candidate.parentRunId === runId)!; - const childTool = childRun.toolNodes?.[0]!; - const childStage = childRun.stages[0]!; - assert.equal(childTool.status, "cached"); - assert.deepEqual(childStage.parentIds, [childTool.id]); - const records = backend.listCheckpoints(runId); - const migrated = records.filter( - (checkpoint): checkpoint is DurableToolCheckpoint => checkpoint.kind === "tool" && checkpoint.argsHash === seeded.argsHash, - ).at(-1)!; - const stageCheckpoint = records.find((checkpoint) => checkpoint.kind === "stage" && checkpoint.name === "after-legacy-tool"); - assert.equal(stageCheckpoint?.kind, "stage"); - assert.equal(migrated.topology?.run?.runId, childRun.id); - if (stageCheckpoint?.kind !== "stage") return; - assert.equal(stageCheckpoint.topology?.run?.runId, childRun.id); - const entry = backend.listCompletedWorkflows().find((candidate) => candidate.workflowId === runId)!; - const catalogRuns = completedWorkflowRunSnapshots(backend, entry); - const ids = new Set(catalogRuns.flatMap((snapshot) => [ - ...snapshot.stages.map((stage) => stage.id), - ...(snapshot.toolNodes ?? []).map((node) => node.id), - ])); - for (const snapshot of catalogRuns) { - for (const node of [...snapshot.stages, ...(snapshot.toolNodes ?? [])]) { - for (const parentId of node.parentIds) assert.equal(ids.has(parentId), true, `dangling parent ${parentId}`); - } - } - }); + assert.equal(result.status, "completed"); + assert.equal(callbackCalls, 0); + const childRun = store.runs().find((candidate) => candidate.parentRunId === runId)!; + const childTool = childRun.toolNodes?.[0]; + assert.ok(childTool); + const childStage = childRun.stages[0]!; + assert.equal(childTool.status, "cached"); + assert.deepEqual(childStage.parentIds, [childTool.id]); + const records = backend.listCheckpoints(runId); + const migrated = records + .filter( + (checkpoint): checkpoint is DurableToolCheckpoint => + checkpoint.kind === "tool" && checkpoint.argsHash === seeded.argsHash, + ) + .at(-1)!; + const stageCheckpoint = records.find( + (checkpoint) => checkpoint.kind === "stage" && checkpoint.name === "after-legacy-tool", + ); + assert.equal(stageCheckpoint?.kind, "stage"); + assert.equal(migrated.topology?.run?.runId, childRun.id); + if (stageCheckpoint?.kind !== "stage") return; + assert.equal(stageCheckpoint.topology?.run?.runId, childRun.id); + const entry = backend.listCompletedWorkflows().find((candidate) => candidate.workflowId === runId)!; + const catalogRuns = completedWorkflowRunSnapshots(backend, entry); + const ids = new Set( + catalogRuns.flatMap((snapshot) => [ + ...snapshot.stages.map((stage) => stage.id), + ...(snapshot.toolNodes ?? []).map((node) => node.id), + ]), + ); + for (const snapshot of catalogRuns) { + for (const node of [...snapshot.stages, ...(snapshot.toolNodes ?? [])]) { + for (const parentId of node.parentIds) assert.equal(ids.has(parentId), true, `dangling parent ${parentId}`); + } + } + }); - test("migrates topology-less tools into the current nested grandchild run", async () => { - const runId = "legacy-nested-root"; - let callbackCalls = 0; - const grandchild = workflow({ - name: "legacy-grandchild", description: "", inputs: {}, outputs: {}, - run: async (ctx) => { await ctx.tool("nested-write", {}, async () => { callbackCalls += 1; return "nested"; }); return {}; }, - }); - const child = workflow({ - name: "legacy-parent-child", description: "", inputs: {}, outputs: {}, - run: async (ctx) => { await ctx.workflow(grandchild, { stageName: "grandchild-boundary" }); return {}; }, - }); - const parent = workflow({ - name: "legacy-nested-root", description: "", inputs: {}, outputs: {}, - run: async (ctx) => { await ctx.workflow(child, { stageName: "child-boundary" }); return {}; }, - }); - const seedBackend = new InMemoryDurableBackend(); - await run(parent, {}, { runId, store: createStore(), durableBackend: seedBackend }); - const seeded = seedBackend.listCheckpoints(runId).find( - (checkpoint): checkpoint is DurableToolCheckpoint => checkpoint.kind === "tool" && checkpoint.name === "nested-write", - )!; - callbackCalls = 0; - const backend = new InMemoryDurableBackend(); - backend.registerWorkflow({ workflowId: runId, name: parent.name, inputs: {}, createdAt: 1, status: "paused", resumable: true }); - backend.recordCheckpoint({ - kind: "tool", workflowId: runId, checkpointId: seeded.checkpointId, name: seeded.name, - argsHash: seeded.argsHash, output: seeded.output, completedAt: 1, - }); - const store = createStore(); - const result = await run(parent, {}, { runId, store, durableBackend: backend }); + test("migrates topology-less tools into the current nested grandchild run", async () => { + const runId = "legacy-nested-root"; + let callbackCalls = 0; + const grandchild = workflow({ + name: "legacy-grandchild", + description: "", + inputs: {}, + outputs: {}, + run: async (ctx) => { + await ctx.tool("nested-write", {}, async () => { + callbackCalls += 1; + return "nested"; + }); + return {}; + }, + }); + const child = workflow({ + name: "legacy-parent-child", + description: "", + inputs: {}, + outputs: {}, + run: async (ctx) => { + await ctx.workflow(grandchild, { stageName: "grandchild-boundary" }); + return {}; + }, + }); + const parent = workflow({ + name: "legacy-nested-root", + description: "", + inputs: {}, + outputs: {}, + run: async (ctx) => { + await ctx.workflow(child, { stageName: "child-boundary" }); + return {}; + }, + }); + const seedBackend = new InMemoryDurableBackend(); + await run(parent, {}, { runId, store: createStore(), durableBackend: seedBackend }); + const seeded = seedBackend + .listCheckpoints(runId) + .find( + (checkpoint): checkpoint is DurableToolCheckpoint => + checkpoint.kind === "tool" && checkpoint.name === "nested-write", + )!; + callbackCalls = 0; + const backend = new InMemoryDurableBackend(); + backend.registerWorkflow({ + workflowId: runId, + name: parent.name, + inputs: {}, + createdAt: 1, + status: "paused", + resumable: true, + }); + backend.recordCheckpoint({ + kind: "tool", + workflowId: runId, + checkpointId: seeded.checkpointId, + name: seeded.name, + argsHash: seeded.argsHash, + output: seeded.output, + completedAt: 1, + }); + const store = createStore(); + const result = await run(parent, {}, { runId, store, durableBackend: backend }); - assert.equal(result.status, "completed"); - assert.equal(callbackCalls, 0); - const childRun = store.runs().find((candidate) => candidate.parentRunId === runId)!; - const grandchildRun = store.runs().find((candidate) => candidate.parentRunId === childRun.id)!; - assert.equal(grandchildRun.rootRunId, runId); - assert.equal(grandchildRun.toolNodes?.[0]?.status, "cached"); - const migrated = backend.listCheckpoints(runId).filter( - (checkpoint): checkpoint is DurableToolCheckpoint => checkpoint.kind === "tool" && checkpoint.argsHash === seeded.argsHash, - ).at(-1)!; - assert.equal(migrated.topology?.run?.runId, grandchildRun.id); - assert.equal(migrated.topology?.run?.parentRunId, childRun.id); - assert.equal(migrated.topology?.run?.rootRunId, runId); - const entry = backend.listCompletedWorkflows().find((candidate) => candidate.workflowId === runId)!; - const catalogRuns = completedWorkflowRunSnapshots(backend, entry); - assert.equal(catalogRuns.find((candidate) => candidate.id === grandchildRun.id)?.toolNodes?.[0]?.name, "nested-write"); - assert.equal(expandWorkflowGraph({ runs: catalogRuns, notices: [], version: 1 }, runId).tools.filter((node) => node.name === "nested-write").length, 1); - }); + assert.equal(result.status, "completed"); + assert.equal(callbackCalls, 0); + const childRun = store.runs().find((candidate) => candidate.parentRunId === runId)!; + const grandchildRun = store.runs().find((candidate) => candidate.parentRunId === childRun.id)!; + assert.equal(grandchildRun.rootRunId, runId); + assert.equal(grandchildRun.toolNodes?.[0]?.status, "cached"); + const migrated = backend + .listCheckpoints(runId) + .filter( + (checkpoint): checkpoint is DurableToolCheckpoint => + checkpoint.kind === "tool" && checkpoint.argsHash === seeded.argsHash, + ) + .at(-1)!; + assert.equal(migrated.topology?.run?.runId, grandchildRun.id); + assert.equal(migrated.topology?.run?.parentRunId, childRun.id); + assert.equal(migrated.topology?.run?.rootRunId, runId); + const entry = backend.listCompletedWorkflows().find((candidate) => candidate.workflowId === runId)!; + const catalogRuns = completedWorkflowRunSnapshots(backend, entry); + assert.equal( + catalogRuns.find((candidate) => candidate.id === grandchildRun.id)?.toolNodes?.[0]?.name, + "nested-write", + ); + assert.equal( + expandWorkflowGraph({ runs: catalogRuns, notices: [], version: 1 }, runId).tools.filter( + (node) => node.name === "nested-write", + ).length, + 1, + ); + }); - test("keeps topology-less root fallback unchanged and preserves the reserved public name", async () => { - const runId = "legacy-root-fallback"; - let callbackCalls = 0; - const definition = workflow({ - name: "legacy-root-fallback", description: "", inputs: {}, outputs: { value: Type.Number() }, - run: async (ctx) => ({ - value: await ctx.tool("workflow-run-timing", { public: true }, async () => { callbackCalls += 1; return 23; }), - }), - }); - const seedBackend = new InMemoryDurableBackend(); - await run(definition, {}, { runId, store: createStore(), durableBackend: seedBackend }); - const seeded = seedBackend.listCheckpoints(runId).find( - (checkpoint): checkpoint is DurableToolCheckpoint => checkpoint.kind === "tool" && checkpoint.argsHash !== "workflow-run-timing", - )!; - callbackCalls = 0; - const backend = new InMemoryDurableBackend(); - backend.registerWorkflow({ workflowId: runId, name: definition.name, inputs: {}, createdAt: 1, status: "paused", resumable: true }); - backend.recordCheckpoint({ - kind: "tool", workflowId: runId, checkpointId: seeded.checkpointId, name: seeded.name, - argsHash: seeded.argsHash, output: seeded.output, completedAt: 1, - }); + test("keeps topology-less root fallback unchanged and preserves the reserved public name", async () => { + const runId = "legacy-root-fallback"; + let callbackCalls = 0; + const definition = workflow({ + name: "legacy-root-fallback", + description: "", + inputs: {}, + outputs: { value: Type.Number() }, + run: async (ctx) => ({ + value: await ctx.tool("workflow-run-timing", { public: true }, async () => { + callbackCalls += 1; + return 23; + }), + }), + }); + const seedBackend = new InMemoryDurableBackend(); + await run(definition, {}, { runId, store: createStore(), durableBackend: seedBackend }); + const seeded = seedBackend + .listCheckpoints(runId) + .find( + (checkpoint): checkpoint is DurableToolCheckpoint => + checkpoint.kind === "tool" && checkpoint.argsHash !== "workflow-run-timing", + )!; + callbackCalls = 0; + const backend = new InMemoryDurableBackend(); + backend.registerWorkflow({ + workflowId: runId, + name: definition.name, + inputs: {}, + createdAt: 1, + status: "paused", + resumable: true, + }); + backend.recordCheckpoint({ + kind: "tool", + workflowId: runId, + checkpointId: seeded.checkpointId, + name: seeded.name, + argsHash: seeded.argsHash, + output: seeded.output, + completedAt: 1, + }); - const result = await run(definition, {}, { runId, store: createStore(), durableBackend: backend }); - assert.equal(result.status, "completed"); - assert.deepEqual(result.result, { value: 23 }); - assert.equal(callbackCalls, 0); - const logicalRecords = backend.listCheckpoints(runId).filter( - (checkpoint): checkpoint is DurableToolCheckpoint => checkpoint.kind === "tool" && checkpoint.argsHash === seeded.argsHash, - ); - assert.equal(logicalRecords.length, 1, "legacy root replay must not invent topology metadata"); - assert.equal(logicalRecords[0]?.topology, undefined); - const entry = backend.listCompletedWorkflows().find((candidate) => candidate.workflowId === runId)!; - const catalogRoot = completedWorkflowRunSnapshots(backend, entry).find((candidate) => candidate.id === runId)!; - assert.deepEqual(catalogRoot.toolNodes?.map((node) => node.name), ["workflow-run-timing"]); - }); + const result = await run(definition, {}, { runId, store: createStore(), durableBackend: backend }); + assert.equal(result.status, "completed"); + assert.deepEqual(result.result, { value: 23 }); + assert.equal(callbackCalls, 0); + const logicalRecords = backend + .listCheckpoints(runId) + .filter( + (checkpoint): checkpoint is DurableToolCheckpoint => + checkpoint.kind === "tool" && checkpoint.argsHash === seeded.argsHash, + ); + assert.equal(logicalRecords.length, 1, "legacy root replay must not invent topology metadata"); + assert.equal(logicalRecords[0]?.topology, undefined); + const entry = backend.listCompletedWorkflows().find((candidate) => candidate.workflowId === runId)!; + const catalogRoot = completedWorkflowRunSnapshots(backend, entry).find((candidate) => candidate.id === runId)!; + assert.deepEqual( + catalogRoot.toolNodes?.map((node) => node.name), + ["workflow-run-timing"], + ); + }); - test("repeated interrupted child replays keep the latest ownership without duplicates", async () => { - const runId = "legacy-repeated-root"; - let callbackCalls = 0; - let shouldFail = false; - const child = workflow({ - name: "legacy-repeated-child", description: "", inputs: {}, outputs: {}, - run: async (ctx) => { - await ctx.tool("repeated-write", {}, async () => { callbackCalls += 1; return "cached"; }); - if (shouldFail) throw new Error("simulated child interruption"); - return {}; - }, - }); - const parent = workflow({ - name: "legacy-repeated-root", description: "", inputs: {}, outputs: {}, - run: async (ctx) => { await ctx.workflow(child, { stageName: "child-boundary" }); return {}; }, - }); - const seedBackend = new InMemoryDurableBackend(); - await run(parent, {}, { runId, store: createStore(), durableBackend: seedBackend }); - const seeded = seedBackend.listCheckpoints(runId).find( - (checkpoint): checkpoint is DurableToolCheckpoint => checkpoint.kind === "tool" && checkpoint.name === "repeated-write", - )!; - const scopePrefix = seeded.argsHash.slice(0, seeded.argsHash.lastIndexOf(":")); - callbackCalls = 0; - shouldFail = true; - const backend = new InMemoryDurableBackend(); - backend.registerWorkflow({ workflowId: runId, name: parent.name, inputs: {}, createdAt: 1, status: "paused", resumable: true }); - backend.recordCheckpoint({ - kind: "tool", workflowId: runId, checkpointId: seeded.checkpointId, name: seeded.name, - argsHash: seeded.argsHash, output: seeded.output, completedAt: 1, - }); - const attemptRunIds: string[] = []; - for (const attempt of [1, 2]) { - const attemptRunId = `legacy-repeated-child-${attempt}`; - attemptRunIds.push(attemptRunId); - const result = await run(child, {}, { - runId: attemptRunId, - store: createStore(), - durableBackend: backend, - durableScope: { rootWorkflowId: runId, scopePrefix }, - parentRun: { runId, stageId: `attempt-boundary-${attempt}`, rootRunId: runId }, - }); - assert.equal(result.status, "failed"); - assert.equal(result.toolNodes?.[0]?.status, "cached"); - assert.equal(callbackCalls, 0); - } + test("repeated interrupted child replays keep the latest ownership without duplicates", async () => { + const runId = "legacy-repeated-root"; + let callbackCalls = 0; + let shouldFail = false; + const child = workflow({ + name: "legacy-repeated-child", + description: "", + inputs: {}, + outputs: {}, + run: async (ctx) => { + await ctx.tool("repeated-write", {}, async () => { + callbackCalls += 1; + return "cached"; + }); + if (shouldFail) throw new Error("simulated child interruption"); + return {}; + }, + }); + const parent = workflow({ + name: "legacy-repeated-root", + description: "", + inputs: {}, + outputs: {}, + run: async (ctx) => { + await ctx.workflow(child, { stageName: "child-boundary" }); + return {}; + }, + }); + const seedBackend = new InMemoryDurableBackend(); + await run(parent, {}, { runId, store: createStore(), durableBackend: seedBackend }); + const seeded = seedBackend + .listCheckpoints(runId) + .find( + (checkpoint): checkpoint is DurableToolCheckpoint => + checkpoint.kind === "tool" && checkpoint.name === "repeated-write", + )!; + const scopePrefix = seeded.argsHash.slice(0, seeded.argsHash.lastIndexOf(":")); + callbackCalls = 0; + shouldFail = true; + const backend = new InMemoryDurableBackend(); + backend.registerWorkflow({ + workflowId: runId, + name: parent.name, + inputs: {}, + createdAt: 1, + status: "paused", + resumable: true, + }); + backend.recordCheckpoint({ + kind: "tool", + workflowId: runId, + checkpointId: seeded.checkpointId, + name: seeded.name, + argsHash: seeded.argsHash, + output: seeded.output, + completedAt: 1, + }); + const attemptRunIds: string[] = []; + for (const attempt of [1, 2]) { + const attemptRunId = `legacy-repeated-child-${attempt}`; + attemptRunIds.push(attemptRunId); + const result = await run( + child, + {}, + { + runId: attemptRunId, + store: createStore(), + durableBackend: backend, + durableScope: { rootWorkflowId: runId, scopePrefix }, + parentRun: { runId, stageId: `attempt-boundary-${attempt}`, rootRunId: runId }, + }, + ); + assert.equal(result.status, "failed"); + assert.equal(result.toolNodes?.[0]?.status, "cached"); + assert.equal(callbackCalls, 0); + } - shouldFail = false; - const finalStore = createStore(); - const final = await run(parent, {}, { runId, store: finalStore, durableBackend: backend }); - assert.equal(final.status, "completed"); - assert.equal(callbackCalls, 0); - const finalChild = finalStore.runs().find((candidate) => candidate.parentRunId === runId)!; - assert.equal(attemptRunIds.includes(finalChild.id), false); - const logicalRecords = backend.listCheckpoints(runId).filter( - (checkpoint): checkpoint is DurableToolCheckpoint => checkpoint.kind === "tool" && checkpoint.argsHash === seeded.argsHash, - ); - assert.equal(logicalRecords.length, 4); - assert.equal(logicalRecords.at(-1)?.topology?.run?.runId, finalChild.id); - const entry = backend.listCompletedWorkflows().find((candidate) => candidate.workflowId === runId)!; - const catalogRuns = completedWorkflowRunSnapshots(backend, entry); - assert.equal(catalogRuns.find((candidate) => candidate.id === finalChild.id)?.toolNodes?.length, 1); - assert.equal(expandWorkflowGraph({ runs: catalogRuns, notices: [], version: 1 }, runId).tools.filter((node) => node.name === "repeated-write").length, 1); - }); + shouldFail = false; + const finalStore = createStore(); + const final = await run(parent, {}, { runId, store: finalStore, durableBackend: backend }); + assert.equal(final.status, "completed"); + assert.equal(callbackCalls, 0); + const finalChild = finalStore.runs().find((candidate) => candidate.parentRunId === runId)!; + assert.equal(attemptRunIds.includes(finalChild.id), false); + const logicalRecords = backend + .listCheckpoints(runId) + .filter( + (checkpoint): checkpoint is DurableToolCheckpoint => + checkpoint.kind === "tool" && checkpoint.argsHash === seeded.argsHash, + ); + assert.equal(logicalRecords.length, 4); + assert.equal(logicalRecords.at(-1)?.topology?.run?.runId, finalChild.id); + const entry = backend.listCompletedWorkflows().find((candidate) => candidate.workflowId === runId)!; + const catalogRuns = completedWorkflowRunSnapshots(backend, entry); + assert.equal(catalogRuns.find((candidate) => candidate.id === finalChild.id)?.toolNodes?.length, 1); + assert.equal( + expandWorkflowGraph({ runs: catalogRuns, notices: [], version: 1 }, runId).tools.filter( + (node) => node.name === "repeated-write", + ).length, + 1, + ); + }); - test("DBOS hydration preserves additive child migration and replay idempotency", async () => { - const runId = "legacy-child-dbos"; - let callbackCalls = 0; - const child = workflow({ - name: "legacy-dbos-child", description: "", inputs: {}, outputs: { value: Type.Number() }, - run: async (ctx) => ({ - value: await ctx.tool("dbos-legacy-write", {}, async () => { callbackCalls += 1; return 31; }), - }), - }); - const parent = workflow({ - name: "legacy-child-dbos", description: "", inputs: {}, outputs: { value: Type.Number() }, - run: async (ctx) => { - const result = await ctx.workflow(child, { stageName: "child-boundary" }); - if (result.exited) throw new Error("unexpected child exit"); - return result.outputs; - }, - }); - const seedBackend = new InMemoryDurableBackend(); - await run(parent, {}, { runId, store: createStore(), durableBackend: seedBackend }); - const seeded = seedBackend.listCheckpoints(runId).find( - (checkpoint): checkpoint is DurableToolCheckpoint => checkpoint.kind === "tool" && checkpoint.name === "dbos-legacy-write", - )!; - callbackCalls = 0; - const sdk = createMockSdk(); - const writer = new DbosDurableBackend(sdk); - writer.registerWorkflow({ workflowId: runId, name: parent.name, inputs: {}, createdAt: 1, status: "paused", resumable: true }); - writer.recordCheckpoint({ - kind: "tool", workflowId: runId, checkpointId: seeded.checkpointId, name: seeded.name, - argsHash: seeded.argsHash, output: seeded.output, completedAt: 1, - }); - await writer.flush(); + test("DBOS hydration preserves additive child migration and replay idempotency", async () => { + const runId = "legacy-child-dbos"; + let callbackCalls = 0; + const child = workflow({ + name: "legacy-dbos-child", + description: "", + inputs: {}, + outputs: { value: Type.Number() }, + run: async (ctx) => ({ + value: await ctx.tool("dbos-legacy-write", {}, async () => { + callbackCalls += 1; + return 31; + }), + }), + }); + const parent = workflow({ + name: "legacy-child-dbos", + description: "", + inputs: {}, + outputs: { value: Type.Number() }, + run: async (ctx) => { + const result = await ctx.workflow(child, { stageName: "child-boundary" }); + if (result.exited) throw new Error("unexpected child exit"); + return result.outputs; + }, + }); + const seedBackend = new InMemoryDurableBackend(); + await run(parent, {}, { runId, store: createStore(), durableBackend: seedBackend }); + const seeded = seedBackend + .listCheckpoints(runId) + .find( + (checkpoint): checkpoint is DurableToolCheckpoint => + checkpoint.kind === "tool" && checkpoint.name === "dbos-legacy-write", + )!; + callbackCalls = 0; + const sdk = createMockSdk(); + const writer = new DbosDurableBackend(sdk); + writer.registerWorkflow({ + workflowId: runId, + name: parent.name, + inputs: {}, + createdAt: 1, + status: "paused", + resumable: true, + }); + writer.recordCheckpoint({ + kind: "tool", + workflowId: runId, + checkpointId: seeded.checkpointId, + name: seeded.name, + argsHash: seeded.argsHash, + output: seeded.output, + completedAt: 1, + }); + await writer.flush(); - const replayBackend = new DbosDurableBackend(sdk); - await replayBackend.hydrateWorkflow(runId); - const replayStore = createStore(); - const replay = await run(parent, {}, { runId, store: replayStore, durableBackend: replayBackend }); - await replayBackend.flush(); - assert.equal(replay.status, "completed"); - assert.deepEqual(replay.result, { value: 31 }); - assert.equal(callbackCalls, 0); - const replayChild = replayStore.runs().find((candidate) => candidate.parentRunId === runId)!; - const migrated = replayBackend.listCheckpoints(runId).filter( - (checkpoint): checkpoint is DurableToolCheckpoint => checkpoint.kind === "tool" && checkpoint.argsHash === seeded.argsHash, - ); - assert.equal(migrated.length, 2); - assert.equal(migrated.at(-1)?.topology?.run?.runId, replayChild.id); + const replayBackend = new DbosDurableBackend(sdk); + await replayBackend.hydrateWorkflow(runId); + const replayStore = createStore(); + const replay = await run(parent, {}, { runId, store: replayStore, durableBackend: replayBackend }); + await replayBackend.flush(); + assert.equal(replay.status, "completed"); + assert.deepEqual(replay.result, { value: 31 }); + assert.equal(callbackCalls, 0); + const replayChild = replayStore.runs().find((candidate) => candidate.parentRunId === runId)!; + const migrated = replayBackend + .listCheckpoints(runId) + .filter( + (checkpoint): checkpoint is DurableToolCheckpoint => + checkpoint.kind === "tool" && checkpoint.argsHash === seeded.argsHash, + ); + assert.equal(migrated.length, 2); + assert.equal(migrated.at(-1)?.topology?.run?.runId, replayChild.id); - const rehydrated = new DbosDurableBackend(sdk); - await rehydrated.hydrateWorkflow(runId); - const hydratedLogical = rehydrated.listCheckpoints(runId).filter( - (checkpoint): checkpoint is DurableToolCheckpoint => checkpoint.kind === "tool" && checkpoint.argsHash === seeded.argsHash, - ); - assert.equal(hydratedLogical.length, 2); - assert.equal(hydratedLogical.at(-1)?.topology?.run?.runId, replayChild.id); - const second = await run(parent, {}, { runId, store: createStore(), durableBackend: rehydrated }); - await rehydrated.flush(); - assert.equal(second.status, "completed"); - assert.equal(callbackCalls, 0); - const entry = rehydrated.listCompletedWorkflows().find((candidate) => candidate.workflowId === runId)!; - const catalogRuns = completedWorkflowRunSnapshots(rehydrated, entry); - assert.equal(catalogRuns.flatMap((candidate) => candidate.toolNodes ?? []).filter((node) => node.name === "dbos-legacy-write").length, 1); - }); + const rehydrated = new DbosDurableBackend(sdk); + await rehydrated.hydrateWorkflow(runId); + const hydratedLogical = rehydrated + .listCheckpoints(runId) + .filter( + (checkpoint): checkpoint is DurableToolCheckpoint => + checkpoint.kind === "tool" && checkpoint.argsHash === seeded.argsHash, + ); + assert.equal(hydratedLogical.length, 2); + assert.equal(hydratedLogical.at(-1)?.topology?.run?.runId, replayChild.id); + const second = await run(parent, {}, { runId, store: createStore(), durableBackend: rehydrated }); + await rehydrated.flush(); + assert.equal(second.status, "completed"); + assert.equal(callbackCalls, 0); + const entry = rehydrated.listCompletedWorkflows().find((candidate) => candidate.workflowId === runId)!; + const catalogRuns = completedWorkflowRunSnapshots(rehydrated, entry); + assert.equal( + catalogRuns + .flatMap((candidate) => candidate.toolNodes ?? []) + .filter((node) => node.name === "dbos-legacy-write").length, + 1, + ); + }); }); diff --git a/test/unit/workflow-tool-legacy-migration-failures.test.ts b/test/unit/workflow-tool-legacy-migration-failures.test.ts index bd5fae057..59f883e84 100644 --- a/test/unit/workflow-tool-legacy-migration-failures.test.ts +++ b/test/unit/workflow-tool-legacy-migration-failures.test.ts @@ -1,9 +1,9 @@ -import { describe, test } from "bun:test"; import assert from "node:assert/strict"; +import { describe, test } from "vitest"; import { workflow } from "../../packages/workflows/src/authoring/workflow.js"; import { InMemoryDurableBackend } from "../../packages/workflows/src/durable/backend.js"; -import { DbosDurableBackend } from "../../packages/workflows/src/durable/dbos-backend.js"; import { completedWorkflowRunSnapshots } from "../../packages/workflows/src/durable/completed-catalog.js"; +import { DbosDurableBackend } from "../../packages/workflows/src/durable/dbos-backend.js"; import type { DurableCheckpoint, DurableToolCheckpoint } from "../../packages/workflows/src/durable/types.js"; import { run } from "../../packages/workflows/src/engine/run.js"; import { expandWorkflowGraph } from "../../packages/workflows/src/shared/expanded-workflow-graph.js"; @@ -11,179 +11,270 @@ import { createStore } from "../../packages/workflows/src/shared/store.js"; import { createMockSdk } from "./durable-dbos-backend-helpers.js"; function definitions(onCallback: () => void) { - const child = workflow({ - name: "migration-child", description: "", inputs: {}, outputs: {}, - run: async (ctx) => { - await ctx.tool("legacy-child-write", {}, async () => { onCallback(); return "cached-value"; }); - return {}; - }, - }); - const parent = workflow({ - name: "migration-root", description: "", inputs: {}, outputs: {}, - run: async (ctx) => { await ctx.workflow(child, { stageName: "child-boundary" }); return {}; }, - }); - return { child, parent }; + const child = workflow({ + name: "migration-child", + description: "", + inputs: {}, + outputs: {}, + run: async (ctx) => { + await ctx.tool("legacy-child-write", {}, async () => { + onCallback(); + return "cached-value"; + }); + return {}; + }, + }); + const parent = workflow({ + name: "migration-root", + description: "", + inputs: {}, + outputs: {}, + run: async (ctx) => { + await ctx.workflow(child, { stageName: "child-boundary" }); + return {}; + }, + }); + return { child, parent }; } async function legacyCheckpoint(runId: string): Promise { - const seed = new InMemoryDurableBackend(); - const { parent } = definitions(() => undefined); - await run(parent, {}, { runId, store: createStore(), durableBackend: seed }); - const checkpoint = seed.listCheckpoints(runId).find( - (entry): entry is DurableToolCheckpoint => entry.kind === "tool" && entry.name === "legacy-child-write", - ); - assert.ok(checkpoint !== undefined); - return { - kind: "tool", workflowId: runId, checkpointId: checkpoint.checkpointId, - name: checkpoint.name, argsHash: checkpoint.argsHash, output: checkpoint.output, completedAt: 1, - }; + const seed = new InMemoryDurableBackend(); + const { parent } = definitions(() => undefined); + await run(parent, {}, { runId, store: createStore(), durableBackend: seed }); + const checkpoint = seed + .listCheckpoints(runId) + .find((entry): entry is DurableToolCheckpoint => entry.kind === "tool" && entry.name === "legacy-child-write"); + assert.ok(checkpoint !== undefined); + return { + kind: "tool", + workflowId: runId, + checkpointId: checkpoint.checkpointId, + name: checkpoint.name, + argsHash: checkpoint.argsHash, + output: checkpoint.output, + completedAt: 1, + }; } -interface MigrationFailureState { attempts: number; rejectNext: boolean } +interface MigrationFailureState { + attempts: number; + rejectNext: boolean; +} class RejectFirstMigrationBackend extends InMemoryDurableBackend { - constructor(private readonly migration: MigrationFailureState) { super(); } + constructor(private readonly migration: MigrationFailureState) { + super(); + } - override async recordCheckpointAsync(checkpoint: DurableCheckpoint): Promise { - if (checkpoint.kind === "tool" && checkpoint.checkpointId.includes("tool-replay-meta:")) { - this.migration.attempts += 1; - if (this.migration.rejectNext) { - this.migration.rejectNext = false; - throw new Error("additive migration unavailable"); - } - } - await super.recordCheckpointAsync(checkpoint); - } + override async recordCheckpointAsync(checkpoint: DurableCheckpoint): Promise { + if (checkpoint.kind === "tool" && checkpoint.checkpointId.includes("tool-replay-meta:")) { + this.migration.attempts += 1; + if (this.migration.rejectNext) { + this.migration.rejectNext = false; + throw new Error("additive migration unavailable"); + } + } + await super.recordCheckpointAsync(checkpoint); + } } describe("best-effort legacy child topology migration", () => { - test("returns cached child output when additive persistence rejects, then retries migration", async () => { - const runId = "migration-best-effort-root"; - const legacy = await legacyCheckpoint(runId); - let callbackCalls = 0; - const { parent } = definitions(() => { callbackCalls += 1; }); - const migration = { attempts: 0, rejectNext: true }; - const backend = new RejectFirstMigrationBackend(migration); - backend.registerWorkflow({ workflowId: runId, name: parent.name, inputs: {}, createdAt: 1, status: "paused", resumable: true }); - backend.recordCheckpoint(legacy); - - const firstStore = createStore(); - const first = await run(parent, {}, { runId, store: firstStore, durableBackend: backend }); - assert.equal(first.status, "completed"); - assert.equal(callbackCalls, 0); - assert.equal(migration.attempts, 1); - assert.equal(backend.listCheckpoints(runId).some((entry) => entry.checkpointId.includes("tool-replay-meta:")), false); - const childRun = firstStore.runs().find((entry) => entry.parentRunId === runId); - assert.equal(childRun?.toolNodes?.[0]?.status, "cached"); - assert.equal(expandWorkflowGraph(firstStore.snapshot(), runId).tools[0]?.runId, childRun?.id); - await backend.flush(); + test("returns cached child output when additive persistence rejects, then retries migration", async () => { + const runId = "migration-best-effort-root"; + const legacy = await legacyCheckpoint(runId); + let callbackCalls = 0; + const { parent } = definitions(() => { + callbackCalls += 1; + }); + const migration = { attempts: 0, rejectNext: true }; + const backend = new RejectFirstMigrationBackend(migration); + backend.registerWorkflow({ + workflowId: runId, + name: parent.name, + inputs: {}, + createdAt: 1, + status: "paused", + resumable: true, + }); + backend.recordCheckpoint(legacy); - const retryBackend = new RejectFirstMigrationBackend(migration); - retryBackend.registerWorkflow({ workflowId: runId, name: parent.name, inputs: {}, createdAt: 1, status: "paused", resumable: true }); - retryBackend.recordCheckpoint(legacy); - const second = await run(parent, {}, { runId, store: createStore(), durableBackend: retryBackend }); - assert.equal(second.status, "completed"); - assert.equal(callbackCalls, 0); - assert.equal(migration.attempts, 2); - assert.equal(retryBackend.listCheckpoints(runId).filter((entry) => entry.checkpointId.includes("tool-replay-meta:")).length, 1); - const catalogEntry = retryBackend.listCompletedWorkflows().find((entry) => entry.workflowId === runId); - assert.ok(catalogEntry !== undefined); - const restored = completedWorkflowRunSnapshots(retryBackend, catalogEntry); - assert.equal(restored.find((entry) => entry.parentRunId === runId)?.toolNodes?.[0]?.name, "legacy-child-write"); - }); + const firstStore = createStore(); + const first = await run(parent, {}, { runId, store: firstStore, durableBackend: backend }); + assert.equal(first.status, "completed"); + assert.equal(callbackCalls, 0); + assert.equal(migration.attempts, 1); + assert.equal( + backend.listCheckpoints(runId).some((entry) => entry.checkpointId.includes("tool-replay-meta:")), + false, + ); + const childRun = firstStore.runs().find((entry) => entry.parentRunId === runId); + assert.equal(childRun?.toolNodes?.[0]?.status, "cached"); + assert.equal(expandWorkflowGraph(firstStore.snapshot(), runId).tools[0]?.runId, childRun?.id); + await backend.flush(); - test("DBOS best-effort rejection updates neither mirror nor fatal flush state", async () => { - const baseSdk = createMockSdk(); - const persist = baseSdk.recordStepOutput.bind(baseSdk); - let rejectMigration = false; - const sdk = { - ...baseSdk, - async recordStepOutput(...args: Parameters) { - const [, stepName] = args; - if (rejectMigration && stepName.includes("tool-replay-meta:")) throw new Error("dbos additive rejected"); - await persist(...args); - }, - }; - const backend = new DbosDurableBackend(sdk); - backend.registerWorkflow({ workflowId: "dbos-best-effort", name: "dbos", inputs: {}, createdAt: 1, status: "running" }); - await backend.flush(); - const checkpoint: DurableToolCheckpoint = { - kind: "tool", workflowId: "dbos-best-effort", checkpointId: "tool-replay-meta:test", - name: "legacy", argsHash: "legacy-hash", output: "cached", completedAt: 2, - topology: { version: 1, nodeId: "tool:legacy", ordinal: 1, order: 1, parentIds: [], endedAt: 2 }, - }; + const retryBackend = new RejectFirstMigrationBackend(migration); + retryBackend.registerWorkflow({ + workflowId: runId, + name: parent.name, + inputs: {}, + createdAt: 1, + status: "paused", + resumable: true, + }); + retryBackend.recordCheckpoint(legacy); + const second = await run(parent, {}, { runId, store: createStore(), durableBackend: retryBackend }); + assert.equal(second.status, "completed"); + assert.equal(callbackCalls, 0); + assert.equal(migration.attempts, 2); + assert.equal( + retryBackend.listCheckpoints(runId).filter((entry) => entry.checkpointId.includes("tool-replay-meta:")).length, + 1, + ); + const catalogEntry = retryBackend.listCompletedWorkflows().find((entry) => entry.workflowId === runId); + assert.ok(catalogEntry !== undefined); + const restored = completedWorkflowRunSnapshots(retryBackend, catalogEntry); + assert.equal(restored.find((entry) => entry.parentRunId === runId)?.toolNodes?.[0]?.name, "legacy-child-write"); + }); - rejectMigration = true; - assert.equal(await backend.recordAdditiveCheckpointBestEffort(checkpoint), false); - assert.equal(backend.getToolCheckpoint("dbos-best-effort", "legacy-hash"), undefined); - await backend.flush(); + test("DBOS best-effort rejection updates neither mirror nor fatal flush state", async () => { + const baseSdk = createMockSdk(); + const persist = baseSdk.recordStepOutput.bind(baseSdk); + let rejectMigration = false; + const sdk = { + ...baseSdk, + async recordStepOutput(...args: Parameters) { + const [, stepName] = args; + if (rejectMigration && stepName.includes("tool-replay-meta:")) throw new Error("dbos additive rejected"); + await persist(...args); + }, + }; + const backend = new DbosDurableBackend(sdk); + backend.registerWorkflow({ + workflowId: "dbos-best-effort", + name: "dbos", + inputs: {}, + createdAt: 1, + status: "running", + }); + await backend.flush(); + const checkpoint: DurableToolCheckpoint = { + kind: "tool", + workflowId: "dbos-best-effort", + checkpointId: "tool-replay-meta:test", + name: "legacy", + argsHash: "legacy-hash", + output: "cached", + completedAt: 2, + topology: { version: 1, nodeId: "tool:legacy", ordinal: 1, order: 1, parentIds: [], endedAt: 2 }, + }; - rejectMigration = false; - assert.equal(await backend.recordAdditiveCheckpointBestEffort(checkpoint), true); - assert.equal(backend.getToolCheckpoint("dbos-best-effort", "legacy-hash")?.output, "cached"); - await backend.flush(); - }); + rejectMigration = true; + assert.equal(await backend.recordAdditiveCheckpointBestEffort(checkpoint), false); + assert.equal(backend.getToolCheckpoint("dbos-best-effort", "legacy-hash"), undefined); + await backend.flush(); + rejectMigration = false; + assert.equal(await backend.recordAdditiveCheckpointBestEffort(checkpoint), true); + assert.equal(backend.getToolCheckpoint("dbos-best-effort", "legacy-hash")?.output, "cached"); + await backend.flush(); + }); - test("cancellation during additive migration is not swallowed as a cache hit", async () => { - class GatedMigrationBackend extends InMemoryDurableBackend { - readonly started = Promise.withResolvers(); - readonly release = Promise.withResolvers(); - override async recordAdditiveCheckpointBestEffort(): Promise { - this.started.resolve(); - await this.release.promise; - return false; - } - } - const runId = "migration-cancellation-root"; - const legacy = await legacyCheckpoint(runId); - let callbackCalls = 0; - const { parent } = definitions(() => { callbackCalls += 1; }); - const backend = new GatedMigrationBackend(); - backend.registerWorkflow({ workflowId: runId, name: parent.name, inputs: {}, createdAt: 1, status: "paused", resumable: true }); - backend.recordCheckpoint(legacy); - const controller = new AbortController(); - const store = createStore(); - const pending = run(parent, {}, { runId, store, durableBackend: backend, signal: controller.signal }); + test("cancellation during additive migration is not swallowed as a cache hit", async () => { + class GatedMigrationBackend extends InMemoryDurableBackend { + readonly started = Promise.withResolvers(); + readonly release = Promise.withResolvers(); + override async recordAdditiveCheckpointBestEffort(): Promise { + this.started.resolve(); + await this.release.promise; + return false; + } + } + const runId = "migration-cancellation-root"; + const legacy = await legacyCheckpoint(runId); + let callbackCalls = 0; + const { parent } = definitions(() => { + callbackCalls += 1; + }); + const backend = new GatedMigrationBackend(); + backend.registerWorkflow({ + workflowId: runId, + name: parent.name, + inputs: {}, + createdAt: 1, + status: "paused", + resumable: true, + }); + backend.recordCheckpoint(legacy); + const controller = new AbortController(); + const store = createStore(); + const pending = run(parent, {}, { runId, store, durableBackend: backend, signal: controller.signal }); - await backend.started.promise; - controller.abort(new Error("cancel migration replay")); - backend.release.resolve(); - const result = await pending; - assert.equal(result.status, "killed"); - assert.equal(callbackCalls, 0); - assert.equal(store.runs().flatMap((entry) => entry.toolNodes ?? [])[0]?.status, "cancelled"); - }); - test("authoritative fresh checkpoint failures still fail the run", async () => { - class RejectAuthoritativeBackend extends InMemoryDurableBackend { - override async recordCheckpointAsync(checkpoint: DurableCheckpoint): Promise { - if (checkpoint.kind === "tool") throw new Error("authoritative write rejected"); - await super.recordCheckpointAsync(checkpoint); - } - } - let callbackCalls = 0; - const result = await run(workflow({ - name: "authoritative-failure", description: "", inputs: {}, outputs: {}, - run: async (ctx) => { await ctx.tool("fresh", {}, async () => { callbackCalls += 1; return "done"; }); return {}; }, - }), {}, { store: createStore(), durableBackend: new RejectAuthoritativeBackend() }); - assert.equal(result.status, "failed"); - assert.equal(callbackCalls, 1); - assert.match(result.error ?? "", /authoritative write rejected/); - assert.equal(result.toolNodes?.[0]?.status, "failed"); - }); + await backend.started.promise; + controller.abort(new Error("cancel migration replay")); + backend.release.resolve(); + const result = await pending; + assert.equal(result.status, "killed"); + assert.equal(callbackCalls, 0); + assert.equal(store.runs().flatMap((entry) => entry.toolNodes ?? [])[0]?.status, "cancelled"); + }); + test("authoritative fresh checkpoint failures still fail the run", async () => { + class RejectAuthoritativeBackend extends InMemoryDurableBackend { + override async recordCheckpointAsync(checkpoint: DurableCheckpoint): Promise { + if (checkpoint.kind === "tool") throw new Error("authoritative write rejected"); + await super.recordCheckpointAsync(checkpoint); + } + } + let callbackCalls = 0; + const result = await run( + workflow({ + name: "authoritative-failure", + description: "", + inputs: {}, + outputs: {}, + run: async (ctx) => { + await ctx.tool("fresh", {}, async () => { + callbackCalls += 1; + return "done"; + }); + return {}; + }, + }), + {}, + { store: createStore(), durableBackend: new RejectAuthoritativeBackend() }, + ); + assert.equal(result.status, "failed"); + assert.equal(callbackCalls, 1); + assert.match(result.error ?? "", /authoritative write rejected/); + assert.equal(result.toolNodes?.[0]?.status, "failed"); + }); - test("cached checkpoint lookup failures surface before callbacks or migration", async () => { - class RejectLookupBackend extends InMemoryDurableBackend { - override getToolCheckpoint(): DurableToolCheckpoint | undefined { throw new Error("lookup rejected"); } - } - let callbackCalls = 0; - const result = await run(workflow({ - name: "lookup-failure", description: "", inputs: {}, outputs: {}, - run: async (ctx) => { await ctx.tool("lookup", {}, async () => { callbackCalls += 1; return "no"; }); return {}; }, - }), {}, { store: createStore(), durableBackend: new RejectLookupBackend() }); - assert.equal(result.status, "failed"); - assert.equal(callbackCalls, 0); - assert.match(result.error ?? "", /lookup rejected/); - assert.deepEqual(result.toolNodes, []); - }); + test("cached checkpoint lookup failures surface before callbacks or migration", async () => { + class RejectLookupBackend extends InMemoryDurableBackend { + override getToolCheckpoint(): DurableToolCheckpoint | undefined { + throw new Error("lookup rejected"); + } + } + let callbackCalls = 0; + const result = await run( + workflow({ + name: "lookup-failure", + description: "", + inputs: {}, + outputs: {}, + run: async (ctx) => { + await ctx.tool("lookup", {}, async () => { + callbackCalls += 1; + return "no"; + }); + return {}; + }, + }), + {}, + { store: createStore(), durableBackend: new RejectLookupBackend() }, + ); + assert.equal(result.status, "failed"); + assert.equal(callbackCalls, 0); + assert.match(result.error ?? "", /lookup rejected/); + assert.deepEqual(result.toolNodes, []); + }); }); diff --git a/test/unit/workflow-tool-lifecycle.test.ts b/test/unit/workflow-tool-lifecycle.test.ts index 91d290b5a..08b78dc87 100644 --- a/test/unit/workflow-tool-lifecycle.test.ts +++ b/test/unit/workflow-tool-lifecycle.test.ts @@ -1,97 +1,127 @@ -import { describe, test } from "bun:test"; import assert from "node:assert/strict"; +import { describe, test } from "vitest"; import { workflow } from "../../packages/workflows/src/authoring/workflow.js"; import { InMemoryDurableBackend } from "../../packages/workflows/src/durable/backend.js"; import { run } from "../../packages/workflows/src/engine/run.js"; import { - createWorkflowLifecycleNotificationState, - installWorkflowLifecycleNotifications, - type WorkflowLifecycleNoticeDetails, + createWorkflowLifecycleNotificationState, + installWorkflowLifecycleNotifications, + type WorkflowLifecycleNoticeDetails, } from "../../packages/workflows/src/extension/lifecycle-notifications.js"; import { createStore } from "../../packages/workflows/src/shared/store.js"; interface SentNotice { - readonly content?: string; - readonly details?: WorkflowLifecycleNoticeDetails; + readonly content?: string; + readonly details?: WorkflowLifecycleNoticeDetails; } function install() { - const store = createStore(); - const sent: SentNotice[] = []; - const unsubscribe = installWorkflowLifecycleNotifications({ - store, - config: { enabled: true, notifyOn: ["completed", "failed"] }, - state: createWorkflowLifecycleNotificationState(), - seedExisting: false, - sendMessage(message) { sent.push(message as SentNotice); }, - }); - return { store, sent, unsubscribe }; + const store = createStore(); + const sent: SentNotice[] = []; + const unsubscribe = installWorkflowLifecycleNotifications({ + store, + config: { enabled: true, notifyOn: ["completed", "failed"] }, + state: createWorkflowLifecycleNotificationState(), + seedExisting: false, + sendMessage(message) { + sent.push(message as SentNotice); + }, + }); + return { store, sent, unsubscribe }; } describe("ctx.tool lifecycle notices", () => { - test("real tool-only success emits exactly one completion without empty-graph text", async () => { - const { store, sent, unsubscribe } = install(); - let calls = 0; - const result = await run(workflow({ - name: "tool lifecycle success", description: "", inputs: {}, outputs: {}, - run: async (ctx) => { - await ctx.tool("publish-success", {}, async () => { calls += 1; return "ok"; }); - return {}; - }, - }), {}, { store, durableBackend: new InMemoryDurableBackend() }); - unsubscribe(); + test("real tool-only success emits exactly one completion without empty-graph text", async () => { + const { store, sent, unsubscribe } = install(); + let calls = 0; + const result = await run( + workflow({ + name: "tool lifecycle success", + description: "", + inputs: {}, + outputs: {}, + run: async (ctx) => { + await ctx.tool("publish-success", {}, async () => { + calls += 1; + return "ok"; + }); + return {}; + }, + }), + {}, + { store, durableBackend: new InMemoryDurableBackend() }, + ); + unsubscribe(); - assert.equal(result.status, "completed"); - assert.equal(calls, 1); - assert.equal(sent.length, 1); - assert.equal(sent[0]?.details?.kind, "completed"); - assert.doesNotMatch(sent[0]?.content ?? "", /without creating any workflow|empty.graph/i); - }); + assert.equal(result.status, "completed"); + assert.equal(calls, 1); + assert.equal(sent.length, 1); + assert.equal(sent[0]?.details?.kind, "completed"); + assert.doesNotMatch(sent[0]?.content ?? "", /without creating any workflow|empty.graph/i); + }); - test("ordinary tool rejection emits one notice with original error and visible failed node", async () => { - const { store, sent, unsubscribe } = install(); - const result = await run(workflow({ - name: "tool lifecycle failure", description: "", inputs: {}, outputs: {}, - run: async (ctx) => { - await ctx.tool("publish-failure", {}, async () => { throw new Error("remote publish rejected"); }); - return {}; - }, - }), {}, { store, durableBackend: new InMemoryDurableBackend() }); - unsubscribe(); + test("ordinary tool rejection emits one notice with original error and visible failed node", async () => { + const { store, sent, unsubscribe } = install(); + const result = await run( + workflow({ + name: "tool lifecycle failure", + description: "", + inputs: {}, + outputs: {}, + run: async (ctx) => { + await ctx.tool("publish-failure", {}, async () => { + throw new Error("remote publish rejected"); + }); + return {}; + }, + }), + {}, + { store, durableBackend: new InMemoryDurableBackend() }, + ); + unsubscribe(); - assert.equal(result.status, "failed"); - assert.match(result.error ?? "", /remote publish rejected/); - assert.equal(sent.length, 1); - assert.equal(sent[0]?.details?.kind, "failed"); - assert.equal(sent[0]?.details?.toolName, "publish-failure"); - assert.equal(sent[0]?.details?.toolNodeId, result.toolNodes?.[0]?.id); - assert.equal(sent[0]?.details?.failedStageId, undefined); - assert.match(sent[0]?.content ?? "", /remote publish rejected/); - assert.equal(result.toolNodes?.[0]?.status, "failed"); - assert.equal(store.runs()[0]?.failedToolNodeId, result.toolNodes?.[0]?.id); - }); + assert.equal(result.status, "failed"); + assert.match(result.error ?? "", /remote publish rejected/); + assert.equal(sent.length, 1); + assert.equal(sent[0]?.details?.kind, "failed"); + assert.equal(sent[0]?.details?.toolName, "publish-failure"); + assert.equal(sent[0]?.details?.toolNodeId, result.toolNodes?.[0]?.id); + assert.equal(sent[0]?.details?.failedStageId, undefined); + assert.match(sent[0]?.content ?? "", /remote publish rejected/); + assert.equal(result.toolNodes?.[0]?.status, "failed"); + assert.equal(store.runs()[0]?.failedToolNodeId, result.toolNodes?.[0]?.id); + }); - test("caught tool failure still emits exactly one failed lifecycle notice", async () => { - const { store, sent, unsubscribe } = install(); - const result = await run(workflow({ - name: "caught tool lifecycle failure", description: "", inputs: {}, outputs: {}, - run: async (ctx) => { - try { - await ctx.tool("caught-publish", {}, async () => { throw new Error("caught publish rejected"); }); - } catch { - // Tool failures remain workflow failures even when author code catches the promise. - } - return {}; - }, - }), {}, { store, durableBackend: new InMemoryDurableBackend() }); - unsubscribe(); + test("caught tool failure still emits exactly one failed lifecycle notice", async () => { + const { store, sent, unsubscribe } = install(); + const result = await run( + workflow({ + name: "caught tool lifecycle failure", + description: "", + inputs: {}, + outputs: {}, + run: async (ctx) => { + try { + await ctx.tool("caught-publish", {}, async () => { + throw new Error("caught publish rejected"); + }); + } catch { + // Tool failures remain workflow failures even when author code catches the promise. + } + return {}; + }, + }), + {}, + { store, durableBackend: new InMemoryDurableBackend() }, + ); + unsubscribe(); - assert.equal(result.status, "failed"); - assert.match(result.error ?? "", /caught publish rejected/); - assert.equal(sent.length, 1); - assert.equal(sent[0]?.details?.kind, "failed"); - assert.equal(sent[0]?.details?.toolName, "caught-publish"); - assert.equal(sent[0]?.details?.failedStageId, undefined); - assert.equal(store.runs()[0]?.failedToolNodeId, result.toolNodes?.[0]?.id); - }); + assert.equal(result.status, "failed"); + assert.match(result.error ?? "", /caught publish rejected/); + assert.equal(sent.length, 1); + assert.equal(sent[0]?.details?.kind, "failed"); + assert.equal(sent[0]?.details?.toolName, "caught-publish"); + assert.equal(sent[0]?.details?.failedStageId, undefined); + assert.equal(store.runs()[0]?.failedToolNodeId, result.toolNodes?.[0]?.id); + }); }); diff --git a/test/unit/workflow-tool-post-terminal.test.ts b/test/unit/workflow-tool-post-terminal.test.ts index db6ab8159..68e3b52e6 100644 --- a/test/unit/workflow-tool-post-terminal.test.ts +++ b/test/unit/workflow-tool-post-terminal.test.ts @@ -1,277 +1,403 @@ -import { describe, test } from "bun:test"; import assert from "node:assert/strict"; import { Type } from "typebox"; +import { describe, test } from "vitest"; import { workflow } from "../../packages/workflows/src/authoring/workflow.js"; import { InMemoryDurableBackend } from "../../packages/workflows/src/durable/backend.js"; import type { WorkflowToolPrimitive } from "../../packages/workflows/src/durable/tool-primitive.js"; import { run } from "../../packages/workflows/src/engine/run.js"; -import type { Store } from "../../packages/workflows/src/shared/store-public-types.js"; import { createStore } from "../../packages/workflows/src/shared/store.js"; +import type { Store } from "../../packages/workflows/src/shared/store-public-types.js"; +import { sleep } from "../helpers/runtime.js"; async function assertClosedBeforeEffects( - retainedTool: WorkflowToolPrimitive | undefined, - store: Store, - backend: InMemoryDurableBackend, - runId: string, + retainedTool: WorkflowToolPrimitive | undefined, + store: Store, + backend: InMemoryDurableBackend, + runId: string, ): Promise { - assert.ok(retainedTool !== undefined); - const beforeNodes = store.runs().flatMap((entry) => entry.toolNodes ?? []).length; - const beforeCheckpoints = backend.listCheckpoints(runId).length; - let callbacks = 0; - const unhandled: unknown[] = []; - const onUnhandled = (error: unknown): void => { unhandled.push(error); }; - process.on("unhandledRejection", onUnhandled); - try { - const rejected = retainedTool("late", {}, async () => { callbacks += 1; return "too late"; }, { - retriesAllowed: true, maxAttempts: 3, intervalMs: 0, - }); - assert.equal(rejected instanceof Promise, true, "ctx.tool still returns a native promise"); - await assert.rejects(rejected, /ctx\.tool admission is closed for this run/i); - void retainedTool("late-void", {}, async () => { callbacks += 1; return "too late"; }); - await Bun.sleep(0); - } finally { - process.off("unhandledRejection", onUnhandled); - } - assert.equal(callbacks, 0, "post-close callbacks and retries must never begin"); - assert.equal(store.runs().flatMap((entry) => entry.toolNodes ?? []).length, beforeNodes); - assert.equal(backend.listCheckpoints(runId).length, beforeCheckpoints); - assert.deepEqual(unhandled, [], "void post-close calls have an internal rejection observer"); + assert.ok(retainedTool !== undefined); + const beforeNodes = store.runs().flatMap((entry) => entry.toolNodes ?? []).length; + const beforeCheckpoints = backend.listCheckpoints(runId).length; + let callbacks = 0; + const unhandled: unknown[] = []; + const onUnhandled = (error: unknown): void => { + unhandled.push(error); + }; + process.on("unhandledRejection", onUnhandled); + try { + const rejected = retainedTool( + "late", + {}, + async () => { + callbacks += 1; + return "too late"; + }, + { + retriesAllowed: true, + maxAttempts: 3, + intervalMs: 0, + }, + ); + assert.equal(rejected instanceof Promise, true, "ctx.tool still returns a native promise"); + await assert.rejects(rejected, /ctx\.tool admission is closed for this run/i); + void retainedTool("late-void", {}, async () => { + callbacks += 1; + return "too late"; + }); + await sleep(0); + } finally { + process.off("unhandledRejection", onUnhandled); + } + assert.equal(callbacks, 0, "post-close callbacks and retries must never begin"); + assert.equal(store.runs().flatMap((entry) => entry.toolNodes ?? []).length, beforeNodes); + assert.equal(backend.listCheckpoints(runId).length, beforeCheckpoints); + assert.deepEqual(unhandled, [], "void post-close calls have an internal rejection observer"); } describe("ctx.tool terminal admission", () => { - test("completed publication refuses retained calls before all effects", async () => { - const store = createStore(); - const backend = new InMemoryDurableBackend(); - let retainedTool: WorkflowToolPrimitive | undefined; - const result = await run(workflow({ - name: "closed-tool-completed", description: "", inputs: {}, outputs: {}, - run: async (ctx) => { - retainedTool = ctx.tool; - await ctx.tool("admitted", {}, async () => "done"); - return {}; - }, - }), {}, { store, durableBackend: backend }); - - assert.equal(result.status, "completed"); - await assertClosedBeforeEffects(retainedTool, store, backend, result.runId); - }); - - test("ordinary failure closes admission", async () => { - const store = createStore(); - const backend = new InMemoryDurableBackend(); - let retainedTool: WorkflowToolPrimitive | undefined; - const result = await run(workflow({ - name: "closed-tool-failed", description: "", inputs: {}, outputs: {}, - run: async (ctx) => { - retainedTool = ctx.tool; - await ctx.tool("admitted", {}, async () => "done"); - throw new Error("workflow failed after tool"); - }, - }), {}, { store, durableBackend: backend }); + test("completed publication refuses retained calls before all effects", async () => { + const store = createStore(); + const backend = new InMemoryDurableBackend(); + let retainedTool: WorkflowToolPrimitive | undefined; + const result = await run( + workflow({ + name: "closed-tool-completed", + description: "", + inputs: {}, + outputs: {}, + run: async (ctx) => { + retainedTool = ctx.tool; + await ctx.tool("admitted", {}, async () => "done"); + return {}; + }, + }), + {}, + { store, durableBackend: backend }, + ); - assert.equal(result.status, "failed"); - await assertClosedBeforeEffects(retainedTool, store, backend, result.runId); - }); + assert.equal(result.status, "completed"); + await assertClosedBeforeEffects(retainedTool, store, backend, result.runId); + }); - test("explicit exit closes admission", async () => { - const store = createStore(); - const backend = new InMemoryDurableBackend(); - let retainedTool: WorkflowToolPrimitive | undefined; - const result = await run(workflow({ - name: "closed-tool-exit", description: "", inputs: {}, outputs: {}, - run: async (ctx) => { retainedTool = ctx.tool; ctx.exit({ status: "completed" }); }, - }), {}, { store, durableBackend: backend }); + test("ordinary failure closes admission", async () => { + const store = createStore(); + const backend = new InMemoryDurableBackend(); + let retainedTool: WorkflowToolPrimitive | undefined; + const result = await run( + workflow({ + name: "closed-tool-failed", + description: "", + inputs: {}, + outputs: {}, + run: async (ctx) => { + retainedTool = ctx.tool; + await ctx.tool("admitted", {}, async () => "done"); + throw new Error("workflow failed after tool"); + }, + }), + {}, + { store, durableBackend: backend }, + ); - assert.equal(result.status, "completed"); - assert.equal(result.exited, true); - await assertClosedBeforeEffects(retainedTool, store, backend, result.runId); - }); + assert.equal(result.status, "failed"); + await assertClosedBeforeEffects(retainedTool, store, backend, result.runId); + }); - test("external cancellation closes admission after admitted tools settle", async () => { - const store = createStore(); - const backend = new InMemoryDurableBackend(); - const controller = new AbortController(); - const entered = Promise.withResolvers(); - const release = Promise.withResolvers(); - let retainedTool: WorkflowToolPrimitive | undefined; - const pending = run(workflow({ - name: "closed-tool-cancel", description: "", inputs: {}, outputs: {}, - run: async (ctx) => { - retainedTool = ctx.tool; - await ctx.tool("cancelled", {}, async () => { entered.resolve(); await release.promise; return "late"; }); - return {}; - }, - }), {}, { store, durableBackend: backend, signal: controller.signal }); + test("explicit exit closes admission", async () => { + const store = createStore(); + const backend = new InMemoryDurableBackend(); + let retainedTool: WorkflowToolPrimitive | undefined; + const result = await run( + workflow({ + name: "closed-tool-exit", + description: "", + inputs: {}, + outputs: {}, + run: async (ctx) => { + retainedTool = ctx.tool; + ctx.exit({ status: "completed" }); + }, + }), + {}, + { store, durableBackend: backend }, + ); - await entered.promise; - controller.abort(new Error("operator cancelled")); - release.resolve(); - const result = await pending; - assert.equal(result.status, "killed"); - await assertClosedBeforeEffects(retainedTool, store, backend, result.runId); - }); + assert.equal(result.status, "completed"); + assert.equal(result.exited, true); + await assertClosedBeforeEffects(retainedTool, store, backend, result.runId); + }); + test("external cancellation closes admission after admitted tools settle", async () => { + const store = createStore(); + const backend = new InMemoryDurableBackend(); + const controller = new AbortController(); + const entered = Promise.withResolvers(); + const release = Promise.withResolvers(); + let retainedTool: WorkflowToolPrimitive | undefined; + const pending = run( + workflow({ + name: "closed-tool-cancel", + description: "", + inputs: {}, + outputs: {}, + run: async (ctx) => { + retainedTool = ctx.tool; + await ctx.tool("cancelled", {}, async () => { + entered.resolve(); + await release.promise; + return "late"; + }); + return {}; + }, + }), + {}, + { store, durableBackend: backend, signal: controller.signal }, + ); - test("parent cancellation closes a retained child tool admission", async () => { - const store = createStore(); - const backend = new InMemoryDurableBackend(); - const controller = new AbortController(); - const entered = Promise.withResolvers(); - const release = Promise.withResolvers(); - let retainedTool: WorkflowToolPrimitive | undefined; - const child = workflow({ - name: "closed-child-cancel", description: "", inputs: {}, outputs: {}, - run: async (ctx) => { - retainedTool = ctx.tool; - await ctx.tool("child-cancelled", {}, async () => { entered.resolve(); await release.promise; return "late"; }); - return {}; - }, - }); - const parent = workflow({ - name: "closed-parent-cancel", description: "", inputs: {}, outputs: {}, - run: async (ctx) => { await ctx.workflow(child, { stageName: "child" }); return {}; }, - }); - const pending = run(parent, {}, { store, durableBackend: backend, signal: controller.signal }); + await entered.promise; + controller.abort(new Error("operator cancelled")); + release.resolve(); + const result = await pending; + assert.equal(result.status, "killed"); + await assertClosedBeforeEffects(retainedTool, store, backend, result.runId); + }); - await entered.promise; - controller.abort(new Error("cancel parent")); - release.resolve(); - const result = await pending; - assert.equal(result.status, "killed"); - await assertClosedBeforeEffects(retainedTool, store, backend, result.runId); - }); + test("parent cancellation closes a retained child tool admission", async () => { + const store = createStore(); + const backend = new InMemoryDurableBackend(); + const controller = new AbortController(); + const entered = Promise.withResolvers(); + const release = Promise.withResolvers(); + let retainedTool: WorkflowToolPrimitive | undefined; + const child = workflow({ + name: "closed-child-cancel", + description: "", + inputs: {}, + outputs: {}, + run: async (ctx) => { + retainedTool = ctx.tool; + await ctx.tool("child-cancelled", {}, async () => { + entered.resolve(); + await release.promise; + return "late"; + }); + return {}; + }, + }); + const parent = workflow({ + name: "closed-parent-cancel", + description: "", + inputs: {}, + outputs: {}, + run: async (ctx) => { + await ctx.workflow(child, { stageName: "child" }); + return {}; + }, + }); + const pending = run(parent, {}, { store, durableBackend: backend, signal: controller.signal }); - test("active-blocked publication closes admission even though the retained run stays running", async () => { - const store = createStore(); - const backend = new InMemoryDurableBackend(); - let retainedTool: WorkflowToolPrimitive | undefined; - const result = await run(workflow({ - name: "closed-tool-active-blocked", description: "", inputs: {}, outputs: {}, - run: async (ctx) => { - retainedTool = ctx.tool; - await ctx.stage("needs-login").prompt("x"); - return {}; - }, - }), {}, { - store, - durableBackend: backend, - adapters: { prompt: { prompt: async () => { throw new Error("No API key found for provider"); } } }, - }); + await entered.promise; + controller.abort(new Error("cancel parent")); + release.resolve(); + const result = await pending; + assert.equal(result.status, "killed"); + await assertClosedBeforeEffects(retainedTool, store, backend, result.runId); + }); - assert.equal(result.status, "running"); - assert.equal(store.runs()[0]?.failureDisposition, "active_blocked"); - await assertClosedBeforeEffects(retainedTool, store, backend, result.runId); - }); - test("output validation failure closes admission", async () => { - const store = createStore(); - const backend = new InMemoryDurableBackend(); - let retainedTool: WorkflowToolPrimitive | undefined; - const result = await run(workflow({ - name: "closed-tool-output", description: "", inputs: {}, outputs: { value: Type.Number() }, - run: async (ctx) => { - retainedTool = ctx.tool; - await ctx.tool("admitted", {}, async () => "done"); - return { value: "invalid" } as never; - }, - }), {}, { store, durableBackend: backend }); + test("active-blocked publication closes admission even though the retained run stays running", async () => { + const store = createStore(); + const backend = new InMemoryDurableBackend(); + let retainedTool: WorkflowToolPrimitive | undefined; + const result = await run( + workflow({ + name: "closed-tool-active-blocked", + description: "", + inputs: {}, + outputs: {}, + run: async (ctx) => { + retainedTool = ctx.tool; + await ctx.stage("needs-login").prompt("x"); + return {}; + }, + }), + {}, + { + store, + durableBackend: backend, + adapters: { + prompt: { + prompt: async () => { + throw new Error("No API key found for provider"); + }, + }, + }, + }, + ); - assert.equal(result.status, "failed"); - assert.match(result.error ?? "", /output "value" expected number, got string/i); - await assertClosedBeforeEffects(retainedTool, store, backend, result.runId); - }); + assert.equal(result.status, "running"); + assert.equal(store.runs()[0]?.failureDisposition, "active_blocked"); + await assertClosedBeforeEffects(retainedTool, store, backend, result.runId); + }); + test("output validation failure closes admission", async () => { + const store = createStore(); + const backend = new InMemoryDurableBackend(); + let retainedTool: WorkflowToolPrimitive | undefined; + const result = await run( + workflow({ + name: "closed-tool-output", + description: "", + inputs: {}, + outputs: { value: Type.Number() }, + run: async (ctx) => { + retainedTool = ctx.tool; + await ctx.tool("admitted", {}, async () => "done"); + return { value: "invalid" } as never; + }, + }), + {}, + { store, durableBackend: backend }, + ); - test("empty-graph validation failure closes admission", async () => { - const store = createStore(); - const backend = new InMemoryDurableBackend(); - let retainedTool: WorkflowToolPrimitive | undefined; - const result = await run(workflow({ - name: "closed-tool-graph", description: "", inputs: {}, outputs: {}, - run: async (ctx) => { retainedTool = ctx.tool; return {}; }, - }), {}, { store, durableBackend: backend }); + assert.equal(result.status, "failed"); + assert.match(result.error ?? "", /output "value" expected number, got string/i); + await assertClosedBeforeEffects(retainedTool, store, backend, result.runId); + }); - assert.equal(result.status, "failed"); - assert.match(result.error ?? "", /without creating any workflow stages or durable tool nodes/i); - await assertClosedBeforeEffects(retainedTool, store, backend, result.runId); - }); + test("empty-graph validation failure closes admission", async () => { + const store = createStore(); + const backend = new InMemoryDurableBackend(); + let retainedTool: WorkflowToolPrimitive | undefined; + const result = await run( + workflow({ + name: "closed-tool-graph", + description: "", + inputs: {}, + outputs: {}, + run: async (ctx) => { + retainedTool = ctx.tool; + return {}; + }, + }), + {}, + { store, durableBackend: backend }, + ); + assert.equal(result.status, "failed"); + assert.match(result.error ?? "", /without creating any workflow stages or durable tool nodes/i); + await assertClosedBeforeEffects(retainedTool, store, backend, result.runId); + }); - test("authoritative durable failure closes admission", async () => { - class RejectToolCheckpointBackend extends InMemoryDurableBackend { - override async recordCheckpointAsync(): Promise { throw new Error("authoritative checkpoint rejected"); } - } - const store = createStore(); - const backend = new RejectToolCheckpointBackend(); - let retainedTool: WorkflowToolPrimitive | undefined; - const result = await run(workflow({ - name: "closed-tool-durable", description: "", inputs: {}, outputs: {}, - run: async (ctx) => { - retainedTool = ctx.tool; - await ctx.tool("rejected-write", {}, async () => "done"); - return {}; - }, - }), {}, { store, durableBackend: backend }); + test("authoritative durable failure closes admission", async () => { + class RejectToolCheckpointBackend extends InMemoryDurableBackend { + override async recordCheckpointAsync(): Promise { + throw new Error("authoritative checkpoint rejected"); + } + } + const store = createStore(); + const backend = new RejectToolCheckpointBackend(); + let retainedTool: WorkflowToolPrimitive | undefined; + const result = await run( + workflow({ + name: "closed-tool-durable", + description: "", + inputs: {}, + outputs: {}, + run: async (ctx) => { + retainedTool = ctx.tool; + await ctx.tool("rejected-write", {}, async () => "done"); + return {}; + }, + }), + {}, + { store, durableBackend: backend }, + ); - assert.equal(result.status, "failed"); - assert.match(result.error ?? "", /authoritative checkpoint rejected/); - await assertClosedBeforeEffects(retainedTool, store, backend, result.runId); - }); + assert.equal(result.status, "failed"); + assert.match(result.error ?? "", /authoritative checkpoint rejected/); + await assertClosedBeforeEffects(retainedTool, store, backend, result.runId); + }); - test("a rejecting durable terminal finalizer cannot leave admission open", async () => { - class RejectTerminalFlushBackend extends InMemoryDurableBackend { - public flushCalls = 0; - override async flush(): Promise { - this.flushCalls += 1; - if (this.flushCalls === 2) throw new Error("durable terminal finalizer rejected"); - } - } - const store = createStore(); - const backend = new RejectTerminalFlushBackend(); - let retainedTool: WorkflowToolPrimitive | undefined; - let runId = ""; - await assert.rejects(run(workflow({ - name: "closed-tool-durable-finalizer", description: "", inputs: {}, outputs: {}, - run: async (ctx) => { - retainedTool = ctx.tool; - await ctx.tool("admitted", {}, async () => "done"); - throw new Error("ordinary failure"); - }, - }), {}, { - store, - durableBackend: backend, - onRunEnd(id) { runId = id; }, - }), /durable terminal finalizer rejected/); + test("a rejecting durable terminal finalizer cannot leave admission open", async () => { + class RejectTerminalFlushBackend extends InMemoryDurableBackend { + public flushCalls = 0; + override async flush(): Promise { + this.flushCalls += 1; + if (this.flushCalls === 2) throw new Error("durable terminal finalizer rejected"); + } + } + const store = createStore(); + const backend = new RejectTerminalFlushBackend(); + let retainedTool: WorkflowToolPrimitive | undefined; + let runId = ""; + await assert.rejects( + run( + workflow({ + name: "closed-tool-durable-finalizer", + description: "", + inputs: {}, + outputs: {}, + run: async (ctx) => { + retainedTool = ctx.tool; + await ctx.tool("admitted", {}, async () => "done"); + throw new Error("ordinary failure"); + }, + }), + {}, + { + store, + durableBackend: backend, + onRunEnd(id) { + runId = id; + }, + }, + ), + /durable terminal finalizer rejected/, + ); - assert.equal(store.runs()[0]?.status, "failed"); - await assertClosedBeforeEffects(retainedTool, store, backend, runId); - }); - test("a rejecting terminal callback cannot leave admission open", async () => { - const store = createStore(); - const backend = new InMemoryDurableBackend(); - let retainedTool: WorkflowToolPrimitive | undefined; - let runId = ""; - let callbackRefusal: Promise | undefined; - let callbackCalls = 0; - await assert.rejects(run(workflow({ - name: "closed-tool-finalizer", description: "", inputs: {}, outputs: {}, - run: async (ctx) => { - retainedTool = ctx.tool; - await ctx.tool("admitted", {}, async () => "done"); - return {}; - }, - }), {}, { - store, - durableBackend: backend, - onRunEnd(id) { - runId = id; - callbackRefusal = retainedTool!("during-terminal-callback", {}, async () => { callbackCalls += 1; return "late"; }); - throw new Error("terminal callback rejected"); - }, - }), /terminal callback rejected/); + assert.equal(store.runs()[0]?.status, "failed"); + await assertClosedBeforeEffects(retainedTool, store, backend, runId); + }); + test("a rejecting terminal callback cannot leave admission open", async () => { + const store = createStore(); + const backend = new InMemoryDurableBackend(); + let retainedTool: WorkflowToolPrimitive | undefined; + let runId = ""; + let callbackRefusal: Promise | undefined; + let callbackCalls = 0; + await assert.rejects( + run( + workflow({ + name: "closed-tool-finalizer", + description: "", + inputs: {}, + outputs: {}, + run: async (ctx) => { + retainedTool = ctx.tool; + await ctx.tool("admitted", {}, async () => "done"); + return {}; + }, + }), + {}, + { + store, + durableBackend: backend, + onRunEnd(id) { + runId = id; + callbackRefusal = retainedTool!("during-terminal-callback", {}, async () => { + callbackCalls += 1; + return "late"; + }); + throw new Error("terminal callback rejected"); + }, + }, + ), + /terminal callback rejected/, + ); - assert.equal(store.runs()[0]?.status, "completed"); - assert.ok(callbackRefusal !== undefined); - await assert.rejects(callbackRefusal, /ctx\.tool admission is closed/); - assert.equal(callbackCalls, 0); - await assertClosedBeforeEffects(retainedTool, store, backend, runId); - }); + assert.equal(store.runs()[0]?.status, "completed"); + assert.ok(callbackRefusal !== undefined); + await assert.rejects(callbackRefusal, /ctx\.tool admission is closed/); + assert.equal(callbackCalls, 0); + await assertClosedBeforeEffects(retainedTool, store, backend, runId); + }); }); diff --git a/test/unit/workflow-tool-races.test.ts b/test/unit/workflow-tool-races.test.ts index 35144eed6..35e95a4b2 100644 --- a/test/unit/workflow-tool-races.test.ts +++ b/test/unit/workflow-tool-races.test.ts @@ -1,461 +1,615 @@ -import { describe, test } from "bun:test"; import assert from "node:assert/strict"; +import { describe, test } from "vitest"; import { workflow } from "../../packages/workflows/src/authoring/workflow.js"; import { InMemoryDurableBackend } from "../../packages/workflows/src/durable/backend.js"; import type { DurableCheckpoint } from "../../packages/workflows/src/durable/types.js"; import { run } from "../../packages/workflows/src/engine/run.js"; -import { createCancellationRegistry } from "../../packages/workflows/src/runs/background/cancellation-registry.js"; -import { killRun } from "../../packages/workflows/src/runs/background/status.js"; import { - createWorkflowLifecycleNotificationState, - installWorkflowLifecycleNotifications, - type WorkflowLifecycleNoticeDetails, + createWorkflowLifecycleNotificationState, + installWorkflowLifecycleNotifications, + type WorkflowLifecycleNoticeDetails, } from "../../packages/workflows/src/extension/lifecycle-notifications.js"; +import { createCancellationRegistry } from "../../packages/workflows/src/runs/background/cancellation-registry.js"; +import { killRun } from "../../packages/workflows/src/runs/background/status.js"; import { createStore } from "../../packages/workflows/src/shared/store.js"; - +import { sleep } from "../helpers/runtime.js"; function installFailureNotices(store: ReturnType): { - readonly notices: WorkflowLifecycleNoticeDetails[]; - readonly unsubscribe: () => void; + readonly notices: WorkflowLifecycleNoticeDetails[]; + readonly unsubscribe: () => void; } { - const notices: WorkflowLifecycleNoticeDetails[] = []; - const unsubscribe = installWorkflowLifecycleNotifications({ - store, - config: { enabled: true, notifyOn: ["failed"] }, - state: createWorkflowLifecycleNotificationState(), - seedExisting: false, - sendMessage(message) { - notices.push((message as { details: WorkflowLifecycleNoticeDetails }).details); - }, - }); - return { notices, unsubscribe }; + const notices: WorkflowLifecycleNoticeDetails[] = []; + const unsubscribe = installWorkflowLifecycleNotifications({ + store, + config: { enabled: true, notifyOn: ["failed"] }, + state: createWorkflowLifecycleNotificationState(), + seedExisting: false, + sendMessage(message) { + notices.push((message as { details: WorkflowLifecycleNoticeDetails }).details); + }, + }); + return { notices, unsubscribe }; } function waitForToolStatus( - store: ReturnType, - runId: string, - toolName: string, - status: "failed" | "cancelled", + store: ReturnType, + runId: string, + toolName: string, + status: "failed" | "cancelled", ): Promise { - const matches = (): boolean => store.runs().find((run) => run.id === runId) - ?.toolNodes?.some((node) => node.name === toolName && node.status === status) === true; - if (matches()) return Promise.resolve(); - return new Promise((resolve) => { - const unsubscribe = store.subscribe(() => { - if (!matches()) return; - unsubscribe(); - resolve(); - }); - }); + const matches = (): boolean => + store + .runs() + .find((run) => run.id === runId) + ?.toolNodes?.some((node) => node.name === toolName && node.status === status) === true; + if (matches()) return Promise.resolve(); + return new Promise((resolve) => { + const unsubscribe = store.subscribe(() => { + if (!matches()) return; + unsubscribe(); + resolve(); + }); + }); } describe("ctx.tool persistence and cancellation races", () => { - test("cancellation during retry backoff leaves a cancelled node and no checkpoint", async () => { - const store = createStore(); - const backend = new InMemoryDurableBackend(); - const controller = new AbortController(); - const attempted = Promise.withResolvers(); - let attempts = 0; - const pending = run(workflow({ - name: "cancel retry backoff", description: "", inputs: {}, outputs: {}, - run: async (ctx) => { - await ctx.tool("retrying-write", {}, async () => { - attempts += 1; - attempted.resolve(); - throw new Error("transient write failure"); - }, { retriesAllowed: true, maxAttempts: 3, intervalMs: 10_000 }); - return {}; - }, - }), {}, { store, durableBackend: backend, signal: controller.signal }); - - await attempted.promise; - await Promise.resolve(); - controller.abort(new Error("operator cancelled during backoff")); - const result = await pending; - - assert.equal(result.status, "killed"); - assert.equal(attempts, 1); - assert.equal(result.toolNodes?.[0]?.status, "cancelled"); - assert.equal(backend.listCheckpoints(result.runId).some((checkpoint) => checkpoint.kind === "tool"), false); - }); - - test("failure observed before a late cancellation wins while catch drains another tool", async () => { - const store = createStore(); - const backend = new InMemoryDurableBackend(); - const cancellation = createCancellationRegistry(); - const lifecycle = installFailureNotices(store); - const blockerEntered = Promise.withResolvers(); - const blockerRelease = Promise.withResolvers(); - const failureEntered = Promise.withResolvers(); - const failureRelease = Promise.withResolvers(); - const runId = "failure-first-catch-drain"; - const pending = run(workflow({ - name: "failure first catch drain", description: "", inputs: {}, outputs: {}, - run: async (ctx) => { - void ctx.tool("drain-blocker", {}, async () => { - blockerEntered.resolve(); - await blockerRelease.promise; - return "released"; - }); - await ctx.tool("callback-first", {}, async () => { - failureEntered.resolve(); - await failureRelease.promise; - throw new Error("callback failed before cancellation"); - }); - return {}; - }, - }), {}, { runId, store, durableBackend: backend, cancellation }); - - await Promise.all([blockerEntered.promise, failureEntered.promise]); - failureRelease.resolve(); - await waitForToolStatus(store, runId, "callback-first", "failed"); - assert.equal(killRun(runId, { store, cancellation }).ok, true); - blockerRelease.resolve(); - const result = await pending; - lifecycle.unsubscribe(); - - const snapshot = store.runs().find((candidate) => candidate.id === runId); - assert.equal(result.status, "failed"); - assert.equal(snapshot?.failureKind, "unknown"); - assert.equal(snapshot?.failureDisposition, "terminal_failed"); - assert.match(result.error ?? "", /callback failed before cancellation/); - assert.equal(result.toolNodes?.find((node) => node.name === "callback-first")?.status, "failed"); - assert.equal(result.toolNodes?.find((node) => node.name === "drain-blocker")?.status, "cancelled"); - assert.equal(lifecycle.notices.length, 1); - assert.equal(lifecycle.notices[0]?.kind, "failed"); - }); - - test("unawaited failure during normal drain terminates a non-cooperative sibling", async () => { - const store = createStore(); - const backend = new InMemoryDurableBackend(); - const lifecycle = installFailureNotices(store); - const persisted: Array<{ type: string; payload: Record }> = []; - const persistence = { - appendEntry(type: string, payload: Record): string { - persisted.push({ type, payload }); - return `entry-${persisted.length}`; - }, - setLabel(_entryId: string, _label: string): void {}, - }; - const failureEntered = Promise.withResolvers(); - const failureRelease = Promise.withResolvers(); - const blockerEntered = Promise.withResolvers(); - const blockerRelease = Promise.withResolvers(); - const runId = "failure-first-normal-drain"; - const pending = run(workflow({ - name: "failure first normal drain", description: "", inputs: {}, outputs: {}, - run: async (ctx) => { - void ctx.tool("normal-drain-blocker", {}, async () => { - blockerEntered.resolve(); - await blockerRelease.promise; - return "released"; - }); - void ctx.tool("unawaited-second", {}, async () => { - failureEntered.resolve(); - await failureRelease.promise; - throw new Error("second-admitted failure won"); - }); - return {}; - }, - }), {}, { runId, store, durableBackend: backend, persistence }); - - await Promise.all([failureEntered.promise, blockerEntered.promise]); - failureRelease.resolve(); - const result = await Promise.race([ - pending, - Bun.sleep(250).then(() => undefined), - ]); - assert.ok(result, "failure observed during drain must settle without an external cancellation"); - - assert.equal(result.status, "failed"); - assert.equal(result.error, "second-admitted failure won"); - const failedNode = result.toolNodes?.find((node) => node.name === "unawaited-second"); - assert.equal(result.failedToolNodeId, failedNode?.id); - assert.equal(failedNode?.status, "failed"); - assert.equal(result.toolNodes?.find((node) => node.name === "normal-drain-blocker")?.status, "cancelled"); - const snapshot = store.runs().find((candidate) => candidate.id === runId); - assert.equal(snapshot?.error, "second-admitted failure won"); - assert.equal(snapshot?.failedToolNodeId, failedNode?.id); - const runEnds = persisted.filter((entry) => entry.type === "workflow.run.end"); - assert.equal(runEnds.length, 1); - assert.equal(runEnds[0]?.payload["status"], "failed"); - assert.equal(runEnds[0]?.payload["failedToolNodeId"], failedNode?.id); - assert.equal( - (runEnds[0]?.payload["failedToolNode"] as { status?: string } | undefined)?.status, - "failed", - ); - assert.equal(lifecycle.notices.length, 1); - assert.equal(lifecycle.notices[0]?.toolName, "unawaited-second"); - assert.equal(lifecycle.notices[0]?.error, "second-admitted failure won"); - - blockerRelease.resolve(); - await Bun.sleep(0); - lifecycle.unsubscribe(); - assert.equal(backend.listCheckpoints(runId).some( - (checkpoint) => checkpoint.kind === "tool" && checkpoint.name === "normal-drain-blocker", - ), false); - }); - - test("ordinary failure settles despite a non-cooperative sibling tool", async () => { - const store = createStore(); - const backend = new InMemoryDurableBackend(); - const lifecycle = installFailureNotices(store); - const blockerEntered = Promise.withResolvers(); - const blockerRelease = Promise.withResolvers(); - const failureEntered = Promise.withResolvers(); - const releaseFailure = Promise.withResolvers(); - const runId = "failure-with-non-cooperative-sibling"; - const pending = run(workflow({ - name: "failure with non cooperative sibling", description: "", inputs: {}, outputs: {}, - run: async (ctx) => { - void ctx.tool("non-cooperative", {}, async () => { - blockerEntered.resolve(); - await blockerRelease.promise; - return "late success"; - }); - await ctx.tool("failure-winner", {}, async () => { - failureEntered.resolve(); - await releaseFailure.promise; - throw new Error("winner failed without kill"); - }); - return {}; - }, - }), {}, { runId, store, durableBackend: backend }); - - await Promise.all([blockerEntered.promise, failureEntered.promise]); - releaseFailure.resolve(); - const result = await Promise.race([ - pending, - Bun.sleep(250).then(() => undefined), - ]); - - assert.ok(result, "ordinary failure must not wait forever for a callback that ignores cancellation"); - assert.equal(result.status, "failed"); - assert.equal(result.error, "winner failed without kill"); - const winner = result.toolNodes?.find((node) => node.name === "failure-winner"); - assert.equal(result.failedToolNodeId, winner?.id); - assert.equal(result.toolNodes?.find((node) => node.name === "non-cooperative")?.status, "cancelled"); - assert.equal(lifecycle.notices.length, 1); - assert.equal(lifecycle.notices[0]?.toolName, "failure-winner"); - - blockerRelease.resolve(); - await Bun.sleep(0); - lifecycle.unsubscribe(); - assert.equal(result.toolNodes?.find((node) => node.name === "non-cooperative")?.status, "cancelled"); - assert.equal(backend.listCheckpoints(runId).some( - (checkpoint) => checkpoint.kind === "tool" && checkpoint.name === "non-cooperative", - ), false, "a late sibling callback must not create a successful checkpoint"); - assert.equal(lifecycle.notices.length, 1); - }); - - test("preserves first terminal tool attribution when concurrent failures throw the same value", async () => { - const store = createStore(); - const backend = new InMemoryDurableBackend(); - const lifecycle = installFailureNotices(store); - const sharedError = new Error("shared concurrent failure"); - const firstEntered = Promise.withResolvers(); - const secondEntered = Promise.withResolvers(); - const releaseFirst = Promise.withResolvers(); - const releaseSecond = Promise.withResolvers(); - const runId = "same-value-failure-attribution"; - const pending = run(workflow({ - name: "same value failure attribution", description: "", inputs: {}, outputs: {}, - run: async (ctx) => { - const first = ctx.tool("first-admitted", {}, async () => { - firstEntered.resolve(); - await releaseFirst.promise; - throw sharedError; - }); - const second = ctx.tool("first-terminal", {}, async () => { - secondEntered.resolve(); - await releaseSecond.promise; - throw sharedError; - }); - await Promise.all([first, second]); - return {}; - }, - }), {}, { runId, store, durableBackend: backend }); - - await Promise.all([firstEntered.promise, secondEntered.promise]); - releaseSecond.resolve(); - await waitForToolStatus(store, runId, "first-terminal", "failed"); - releaseFirst.resolve(); - const result = await pending; - lifecycle.unsubscribe(); - - const winningNode = result.toolNodes?.find((node) => node.name === "first-terminal"); - assert.equal(result.status, "failed"); - assert.equal(result.failedToolNodeId, winningNode?.id); - assert.equal(lifecycle.notices.length, 1); - assert.equal(lifecycle.notices[0]?.toolName, "first-terminal"); - assert.equal(lifecycle.notices[0]?.error, "shared concurrent failure"); - }); - - test("cancellation observed before callback failure wins and emits no failed notice", async () => { - const store = createStore(); - const backend = new InMemoryDurableBackend(); - const cancellation = createCancellationRegistry(); - const lifecycle = installFailureNotices(store); - const entered = Promise.withResolvers(); - const release = Promise.withResolvers(); - const runId = "cancellation-first"; - const pending = run(workflow({ - name: "cancellation first", description: "", inputs: {}, outputs: {}, - run: async (ctx) => { - await ctx.tool("cancelled-before-throw", {}, async () => { - entered.resolve(); - await release.promise; - throw new Error("late callback failure"); - }); - return {}; - }, - }), {}, { runId, store, durableBackend: backend, cancellation }); - - await entered.promise; - assert.equal(killRun(runId, { store, cancellation }).ok, true); - release.resolve(); - const result = await pending; - lifecycle.unsubscribe(); - - const snapshot = store.runs().find((candidate) => candidate.id === runId); - assert.equal(result.status, "killed"); - assert.equal(snapshot?.failureKind, "cancelled"); - assert.equal(snapshot?.failureDisposition, "terminal_killed"); - assert.equal(result.toolNodes?.[0]?.status, "cancelled"); - assert.equal(lifecycle.notices.length, 0); - }); - - test("writer rejection after callback success fails node and root without a completed checkpoint", async () => { - class RejectingBackend extends InMemoryDurableBackend { - override async recordCheckpointAsync(checkpoint: DurableCheckpoint): Promise { - if (checkpoint.kind === "tool") throw new Error("durable writer rejected"); - await super.recordCheckpointAsync(checkpoint); - } - } - const store = createStore(); - const backend = new RejectingBackend(); - let callbackCalls = 0; - const result = await run(workflow({ - name: "writer rejects", description: "", inputs: {}, outputs: {}, - run: async (ctx) => { - await ctx.tool("committed-side-effect", {}, async () => { callbackCalls += 1; return "external success"; }); - return {}; - }, - }), {}, { store, durableBackend: backend }); - - assert.equal(callbackCalls, 1); - assert.equal(result.status, "failed"); - assert.match(result.error ?? "", /durable writer rejected/); - assert.equal(result.toolNodes?.[0]?.status, "failed"); - assert.equal(backend.listCheckpoints(result.runId).some((checkpoint) => checkpoint.kind === "tool"), false); - }); - - test("checkpoint commit wins an in-flight cancellation but the root never claims completion", async () => { - class GatedBackend extends InMemoryDurableBackend { - readonly writeStarted = Promise.withResolvers(); - readonly releaseWrite = Promise.withResolvers(); - override async recordCheckpointAsync(checkpoint: DurableCheckpoint): Promise { - if (checkpoint.kind === "tool") { - this.writeStarted.resolve(); - await this.releaseWrite.promise; - } - await super.recordCheckpointAsync(checkpoint); - } - } - const store = createStore(); - const backend = new GatedBackend(); - const controller = new AbortController(); - const pending = run(workflow({ - name: "cancel during commit", description: "", inputs: {}, outputs: {}, - run: async (ctx) => { - await ctx.tool("commit-linearization", {}, async () => "ready-to-commit"); - return {}; - }, - }), {}, { store, durableBackend: backend, signal: controller.signal }); - - await backend.writeStarted.promise; - controller.abort(new Error("cancelled while durable write was in flight")); - backend.releaseWrite.resolve(); - const result = await pending; - - assert.equal(result.status, "killed", "root cancellation remains authoritative"); - assert.equal(result.toolNodes?.[0]?.status, "completed", "successful durable commit is not rolled back"); - assert.equal(backend.listCheckpoints(result.runId).filter((checkpoint) => checkpoint.kind === "tool").length, 1); - }); - - test("return-mode cancellation during failure commit throws before downstream code", async () => { - class GatedFailureBackend extends InMemoryDurableBackend { - readonly writeStarted = Promise.withResolvers(); - readonly releaseWrite = Promise.withResolvers(); - override async recordCheckpointAsync(checkpoint: DurableCheckpoint): Promise { - if (checkpoint.kind === "tool") { - this.writeStarted.resolve(); - await this.releaseWrite.promise; - } - await super.recordCheckpointAsync(checkpoint); - } - } - const backend = new GatedFailureBackend(); - const controller = new AbortController(); - let downstreamRan = false; - let observedOutcome = false; - const pending = run(workflow({ - name: "cancel during recoverable failure commit", description: "", inputs: {}, outputs: {}, - run: async (ctx) => { - const outcome = await ctx.tool("failed-check", {}, async () => { - throw new Error("expected check failure"); - }, { failureMode: "return" }); - observedOutcome = outcome.ok === false; - downstreamRan = true; - return {}; - }, - }), {}, { store: createStore(), durableBackend: backend, signal: controller.signal }); - - await backend.writeStarted.promise; - controller.abort(new Error("cancelled while failure checkpoint was in flight")); - backend.releaseWrite.resolve(); - const result = await pending; - - assert.equal(result.status, "killed"); - assert.equal(observedOutcome, false); - assert.equal(downstreamRan, false); - assert.equal(result.toolNodes?.[0]?.status, "failed"); - assert.equal(backend.listCheckpoints(result.runId).filter((checkpoint) => checkpoint.kind === "tool").length, 1); - }); - - test("tool-node terminal updates are idempotent", () => { - const store = createStore(); - store.recordRunStart({ id: "terminal-idempotence", name: "terminal", inputs: {}, status: "running", stages: [], toolNodes: [], startedAt: 1 }); - store.recordToolNodeStart("terminal-idempotence", { - kind: "tool", id: "tool:terminal", name: "terminal", argsHash: "hash", ordinal: 1, - parentIds: [], status: "pending", attachable: false, - }); - store.recordToolNodeRunning("terminal-idempotence", "tool:terminal", 2); - - assert.equal(store.recordToolNodeEnd("terminal-idempotence", "tool:terminal", { status: "completed", endedAt: 3, resultSummary: "first" }), true); - assert.equal(store.recordToolNodeEnd("terminal-idempotence", "tool:terminal", { status: "failed", endedAt: 4, error: "late" }), false); - assert.deepEqual(store.runs()[0]?.toolNodes?.[0], { - kind: "tool", id: "tool:terminal", name: "terminal", argsHash: "hash", ordinal: 1, - parentIds: [], status: "completed", attachable: false, startedAt: 2, endedAt: 3, resultSummary: "first", - executionOrder: 1, - }); - }); - - test("terminal runs refuse new tool nodes but accept admitted terminal updates", () => { - const store = createStore(); - store.recordRunStart({ id: "terminal-store-guard", name: "terminal", inputs: {}, status: "running", stages: [], toolNodes: [], startedAt: 1 }); - assert.equal(store.recordToolNodeStart("terminal-store-guard", { - kind: "tool", id: "tool:admitted", name: "admitted", argsHash: "admitted", ordinal: 1, - parentIds: [], status: "pending", attachable: false, - }), true); - assert.equal(store.recordRunEnd("terminal-store-guard", "completed", {}, undefined), true); - assert.equal(store.recordToolNodeStart("terminal-store-guard", { - kind: "tool", id: "tool:late", name: "late", argsHash: "late", ordinal: 2, - parentIds: [], status: "pending", attachable: false, - }), false); - assert.equal(store.recordToolNodeRunning("terminal-store-guard", "tool:admitted", 2), true); - assert.equal(store.recordToolNodeEnd("terminal-store-guard", "tool:admitted", { - status: "completed", endedAt: 3, resultSummary: "settled", - }), true); - assert.deepEqual(store.runs()[0]?.toolNodes?.map((node) => [node.id, node.status]), [["tool:admitted", "completed"]]); - }); + test("cancellation during retry backoff leaves a cancelled node and no checkpoint", async () => { + const store = createStore(); + const backend = new InMemoryDurableBackend(); + const controller = new AbortController(); + const attempted = Promise.withResolvers(); + let attempts = 0; + const pending = run( + workflow({ + name: "cancel retry backoff", + description: "", + inputs: {}, + outputs: {}, + run: async (ctx) => { + await ctx.tool( + "retrying-write", + {}, + async () => { + attempts += 1; + attempted.resolve(); + throw new Error("transient write failure"); + }, + { retriesAllowed: true, maxAttempts: 3, intervalMs: 10_000 }, + ); + return {}; + }, + }), + {}, + { store, durableBackend: backend, signal: controller.signal }, + ); + + await attempted.promise; + await Promise.resolve(); + controller.abort(new Error("operator cancelled during backoff")); + const result = await pending; + + assert.equal(result.status, "killed"); + assert.equal(attempts, 1); + assert.equal(result.toolNodes?.[0]?.status, "cancelled"); + assert.equal( + backend.listCheckpoints(result.runId).some((checkpoint) => checkpoint.kind === "tool"), + false, + ); + }); + + test("failure observed before a late cancellation wins while catch drains another tool", async () => { + const store = createStore(); + const backend = new InMemoryDurableBackend(); + const cancellation = createCancellationRegistry(); + const lifecycle = installFailureNotices(store); + const blockerEntered = Promise.withResolvers(); + const blockerRelease = Promise.withResolvers(); + const failureEntered = Promise.withResolvers(); + const failureRelease = Promise.withResolvers(); + const runId = "failure-first-catch-drain"; + const pending = run( + workflow({ + name: "failure first catch drain", + description: "", + inputs: {}, + outputs: {}, + run: async (ctx) => { + void ctx.tool("drain-blocker", {}, async () => { + blockerEntered.resolve(); + await blockerRelease.promise; + return "released"; + }); + await ctx.tool("callback-first", {}, async () => { + failureEntered.resolve(); + await failureRelease.promise; + throw new Error("callback failed before cancellation"); + }); + return {}; + }, + }), + {}, + { runId, store, durableBackend: backend, cancellation }, + ); + + await Promise.all([blockerEntered.promise, failureEntered.promise]); + failureRelease.resolve(); + await waitForToolStatus(store, runId, "callback-first", "failed"); + assert.equal(killRun(runId, { store, cancellation }).ok, true); + blockerRelease.resolve(); + const result = await pending; + lifecycle.unsubscribe(); + + const snapshot = store.runs().find((candidate) => candidate.id === runId); + assert.equal(result.status, "failed"); + assert.equal(snapshot?.failureKind, "unknown"); + assert.equal(snapshot?.failureDisposition, "terminal_failed"); + assert.match(result.error ?? "", /callback failed before cancellation/); + assert.equal(result.toolNodes?.find((node) => node.name === "callback-first")?.status, "failed"); + assert.equal(result.toolNodes?.find((node) => node.name === "drain-blocker")?.status, "cancelled"); + assert.equal(lifecycle.notices.length, 1); + assert.equal(lifecycle.notices[0]?.kind, "failed"); + }); + + test("unawaited failure during normal drain terminates a non-cooperative sibling", async () => { + const store = createStore(); + const backend = new InMemoryDurableBackend(); + const lifecycle = installFailureNotices(store); + const persisted: Array<{ type: string; payload: Record }> = []; + const persistence = { + appendEntry(type: string, payload: Record): string { + persisted.push({ type, payload }); + return `entry-${persisted.length}`; + }, + setLabel(_entryId: string, _label: string): void {}, + }; + const failureEntered = Promise.withResolvers(); + const failureRelease = Promise.withResolvers(); + const blockerEntered = Promise.withResolvers(); + const blockerRelease = Promise.withResolvers(); + const runId = "failure-first-normal-drain"; + const pending = run( + workflow({ + name: "failure first normal drain", + description: "", + inputs: {}, + outputs: {}, + run: async (ctx) => { + void ctx.tool("normal-drain-blocker", {}, async () => { + blockerEntered.resolve(); + await blockerRelease.promise; + return "released"; + }); + void ctx.tool("unawaited-second", {}, async () => { + failureEntered.resolve(); + await failureRelease.promise; + throw new Error("second-admitted failure won"); + }); + return {}; + }, + }), + {}, + { runId, store, durableBackend: backend, persistence }, + ); + + await Promise.all([failureEntered.promise, blockerEntered.promise]); + failureRelease.resolve(); + const result = await Promise.race([pending, sleep(250).then(() => undefined)]); + assert.ok(result, "failure observed during drain must settle without an external cancellation"); + + assert.equal(result.status, "failed"); + assert.equal(result.error, "second-admitted failure won"); + const failedNode = result.toolNodes?.find((node) => node.name === "unawaited-second"); + assert.equal(result.failedToolNodeId, failedNode?.id); + assert.equal(failedNode?.status, "failed"); + assert.equal(result.toolNodes?.find((node) => node.name === "normal-drain-blocker")?.status, "cancelled"); + const snapshot = store.runs().find((candidate) => candidate.id === runId); + assert.equal(snapshot?.error, "second-admitted failure won"); + assert.equal(snapshot?.failedToolNodeId, failedNode?.id); + const runEnds = persisted.filter((entry) => entry.type === "workflow.run.end"); + assert.equal(runEnds.length, 1); + assert.equal(runEnds[0]?.payload.status, "failed"); + assert.equal(runEnds[0]?.payload.failedToolNodeId, failedNode?.id); + assert.equal((runEnds[0]?.payload.failedToolNode as { status?: string } | undefined)?.status, "failed"); + assert.equal(lifecycle.notices.length, 1); + assert.equal(lifecycle.notices[0]?.toolName, "unawaited-second"); + assert.equal(lifecycle.notices[0]?.error, "second-admitted failure won"); + + blockerRelease.resolve(); + await sleep(0); + lifecycle.unsubscribe(); + assert.equal( + backend + .listCheckpoints(runId) + .some((checkpoint) => checkpoint.kind === "tool" && checkpoint.name === "normal-drain-blocker"), + false, + ); + }); + + test("ordinary failure settles despite a non-cooperative sibling tool", async () => { + const store = createStore(); + const backend = new InMemoryDurableBackend(); + const lifecycle = installFailureNotices(store); + const blockerEntered = Promise.withResolvers(); + const blockerRelease = Promise.withResolvers(); + const failureEntered = Promise.withResolvers(); + const releaseFailure = Promise.withResolvers(); + const runId = "failure-with-non-cooperative-sibling"; + const pending = run( + workflow({ + name: "failure with non cooperative sibling", + description: "", + inputs: {}, + outputs: {}, + run: async (ctx) => { + void ctx.tool("non-cooperative", {}, async () => { + blockerEntered.resolve(); + await blockerRelease.promise; + return "late success"; + }); + await ctx.tool("failure-winner", {}, async () => { + failureEntered.resolve(); + await releaseFailure.promise; + throw new Error("winner failed without kill"); + }); + return {}; + }, + }), + {}, + { runId, store, durableBackend: backend }, + ); + + await Promise.all([blockerEntered.promise, failureEntered.promise]); + releaseFailure.resolve(); + const result = await Promise.race([pending, sleep(250).then(() => undefined)]); + + assert.ok(result, "ordinary failure must not wait forever for a callback that ignores cancellation"); + assert.equal(result.status, "failed"); + assert.equal(result.error, "winner failed without kill"); + const winner = result.toolNodes?.find((node) => node.name === "failure-winner"); + assert.equal(result.failedToolNodeId, winner?.id); + assert.equal(result.toolNodes?.find((node) => node.name === "non-cooperative")?.status, "cancelled"); + assert.equal(lifecycle.notices.length, 1); + assert.equal(lifecycle.notices[0]?.toolName, "failure-winner"); + + blockerRelease.resolve(); + await sleep(0); + lifecycle.unsubscribe(); + assert.equal(result.toolNodes?.find((node) => node.name === "non-cooperative")?.status, "cancelled"); + assert.equal( + backend + .listCheckpoints(runId) + .some((checkpoint) => checkpoint.kind === "tool" && checkpoint.name === "non-cooperative"), + false, + "a late sibling callback must not create a successful checkpoint", + ); + assert.equal(lifecycle.notices.length, 1); + }); + + test("preserves first terminal tool attribution when concurrent failures throw the same value", async () => { + const store = createStore(); + const backend = new InMemoryDurableBackend(); + const lifecycle = installFailureNotices(store); + const sharedError = new Error("shared concurrent failure"); + const firstEntered = Promise.withResolvers(); + const secondEntered = Promise.withResolvers(); + const releaseFirst = Promise.withResolvers(); + const releaseSecond = Promise.withResolvers(); + const runId = "same-value-failure-attribution"; + const pending = run( + workflow({ + name: "same value failure attribution", + description: "", + inputs: {}, + outputs: {}, + run: async (ctx) => { + const first = ctx.tool("first-admitted", {}, async () => { + firstEntered.resolve(); + await releaseFirst.promise; + throw sharedError; + }); + const second = ctx.tool("first-terminal", {}, async () => { + secondEntered.resolve(); + await releaseSecond.promise; + throw sharedError; + }); + await Promise.all([first, second]); + return {}; + }, + }), + {}, + { runId, store, durableBackend: backend }, + ); + + await Promise.all([firstEntered.promise, secondEntered.promise]); + releaseSecond.resolve(); + await waitForToolStatus(store, runId, "first-terminal", "failed"); + releaseFirst.resolve(); + const result = await pending; + lifecycle.unsubscribe(); + + const winningNode = result.toolNodes?.find((node) => node.name === "first-terminal"); + assert.equal(result.status, "failed"); + assert.equal(result.failedToolNodeId, winningNode?.id); + assert.equal(lifecycle.notices.length, 1); + assert.equal(lifecycle.notices[0]?.toolName, "first-terminal"); + assert.equal(lifecycle.notices[0]?.error, "shared concurrent failure"); + }); + + test("cancellation observed before callback failure wins and emits no failed notice", async () => { + const store = createStore(); + const backend = new InMemoryDurableBackend(); + const cancellation = createCancellationRegistry(); + const lifecycle = installFailureNotices(store); + const entered = Promise.withResolvers(); + const release = Promise.withResolvers(); + const runId = "cancellation-first"; + const pending = run( + workflow({ + name: "cancellation first", + description: "", + inputs: {}, + outputs: {}, + run: async (ctx) => { + await ctx.tool("cancelled-before-throw", {}, async () => { + entered.resolve(); + await release.promise; + throw new Error("late callback failure"); + }); + return {}; + }, + }), + {}, + { runId, store, durableBackend: backend, cancellation }, + ); + + await entered.promise; + assert.equal(killRun(runId, { store, cancellation }).ok, true); + release.resolve(); + const result = await pending; + lifecycle.unsubscribe(); + + const snapshot = store.runs().find((candidate) => candidate.id === runId); + assert.equal(result.status, "killed"); + assert.equal(snapshot?.failureKind, "cancelled"); + assert.equal(snapshot?.failureDisposition, "terminal_killed"); + assert.equal(result.toolNodes?.[0]?.status, "cancelled"); + assert.equal(lifecycle.notices.length, 0); + }); + + test("writer rejection after callback success fails node and root without a completed checkpoint", async () => { + class RejectingBackend extends InMemoryDurableBackend { + override async recordCheckpointAsync(checkpoint: DurableCheckpoint): Promise { + if (checkpoint.kind === "tool") throw new Error("durable writer rejected"); + await super.recordCheckpointAsync(checkpoint); + } + } + const store = createStore(); + const backend = new RejectingBackend(); + let callbackCalls = 0; + const result = await run( + workflow({ + name: "writer rejects", + description: "", + inputs: {}, + outputs: {}, + run: async (ctx) => { + await ctx.tool("committed-side-effect", {}, async () => { + callbackCalls += 1; + return "external success"; + }); + return {}; + }, + }), + {}, + { store, durableBackend: backend }, + ); + + assert.equal(callbackCalls, 1); + assert.equal(result.status, "failed"); + assert.match(result.error ?? "", /durable writer rejected/); + assert.equal(result.toolNodes?.[0]?.status, "failed"); + assert.equal( + backend.listCheckpoints(result.runId).some((checkpoint) => checkpoint.kind === "tool"), + false, + ); + }); + + test("checkpoint commit wins an in-flight cancellation but the root never claims completion", async () => { + class GatedBackend extends InMemoryDurableBackend { + readonly writeStarted = Promise.withResolvers(); + readonly releaseWrite = Promise.withResolvers(); + override async recordCheckpointAsync(checkpoint: DurableCheckpoint): Promise { + if (checkpoint.kind === "tool") { + this.writeStarted.resolve(); + await this.releaseWrite.promise; + } + await super.recordCheckpointAsync(checkpoint); + } + } + const store = createStore(); + const backend = new GatedBackend(); + const controller = new AbortController(); + const pending = run( + workflow({ + name: "cancel during commit", + description: "", + inputs: {}, + outputs: {}, + run: async (ctx) => { + await ctx.tool("commit-linearization", {}, async () => "ready-to-commit"); + return {}; + }, + }), + {}, + { store, durableBackend: backend, signal: controller.signal }, + ); + + await backend.writeStarted.promise; + controller.abort(new Error("cancelled while durable write was in flight")); + backend.releaseWrite.resolve(); + const result = await pending; + + assert.equal(result.status, "killed", "root cancellation remains authoritative"); + assert.equal(result.toolNodes?.[0]?.status, "completed", "successful durable commit is not rolled back"); + assert.equal(backend.listCheckpoints(result.runId).filter((checkpoint) => checkpoint.kind === "tool").length, 1); + }); + + test("return-mode cancellation during failure commit throws before downstream code", async () => { + class GatedFailureBackend extends InMemoryDurableBackend { + readonly writeStarted = Promise.withResolvers(); + readonly releaseWrite = Promise.withResolvers(); + override async recordCheckpointAsync(checkpoint: DurableCheckpoint): Promise { + if (checkpoint.kind === "tool") { + this.writeStarted.resolve(); + await this.releaseWrite.promise; + } + await super.recordCheckpointAsync(checkpoint); + } + } + const backend = new GatedFailureBackend(); + const controller = new AbortController(); + let downstreamRan = false; + let observedOutcome = false; + const pending = run( + workflow({ + name: "cancel during recoverable failure commit", + description: "", + inputs: {}, + outputs: {}, + run: async (ctx) => { + const outcome = await ctx.tool( + "failed-check", + {}, + async () => { + throw new Error("expected check failure"); + }, + { failureMode: "return" }, + ); + observedOutcome = outcome.ok === false; + downstreamRan = true; + return {}; + }, + }), + {}, + { store: createStore(), durableBackend: backend, signal: controller.signal }, + ); + + await backend.writeStarted.promise; + controller.abort(new Error("cancelled while failure checkpoint was in flight")); + backend.releaseWrite.resolve(); + const result = await pending; + + assert.equal(result.status, "killed"); + assert.equal(observedOutcome, false); + assert.equal(downstreamRan, false); + assert.equal(result.toolNodes?.[0]?.status, "failed"); + assert.equal(backend.listCheckpoints(result.runId).filter((checkpoint) => checkpoint.kind === "tool").length, 1); + }); + + test("tool-node terminal updates are idempotent", () => { + const store = createStore(); + store.recordRunStart({ + id: "terminal-idempotence", + name: "terminal", + inputs: {}, + status: "running", + stages: [], + toolNodes: [], + startedAt: 1, + }); + store.recordToolNodeStart("terminal-idempotence", { + kind: "tool", + id: "tool:terminal", + name: "terminal", + argsHash: "hash", + ordinal: 1, + parentIds: [], + status: "pending", + attachable: false, + }); + store.recordToolNodeRunning("terminal-idempotence", "tool:terminal", 2); + + assert.equal( + store.recordToolNodeEnd("terminal-idempotence", "tool:terminal", { + status: "completed", + endedAt: 3, + resultSummary: "first", + }), + true, + ); + assert.equal( + store.recordToolNodeEnd("terminal-idempotence", "tool:terminal", { + status: "failed", + endedAt: 4, + error: "late", + }), + false, + ); + assert.deepEqual(store.runs()[0]?.toolNodes?.[0], { + kind: "tool", + id: "tool:terminal", + name: "terminal", + argsHash: "hash", + ordinal: 1, + parentIds: [], + status: "completed", + attachable: false, + startedAt: 2, + endedAt: 3, + resultSummary: "first", + executionOrder: 1, + }); + }); + + test("terminal runs refuse new tool nodes but accept admitted terminal updates", () => { + const store = createStore(); + store.recordRunStart({ + id: "terminal-store-guard", + name: "terminal", + inputs: {}, + status: "running", + stages: [], + toolNodes: [], + startedAt: 1, + }); + assert.equal( + store.recordToolNodeStart("terminal-store-guard", { + kind: "tool", + id: "tool:admitted", + name: "admitted", + argsHash: "admitted", + ordinal: 1, + parentIds: [], + status: "pending", + attachable: false, + }), + true, + ); + assert.equal(store.recordRunEnd("terminal-store-guard", "completed", {}, undefined), true); + assert.equal( + store.recordToolNodeStart("terminal-store-guard", { + kind: "tool", + id: "tool:late", + name: "late", + argsHash: "late", + ordinal: 2, + parentIds: [], + status: "pending", + attachable: false, + }), + false, + ); + assert.equal(store.recordToolNodeRunning("terminal-store-guard", "tool:admitted", 2), true); + assert.equal( + store.recordToolNodeEnd("terminal-store-guard", "tool:admitted", { + status: "completed", + endedAt: 3, + resultSummary: "settled", + }), + true, + ); + assert.deepEqual( + store.runs()[0]?.toolNodes?.map((node) => [node.id, node.status]), + [["tool:admitted", "completed"]], + ); + }); }); diff --git a/test/unit/workflow-tool-recoverable-failure.test.ts b/test/unit/workflow-tool-recoverable-failure.test.ts index 57b76ce69..f89d440d6 100644 --- a/test/unit/workflow-tool-recoverable-failure.test.ts +++ b/test/unit/workflow-tool-recoverable-failure.test.ts @@ -1,6 +1,6 @@ -import { describe, test } from "bun:test"; import assert from "node:assert/strict"; import { Type } from "typebox"; +import { describe, test } from "vitest"; import { workflow } from "../../packages/workflows/src/authoring/workflow.js"; import { InMemoryDurableBackend } from "../../packages/workflows/src/durable/backend.js"; import { completedWorkflowRunSnapshots } from "../../packages/workflows/src/durable/completed-catalog.js"; @@ -8,448 +8,573 @@ import { createToolPrimitive } from "../../packages/workflows/src/durable/tool-p import type { DurableCheckpoint } from "../../packages/workflows/src/durable/types.js"; import { run } from "../../packages/workflows/src/engine/run.js"; import { - createWorkflowLifecycleNotificationState, - installWorkflowLifecycleNotifications, - type WorkflowLifecycleNoticeDetails, + createWorkflowLifecycleNotificationState, + installWorkflowLifecycleNotifications, + type WorkflowLifecycleNoticeDetails, } from "../../packages/workflows/src/extension/lifecycle-notifications.js"; -import type { WorkflowToolFailure } from "../../packages/workflows/src/shared/types.js"; import { createStore } from "../../packages/workflows/src/shared/store.js"; +import type { WorkflowToolFailure } from "../../packages/workflows/src/shared/types.js"; describe("ctx.tool recoverable failures", () => { - test("returns a typed failure after retries while the workflow continues", async () => { - let attempts = 0; - let observed: object | undefined; - const definition = workflow({ - name: "recoverable-tool-failure", - description: "", - inputs: {}, - outputs: { repaired: Type.Boolean() }, - run: async (ctx) => { - const outcome = await ctx.tool("check", {}, async () => { - attempts += 1; - const error = new Error("tests failed") as Error & { - exitCode: number; - stdout: string; - stderr: string; - }; - error.exitCode = 7; - error.stdout = "partial output"; - error.stderr = "assertion failed"; - throw error; - }, { - failureMode: "return", - retriesAllowed: true, - maxAttempts: 2, - intervalMs: 0, - }); - observed = outcome; - return { repaired: !outcome.ok }; - }, - }); - - const result = await run(definition, {}, { - store: createStore(), - durableBackend: new InMemoryDurableBackend(), - }); - - assert.equal(result.status, "completed"); - assert.equal(attempts, 2); - assert.deepEqual(observed, { - ok: false, - error: { - name: "Error", - message: "tests failed", - exitCode: 7, - stdout: "partial output", - stderr: "assertion failed", - }, - attempts: 2, - cached: false, - }); - assert.equal(result.toolNodes?.[0]?.status, "failed"); - assert.equal(result.failedToolNodeId, undefined); - }); - - test("replays the durable failed outcome without rerunning the callback", async () => { - const backend = new InMemoryDurableBackend(); - const runId = "recoverable-tool-replay"; - const outcomes: object[] = []; - let callbackCalls = 0; - const definition = workflow({ - name: "recoverable-tool-replay", - description: "", - inputs: {}, - outputs: { done: Type.Boolean() }, - run: async (ctx) => { - const outcome = await ctx.tool("check", { suite: "unit" }, async () => { - callbackCalls += 1; - throw Object.assign(new Error("still red"), { exitCode: 1, stderr: "failed test" }); - }, { failureMode: "return" }); - outcomes.push(outcome); - return { done: true }; - }, - }); - - const first = await run(definition, {}, { runId, store: createStore(), durableBackend: backend }); - const replay = await run(definition, {}, { runId, store: createStore(), durableBackend: backend }); - - assert.equal(first.status, "completed"); - assert.equal(replay.status, "completed"); - assert.equal(callbackCalls, 1); - assert.deepEqual(outcomes.map((outcome) => ({ ...outcome })), [ - { ok: false, error: { name: "Error", message: "still red", exitCode: 1, stderr: "failed test" }, attempts: 1, cached: false }, - { ok: false, error: { name: "Error", message: "still red", exitCode: 1, stderr: "failed test" }, attempts: 1, cached: true }, - ]); - assert.equal(replay.toolNodes?.[0]?.status, "failed"); - assert.equal(replay.toolNodes?.[0]?.replayed, true); - }); - - test("keeps a recoverable failure node failed in completed durable inspection", async () => { - const backend = new InMemoryDurableBackend(); - const runId = "recoverable-tool-completed-inspection"; - const definition = workflow({ - name: "recoverable-tool-completed-inspection", - description: "", - inputs: {}, - outputs: { done: Type.Boolean() }, - run: async (ctx) => { - await ctx.tool("red-check", {}, async () => { - throw new Error("inspection evidence"); - }, { failureMode: "return" }); - return { done: true }; - }, - }); - - await run(definition, {}, { runId, store: createStore(), durableBackend: backend }); - const entry = backend.listCompletedWorkflows().find((candidate) => candidate.workflowId === runId)!; - const restored = completedWorkflowRunSnapshots(backend, entry).find((candidate) => candidate.id === runId)!; - - assert.equal(restored.status, "completed"); - assert.equal(restored.toolNodes?.[0]?.status, "failed"); - assert.equal(restored.toolNodes?.[0]?.error, "inspection evidence"); - }); - - test("keeps cancellation throwing in return mode", async () => { - const backend = new InMemoryDurableBackend(); - const controller = new AbortController(); - const entered = Promise.withResolvers(); - const release = Promise.withResolvers(); - const definition = workflow({ - name: "recoverable-tool-cancel", - description: "", - inputs: {}, - outputs: { done: Type.Boolean() }, - run: async (ctx) => { - await ctx.tool("cancelled-check", {}, async () => { - entered.resolve(); - await release.promise; - throw Object.assign(new Error("ordinary failure must not win"), { - code: "CANCELLED", - exitCode: 1, - stderr: "late command failure", - }); - }, { failureMode: "return" }); - return { done: true }; - }, - }); - - const pending = run(definition, {}, { - store: createStore(), - durableBackend: backend, - signal: controller.signal, - }); - await entered.promise; - controller.abort(new Error("stop workflow")); - release.resolve(); - const result = await pending; - - assert.equal(result.status, "killed"); - assert.equal(result.toolNodes?.[0]?.status, "cancelled"); - assert.equal(backend.listCheckpoints(result.runId).some((entry) => entry.kind === "tool" && entry.name === "cancelled-check"), false); - }); - - test("fails and notifies for a nested callback-origin AbortError without a run abort", async () => { - const backend = new InMemoryDurableBackend(); - const store = createStore(); - const notices: WorkflowLifecycleNoticeDetails[] = []; - const unsubscribe = installWorkflowLifecycleNotifications({ - store, - config: { enabled: true, notifyOn: ["failed"] }, - state: createWorkflowLifecycleNotificationState(), - seedExisting: false, - sendMessage(message) { - notices.push((message as { details: WorkflowLifecycleNoticeDetails }).details); - }, - }); - let downstreamRan = false; - let callbackCalls = 0; - const definition = workflow({ - name: "recoverable-tool-callback-abort", - description: "", - inputs: {}, - outputs: { done: Type.Boolean() }, - run: async (ctx) => { - await ctx.tool("callback-abort", {}, async () => { - callbackCalls += 1; - throw { message: "callback wrapper", error: new DOMException("operator aborted", "AbortError") }; - }, { failureMode: "return", retriesAllowed: true, maxAttempts: 3, intervalMs: 0 }); - downstreamRan = true; - return { done: true }; - }, - }); - - const result = await run(definition, {}, { store, durableBackend: backend }); - unsubscribe(); - - const snapshot = store.runs()[0]; - assert.equal(result.status, "failed"); - assert.equal(snapshot?.failureKind, "unknown"); - assert.equal(snapshot?.failureCode, "unknown"); - assert.equal(snapshot?.failureDisposition, "terminal_failed"); - assert.equal(downstreamRan, false); - assert.equal(callbackCalls, 1); - assert.equal(result.toolNodes?.[0]?.status, "failed"); - assert.equal(notices.length, 1); - assert.equal(notices[0]?.kind, "failed"); - assert.match(notices[0]?.error ?? "", /operator aborted/); - assert.equal(backend.listCheckpoints(result.runId).some((entry) => entry.kind === "tool" && entry.name === "callback-abort" && entry.throwingFailureError !== undefined), true); - assert.equal(backend.getToolCheckpoint(result.runId, result.toolNodes![0]!.argsHash), undefined); - }); - - test("retries process errors whose message contains cancellation words", async () => { - const backend = new InMemoryDurableBackend(); - let callbackCalls = 0; - let observed: WorkflowToolFailure | undefined; - const definition = workflow({ - name: "recoverable-tool-cancelled-command-text", - description: "", - inputs: {}, - outputs: { done: Type.Boolean() }, - run: async (ctx) => { - const outcome = await ctx.tool("cancelled-command-text", {}, async () => { - callbackCalls += 1; - throw Object.assign(new Error("Command failed: 2 tests cancelled after worker crash"), { - name: "AbortError", - stopReason: "aborted", - code: "CANCELLED", - exitCode: 1, - stderr: "worker crashed", - }); - }, { failureMode: "return", retriesAllowed: true, maxAttempts: 3, intervalMs: 0 }); - if (!outcome.ok) observed = outcome; - return { done: true }; - }, - }); - - const result = await run(definition, {}, { - store: createStore(), - durableBackend: backend, - }); - - assert.equal(result.status, "completed"); - assert.equal(callbackCalls, 3); - assert.deepEqual(observed, { - ok: false, - error: { - name: "AbortError", - message: "Command failed: 2 tests cancelled after worker crash", - exitCode: 1, - stderr: "worker crashed", - }, - attempts: 3, - cached: false, - }); - assert.equal(result.toolNodes?.[0]?.status, "failed"); - assert.equal(backend.listCheckpoints(result.runId).some((entry) => entry.kind === "tool" && entry.name === "cancelled-command-text"), true); - }); - - for (const maxAttempts of [0, -1, 1.5]) { - test(`rejects invalid maxAttempts ${maxAttempts} without invoking or checkpointing`, async () => { - const backend = new InMemoryDurableBackend(); - let callbackCalls = 0; - let downstreamRan = false; - const definition = workflow({ - name: `recoverable-tool-invalid-attempts-${maxAttempts}`, - description: "", - inputs: {}, - outputs: {}, - run: async (ctx) => { - await ctx.tool("invalid-attempts", {}, async () => { - callbackCalls += 1; - throw new Error("callback must not run"); - }, { failureMode: "return", retriesAllowed: true, maxAttempts, intervalMs: 0 }); - downstreamRan = true; - return {}; - }, - }); - - const result = await run(definition, {}, { - store: createStore(), - durableBackend: backend, - }); - - assert.equal(result.status, "failed"); - assert.match(result.error ?? "", /maxAttempts.*positive integer/); - assert.equal(callbackCalls, 0); - assert.equal(downstreamRan, false); - assert.equal(backend.listCheckpoints(result.runId).some((entry) => entry.kind === "tool"), false); - }); - } - - test("supports explicit repair evidence and a bounded rerun", async () => { - const store = createStore(); - const repairPrompts: string[] = []; - let checks = 0; - const definition = workflow({ - name: "recoverable-tool-repair-loop", - description: "", - inputs: {}, - outputs: { passed: Type.Boolean() }, - run: async (ctx) => { - for (let iteration = 1; iteration <= 2; iteration += 1) { - const outcome = await ctx.tool("run-check", { iteration }, async () => { - checks += 1; - if (iteration === 1) { - throw Object.assign(new Error("check failed"), { stderr: "one failed assertion" }); - } - return "green"; - }, { failureMode: "return" }); - if (outcome.ok) return { passed: outcome.value === "green" }; - await ctx.task("repair", { prompt: `Repair from explicit evidence: ${outcome.error.stderr}` }); - } - return { passed: false }; - }, - }); - - const result = await run(definition, {}, { - store, - durableBackend: new InMemoryDurableBackend(), - adapters: { - prompt: { - prompt: async (text) => { - repairPrompts.push(text); - return "repaired"; - }, - }, - }, - }); - - assert.equal(result.status, "completed"); - assert.deepEqual(result.result, { passed: true }); - assert.equal(checks, 2); - assert.deepEqual(repairPrompts, ["Repair from explicit evidence: one failed assertion"]); - assert.deepEqual(result.toolNodes?.map((node) => node.status), ["failed", "completed"]); - assert.deepEqual(result.stages[0]?.parentIds, [result.toolNodes?.[0]?.id]); - assert.deepEqual(result.toolNodes?.[1]?.parentIds, [result.stages[0]?.id]); - }); - - test("redacts and UTF-8 byte-bounds persisted process output", async () => { - let capturedStderr = ""; - let capturedMessage = ""; - let capturedStdout = ""; - const backend = new InMemoryDurableBackend(); - const definition = workflow({ - name: "recoverable-tool-safe-output", - description: "", - inputs: {}, - outputs: { done: Type.Boolean() }, - run: async (ctx) => { - const outcome = await ctx.tool("safe-check", {}, async () => { - const error = Object.assign(new Error("token=message-secret-value"), { - stdout: new TextEncoder().encode("Authorization: Bearer stdout-secret-value"), - stderr: `${"終".repeat(10_000)} api_key=stderr-secret-value`, - }); - throw error; - }, { failureMode: "return" }); - assert.equal(outcome.ok, false); - if (!outcome.ok) { - capturedMessage = outcome.error.message; - capturedStdout = outcome.error.stdout ?? ""; - capturedStderr = outcome.error.stderr ?? ""; - } - return { done: true }; - }, - }); - - const result = await run(definition, {}, { - store: createStore(), - durableBackend: backend, - }); - - assert.equal(result.status, "completed"); - assert.equal(new TextEncoder().encode(capturedStderr).byteLength <= 16_384, true); - assert.match(capturedStderr, /^\[workflow tool output truncated/); - assert.match(`${capturedMessage}\n${capturedStdout}\n${capturedStderr}`, /\[redacted\]/); - assert.doesNotMatch(`${capturedMessage}\n${capturedStdout}\n${capturedStderr}`, /secret-value/); - const checkpoint = backend.listCheckpoints(result.runId).find((entry) => entry.kind === "tool" && entry.name === "safe-check"); - assert.equal(checkpoint?.kind, "tool"); - const persisted = checkpoint?.kind === "tool" ? checkpoint.output as WorkflowToolFailure : undefined; - assert.equal(persisted?.ok, false); - assert.equal(persisted?.error.stderr, capturedStderr); - assert.doesNotMatch(JSON.stringify(persisted), /secret-value/); - }); - - test("preserves fields from a non-Error callback rejection after retries", async () => { - let captured: WorkflowToolFailure | undefined; - const definition = workflow({ - name: "recoverable-tool-structured-rejection", - description: "", - inputs: {}, - outputs: { done: Type.Boolean() }, - run: async (ctx) => { - const outcome = await ctx.tool("structured-check", {}, async () => { - throw { - name: "CommandFailure", - message: "command rejected", - exitCode: 9, - stdout: "build output", - stderr: "build error", - }; - }, { failureMode: "return", retriesAllowed: true, maxAttempts: 2, intervalMs: 0 }); - if (!outcome.ok) captured = outcome; - return { done: true }; - }, - }); - - await run(definition, {}, { - store: createStore(), - durableBackend: new InMemoryDurableBackend(), - }); - - assert.deepEqual(captured?.error, { - name: "CommandFailure", - message: "command rejected", - exitCode: 9, - stdout: "build output", - stderr: "build error", - }); - assert.equal(captured?.attempts, 2); - }); - - test("keeps durable storage failures throwing in return mode", async () => { - class RejectingBackend extends InMemoryDurableBackend { - override async recordCheckpointAsync(_checkpoint: DurableCheckpoint): Promise { - throw new Error("checkpoint storage unavailable"); - } - } - const backend = new RejectingBackend(); - backend.registerWorkflow({ - workflowId: "recoverable-storage-failure", - name: "recoverable-storage-failure", - inputs: {}, - createdAt: 1, - status: "running", - }); - const tool = createToolPrimitive({ - workflowId: "recoverable-storage-failure", - backend, - nextCheckpointId: () => "unused", - throwIfCancelled: () => {}, - }); - - await assert.rejects( - () => tool("check", {}, async () => { throw new Error("expected check failure"); }, { failureMode: "return" }), - /checkpoint storage unavailable/, - ); - }); + test("returns a typed failure after retries while the workflow continues", async () => { + let attempts = 0; + let observed: object | undefined; + const definition = workflow({ + name: "recoverable-tool-failure", + description: "", + inputs: {}, + outputs: { repaired: Type.Boolean() }, + run: async (ctx) => { + const outcome = await ctx.tool( + "check", + {}, + async () => { + attempts += 1; + const error = new Error("tests failed") as Error & { + exitCode: number; + stdout: string; + stderr: string; + }; + error.exitCode = 7; + error.stdout = "partial output"; + error.stderr = "assertion failed"; + throw error; + }, + { + failureMode: "return", + retriesAllowed: true, + maxAttempts: 2, + intervalMs: 0, + }, + ); + observed = outcome; + return { repaired: !outcome.ok }; + }, + }); + + const result = await run( + definition, + {}, + { + store: createStore(), + durableBackend: new InMemoryDurableBackend(), + }, + ); + + assert.equal(result.status, "completed"); + assert.equal(attempts, 2); + assert.deepEqual(observed, { + ok: false, + error: { + name: "Error", + message: "tests failed", + exitCode: 7, + stdout: "partial output", + stderr: "assertion failed", + }, + attempts: 2, + cached: false, + }); + assert.equal(result.toolNodes?.[0]?.status, "failed"); + assert.equal(result.failedToolNodeId, undefined); + }); + + test("replays the durable failed outcome without rerunning the callback", async () => { + const backend = new InMemoryDurableBackend(); + const runId = "recoverable-tool-replay"; + const outcomes: object[] = []; + let callbackCalls = 0; + const definition = workflow({ + name: "recoverable-tool-replay", + description: "", + inputs: {}, + outputs: { done: Type.Boolean() }, + run: async (ctx) => { + const outcome = await ctx.tool( + "check", + { suite: "unit" }, + async () => { + callbackCalls += 1; + throw Object.assign(new Error("still red"), { exitCode: 1, stderr: "failed test" }); + }, + { failureMode: "return" }, + ); + outcomes.push(outcome); + return { done: true }; + }, + }); + + const first = await run(definition, {}, { runId, store: createStore(), durableBackend: backend }); + const replay = await run(definition, {}, { runId, store: createStore(), durableBackend: backend }); + + assert.equal(first.status, "completed"); + assert.equal(replay.status, "completed"); + assert.equal(callbackCalls, 1); + assert.deepEqual( + outcomes.map((outcome) => ({ ...outcome })), + [ + { + ok: false, + error: { name: "Error", message: "still red", exitCode: 1, stderr: "failed test" }, + attempts: 1, + cached: false, + }, + { + ok: false, + error: { name: "Error", message: "still red", exitCode: 1, stderr: "failed test" }, + attempts: 1, + cached: true, + }, + ], + ); + assert.equal(replay.toolNodes?.[0]?.status, "failed"); + assert.equal(replay.toolNodes?.[0]?.replayed, true); + }); + + test("keeps a recoverable failure node failed in completed durable inspection", async () => { + const backend = new InMemoryDurableBackend(); + const runId = "recoverable-tool-completed-inspection"; + const definition = workflow({ + name: "recoverable-tool-completed-inspection", + description: "", + inputs: {}, + outputs: { done: Type.Boolean() }, + run: async (ctx) => { + await ctx.tool( + "red-check", + {}, + async () => { + throw new Error("inspection evidence"); + }, + { failureMode: "return" }, + ); + return { done: true }; + }, + }); + + await run(definition, {}, { runId, store: createStore(), durableBackend: backend }); + const entry = backend.listCompletedWorkflows().find((candidate) => candidate.workflowId === runId)!; + const restored = completedWorkflowRunSnapshots(backend, entry).find((candidate) => candidate.id === runId)!; + + assert.equal(restored.status, "completed"); + assert.equal(restored.toolNodes?.[0]?.status, "failed"); + assert.equal(restored.toolNodes?.[0]?.error, "inspection evidence"); + }); + + test("keeps cancellation throwing in return mode", async () => { + const backend = new InMemoryDurableBackend(); + const controller = new AbortController(); + const entered = Promise.withResolvers(); + const release = Promise.withResolvers(); + const definition = workflow({ + name: "recoverable-tool-cancel", + description: "", + inputs: {}, + outputs: { done: Type.Boolean() }, + run: async (ctx) => { + await ctx.tool( + "cancelled-check", + {}, + async () => { + entered.resolve(); + await release.promise; + throw Object.assign(new Error("ordinary failure must not win"), { + code: "CANCELLED", + exitCode: 1, + stderr: "late command failure", + }); + }, + { failureMode: "return" }, + ); + return { done: true }; + }, + }); + + const pending = run( + definition, + {}, + { + store: createStore(), + durableBackend: backend, + signal: controller.signal, + }, + ); + await entered.promise; + controller.abort(new Error("stop workflow")); + release.resolve(); + const result = await pending; + + assert.equal(result.status, "killed"); + assert.equal(result.toolNodes?.[0]?.status, "cancelled"); + assert.equal( + backend + .listCheckpoints(result.runId) + .some((entry) => entry.kind === "tool" && entry.name === "cancelled-check"), + false, + ); + }); + + test("fails and notifies for a nested callback-origin AbortError without a run abort", async () => { + const backend = new InMemoryDurableBackend(); + const store = createStore(); + const notices: WorkflowLifecycleNoticeDetails[] = []; + const unsubscribe = installWorkflowLifecycleNotifications({ + store, + config: { enabled: true, notifyOn: ["failed"] }, + state: createWorkflowLifecycleNotificationState(), + seedExisting: false, + sendMessage(message) { + notices.push((message as { details: WorkflowLifecycleNoticeDetails }).details); + }, + }); + let downstreamRan = false; + let callbackCalls = 0; + const definition = workflow({ + name: "recoverable-tool-callback-abort", + description: "", + inputs: {}, + outputs: { done: Type.Boolean() }, + run: async (ctx) => { + await ctx.tool( + "callback-abort", + {}, + async () => { + callbackCalls += 1; + throw { message: "callback wrapper", error: new DOMException("operator aborted", "AbortError") }; + }, + { failureMode: "return", retriesAllowed: true, maxAttempts: 3, intervalMs: 0 }, + ); + downstreamRan = true; + return { done: true }; + }, + }); + + const result = await run(definition, {}, { store, durableBackend: backend }); + unsubscribe(); + + const snapshot = store.runs()[0]; + assert.equal(result.status, "failed"); + assert.equal(snapshot?.failureKind, "unknown"); + assert.equal(snapshot?.failureCode, "unknown"); + assert.equal(snapshot?.failureDisposition, "terminal_failed"); + assert.equal(downstreamRan, false); + assert.equal(callbackCalls, 1); + assert.equal(result.toolNodes?.[0]?.status, "failed"); + assert.equal(notices.length, 1); + assert.equal(notices[0]?.kind, "failed"); + assert.match(notices[0]?.error ?? "", /operator aborted/); + assert.equal( + backend + .listCheckpoints(result.runId) + .some( + (entry) => + entry.kind === "tool" && entry.name === "callback-abort" && entry.throwingFailureError !== undefined, + ), + true, + ); + assert.equal(backend.getToolCheckpoint(result.runId, result.toolNodes![0]!.argsHash), undefined); + }); + + test("retries process errors whose message contains cancellation words", async () => { + const backend = new InMemoryDurableBackend(); + let callbackCalls = 0; + let observed: WorkflowToolFailure | undefined; + const definition = workflow({ + name: "recoverable-tool-cancelled-command-text", + description: "", + inputs: {}, + outputs: { done: Type.Boolean() }, + run: async (ctx) => { + const outcome = await ctx.tool( + "cancelled-command-text", + {}, + async () => { + callbackCalls += 1; + throw Object.assign(new Error("Command failed: 2 tests cancelled after worker crash"), { + name: "AbortError", + stopReason: "aborted", + code: "CANCELLED", + exitCode: 1, + stderr: "worker crashed", + }); + }, + { failureMode: "return", retriesAllowed: true, maxAttempts: 3, intervalMs: 0 }, + ); + if (!outcome.ok) observed = outcome; + return { done: true }; + }, + }); + + const result = await run( + definition, + {}, + { + store: createStore(), + durableBackend: backend, + }, + ); + + assert.equal(result.status, "completed"); + assert.equal(callbackCalls, 3); + assert.deepEqual(observed, { + ok: false, + error: { + name: "AbortError", + message: "Command failed: 2 tests cancelled after worker crash", + exitCode: 1, + stderr: "worker crashed", + }, + attempts: 3, + cached: false, + }); + assert.equal(result.toolNodes?.[0]?.status, "failed"); + assert.equal( + backend + .listCheckpoints(result.runId) + .some((entry) => entry.kind === "tool" && entry.name === "cancelled-command-text"), + true, + ); + }); + + for (const maxAttempts of [0, -1, 1.5]) { + test(`rejects invalid maxAttempts ${maxAttempts} without invoking or checkpointing`, async () => { + const backend = new InMemoryDurableBackend(); + let callbackCalls = 0; + let downstreamRan = false; + const definition = workflow({ + name: `recoverable-tool-invalid-attempts-${maxAttempts}`, + description: "", + inputs: {}, + outputs: {}, + run: async (ctx) => { + await ctx.tool( + "invalid-attempts", + {}, + async () => { + callbackCalls += 1; + throw new Error("callback must not run"); + }, + { failureMode: "return", retriesAllowed: true, maxAttempts, intervalMs: 0 }, + ); + downstreamRan = true; + return {}; + }, + }); + + const result = await run( + definition, + {}, + { + store: createStore(), + durableBackend: backend, + }, + ); + + assert.equal(result.status, "failed"); + assert.match(result.error ?? "", /maxAttempts.*positive integer/); + assert.equal(callbackCalls, 0); + assert.equal(downstreamRan, false); + assert.equal( + backend.listCheckpoints(result.runId).some((entry) => entry.kind === "tool"), + false, + ); + }); + } + + test("supports explicit repair evidence and a bounded rerun", async () => { + const store = createStore(); + const repairPrompts: string[] = []; + let checks = 0; + const definition = workflow({ + name: "recoverable-tool-repair-loop", + description: "", + inputs: {}, + outputs: { passed: Type.Boolean() }, + run: async (ctx) => { + for (let iteration = 1; iteration <= 2; iteration += 1) { + const outcome = await ctx.tool( + "run-check", + { iteration }, + async () => { + checks += 1; + if (iteration === 1) { + throw Object.assign(new Error("check failed"), { stderr: "one failed assertion" }); + } + return "green"; + }, + { failureMode: "return" }, + ); + if (outcome.ok) return { passed: outcome.value === "green" }; + await ctx.task("repair", { prompt: `Repair from explicit evidence: ${outcome.error.stderr}` }); + } + return { passed: false }; + }, + }); + + const result = await run( + definition, + {}, + { + store, + durableBackend: new InMemoryDurableBackend(), + adapters: { + prompt: { + prompt: async (text) => { + repairPrompts.push(text); + return "repaired"; + }, + }, + }, + }, + ); + + assert.equal(result.status, "completed"); + assert.deepEqual(result.result, { passed: true }); + assert.equal(checks, 2); + assert.deepEqual(repairPrompts, ["Repair from explicit evidence: one failed assertion"]); + assert.deepEqual( + result.toolNodes?.map((node) => node.status), + ["failed", "completed"], + ); + assert.deepEqual(result.stages[0]?.parentIds, [result.toolNodes?.[0]?.id]); + assert.deepEqual(result.toolNodes?.[1]?.parentIds, [result.stages[0]?.id]); + }); + + test("redacts and UTF-8 byte-bounds persisted process output", async () => { + let capturedStderr = ""; + let capturedMessage = ""; + let capturedStdout = ""; + const backend = new InMemoryDurableBackend(); + const definition = workflow({ + name: "recoverable-tool-safe-output", + description: "", + inputs: {}, + outputs: { done: Type.Boolean() }, + run: async (ctx) => { + const outcome = await ctx.tool( + "safe-check", + {}, + async () => { + const error = Object.assign(new Error("token=message-secret-value"), { + stdout: new TextEncoder().encode("Authorization: Bearer stdout-secret-value"), + stderr: `${"終".repeat(10_000)} api_key=stderr-secret-value`, + }); + throw error; + }, + { failureMode: "return" }, + ); + assert.equal(outcome.ok, false); + if (!outcome.ok) { + capturedMessage = outcome.error.message; + capturedStdout = outcome.error.stdout ?? ""; + capturedStderr = outcome.error.stderr ?? ""; + } + return { done: true }; + }, + }); + + const result = await run( + definition, + {}, + { + store: createStore(), + durableBackend: backend, + }, + ); + + assert.equal(result.status, "completed"); + assert.equal(new TextEncoder().encode(capturedStderr).byteLength <= 16_384, true); + assert.match(capturedStderr, /^\[workflow tool output truncated/); + assert.match(`${capturedMessage}\n${capturedStdout}\n${capturedStderr}`, /\[redacted\]/); + assert.doesNotMatch(`${capturedMessage}\n${capturedStdout}\n${capturedStderr}`, /secret-value/); + const checkpoint = backend + .listCheckpoints(result.runId) + .find((entry) => entry.kind === "tool" && entry.name === "safe-check"); + assert.equal(checkpoint?.kind, "tool"); + const persisted = checkpoint?.kind === "tool" ? (checkpoint.output as WorkflowToolFailure) : undefined; + assert.equal(persisted?.ok, false); + assert.equal(persisted?.error.stderr, capturedStderr); + assert.doesNotMatch(JSON.stringify(persisted), /secret-value/); + }); + + test("preserves fields from a non-Error callback rejection after retries", async () => { + let captured: WorkflowToolFailure | undefined; + const definition = workflow({ + name: "recoverable-tool-structured-rejection", + description: "", + inputs: {}, + outputs: { done: Type.Boolean() }, + run: async (ctx) => { + const outcome = await ctx.tool( + "structured-check", + {}, + async () => { + throw { + name: "CommandFailure", + message: "command rejected", + exitCode: 9, + stdout: "build output", + stderr: "build error", + }; + }, + { failureMode: "return", retriesAllowed: true, maxAttempts: 2, intervalMs: 0 }, + ); + if (!outcome.ok) captured = outcome; + return { done: true }; + }, + }); + + await run( + definition, + {}, + { + store: createStore(), + durableBackend: new InMemoryDurableBackend(), + }, + ); + + assert.deepEqual(captured?.error, { + name: "CommandFailure", + message: "command rejected", + exitCode: 9, + stdout: "build output", + stderr: "build error", + }); + assert.equal(captured?.attempts, 2); + }); + + test("keeps durable storage failures throwing in return mode", async () => { + class RejectingBackend extends InMemoryDurableBackend { + override async recordCheckpointAsync(_checkpoint: DurableCheckpoint): Promise { + throw new Error("checkpoint storage unavailable"); + } + } + const backend = new RejectingBackend(); + backend.registerWorkflow({ + workflowId: "recoverable-storage-failure", + name: "recoverable-storage-failure", + inputs: {}, + createdAt: 1, + status: "running", + }); + const tool = createToolPrimitive({ + workflowId: "recoverable-storage-failure", + backend, + nextCheckpointId: () => "unused", + throwIfCancelled: () => {}, + }); + + await assert.rejects( + () => + tool( + "check", + {}, + async () => { + throw new Error("expected check failure"); + }, + { failureMode: "return" }, + ), + /checkpoint storage unavailable/, + ); + }); }); diff --git a/test/unit/workflow-tool-send-idle-routing.test.ts b/test/unit/workflow-tool-send-idle-routing.test.ts index 8faa5b2d7..51fa5b263 100644 --- a/test/unit/workflow-tool-send-idle-routing.test.ts +++ b/test/unit/workflow-tool-send-idle-routing.test.ts @@ -1,251 +1,288 @@ -import { afterEach, describe, test } from "bun:test"; import assert from "node:assert/strict"; +import { afterEach, describe, test } from "vitest"; import { workflowSendAction } from "../../packages/workflows/src/extension/workflow-tool-send.js"; import { - stageControlRegistry, - type StageControlHandle, + type StageControlHandle, + stageControlRegistry, } from "../../packages/workflows/src/runs/foreground/stage-control-registry.js"; import { store } from "../../packages/workflows/src/shared/store.js"; const runIds = new Set(); afterEach(() => { - stageControlRegistry.clear(); - for (const runId of runIds) store.removeRun(runId); - runIds.clear(); + stageControlRegistry.clear(); + for (const runId of runIds) store.removeRun(runId); + runIds.clear(); }); function liveHandle(input: { - readonly runId: string; - readonly streaming: boolean; - readonly calls: string[]; - readonly status?: StageControlHandle["status"]; - readonly runStatus?: "running" | "paused"; + readonly runId: string; + readonly streaming: boolean; + readonly calls: string[]; + readonly status?: StageControlHandle["status"]; + readonly runStatus?: "running" | "paused"; }): StageControlHandle { - runIds.add(input.runId); - store.recordRunStart({ - id: input.runId, - name: "idle-routing", - inputs: {}, - status: input.runStatus ?? "running", - stages: [], - startedAt: 1, - }); - store.recordStageStart(input.runId, { - id: "stage-a", - name: "chat", - status: input.status === "paused" ? "paused" : "running", - parentIds: [], - toolEvents: [], - }); - const handle: StageControlHandle = { - runId: input.runId, - stageId: "stage-a", - stageName: "chat", - status: input.status ?? "running", - sessionId: "session-a", - sessionFile: undefined, - isStreaming: input.streaming, - messages: [], - async ensureAttached() {}, - async sendUserMessage(text, options, beforeDelivery) { - beforeDelivery?.(); - if (!input.streaming) { - input.calls.push(`prompt:${text}`); - return "prompt"; - } - const delivery = options?.deliverAs ?? "followUp"; - input.calls.push(`${delivery}:${text}`); - return delivery; - }, - async prompt(text) { input.calls.push(`prompt:${text}`); }, - async steer(text) { input.calls.push(`steer:${text}`); }, - async followUp(text) { input.calls.push(`followUp:${text}`); }, - async pause() {}, - async resume(text, beforeResume) { - beforeResume?.(); - input.calls.push(`resume:${text ?? ""}`); - }, - subscribe() { return () => {}; }, - }; - stageControlRegistry.register(handle); - return handle; + runIds.add(input.runId); + store.recordRunStart({ + id: input.runId, + name: "idle-routing", + inputs: {}, + status: input.runStatus ?? "running", + stages: [], + startedAt: 1, + }); + store.recordStageStart(input.runId, { + id: "stage-a", + name: "chat", + status: input.status === "paused" ? "paused" : "running", + parentIds: [], + toolEvents: [], + }); + const handle: StageControlHandle = { + runId: input.runId, + stageId: "stage-a", + stageName: "chat", + status: input.status ?? "running", + sessionId: "session-a", + sessionFile: undefined, + isStreaming: input.streaming, + messages: [], + async ensureAttached() {}, + async sendUserMessage(text, options, beforeDelivery) { + beforeDelivery?.(); + if (!input.streaming) { + input.calls.push(`prompt:${text}`); + return "prompt"; + } + const delivery = options?.deliverAs ?? "followUp"; + input.calls.push(`${delivery}:${text}`); + return delivery; + }, + async prompt(text) { + input.calls.push(`prompt:${text}`); + }, + async steer(text) { + input.calls.push(`steer:${text}`); + }, + async followUp(text) { + input.calls.push(`followUp:${text}`); + }, + async pause() {}, + async resume(text, beforeResume) { + beforeResume?.(); + input.calls.push(`resume:${text ?? ""}`); + }, + subscribe() { + return () => {}; + }, + }; + stageControlRegistry.register(handle); + return handle; } describe("workflow send — idle-aware live-stage routing", () => { - for (const delivery of ["auto", "followUp"] as const) { - test(`idle ${delivery} starts a prompt and reports the actual action`, async () => { - const runId = `idle-${delivery}`; - const calls: string[] = []; - liveHandle({ runId, streaming: false, calls }); - - const result = await workflowSendAction({ - runId, - stageId: "stage-a", - text: "continue now", - delivery, - }); - - assert.deepEqual(calls, ["prompt:continue now"]); - assert.deepEqual(result, { - action: "send", - runId, - stageId: "stage-a", - delivery: "prompt", - status: "ok", - message: "Prompt started for stage.", - }); - }); - } - - test("explicit idle prompt preserves its established response string", async () => { - const runId = "explicit-idle-prompt"; - const calls: string[] = []; - liveHandle({ runId, streaming: false, calls }); - - const result = await workflowSendAction({ - runId, - stageId: "stage-a", - text: "explicit prompt", - delivery: "prompt", - }); - - assert.deepEqual(calls, ["prompt:explicit prompt"]); - assert.equal(result.delivery, "prompt"); - assert.equal(result.status, "ok"); - assert.equal(result.message, "Prompt sent to stage."); - }); - - test("paused root resume preserves its established response string", async () => { - const runId = "ordinary-resume"; - const calls: string[] = []; - liveHandle({ runId, streaming: false, calls, status: "paused", runStatus: "paused" }); - assert.equal(store.runs().find((run) => run.id === runId)?.status, "paused"); - - const result = await workflowSendAction({ - runId, - stageId: "stage-a", - text: "resume normally", - delivery: "resume", - }); - - assert.deepEqual(calls, ["resume:resume normally"]); - assert.equal(result.delivery, "resume"); - assert.equal(result.status, "ok"); - assert.equal(result.message, "Resumed interrupted stage with message."); - }); - - test("resume against a running stage is a truthful noop", async () => { - const runId = "running-resume-noop"; - const calls: string[] = []; - liveHandle({ runId, streaming: false, calls }); - - const result = await workflowSendAction({ - runId, - stageId: "stage-a", - text: "must not be discarded", - delivery: "resume", - }); - - assert.deepEqual(calls, []); - assert.equal(result.delivery, "resume"); - assert.equal(result.status, "noop"); - assert.equal(result.message, "Stage is not paused; no resume message was delivered."); - }); - - test("explicit sends cannot bypass a paused stage", async () => { - const runId = "paused-follow-up-noop"; - const calls: string[] = []; - liveHandle({ runId, streaming: false, calls, status: "paused" }); - - const result = await workflowSendAction({ - runId, - stageId: "stage-a", - text: "must wait for resume", - delivery: "followUp", - }); - - assert.deepEqual(calls, []); - assert.equal(result.delivery, "followUp"); - assert.equal(result.status, "noop"); - assert.equal(result.message, "Stage is paused; resume it before sending a new message."); - }); - - test("streaming followUp queues without starting a concurrent prompt", async () => { - const runId = "streaming-follow-up"; - const calls: string[] = []; - liveHandle({ runId, streaming: true, calls }); - - const result = await workflowSendAction({ - runId, - stageId: "stage-a", - text: "after this turn", - delivery: "followUp", - }); - - assert.deepEqual(calls, ["followUp:after this turn"]); - assert.equal(result.delivery, "followUp"); - assert.equal(result.message, "Follow-up queued for stage."); - }); - - test("streaming steer steers without starting a concurrent prompt", async () => { - const runId = "streaming-steer"; - const calls: string[] = []; - liveHandle({ runId, streaming: true, calls }); - - const result = await workflowSendAction({ - runId, - stageId: "stage-a", - text: "change direction", - delivery: "steer", - }); - - assert.deepEqual(calls, ["steer:change direction"]); - assert.equal(result.delivery, "steer"); - assert.equal(result.message, "Steered live stage."); - }); - - test("expanded root target sends to the hydrated child owner", async () => { - const rootId = "hydrated-routing-root"; - const childId = "hydrated-routing-child"; - const calls: string[] = []; - runIds.add(rootId); - runIds.add(childId); - store.recordRunStart({ - id: rootId, name: "root", inputs: {}, status: "running", startedAt: 1, - stages: [{ - id: "child-boundary", name: "workflow:child", status: "running", parentIds: [], - toolEvents: [], attachable: false, - workflowChildRun: { alias: "child", workflow: "child", runId: childId }, - }], - }); - store.recordRunStart({ - id: childId, name: "child", inputs: {}, status: "running", startedAt: 1, - parentRunId: rootId, parentStageId: "child-boundary", rootRunId: rootId, - stages: [{ id: "stage-a", name: "chat", status: "running", parentIds: [], toolEvents: [], attachable: true }], - }); - stageControlRegistry.register({ - runId: childId, stageId: "stage-a", stageName: "chat", status: "running", - sessionId: "session-a", sessionFile: undefined, isStreaming: false, messages: [], - async ensureAttached() {}, - async sendUserMessage(text, _options, beforeDelivery) { - beforeDelivery?.(); - calls.push(`prompt:${text}`); - return "prompt"; - }, - async prompt(text) { calls.push(`prompt:${text}`); }, - async steer() {}, async followUp() {}, async pause() {}, async resume() {}, - subscribe() { return () => {}; }, - }); - - const result = await workflowSendAction({ - runId: rootId, - stageId: `${childId}:stage-a`, - text: "child message", - delivery: "prompt", - }); - assert.deepEqual(calls, ["prompt:child message"]); - assert.equal(result.runId, childId); - assert.equal(result.stageId, "stage-a"); - }); + for (const delivery of ["auto", "followUp"] as const) { + test(`idle ${delivery} starts a prompt and reports the actual action`, async () => { + const runId = `idle-${delivery}`; + const calls: string[] = []; + liveHandle({ runId, streaming: false, calls }); + + const result = await workflowSendAction({ + runId, + stageId: "stage-a", + text: "continue now", + delivery, + }); + + assert.deepEqual(calls, ["prompt:continue now"]); + assert.deepEqual(result, { + action: "send", + runId, + stageId: "stage-a", + delivery: "prompt", + status: "ok", + message: "Prompt started for stage.", + }); + }); + } + + test("explicit idle prompt preserves its established response string", async () => { + const runId = "explicit-idle-prompt"; + const calls: string[] = []; + liveHandle({ runId, streaming: false, calls }); + + const result = await workflowSendAction({ + runId, + stageId: "stage-a", + text: "explicit prompt", + delivery: "prompt", + }); + + assert.deepEqual(calls, ["prompt:explicit prompt"]); + assert.equal(result.delivery, "prompt"); + assert.equal(result.status, "ok"); + assert.equal(result.message, "Prompt sent to stage."); + }); + + test("paused root resume preserves its established response string", async () => { + const runId = "ordinary-resume"; + const calls: string[] = []; + liveHandle({ runId, streaming: false, calls, status: "paused", runStatus: "paused" }); + assert.equal(store.runs().find((run) => run.id === runId)?.status, "paused"); + + const result = await workflowSendAction({ + runId, + stageId: "stage-a", + text: "resume normally", + delivery: "resume", + }); + + assert.deepEqual(calls, ["resume:resume normally"]); + assert.equal(result.delivery, "resume"); + assert.equal(result.status, "ok"); + assert.equal(result.message, "Resumed interrupted stage with message."); + }); + + test("resume against a running stage is a truthful noop", async () => { + const runId = "running-resume-noop"; + const calls: string[] = []; + liveHandle({ runId, streaming: false, calls }); + + const result = await workflowSendAction({ + runId, + stageId: "stage-a", + text: "must not be discarded", + delivery: "resume", + }); + + assert.deepEqual(calls, []); + assert.equal(result.delivery, "resume"); + assert.equal(result.status, "noop"); + assert.equal(result.message, "Stage is not paused; no resume message was delivered."); + }); + + test("explicit sends cannot bypass a paused stage", async () => { + const runId = "paused-follow-up-noop"; + const calls: string[] = []; + liveHandle({ runId, streaming: false, calls, status: "paused" }); + + const result = await workflowSendAction({ + runId, + stageId: "stage-a", + text: "must wait for resume", + delivery: "followUp", + }); + + assert.deepEqual(calls, []); + assert.equal(result.delivery, "followUp"); + assert.equal(result.status, "noop"); + assert.equal(result.message, "Stage is paused; resume it before sending a new message."); + }); + + test("streaming followUp queues without starting a concurrent prompt", async () => { + const runId = "streaming-follow-up"; + const calls: string[] = []; + liveHandle({ runId, streaming: true, calls }); + + const result = await workflowSendAction({ + runId, + stageId: "stage-a", + text: "after this turn", + delivery: "followUp", + }); + + assert.deepEqual(calls, ["followUp:after this turn"]); + assert.equal(result.delivery, "followUp"); + assert.equal(result.message, "Follow-up queued for stage."); + }); + + test("streaming steer steers without starting a concurrent prompt", async () => { + const runId = "streaming-steer"; + const calls: string[] = []; + liveHandle({ runId, streaming: true, calls }); + + const result = await workflowSendAction({ + runId, + stageId: "stage-a", + text: "change direction", + delivery: "steer", + }); + + assert.deepEqual(calls, ["steer:change direction"]); + assert.equal(result.delivery, "steer"); + assert.equal(result.message, "Steered live stage."); + }); + + test("expanded root target sends to the hydrated child owner", async () => { + const rootId = "hydrated-routing-root"; + const childId = "hydrated-routing-child"; + const calls: string[] = []; + runIds.add(rootId); + runIds.add(childId); + store.recordRunStart({ + id: rootId, + name: "root", + inputs: {}, + status: "running", + startedAt: 1, + stages: [ + { + id: "child-boundary", + name: "workflow:child", + status: "running", + parentIds: [], + toolEvents: [], + attachable: false, + workflowChildRun: { alias: "child", workflow: "child", runId: childId }, + }, + ], + }); + store.recordRunStart({ + id: childId, + name: "child", + inputs: {}, + status: "running", + startedAt: 1, + parentRunId: rootId, + parentStageId: "child-boundary", + rootRunId: rootId, + stages: [{ id: "stage-a", name: "chat", status: "running", parentIds: [], toolEvents: [], attachable: true }], + }); + stageControlRegistry.register({ + runId: childId, + stageId: "stage-a", + stageName: "chat", + status: "running", + sessionId: "session-a", + sessionFile: undefined, + isStreaming: false, + messages: [], + async ensureAttached() {}, + async sendUserMessage(text, _options, beforeDelivery) { + beforeDelivery?.(); + calls.push(`prompt:${text}`); + return "prompt"; + }, + async prompt(text) { + calls.push(`prompt:${text}`); + }, + async steer() {}, + async followUp() {}, + async pause() {}, + async resume() {}, + subscribe() { + return () => {}; + }, + }); + + const result = await workflowSendAction({ + runId: rootId, + stageId: `${childId}:stage-a`, + text: "child message", + delivery: "prompt", + }); + assert.deepEqual(calls, ["prompt:child message"]); + assert.equal(result.runId, childId); + assert.equal(result.stageId, "stage-a"); + }); }); diff --git a/test/unit/workflow-tool-send-postmortem.test.ts b/test/unit/workflow-tool-send-postmortem.test.ts index 4790bcefd..d4e2fd5f4 100644 --- a/test/unit/workflow-tool-send-postmortem.test.ts +++ b/test/unit/workflow-tool-send-postmortem.test.ts @@ -5,21 +5,22 @@ * separate post-mortem resolver and `/workflow attach` tests retain explicit * user-driven terminal chat coverage. */ -import { afterEach, beforeEach, describe, test } from "bun:test"; + import assert from "node:assert/strict"; import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { afterEach, beforeEach, describe, test } from "vitest"; import { workflowSendAction } from "../../packages/workflows/src/extension/workflow-tool-send.js"; -import { store } from "../../packages/workflows/src/shared/store.js"; -import { stageUiBroker } from "../../packages/workflows/src/shared/stage-ui-broker.js"; +import type { PostMortemStageChatDeps } from "../../packages/workflows/src/runs/foreground/postmortem-stage-chat.js"; import { - createStageControlRegistry, - stageControlRegistry, - type StageControlHandle, + createStageControlRegistry, + type StageControlHandle, + stageControlRegistry, } from "../../packages/workflows/src/runs/foreground/stage-control-registry.js"; -import type { PostMortemStageChatDeps } from "../../packages/workflows/src/runs/foreground/postmortem-stage-chat.js"; import type { StageAdapters } from "../../packages/workflows/src/runs/foreground/stage-runner.js"; +import { stageUiBroker } from "../../packages/workflows/src/shared/stage-ui-broker.js"; +import { store } from "../../packages/workflows/src/shared/store.js"; import { mockSession, type StageSessionRuntime } from "./executor-shared.js"; let tempDir = ""; @@ -28,443 +29,503 @@ const CHILD_RUN_ID = "postmortem-send-child"; const TERMINAL_ROOT_STATUSES = ["completed", "failed", "skipped", "cancelled", "killed", "blocked"] as const; interface RevivalCounter { - creates: number; - resolves: number; + creates: number; + resolves: number; } -beforeEach(() => { tempDir = mkdtempSync(join(tmpdir(), "atomic-send-postmortem-")); }); +beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), "atomic-send-postmortem-")); +}); afterEach(() => { - stageControlRegistry.clear(); - rmSync(tempDir, { recursive: true, force: true }); - store.removeRun(CHILD_RUN_ID); - store.removeRun(RUN_ID); + stageControlRegistry.clear(); + rmSync(tempDir, { recursive: true, force: true }); + store.removeRun(CHILD_RUN_ID); + store.removeRun(RUN_ID); }); function retainedSession(name: string): string { - const path = join(tempDir, `${name}.jsonl`); - writeFileSync(path, [ - JSON.stringify({ type: "session", version: 3, id: `${name}-session`, timestamp: new Date().toISOString(), cwd: tempDir }), - JSON.stringify({ type: "message", id: `${name}-msg`, parentId: null, timestamp: new Date().toISOString(), message: { role: "user", content: "Original request" } }), - ].join("\n") + "\n"); - return path; + const path = join(tempDir, `${name}.jsonl`); + writeFileSync( + path, + `${[ + JSON.stringify({ + type: "session", + version: 3, + id: `${name}-session`, + timestamp: new Date().toISOString(), + cwd: tempDir, + }), + JSON.stringify({ + type: "message", + id: `${name}-msg`, + parentId: null, + timestamp: new Date().toISOString(), + message: { role: "user", content: "Original request" }, + }), + ].join("\n")}\n`, + ); + return path; } -function seedTerminalRun( - sessionFile: string | undefined, - status: "completed" | "failed" = "completed", -): void { - store.recordRunStart({ id: RUN_ID, name: "send-flow", inputs: {}, status, stages: [], startedAt: 1 }); - store.recordStageStart(RUN_ID, { - id: "stage-a", - name: "final", - status: "completed", - parentIds: [], - toolEvents: [], - result: "done", - attachable: false, - ...(sessionFile !== undefined ? { sessionFile } : {}), - }); +function seedTerminalRun(sessionFile: string | undefined, status: "completed" | "failed" = "completed"): void { + store.recordRunStart({ id: RUN_ID, name: "send-flow", inputs: {}, status, stages: [], startedAt: 1 }); + store.recordStageStart(RUN_ID, { + id: "stage-a", + name: "final", + status: "completed", + parentIds: [], + toolEvents: [], + result: "done", + attachable: false, + ...(sessionFile !== undefined ? { sessionFile } : {}), + }); } -function resolvePostMortemDeps(session: StageSessionRuntime, counter: RevivalCounter): (runId: string) => PostMortemStageChatDeps { - const adapters: StageAdapters = { - agentSession: { - async create() { counter.creates += 1; return session; }, - }, - }; - return () => { - counter.resolves += 1; - return { registry: createStageControlRegistry(), adapters, cwd: tempDir }; - }; +function resolvePostMortemDeps( + session: StageSessionRuntime, + counter: RevivalCounter, +): (runId: string) => PostMortemStageChatDeps { + const adapters: StageAdapters = { + agentSession: { + async create() { + counter.creates += 1; + return session; + }, + }, + }; + return () => { + counter.resolves += 1; + return { registry: createStageControlRegistry(), adapters, cwd: tempDir }; + }; } function runExecutionSnapshot(): object { - const run = store.runs().find((candidate) => candidate.id === RUN_ID); - assert.ok(run); - return structuredClone(run); + const run = store.runs().find((candidate) => candidate.id === RUN_ID); + assert.ok(run); + return structuredClone(run); } function assertTerminalFailure( - result: Awaited>, - workflowStatus: typeof TERMINAL_ROOT_STATUSES[number] = "completed", + result: Awaited>, + workflowStatus: (typeof TERMINAL_ROOT_STATUSES)[number] = "completed", ): void { - assert.equal(result.status, "failed"); - if (result.status !== "failed") return; - assert.equal(result.code, "WORKFLOW_TERMINAL"); - assert.equal(result.workflowStatus, workflowStatus); - assert.equal(result.delivery, "rejected"); - assert.equal(result.error, result.message); - assert.match(result.message, new RegExp(RUN_ID)); - assert.match(result.message, new RegExp(`status ${workflowStatus}`)); - assert.match(result.message, /start a new workflow/); - assert.match(result.message, /small, deterministic, and low risk/); - assert.doesNotMatch(result.message, /\bok\b|accepted|running|Prompt started|queued|sent|steered|resumed|answered/i); + assert.equal(result.status, "failed"); + if (result.status !== "failed") return; + assert.equal(result.code, "WORKFLOW_TERMINAL"); + assert.equal(result.workflowStatus, workflowStatus); + assert.equal(result.delivery, "rejected"); + assert.equal(result.error, result.message); + assert.match(result.message, new RegExp(RUN_ID)); + assert.match(result.message, new RegExp(`status ${workflowStatus}`)); + assert.match(result.message, /start a new workflow/); + assert.match(result.message, /small, deterministic, and low risk/); + assert.doesNotMatch(result.message, /\bok\b|accepted|running|Prompt started|queued|sent|steered|resumed|answered/i); } describe("workflow send — terminal root rejection", () => { - for (const workflowStatus of TERMINAL_ROOT_STATUSES) { - test(`rejects ${workflowStatus} roots before resolving a nested stage target`, async () => { - store.recordRunStart({ - id: RUN_ID, - name: "terminal-root", - inputs: {}, - status: workflowStatus, - stages: [], - startedAt: 1, - }); - let resolverCalls = 0; - const before = store.snapshot(); - - const result = await workflowSendAction( - { runId: RUN_ID, stageId: "missing-child:missing-stage", text: "must not route" }, - { - resolvePostMortemDeps: () => { - resolverCalls += 1; - throw new Error("terminal roots must not probe retained sessions"); - }, - }, - ); - - assertTerminalFailure(result, workflowStatus); - assert.equal(result.runId, RUN_ID); - assert.equal(result.stageId, "missing-child:missing-stage"); - assert.equal(resolverCalls, 0); - assert.equal(stageControlRegistry.get(RUN_ID, "missing-child:missing-stage"), undefined); - assert.deepEqual(store.snapshot(), before); - }); - } - - test("uses the authoritative root for direct and expanded nested targets", async () => { - store.recordRunStart({ - id: RUN_ID, - name: "terminal-root", - inputs: {}, - status: "running", - startedAt: 1, - stages: [{ - id: "child-boundary", - name: "workflow:child", - status: "completed", - parentIds: [], - toolEvents: [], - attachable: false, - workflowChildRun: { alias: "child", workflow: "child", runId: CHILD_RUN_ID }, - }], - }); - store.recordRunStart({ - id: CHILD_RUN_ID, - name: "child", - inputs: {}, - status: "completed", - startedAt: 1, - parentRunId: RUN_ID, - parentStageId: "child-boundary", - rootRunId: RUN_ID, - stages: [{ - id: "child-stage", - name: "child stage", - status: "completed", - parentIds: [], - toolEvents: [], - attachable: true, - }], - }); - const calls: string[] = []; - stageControlRegistry.register({ - runId: CHILD_RUN_ID, - stageId: "child-stage", - stageName: "child stage", - status: "completed", - sessionId: "child-session", - sessionFile: undefined, - isStreaming: false, - messages: [], - async ensureAttached() { calls.push("attach"); }, - async sendUserMessage(_text, _options, beforeDelivery) { - beforeDelivery?.(); - calls.push("prompt"); - return "prompt"; - }, - async prompt() { calls.push("prompt"); }, - async steer() { calls.push("steer"); }, - async followUp() { calls.push("followUp"); }, - async pause() { calls.push("pause"); }, - async resume() { calls.push("resume"); }, - subscribe() { return () => {}; }, - }); - const liveChildResult = await workflowSendAction({ - runId: CHILD_RUN_ID, - stageId: "child-stage", - text: "allowed while root remains live", - }); - assert.equal(liveChildResult.status, "ok"); - assert.equal(liveChildResult.runId, CHILD_RUN_ID); - assert.deepEqual(calls, ["prompt"]); - store.recordRunEnd(RUN_ID, "failed"); - const before = store.snapshot(); - - const result = await workflowSendAction({ - runId: RUN_ID, - stageId: `${CHILD_RUN_ID}:child-stage`, - text: "must not reach child", - }); - - assertTerminalFailure(result, "failed"); - assert.equal(result.runId, RUN_ID); - assert.equal(result.stageId, `${CHILD_RUN_ID}:child-stage`); - const directChildResult = await workflowSendAction({ - runId: CHILD_RUN_ID, - stageId: "child-stage", - text: "must not bypass terminal root", - }); - assertTerminalFailure(directChildResult, "failed"); - assert.equal(directChildResult.runId, RUN_ID); - assert.equal(directChildResult.stageId, "child-stage"); - assert.deepEqual(calls, ["prompt"]); - assert.deepEqual(store.snapshot(), before); - }); - - for (const workflowStatus of ["completed", "failed"] as const) { - test(`rejects a ${workflowStatus} root before reviving its retained stage session`, async () => { - const sessionFile = retainedSession(`send-terminal-${workflowStatus}`); - seedTerminalRun(sessionFile, workflowStatus); - const prompts: string[] = []; - const counter: RevivalCounter = { creates: 0, resolves: 0 }; - const sideEffectPath = join(tempDir, "model-tool-side-effect.txt"); - const session: StageSessionRuntime = { - ...mockSession(), - sessionFile, - async prompt(text: string) { - prompts.push(text); - writeFileSync(sideEffectPath, "unexpected model/tool work"); - }, - }; - const transcriptBefore = readFileSync(sessionFile); - const before = store.snapshot(); - - const result = await workflowSendAction( - { runId: RUN_ID, stageId: "stage-a", text: "any regressions?" }, - { resolvePostMortemDeps: resolvePostMortemDeps(session, counter) }, - ); - - assertTerminalFailure(result, workflowStatus); - assert.deepEqual(prompts, []); - assert.equal(counter.resolves, 0); - assert.equal(counter.creates, 0); - assert.equal(existsSync(sideEffectPath), false); - assert.deepEqual(readFileSync(sessionFile), transcriptBefore); - assert.equal(stageControlRegistry.get(RUN_ID, "stage-a"), undefined); - assert.deepEqual(store.snapshot(), before); - }); - } - - test("does not inspect or answer a stale brokered prompt on a terminal root", async () => { - seedTerminalRun(undefined); - const controller = new AbortController(); - let answerBuilds = 0; - stageUiBroker.provideStagePrompt(RUN_ID, "stage-a", { - prompt: { - id: "stale-input", - kind: "ask_user_question", - questions: [{ question: "Proceed?", options: [{ label: "Yes" }, { label: "No" }] }], - createdAt: 1, - }, - buildResult() { - answerBuilds += 1; - return { answers: [], cancelled: false }; - }, - }); - const pending = stageUiBroker.requestCustomUi( - RUN_ID, - "stage-a", - () => ({ render: () => [], invalidate: () => {} }), - undefined, - controller.signal, - ).catch(() => undefined); - assert.equal(stageUiBroker.peekStagePrompt(RUN_ID, "stage-a")?.id, "stale-input"); - const before = store.snapshot(); - - const result = await workflowSendAction({ - runId: RUN_ID, - stageId: "stage-a", - promptId: "stale-input", - response: "Yes", - delivery: "answer", - }); - - assertTerminalFailure(result); - assert.equal(answerBuilds, 0); - assert.equal(stageUiBroker.peekStagePrompt(RUN_ID, "stage-a")?.id, "stale-input"); - assert.deepEqual(store.snapshot(), before); - controller.abort(new Error("test cleanup")); - await pending; - }); - - test("leaves a native pending prompt and private answer ledger untouched", async () => { - store.recordRunStart({ - id: RUN_ID, - name: "terminal-native-prompt", - inputs: {}, - status: "running", - stages: [], - startedAt: 1, - }); - store.recordStageStart(RUN_ID, { - id: "stage-a", - name: "ask", - status: "running", - parentIds: [], - toolEvents: [], - }); - const priorPrompt = { id: "prior-input", kind: "input" as const, message: "Prior?", createdAt: 1 }; - const pendingPrompt = { id: "native-input", kind: "input" as const, message: "Value?", createdAt: 2 }; - assert.equal(store.recordStagePromptAnswer(RUN_ID, "stage-a", priorPrompt, "private-prior-answer"), true); - assert.equal(store.recordStagePendingPrompt(RUN_ID, "stage-a", pendingPrompt), true); - assert.equal(store.recordRunEnd(RUN_ID, "failed"), true); - const terminalStage = store.runs().find((run) => run.id === RUN_ID)?.stages[0]; - assert.ok(terminalStage); - terminalStage.pendingPrompt = structuredClone(pendingPrompt); - terminalStage.status = "awaiting_input"; - terminalStage.awaitingInputSince = pendingPrompt.createdAt; - const answerBefore = structuredClone(store.getStagePromptAnswer(RUN_ID, "stage-a")); - const before = store.snapshot(); - assert.equal(answerBefore?.value, "private-prior-answer"); - - const originalResolve = store.resolveStagePendingPrompt; - let resolveCalls = 0; - store.resolveStagePendingPrompt = (...args) => { - resolveCalls += 1; - return originalResolve(...args); - }; - let result: Awaited> | undefined; - try { - result = await workflowSendAction({ - runId: RUN_ID, - stageId: "stage-a", - promptId: pendingPrompt.id, - response: "must-not-answer", - delivery: "answer", - }); - } finally { - store.resolveStagePendingPrompt = originalResolve; - } - - assert.ok(result); - assertTerminalFailure(result, "failed"); - assert.equal(resolveCalls, 0); - const stageAfter = store.runs().find((run) => run.id === RUN_ID)?.stages[0]; - assert.deepEqual(stageAfter?.pendingPrompt, pendingPrompt); - assert.deepEqual(store.getStagePromptAnswer(RUN_ID, "stage-a"), answerBefore); - assert.deepEqual(store.snapshot(), before); - }); - - test("rejects explicit resume without reviving or mutating terminal state", async () => { - const sessionFile = retainedSession("send-no-resume"); - seedTerminalRun(sessionFile); - const deliveryCalls: string[] = []; - const counter: RevivalCounter = { creates: 0, resolves: 0 }; - const session: StageSessionRuntime = { - ...mockSession(), - sessionFile, - async prompt(text: string) { deliveryCalls.push(`prompt:${text}`); }, - async followUp(text: string) { deliveryCalls.push(`followUp:${text}`); }, - async steer(text: string) { deliveryCalls.push(`steer:${text}`); }, - }; - const before = runExecutionSnapshot(); - - const result = await workflowSendAction( - { runId: RUN_ID, stageId: "stage-a", text: "resume should be rejected", delivery: "resume" }, - { resolvePostMortemDeps: resolvePostMortemDeps(session, counter) }, - ); - - assertTerminalFailure(result); - assert.equal(result.delivery, "rejected"); - assert.deepEqual(deliveryCalls, []); - assert.equal(counter.resolves, 0); - assert.equal(counter.creates, 0); - assert.deepEqual(runExecutionSnapshot(), before); - }); - - test("rejects explicit steer without reviving a terminal stage", async () => { - const sessionFile = retainedSession("send-no-steer"); - seedTerminalRun(sessionFile); - const deliveryCalls: string[] = []; - const counter: RevivalCounter = { creates: 0, resolves: 0 }; - const session: StageSessionRuntime = { - ...mockSession(), - sessionFile, - async prompt(text: string) { deliveryCalls.push(`prompt:${text}`); }, - async followUp(text: string) { deliveryCalls.push(`followUp:${text}`); }, - async steer(text: string) { deliveryCalls.push(`steer:${text}`); }, - }; - const before = runExecutionSnapshot(); - const result = await workflowSendAction( - { runId: RUN_ID, stageId: "stage-a", text: "steer attempt", delivery: "steer" }, - { resolvePostMortemDeps: resolvePostMortemDeps(session, counter) }, - ); - assertTerminalFailure(result); - assert.equal(result.delivery, "rejected"); - assert.deepEqual(deliveryCalls, []); - assert.equal(counter.resolves, 0); - assert.equal(counter.creates, 0); - assert.deepEqual(runExecutionSnapshot(), before); - }); - - test("rejects auto delivery to a retained streaming terminal handle", async () => { - seedTerminalRun(undefined); - const deliveryCalls: string[] = []; - const handle: StageControlHandle = { - runId: RUN_ID, - stageId: "stage-a", - stageName: "final", - status: "completed", - sessionId: "retained-session", - sessionFile: undefined, - isStreaming: true, - messages: [], - async ensureAttached() {}, - async prompt(text: string) { deliveryCalls.push(`prompt:${text}`); }, - async followUp(text: string) { deliveryCalls.push(`followUp:${text}`); }, - async steer(text: string) { deliveryCalls.push(`steer:${text}`); }, - async pause() {}, - async resume() {}, - subscribe() { return () => {}; }, - }; - stageControlRegistry.register(handle); - const before = runExecutionSnapshot(); - - const result = await workflowSendAction({ - runId: RUN_ID, - stageId: "stage-a", - text: "queue after the active turn", - }); - - assertTerminalFailure(result); - assert.equal(result.delivery, "rejected"); - assert.deepEqual(deliveryCalls, []); - assert.deepEqual(runExecutionSnapshot(), before); - }); - - test("returns the root-terminal error without probing an invalid session", async () => { - seedTerminalRun(join(tempDir, "missing.jsonl")); - const counter: RevivalCounter = { creates: 0, resolves: 0 }; - const result = await workflowSendAction( - { runId: RUN_ID, stageId: "stage-a", text: "hello" }, - { resolvePostMortemDeps: resolvePostMortemDeps(mockSession(), counter) }, - ); - assertTerminalFailure(result); - assert.equal(counter.resolves, 0); - assert.equal(counter.creates, 0); - }); - - test("returns the same root-terminal error when no session was retained", async () => { - seedTerminalRun(undefined); - const counter: RevivalCounter = { creates: 0, resolves: 0 }; - const result = await workflowSendAction( - { runId: RUN_ID, stageId: "stage-a", text: "hello" }, - { resolvePostMortemDeps: resolvePostMortemDeps(mockSession(), counter) }, - ); - assertTerminalFailure(result); - assert.equal(counter.resolves, 0); - assert.equal(counter.creates, 0); - }); + for (const workflowStatus of TERMINAL_ROOT_STATUSES) { + test(`rejects ${workflowStatus} roots before resolving a nested stage target`, async () => { + store.recordRunStart({ + id: RUN_ID, + name: "terminal-root", + inputs: {}, + status: workflowStatus, + stages: [], + startedAt: 1, + }); + let resolverCalls = 0; + const before = store.snapshot(); + + const result = await workflowSendAction( + { runId: RUN_ID, stageId: "missing-child:missing-stage", text: "must not route" }, + { + resolvePostMortemDeps: () => { + resolverCalls += 1; + throw new Error("terminal roots must not probe retained sessions"); + }, + }, + ); + + assertTerminalFailure(result, workflowStatus); + assert.equal(result.runId, RUN_ID); + assert.equal(result.stageId, "missing-child:missing-stage"); + assert.equal(resolverCalls, 0); + assert.equal(stageControlRegistry.get(RUN_ID, "missing-child:missing-stage"), undefined); + assert.deepEqual(store.snapshot(), before); + }); + } + + test("uses the authoritative root for direct and expanded nested targets", async () => { + store.recordRunStart({ + id: RUN_ID, + name: "terminal-root", + inputs: {}, + status: "running", + startedAt: 1, + stages: [ + { + id: "child-boundary", + name: "workflow:child", + status: "completed", + parentIds: [], + toolEvents: [], + attachable: false, + workflowChildRun: { alias: "child", workflow: "child", runId: CHILD_RUN_ID }, + }, + ], + }); + store.recordRunStart({ + id: CHILD_RUN_ID, + name: "child", + inputs: {}, + status: "completed", + startedAt: 1, + parentRunId: RUN_ID, + parentStageId: "child-boundary", + rootRunId: RUN_ID, + stages: [ + { + id: "child-stage", + name: "child stage", + status: "completed", + parentIds: [], + toolEvents: [], + attachable: true, + }, + ], + }); + const calls: string[] = []; + stageControlRegistry.register({ + runId: CHILD_RUN_ID, + stageId: "child-stage", + stageName: "child stage", + status: "completed", + sessionId: "child-session", + sessionFile: undefined, + isStreaming: false, + messages: [], + async ensureAttached() { + calls.push("attach"); + }, + async sendUserMessage(_text, _options, beforeDelivery) { + beforeDelivery?.(); + calls.push("prompt"); + return "prompt"; + }, + async prompt() { + calls.push("prompt"); + }, + async steer() { + calls.push("steer"); + }, + async followUp() { + calls.push("followUp"); + }, + async pause() { + calls.push("pause"); + }, + async resume() { + calls.push("resume"); + }, + subscribe() { + return () => {}; + }, + }); + const liveChildResult = await workflowSendAction({ + runId: CHILD_RUN_ID, + stageId: "child-stage", + text: "allowed while root remains live", + }); + assert.equal(liveChildResult.status, "ok"); + assert.equal(liveChildResult.runId, CHILD_RUN_ID); + assert.deepEqual(calls, ["prompt"]); + store.recordRunEnd(RUN_ID, "failed"); + const before = store.snapshot(); + + const result = await workflowSendAction({ + runId: RUN_ID, + stageId: `${CHILD_RUN_ID}:child-stage`, + text: "must not reach child", + }); + + assertTerminalFailure(result, "failed"); + assert.equal(result.runId, RUN_ID); + assert.equal(result.stageId, `${CHILD_RUN_ID}:child-stage`); + const directChildResult = await workflowSendAction({ + runId: CHILD_RUN_ID, + stageId: "child-stage", + text: "must not bypass terminal root", + }); + assertTerminalFailure(directChildResult, "failed"); + assert.equal(directChildResult.runId, RUN_ID); + assert.equal(directChildResult.stageId, "child-stage"); + assert.deepEqual(calls, ["prompt"]); + assert.deepEqual(store.snapshot(), before); + }); + + for (const workflowStatus of ["completed", "failed"] as const) { + test(`rejects a ${workflowStatus} root before reviving its retained stage session`, async () => { + const sessionFile = retainedSession(`send-terminal-${workflowStatus}`); + seedTerminalRun(sessionFile, workflowStatus); + const prompts: string[] = []; + const counter: RevivalCounter = { creates: 0, resolves: 0 }; + const sideEffectPath = join(tempDir, "model-tool-side-effect.txt"); + const session: StageSessionRuntime = { + ...mockSession(), + sessionFile, + async prompt(text: string) { + prompts.push(text); + writeFileSync(sideEffectPath, "unexpected model/tool work"); + }, + }; + const transcriptBefore = readFileSync(sessionFile); + const before = store.snapshot(); + + const result = await workflowSendAction( + { runId: RUN_ID, stageId: "stage-a", text: "any regressions?" }, + { resolvePostMortemDeps: resolvePostMortemDeps(session, counter) }, + ); + + assertTerminalFailure(result, workflowStatus); + assert.deepEqual(prompts, []); + assert.equal(counter.resolves, 0); + assert.equal(counter.creates, 0); + assert.equal(existsSync(sideEffectPath), false); + assert.deepEqual(readFileSync(sessionFile), transcriptBefore); + assert.equal(stageControlRegistry.get(RUN_ID, "stage-a"), undefined); + assert.deepEqual(store.snapshot(), before); + }); + } + + test("does not inspect or answer a stale brokered prompt on a terminal root", async () => { + seedTerminalRun(undefined); + const controller = new AbortController(); + let answerBuilds = 0; + stageUiBroker.provideStagePrompt(RUN_ID, "stage-a", { + prompt: { + id: "stale-input", + kind: "ask_user_question", + questions: [{ question: "Proceed?", options: [{ label: "Yes" }, { label: "No" }] }], + createdAt: 1, + }, + buildResult() { + answerBuilds += 1; + return { answers: [], cancelled: false }; + }, + }); + const pending = stageUiBroker + .requestCustomUi( + RUN_ID, + "stage-a", + () => ({ render: () => [], invalidate: () => {} }), + undefined, + controller.signal, + ) + .catch(() => undefined); + assert.equal(stageUiBroker.peekStagePrompt(RUN_ID, "stage-a")?.id, "stale-input"); + const before = store.snapshot(); + + const result = await workflowSendAction({ + runId: RUN_ID, + stageId: "stage-a", + promptId: "stale-input", + response: "Yes", + delivery: "answer", + }); + + assertTerminalFailure(result); + assert.equal(answerBuilds, 0); + assert.equal(stageUiBroker.peekStagePrompt(RUN_ID, "stage-a")?.id, "stale-input"); + assert.deepEqual(store.snapshot(), before); + controller.abort(new Error("test cleanup")); + await pending; + }); + + test("leaves a native pending prompt and private answer ledger untouched", async () => { + store.recordRunStart({ + id: RUN_ID, + name: "terminal-native-prompt", + inputs: {}, + status: "running", + stages: [], + startedAt: 1, + }); + store.recordStageStart(RUN_ID, { + id: "stage-a", + name: "ask", + status: "running", + parentIds: [], + toolEvents: [], + }); + const priorPrompt = { id: "prior-input", kind: "input" as const, message: "Prior?", createdAt: 1 }; + const pendingPrompt = { id: "native-input", kind: "input" as const, message: "Value?", createdAt: 2 }; + assert.equal(store.recordStagePromptAnswer(RUN_ID, "stage-a", priorPrompt, "private-prior-answer"), true); + assert.equal(store.recordStagePendingPrompt(RUN_ID, "stage-a", pendingPrompt), true); + assert.equal(store.recordRunEnd(RUN_ID, "failed"), true); + const terminalStage = store.runs().find((run) => run.id === RUN_ID)?.stages[0]; + assert.ok(terminalStage); + terminalStage.pendingPrompt = structuredClone(pendingPrompt); + terminalStage.status = "awaiting_input"; + terminalStage.awaitingInputSince = pendingPrompt.createdAt; + const answerBefore = structuredClone(store.getStagePromptAnswer(RUN_ID, "stage-a")); + const before = store.snapshot(); + assert.equal(answerBefore?.value, "private-prior-answer"); + + const originalResolve = store.resolveStagePendingPrompt; + let resolveCalls = 0; + store.resolveStagePendingPrompt = (...args) => { + resolveCalls += 1; + return originalResolve(...args); + }; + let result: Awaited> | undefined; + try { + result = await workflowSendAction({ + runId: RUN_ID, + stageId: "stage-a", + promptId: pendingPrompt.id, + response: "must-not-answer", + delivery: "answer", + }); + } finally { + store.resolveStagePendingPrompt = originalResolve; + } + + assert.ok(result); + assertTerminalFailure(result, "failed"); + assert.equal(resolveCalls, 0); + const stageAfter = store.runs().find((run) => run.id === RUN_ID)?.stages[0]; + assert.deepEqual(stageAfter?.pendingPrompt, pendingPrompt); + assert.deepEqual(store.getStagePromptAnswer(RUN_ID, "stage-a"), answerBefore); + assert.deepEqual(store.snapshot(), before); + }); + + test("rejects explicit resume without reviving or mutating terminal state", async () => { + const sessionFile = retainedSession("send-no-resume"); + seedTerminalRun(sessionFile); + const deliveryCalls: string[] = []; + const counter: RevivalCounter = { creates: 0, resolves: 0 }; + const session: StageSessionRuntime = { + ...mockSession(), + sessionFile, + async prompt(text: string) { + deliveryCalls.push(`prompt:${text}`); + }, + async followUp(text: string) { + deliveryCalls.push(`followUp:${text}`); + }, + async steer(text: string) { + deliveryCalls.push(`steer:${text}`); + }, + }; + const before = runExecutionSnapshot(); + + const result = await workflowSendAction( + { runId: RUN_ID, stageId: "stage-a", text: "resume should be rejected", delivery: "resume" }, + { resolvePostMortemDeps: resolvePostMortemDeps(session, counter) }, + ); + + assertTerminalFailure(result); + assert.equal(result.delivery, "rejected"); + assert.deepEqual(deliveryCalls, []); + assert.equal(counter.resolves, 0); + assert.equal(counter.creates, 0); + assert.deepEqual(runExecutionSnapshot(), before); + }); + + test("rejects explicit steer without reviving a terminal stage", async () => { + const sessionFile = retainedSession("send-no-steer"); + seedTerminalRun(sessionFile); + const deliveryCalls: string[] = []; + const counter: RevivalCounter = { creates: 0, resolves: 0 }; + const session: StageSessionRuntime = { + ...mockSession(), + sessionFile, + async prompt(text: string) { + deliveryCalls.push(`prompt:${text}`); + }, + async followUp(text: string) { + deliveryCalls.push(`followUp:${text}`); + }, + async steer(text: string) { + deliveryCalls.push(`steer:${text}`); + }, + }; + const before = runExecutionSnapshot(); + const result = await workflowSendAction( + { runId: RUN_ID, stageId: "stage-a", text: "steer attempt", delivery: "steer" }, + { resolvePostMortemDeps: resolvePostMortemDeps(session, counter) }, + ); + assertTerminalFailure(result); + assert.equal(result.delivery, "rejected"); + assert.deepEqual(deliveryCalls, []); + assert.equal(counter.resolves, 0); + assert.equal(counter.creates, 0); + assert.deepEqual(runExecutionSnapshot(), before); + }); + + test("rejects auto delivery to a retained streaming terminal handle", async () => { + seedTerminalRun(undefined); + const deliveryCalls: string[] = []; + const handle: StageControlHandle = { + runId: RUN_ID, + stageId: "stage-a", + stageName: "final", + status: "completed", + sessionId: "retained-session", + sessionFile: undefined, + isStreaming: true, + messages: [], + async ensureAttached() {}, + async prompt(text: string) { + deliveryCalls.push(`prompt:${text}`); + }, + async followUp(text: string) { + deliveryCalls.push(`followUp:${text}`); + }, + async steer(text: string) { + deliveryCalls.push(`steer:${text}`); + }, + async pause() {}, + async resume() {}, + subscribe() { + return () => {}; + }, + }; + stageControlRegistry.register(handle); + const before = runExecutionSnapshot(); + + const result = await workflowSendAction({ + runId: RUN_ID, + stageId: "stage-a", + text: "queue after the active turn", + }); + + assertTerminalFailure(result); + assert.equal(result.delivery, "rejected"); + assert.deepEqual(deliveryCalls, []); + assert.deepEqual(runExecutionSnapshot(), before); + }); + + test("returns the root-terminal error without probing an invalid session", async () => { + seedTerminalRun(join(tempDir, "missing.jsonl")); + const counter: RevivalCounter = { creates: 0, resolves: 0 }; + const result = await workflowSendAction( + { runId: RUN_ID, stageId: "stage-a", text: "hello" }, + { resolvePostMortemDeps: resolvePostMortemDeps(mockSession(), counter) }, + ); + assertTerminalFailure(result); + assert.equal(counter.resolves, 0); + assert.equal(counter.creates, 0); + }); + + test("returns the same root-terminal error when no session was retained", async () => { + seedTerminalRun(undefined); + const counter: RevivalCounter = { creates: 0, resolves: 0 }; + const result = await workflowSendAction( + { runId: RUN_ID, stageId: "stage-a", text: "hello" }, + { resolvePostMortemDeps: resolvePostMortemDeps(mockSession(), counter) }, + ); + assertTerminalFailure(result); + assert.equal(counter.resolves, 0); + assert.equal(counter.creates, 0); + }); }); diff --git a/test/unit/workflow-tool-send-terminal-race.test.ts b/test/unit/workflow-tool-send-terminal-race.test.ts index be527858b..61867821f 100644 --- a/test/unit/workflow-tool-send-terminal-race.test.ts +++ b/test/unit/workflow-tool-send-terminal-race.test.ts @@ -1,12 +1,12 @@ -import { afterEach, beforeEach, test } from "bun:test"; import assert from "node:assert/strict"; import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { afterEach, beforeEach, test } from "vitest"; import { workflowSendAction } from "../../packages/workflows/src/extension/workflow-tool-send.js"; import { createStageControlHandle } from "../../packages/workflows/src/runs/foreground/executor-stage-control.js"; -import { stageControlRegistry } from "../../packages/workflows/src/runs/foreground/stage-control-registry.js"; import type { PostMortemStageChatDeps } from "../../packages/workflows/src/runs/foreground/postmortem-stage-chat.js"; +import { stageControlRegistry } from "../../packages/workflows/src/runs/foreground/stage-control-registry.js"; import type { StageAdapters } from "../../packages/workflows/src/runs/foreground/stage-runner.js"; import { store } from "../../packages/workflows/src/shared/store.js"; import { mockSession, type StageSessionRuntime } from "./executor-shared.js"; @@ -15,387 +15,443 @@ const RUN_ID = "terminal-send-race"; let tempDir = ""; beforeEach(() => { - tempDir = mkdtempSync(join(tmpdir(), "atomic-terminal-send-race-")); + tempDir = mkdtempSync(join(tmpdir(), "atomic-terminal-send-race-")); }); afterEach(() => { - stageControlRegistry.clear(); - store.removeRun(RUN_ID); - rmSync(tempDir, { recursive: true, force: true }); + stageControlRegistry.clear(); + store.removeRun(RUN_ID); + rmSync(tempDir, { recursive: true, force: true }); }); function retainedSession(): string { - const sessionFile = join(tempDir, "retained.jsonl"); - writeFileSync(sessionFile, [ - JSON.stringify({ type: "session", version: 3, id: "retained-session", timestamp: new Date().toISOString(), cwd: tempDir }), - JSON.stringify({ type: "message", id: "original", parentId: null, timestamp: new Date().toISOString(), message: { role: "user", content: "Original request" } }), - ].join("\n") + "\n"); - return sessionFile; + const sessionFile = join(tempDir, "retained.jsonl"); + writeFileSync( + sessionFile, + `${[ + JSON.stringify({ + type: "session", + version: 3, + id: "retained-session", + timestamp: new Date().toISOString(), + cwd: tempDir, + }), + JSON.stringify({ + type: "message", + id: "original", + parentId: null, + timestamp: new Date().toISOString(), + message: { role: "user", content: "Original request" }, + }), + ].join("\n")}\n`, + ); + return sessionFile; } test("terminal publication revokes a send awaiting retained-session creation", async () => { - const sessionFile = retainedSession(); - store.recordRunStart({ - id: RUN_ID, - name: "send-race", - inputs: {}, - status: "running", - stages: [{ - id: "stage-a", - name: "completed stage", - status: "completed", - parentIds: [], - toolEvents: [], - attachable: true, - sessionFile, - }], - startedAt: 1, - }); - const creationStarted = Promise.withResolvers(); - const releaseCreation = Promise.withResolvers(); - const sessionDisposed = Promise.withResolvers(); - const prompts: string[] = []; - let creates = 0; - let sdkMessageCalls = 0; - const sideEffectPath = join(tempDir, "unexpected-side-effect.txt"); - let disposes = 0; - const session: StageSessionRuntime = { - ...mockSession(), - sessionFile, - async prompt(text: string) { - prompts.push(text); - writeFileSync(sideEffectPath, "prompt side effect"); - }, - async sendUserMessage() { - sdkMessageCalls += 1; - writeFileSync(sideEffectPath, "SDK message side effect"); - }, - async dispose() { disposes += 1; sessionDisposed.resolve(); }, - }; - const adapters: StageAdapters = { - agentSession: { - async create() { - creates += 1; - creationStarted.resolve(); - await releaseCreation.promise; - return session; - }, - }, - }; - const resolvePostMortemDeps = (): PostMortemStageChatDeps => ({ - registry: stageControlRegistry, - adapters, - cwd: tempDir, - }); - const transcriptBefore = readFileSync(sessionFile); + const sessionFile = retainedSession(); + store.recordRunStart({ + id: RUN_ID, + name: "send-race", + inputs: {}, + status: "running", + stages: [ + { + id: "stage-a", + name: "completed stage", + status: "completed", + parentIds: [], + toolEvents: [], + attachable: true, + sessionFile, + }, + ], + startedAt: 1, + }); + const creationStarted = Promise.withResolvers(); + const releaseCreation = Promise.withResolvers(); + const sessionDisposed = Promise.withResolvers(); + const prompts: string[] = []; + let creates = 0; + let sdkMessageCalls = 0; + const sideEffectPath = join(tempDir, "unexpected-side-effect.txt"); + let disposes = 0; + const session: StageSessionRuntime = { + ...mockSession(), + sessionFile, + async prompt(text: string) { + prompts.push(text); + writeFileSync(sideEffectPath, "prompt side effect"); + }, + async sendUserMessage() { + sdkMessageCalls += 1; + writeFileSync(sideEffectPath, "SDK message side effect"); + }, + async dispose() { + disposes += 1; + sessionDisposed.resolve(); + }, + }; + const adapters: StageAdapters = { + agentSession: { + async create() { + creates += 1; + creationStarted.resolve(); + await releaseCreation.promise; + return session; + }, + }, + }; + const resolvePostMortemDeps = (): PostMortemStageChatDeps => ({ + registry: stageControlRegistry, + adapters, + cwd: tempDir, + }); + const transcriptBefore = readFileSync(sessionFile); - const send = workflowSendAction( - { runId: RUN_ID, stageId: "stage-a", text: "late work" }, - { resolvePostMortemDeps }, - ); - await creationStarted.promise; - const [capturedHandle] = stageControlRegistry.forRun(RUN_ID); - assert.ok(capturedHandle); - assert.equal(store.recordRunEnd(RUN_ID, "failed", undefined, "terminal during revival"), true); - const terminalSnapshot = store.snapshot(); - const result = await send; + const send = workflowSendAction({ runId: RUN_ID, stageId: "stage-a", text: "late work" }, { resolvePostMortemDeps }); + await creationStarted.promise; + const [capturedHandle] = stageControlRegistry.forRun(RUN_ID); + assert.ok(capturedHandle); + assert.equal(store.recordRunEnd(RUN_ID, "failed", undefined, "terminal during revival"), true); + const terminalSnapshot = store.snapshot(); + const result = await send; - assert.equal(result.status, "failed"); - if (result.status === "failed") { - assert.equal(result.code, "WORKFLOW_TERMINAL"); - assert.equal(result.workflowStatus, "failed"); - assert.equal(result.error, result.message); - } - assert.equal(result.runId, RUN_ID); - assert.match(result.message, new RegExp(`${RUN_ID}.*status failed`)); - assert.match(result.message, /start a new workflow/); - assert.match(result.message, /small, deterministic, and low risk/); - assert.doesNotMatch(result.message, /accepted|running|Prompt started|queued|sent|steered|resumed|answered/i); - assert.equal(result.delivery, "rejected"); - assert.equal(creates, 1); - assert.deepEqual(prompts, []); - assert.equal(sdkMessageCalls, 0); - assert.equal(disposes, 0); - assert.equal(capturedHandle.isDisposed, true); - assert.equal(stageControlRegistry.get(RUN_ID, "stage-a"), undefined); - releaseCreation.resolve(); - await sessionDisposed.promise; - assert.equal(disposes, 1); - assert.deepEqual(readFileSync(sessionFile), transcriptBefore); - assert.equal(existsSync(sideEffectPath), false); - assert.deepEqual(store.snapshot(), terminalSnapshot); + assert.equal(result.status, "failed"); + if (result.status === "failed") { + assert.equal(result.code, "WORKFLOW_TERMINAL"); + assert.equal(result.workflowStatus, "failed"); + assert.equal(result.error, result.message); + } + assert.equal(result.runId, RUN_ID); + assert.match(result.message, new RegExp(`${RUN_ID}.*status failed`)); + assert.match(result.message, /start a new workflow/); + assert.match(result.message, /small, deterministic, and low risk/); + assert.doesNotMatch(result.message, /accepted|running|Prompt started|queued|sent|steered|resumed|answered/i); + assert.equal(result.delivery, "rejected"); + assert.equal(creates, 1); + assert.deepEqual(prompts, []); + assert.equal(sdkMessageCalls, 0); + assert.equal(disposes, 0); + assert.equal(capturedHandle.isDisposed, true); + assert.equal(stageControlRegistry.get(RUN_ID, "stage-a"), undefined); + releaseCreation.resolve(); + await sessionDisposed.promise; + assert.equal(disposes, 1); + assert.deepEqual(readFileSync(sessionFile), transcriptBefore); + assert.equal(existsSync(sideEffectPath), false); + assert.deepEqual(store.snapshot(), terminalSnapshot); }); test("terminal publication does not revoke a turn already admitted to the SDK", async () => { - const sessionFile = retainedSession(); - store.recordRunStart({ - id: RUN_ID, - name: "send-admitted-race", - inputs: {}, - status: "running", - stages: [{ - id: "stage-a", - name: "completed stage", - status: "completed", - parentIds: [], - toolEvents: [], - attachable: true, - sessionFile, - }], - startedAt: 1, - }); - const promptStarted = Promise.withResolvers(); - const finishPrompt = Promise.withResolvers(); - const prompts: string[] = []; - let disposes = 0; - const session: StageSessionRuntime = { - ...mockSession(), - sessionFile, - async prompt(text: string) { - prompts.push(text); - promptStarted.resolve(); - await finishPrompt.promise; - }, - async dispose() { disposes += 1; }, - }; - const resolvePostMortemDeps = (): PostMortemStageChatDeps => ({ - registry: stageControlRegistry, - adapters: { agentSession: { async create() { return session; } } }, - cwd: tempDir, - }); + const sessionFile = retainedSession(); + store.recordRunStart({ + id: RUN_ID, + name: "send-admitted-race", + inputs: {}, + status: "running", + stages: [ + { + id: "stage-a", + name: "completed stage", + status: "completed", + parentIds: [], + toolEvents: [], + attachable: true, + sessionFile, + }, + ], + startedAt: 1, + }); + const promptStarted = Promise.withResolvers(); + const finishPrompt = Promise.withResolvers(); + const prompts: string[] = []; + let disposes = 0; + const session: StageSessionRuntime = { + ...mockSession(), + sessionFile, + async prompt(text: string) { + prompts.push(text); + promptStarted.resolve(); + await finishPrompt.promise; + }, + async dispose() { + disposes += 1; + }, + }; + const resolvePostMortemDeps = (): PostMortemStageChatDeps => ({ + registry: stageControlRegistry, + adapters: { + agentSession: { + async create() { + return session; + }, + }, + }, + cwd: tempDir, + }); - const send = workflowSendAction( - { runId: RUN_ID, stageId: "stage-a", text: "admitted work" }, - { resolvePostMortemDeps }, - ); - await promptStarted.promise; - assert.equal(store.recordRunEnd(RUN_ID, "failed", undefined, "terminal after admission"), true); - finishPrompt.resolve(); - const result = await send; + const send = workflowSendAction( + { runId: RUN_ID, stageId: "stage-a", text: "admitted work" }, + { resolvePostMortemDeps }, + ); + await promptStarted.promise; + assert.equal(store.recordRunEnd(RUN_ID, "failed", undefined, "terminal after admission"), true); + finishPrompt.resolve(); + const result = await send; - assert.equal(result.status, "ok"); - assert.equal(result.delivery, "prompt"); - assert.deepEqual(prompts, ["admitted work"]); - assert.equal(disposes, 0); - assert.ok(stageControlRegistry.get(RUN_ID, "stage-a")); - assert.equal(store.runs().find((run) => run.id === RUN_ID)?.status, "failed"); + assert.equal(result.status, "ok"); + assert.equal(result.delivery, "prompt"); + assert.deepEqual(prompts, ["admitted work"]); + assert.equal(disposes, 0); + assert.ok(stageControlRegistry.get(RUN_ID, "stage-a")); + assert.equal(store.runs().find((run) => run.id === RUN_ID)?.status, "failed"); }); test("terminal publication during awaited __resume revokes paused-root admission", async () => { - store.recordRunStart({ - id: RUN_ID, - name: "resume-race", - inputs: {}, - status: "paused", - stages: [{ - id: "stage-a", - name: "paused stage", - status: "paused", - parentIds: [], - toolEvents: [], - attachable: true, - }], - startedAt: 1, - }); - const resumeStarted = Promise.withResolvers(); - const continueResume = Promise.withResolvers(); - const resumeFinished = Promise.withResolvers(); - let resumeMutations = 0; - const runtime = { - runId: RUN_ID, - stageId: "stage-a", - name: "paused stage", - stageSnapshot: { status: "paused", sessionId: "paused-session" }, - state: { - liveHandleReleased: false, - waitingForStageChatTurn: false, - resumeContinuationPending: false, - }, - innerCtx: { - __sessionMeta: () => ({ sessionId: "paused-session", sessionFile: undefined }), - __isPaused: () => true, - subscribe: () => () => {}, - async __resume( - _message: string | undefined, - _beforeResolve: ((result: { releasedQueuedMessages: boolean; runnerOwnedDeliveryPending: boolean }) => void) | undefined, - beforeRelease: (() => void) | undefined, - ) { - resumeStarted.resolve(); - try { - await continueResume.promise; - beforeRelease?.(); - resumeMutations += 1; - return { releasedQueuedMessages: false, runnerOwnedDeliveryPending: false }; - } finally { - resumeFinished.resolve(); - } - }, - }, - activeStore: { - recordStageResumed() { resumeMutations += 1; return true; }, - recordRunResumed() { resumeMutations += 1; }, - }, - throwIfStageMutationBlocked() {}, - captureStageSessionMeta() {}, - async releaseLiveHandle() {}, - }; - stageControlRegistry.register(createStageControlHandle(runtime as never)); + store.recordRunStart({ + id: RUN_ID, + name: "resume-race", + inputs: {}, + status: "paused", + stages: [ + { + id: "stage-a", + name: "paused stage", + status: "paused", + parentIds: [], + toolEvents: [], + attachable: true, + }, + ], + startedAt: 1, + }); + const resumeStarted = Promise.withResolvers(); + const continueResume = Promise.withResolvers(); + const resumeFinished = Promise.withResolvers(); + let resumeMutations = 0; + const runtime = { + runId: RUN_ID, + stageId: "stage-a", + name: "paused stage", + stageSnapshot: { status: "paused", sessionId: "paused-session" }, + state: { + liveHandleReleased: false, + waitingForStageChatTurn: false, + resumeContinuationPending: false, + }, + innerCtx: { + __sessionMeta: () => ({ sessionId: "paused-session", sessionFile: undefined }), + __isPaused: () => true, + subscribe: () => () => {}, + async __resume( + _message: string | undefined, + _beforeResolve: + | ((result: { releasedQueuedMessages: boolean; runnerOwnedDeliveryPending: boolean }) => void) + | undefined, + beforeRelease: (() => void) | undefined, + ) { + resumeStarted.resolve(); + try { + await continueResume.promise; + beforeRelease?.(); + resumeMutations += 1; + return { releasedQueuedMessages: false, runnerOwnedDeliveryPending: false }; + } finally { + resumeFinished.resolve(); + } + }, + }, + activeStore: { + recordStageResumed() { + resumeMutations += 1; + return true; + }, + recordRunResumed() { + resumeMutations += 1; + }, + }, + throwIfStageMutationBlocked() {}, + captureStageSessionMeta() {}, + async releaseLiveHandle() {}, + }; + stageControlRegistry.register(createStageControlHandle(runtime as never)); - const send = workflowSendAction({ - runId: RUN_ID, - stageId: "stage-a", - text: "resume late", - delivery: "resume", - }); - await resumeStarted.promise; - assert.equal(store.recordRunEnd(RUN_ID, "failed", undefined, "terminal during resume"), true); - const terminalSnapshot = store.snapshot(); - const result = await send; + const send = workflowSendAction({ + runId: RUN_ID, + stageId: "stage-a", + text: "resume late", + delivery: "resume", + }); + await resumeStarted.promise; + assert.equal(store.recordRunEnd(RUN_ID, "failed", undefined, "terminal during resume"), true); + const terminalSnapshot = store.snapshot(); + const result = await send; - assert.equal(result.status, "failed"); - assert.equal(result.delivery, "rejected"); - assert.equal(resumeMutations, 0); - assert.deepEqual(store.snapshot(), terminalSnapshot); + assert.equal(result.status, "failed"); + assert.equal(result.delivery, "rejected"); + assert.equal(resumeMutations, 0); + assert.deepEqual(store.snapshot(), terminalSnapshot); - continueResume.resolve(); - await resumeFinished.promise; - assert.equal(resumeMutations, 0); - assert.deepEqual(store.snapshot(), terminalSnapshot); + continueResume.resolve(); + await resumeFinished.promise; + assert.equal(resumeMutations, 0); + assert.deepEqual(store.snapshot(), terminalSnapshot); }); test("a handle without admission-aware send never enters the legacy fallback", async () => { - store.recordRunStart({ - id: RUN_ID, - name: "legacy-fallback-race", - inputs: {}, - status: "running", - stages: [{ - id: "stage-a", - name: "legacy stage", - status: "running", - parentIds: [], - toolEvents: [], - attachable: true, - }], - startedAt: 1, - }); - const fallbackStarted = Promise.withResolvers(); - const neverFinishFallback = Promise.withResolvers(); - let fallbackMutations = 0; - stageControlRegistry.register({ - runId: RUN_ID, - stageId: "stage-a", - stageName: "legacy stage", - status: "running", - sessionId: "legacy-session", - sessionFile: undefined, - isStreaming: false, - messages: [], - async ensureAttached() {}, - async prompt() { - fallbackStarted.resolve(); - await neverFinishFallback.promise; - fallbackMutations += 1; - }, - async steer() { fallbackMutations += 1; }, - async followUp() { fallbackMutations += 1; }, - async pause() {}, - async resume() {}, - subscribe() { return () => {}; }, - }); + store.recordRunStart({ + id: RUN_ID, + name: "legacy-fallback-race", + inputs: {}, + status: "running", + stages: [ + { + id: "stage-a", + name: "legacy stage", + status: "running", + parentIds: [], + toolEvents: [], + attachable: true, + }, + ], + startedAt: 1, + }); + const fallbackStarted = Promise.withResolvers(); + const neverFinishFallback = Promise.withResolvers(); + let fallbackMutations = 0; + stageControlRegistry.register({ + runId: RUN_ID, + stageId: "stage-a", + stageName: "legacy stage", + status: "running", + sessionId: "legacy-session", + sessionFile: undefined, + isStreaming: false, + messages: [], + async ensureAttached() {}, + async prompt() { + fallbackStarted.resolve(); + await neverFinishFallback.promise; + fallbackMutations += 1; + }, + async steer() { + fallbackMutations += 1; + }, + async followUp() { + fallbackMutations += 1; + }, + async pause() {}, + async resume() { + return undefined; + }, + subscribe() { + return () => {}; + }, + }); - const send = workflowSendAction({ - runId: RUN_ID, - stageId: "stage-a", - text: "must not use an unsafe fallback", - }); - const first = await Promise.race([ - send.then((result) => ({ kind: "result" as const, result })), - fallbackStarted.promise.then(() => ({ kind: "fallback" as const })), - ]); + const send = workflowSendAction({ + runId: RUN_ID, + stageId: "stage-a", + text: "must not use an unsafe fallback", + }); + const first = await Promise.race([ + send.then((result) => ({ kind: "result" as const, result })), + fallbackStarted.promise.then(() => ({ kind: "fallback" as const })), + ]); - assert.equal(first.kind, "result"); - if (first.kind !== "result") return; - assert.equal(first.result.status, "noop"); - assert.match(first.result.message, /does not support admission-aware message delivery/); - assert.equal(fallbackMutations, 0); - assert.equal(store.recordRunEnd(RUN_ID, "failed", undefined, "terminal after rejection"), true); - assert.equal(fallbackMutations, 0); + assert.equal(first.kind, "result"); + if (first.kind !== "result") return; + assert.equal(first.result.status, "noop"); + assert.match(first.result.message, /does not support admission-aware message delivery/); + assert.equal(fallbackMutations, 0); + assert.equal(store.recordRunEnd(RUN_ID, "failed", undefined, "terminal after rejection"), true); + assert.equal(fallbackMutations, 0); }); test("replacement revokes provisional admission and settles its send lease", async () => { - store.recordRunStart({ - id: RUN_ID, - name: "replacement-race", - inputs: {}, - status: "running", - stages: [{ - id: "stage-a", - name: "replaceable stage", - status: "running", - parentIds: [], - toolEvents: [], - attachable: true, - }], - startedAt: 1, - }); - const deliverySetup = Promise.withResolvers(); - const continueDelivery = Promise.withResolvers(); - const disposeStarted = Promise.withResolvers(); - const finishDispose = Promise.withResolvers(); - let deliveryMutations = 0; - let disposes = 0; - const provisional = { - runId: RUN_ID, - stageId: "stage-a", - stageName: "replaceable stage", - status: "running" as const, - sessionId: "provisional-session", - sessionFile: undefined, - isStreaming: false, - messages: [], - async ensureAttached() {}, - async sendUserMessage(_text: string, _options: object | undefined, beforeDelivery?: () => void) { - deliverySetup.resolve(); - await continueDelivery.promise; - beforeDelivery?.(); - deliveryMutations += 1; - return "prompt" as const; - }, - async prompt() {}, - async steer() {}, - async followUp() {}, - async pause() {}, - async resume() {}, - subscribe() { return () => {}; }, - async dispose() { - disposes += 1; - disposeStarted.resolve(); - await finishDispose.promise; - }, - }; - const ownerLease = stageControlRegistry.acquireDetached(RUN_ID, "stage-a", () => provisional); - const send = workflowSendAction({ runId: RUN_ID, stageId: "stage-a", text: "racing message" }); - await deliverySetup.promise; - const replacement = { ...provisional, sessionId: "replacement-session", dispose: undefined }; + store.recordRunStart({ + id: RUN_ID, + name: "replacement-race", + inputs: {}, + status: "running", + stages: [ + { + id: "stage-a", + name: "replaceable stage", + status: "running", + parentIds: [], + toolEvents: [], + attachable: true, + }, + ], + startedAt: 1, + }); + const deliverySetup = Promise.withResolvers(); + const continueDelivery = Promise.withResolvers(); + const disposeStarted = Promise.withResolvers(); + const finishDispose = Promise.withResolvers(); + let deliveryMutations = 0; + let disposes = 0; + const provisional = { + runId: RUN_ID, + stageId: "stage-a", + stageName: "replaceable stage", + status: "running" as const, + sessionId: "provisional-session", + sessionFile: undefined, + isStreaming: false, + messages: [], + async ensureAttached() {}, + async sendUserMessage(_text: string, _options: object | undefined, beforeDelivery?: () => void) { + deliverySetup.resolve(); + await continueDelivery.promise; + beforeDelivery?.(); + deliveryMutations += 1; + return "prompt" as const; + }, + async prompt() { + return undefined; + }, + async steer() {}, + async followUp() {}, + async pause() {}, + async resume() { + return undefined; + }, + subscribe() { + return () => {}; + }, + async dispose() { + disposes += 1; + disposeStarted.resolve(); + await finishDispose.promise; + }, + }; + const ownerLease = stageControlRegistry.acquireDetached(RUN_ID, "stage-a", () => provisional); + const send = workflowSendAction({ runId: RUN_ID, stageId: "stage-a", text: "racing message" }); + await deliverySetup.promise; + const replacement = { ...provisional, sessionId: "replacement-session", dispose: undefined }; - stageControlRegistry.register(replacement); - await disposeStarted.promise; - continueDelivery.resolve(); - let sendSettled = false; - void send.then(() => { sendSettled = true; }); - await Promise.resolve(); + stageControlRegistry.register(replacement); + await disposeStarted.promise; + continueDelivery.resolve(); + let sendSettled = false; + void send.then(() => { + sendSettled = true; + }); + await Promise.resolve(); - assert.equal(deliveryMutations, 0); - assert.equal(sendSettled, false); - assert.equal(stageControlRegistry.get(RUN_ID, "stage-a"), replacement); - finishDispose.resolve(); - const result = await send; - await ownerLease.release(); + assert.equal(deliveryMutations, 0); + assert.equal(sendSettled, false); + assert.equal(stageControlRegistry.get(RUN_ID, "stage-a"), replacement); + finishDispose.resolve(); + const result = await send; + await ownerLease.release(); - assert.equal(result.status, "noop"); - assert.match(result.message, /Stage handle changed before message admission/); - assert.equal(deliveryMutations, 0); - assert.equal(disposes, 1); - assert.equal(stageControlRegistry.get(RUN_ID, "stage-a"), replacement); + assert.equal(result.status, "noop"); + assert.match(result.message, /Stage handle changed before message admission/); + assert.equal(deliveryMutations, 0); + assert.equal(disposes, 1); + assert.equal(stageControlRegistry.get(RUN_ID, "stage-a"), replacement); }); diff --git a/test/unit/workflow-tool-status-nodes.test.ts b/test/unit/workflow-tool-status-nodes.test.ts index bccf95174..384443300 100644 --- a/test/unit/workflow-tool-status-nodes.test.ts +++ b/test/unit/workflow-tool-status-nodes.test.ts @@ -1,7 +1,7 @@ -import { describe, test } from "bun:test"; import assert from "node:assert/strict"; -import { renderWorkflowToolContent } from "../../packages/workflows/src/extension/workflow-tool-content.js"; +import { describe, test } from "vitest"; import { summarizeRunSnapshot } from "../../packages/workflows/src/extension/workflow-status-summary.js"; +import { renderWorkflowToolContent } from "../../packages/workflows/src/extension/workflow-tool-content.js"; import { expandWorkflowGraph } from "../../packages/workflows/src/shared/expanded-workflow-graph.js"; import type { RunSnapshot, ToolNodeSnapshot, ToolNodeStatus } from "../../packages/workflows/src/shared/store-types.js"; import { deriveGraphTheme } from "../../packages/workflows/src/tui/graph-theme.js"; @@ -9,127 +9,152 @@ import { renderNodeCard } from "../../packages/workflows/src/tui/node-card.js"; import { renderRunDetail } from "../../packages/workflows/src/tui/run-detail.js"; function tool(status: ToolNodeStatus, index: number): ToolNodeSnapshot { - return { - kind: "tool", - id: `tool-${status}`, - name: `tool-${status}`, - argsHash: `hash-${status}`, - ordinal: index + 1, - parentIds: [], - status, - executionOrder: index + 1, - startedAt: 100 + index, - ...(status === "running" ? {} : { endedAt: 200 + index }), - ...(status === "cached" ? { replayed: true } : {}), - ...(status === "failed" ? { error: "publish rejected" } : { resultSummary: `result-${status}` }), - attachable: false, - }; + return { + kind: "tool", + id: `tool-${status}`, + name: `tool-${status}`, + argsHash: `hash-${status}`, + ordinal: index + 1, + parentIds: [], + status, + executionOrder: index + 1, + startedAt: 100 + index, + ...(status === "running" ? {} : { endedAt: 200 + index }), + ...(status === "cached" ? { replayed: true } : {}), + ...(status === "failed" ? { error: "publish rejected" } : { resultSummary: `result-${status}` }), + attachable: false, + }; } function toolRun(): RunSnapshot { - const statuses: ToolNodeStatus[] = ["running", "completed", "failed", "cached", "cancelled"]; - return { - id: "tool-status-run", - name: "tool status workflow", - inputs: {}, - status: "running", - stages: [], - toolNodes: statuses.map(tool), - startedAt: 100, - }; + const statuses: ToolNodeStatus[] = ["running", "completed", "failed", "cached", "cancelled"]; + return { + id: "tool-status-run", + name: "tool status workflow", + inputs: {}, + status: "running", + stages: [], + toolNodes: statuses.map(tool), + startedAt: 100, + }; } describe("workflow tool status nodes", () => { - test("targeted run detail renders tool names and every visible state", () => { - const run = toolRun(); - const text = renderRunDetail({ - runId: run.id, - name: run.name, - status: run.status, - mode: "single", - startedAt: run.startedAt, - inputs: run.inputs, - stages: [], - tools: run.toolNodes ?? [], - }, { now: 300, width: 100 }); - - assert.match(text, /TOOLS/); - for (const status of ["running", "completed", "failed", "cached", "cancelled"]) { - assert.match(text, new RegExp(`tool-${status}.*${status}`)); - } - assert.doesNotMatch(text, /no stages recorded yet/); - }); + test("targeted run detail renders tool names and every visible state", () => { + const run = toolRun(); + const text = renderRunDetail( + { + runId: run.id, + name: run.name, + status: run.status, + mode: "single", + startedAt: run.startedAt, + inputs: run.inputs, + stages: [], + tools: run.toolNodes ?? [], + }, + { now: 300, width: 100 }, + ); - test("no-target status includes additive tool metadata in text and JSON", () => { - const run = toolRun(); - const summary = summarizeRunSnapshot(run, 300); - const result = { action: "status" as const, filter: "all" as const, runs: [summary], snapshots: [run] }; + assert.match(text, /TOOLS/); + for (const status of ["running", "completed", "failed", "cached", "cancelled"]) { + assert.match(text, new RegExp(`tool-${status}.*${status}`)); + } + assert.doesNotMatch(text, /no stages recorded yet/); + }); - assert.deepEqual(summary.tools?.map(({ id, name, status, attachable }) => ({ id, name, status, attachable })), - (run.toolNodes ?? []).map(({ id, name, status, attachable }) => ({ id, name, status, attachable }))); - assert.deepEqual(summary.tools?.map(({ runId, runName, depth }) => ({ runId, runName, depth })), - (run.toolNodes ?? []).map(() => ({ runId: run.id, runName: run.name, depth: 0 }))); - const text = renderWorkflowToolContent(result, { action: "status" }); - for (const status of ["running", "completed", "failed", "cached", "cancelled"]) { - assert.match(text, new RegExp(`tool-${status} \\(${status}\\)`)); - } - const json = JSON.parse(renderWorkflowToolContent(result, { action: "status", format: "json" })) as { - runs: Array<{ tools: Array<{ name: string; status: string }> }>; - }; - assert.deepEqual(json.runs[0]?.tools.map(({ name, status }) => ({ name, status })), - (run.toolNodes ?? []).map(({ name, status }) => ({ name, status }))); - }); + test("no-target status includes additive tool metadata in text and JSON", () => { + const run = toolRun(); + const summary = summarizeRunSnapshot(run, 300); + const result = { action: "status" as const, filter: "all" as const, runs: [summary], snapshots: [run] }; + assert.deepEqual( + summary.tools?.map(({ id, name, status, attachable }) => ({ id, name, status, attachable })), + (run.toolNodes ?? []).map(({ id, name, status, attachable }) => ({ id, name, status, attachable })), + ); + assert.deepEqual( + summary.tools?.map(({ runId, runName, depth }) => ({ runId, runName, depth })), + (run.toolNodes ?? []).map(() => ({ runId: run.id, runName: run.name, depth: 0 })), + ); + const text = renderWorkflowToolContent(result, { action: "status" }); + for (const status of ["running", "completed", "failed", "cached", "cancelled"]) { + assert.match(text, new RegExp(`tool-${status} \\(${status}\\)`)); + } + const json = JSON.parse(renderWorkflowToolContent(result, { action: "status", format: "json" })) as { + runs: Array<{ tools: Array<{ name: string; status: string }> }>; + }; + assert.deepEqual( + json.runs[0]?.tools.map(({ name, status }) => ({ name, status })), + (run.toolNodes ?? []).map(({ name, status }) => ({ name, status })), + ); + }); - test("mixed compact status keeps the stage hint and ordered tools", () => { - const run: RunSnapshot = { - id: "mixed-status-run", name: "mixed status", inputs: {}, status: "running", startedAt: 100, - stages: [{ id: "stage-1", name: "model-stage", status: "running", parentIds: [], toolEvents: [] }], - toolNodes: [ - { ...tool("completed", 0), id: "prepare", name: "prepare" }, - { ...tool("running", 1), id: "publish", name: "publish-api" }, - ], - }; - const summary = summarizeRunSnapshot(run, 300); - const result = { action: "status" as const, filter: "all" as const, runs: [summary], snapshots: [run] }; + test("mixed compact status keeps the stage hint and ordered tools", () => { + const run: RunSnapshot = { + id: "mixed-status-run", + name: "mixed status", + inputs: {}, + status: "running", + startedAt: 100, + stages: [{ id: "stage-1", name: "model-stage", status: "running", parentIds: [], toolEvents: [] }], + toolNodes: [ + { ...tool("completed", 0), id: "prepare", name: "prepare" }, + { ...tool("running", 1), id: "publish", name: "publish-api" }, + ], + }; + const summary = summarizeRunSnapshot(run, 300); + const result = { action: "status" as const, filter: "all" as const, runs: [summary], snapshots: [run] }; - const text = renderWorkflowToolContent(result, { action: "status" }); - assert.match(text, /stage: model-stage · tools: prepare \(completed\), publish-api \(running\)/); - const json = JSON.parse(renderWorkflowToolContent(result, { action: "status", format: "json" })) as { - runs: Array<{ tools: Array<{ name: string; status: string }> }>; - }; - assert.deepEqual(json.runs[0]?.tools.map(({ name, status }) => ({ name, status })), [ - { name: "prepare", status: "completed" }, - { name: "publish-api", status: "running" }, - ]); - const paused = summarizeRunSnapshot({ ...run, status: "paused", stages: [] }, 300); - const pausedText = renderWorkflowToolContent({ ...result, runs: [paused] }, { action: "status" }); - assert.match(pausedText, /awaiting resume · tools: prepare \(completed\), publish-api \(running\)/); - const awaiting = summarizeRunSnapshot({ - ...run, - pendingPrompt: { id: "prompt-1", kind: "input", message: "approve?", createdAt: 200 }, - }, 300); - const awaitingText = renderWorkflowToolContent({ ...result, runs: [awaiting] }, { action: "status" }); - assert.match(awaitingText, /awaiting input \(1\) · tools: prepare \(completed\), publish-api \(running\)/); - const stageOnly = summarizeRunSnapshot({ ...run, toolNodes: [] }, 300); - const stageOnlyLine = renderWorkflowToolContent({ ...result, runs: [stageOnly] }, { action: "status" }) - .split("\n").find((line) => line.startsWith("[1]")); - assert.equal(stageOnlyLine?.endsWith("stage: model-stage"), true); - const toolOnlyRun = { ...run, stages: [], toolNodes: [run.toolNodes![1]!] }; - const toolOnly = summarizeRunSnapshot(toolOnlyRun, 300); - const toolOnlyLine = renderWorkflowToolContent({ ...result, runs: [toolOnly], snapshots: [toolOnlyRun] }, { action: "status" }) - .split("\n").find((line) => line.startsWith("[1]")); - assert.equal(toolOnlyLine?.endsWith("tools: publish-api (running)"), true); - }); - test("tool cards render terminal state labels", () => { - const run = toolRun(); - const graph = expandWorkflowGraph({ runs: [run], notices: [], version: 1 }, run.id); - const theme = deriveGraphTheme({}); - for (const status of ["completed", "failed", "cached", "cancelled"] as const) { - const card = graph.renderStages.find((stage) => stage.toolStatus === status); - assert.ok(card !== undefined); - const rendered = renderNodeCard(card, { theme }).join("\n").replace(/\x1b\[[0-9;]*m/g, ""); - assert.match(rendered, new RegExp(status)); - } - }); + const text = renderWorkflowToolContent(result, { action: "status" }); + assert.match(text, /stage: model-stage · tools: prepare \(completed\), publish-api \(running\)/); + const json = JSON.parse(renderWorkflowToolContent(result, { action: "status", format: "json" })) as { + runs: Array<{ tools: Array<{ name: string; status: string }> }>; + }; + assert.deepEqual( + json.runs[0]?.tools.map(({ name, status }) => ({ name, status })), + [ + { name: "prepare", status: "completed" }, + { name: "publish-api", status: "running" }, + ], + ); + const paused = summarizeRunSnapshot({ ...run, status: "paused", stages: [] }, 300); + const pausedText = renderWorkflowToolContent({ ...result, runs: [paused] }, { action: "status" }); + assert.match(pausedText, /awaiting resume · tools: prepare \(completed\), publish-api \(running\)/); + const awaiting = summarizeRunSnapshot( + { + ...run, + pendingPrompt: { id: "prompt-1", kind: "input", message: "approve?", createdAt: 200 }, + }, + 300, + ); + const awaitingText = renderWorkflowToolContent({ ...result, runs: [awaiting] }, { action: "status" }); + assert.match(awaitingText, /awaiting input \(1\) · tools: prepare \(completed\), publish-api \(running\)/); + const stageOnly = summarizeRunSnapshot({ ...run, toolNodes: [] }, 300); + const stageOnlyLine = renderWorkflowToolContent({ ...result, runs: [stageOnly] }, { action: "status" }) + .split("\n") + .find((line) => line.startsWith("[1]")); + assert.equal(stageOnlyLine?.endsWith("stage: model-stage"), true); + const toolOnlyRun = { ...run, stages: [], toolNodes: [run.toolNodes![1]!] }; + const toolOnly = summarizeRunSnapshot(toolOnlyRun, 300); + const toolOnlyLine = renderWorkflowToolContent( + { ...result, runs: [toolOnly], snapshots: [toolOnlyRun] }, + { action: "status" }, + ) + .split("\n") + .find((line) => line.startsWith("[1]")); + assert.equal(toolOnlyLine?.endsWith("tools: publish-api (running)"), true); + }); + test("tool cards render terminal state labels", () => { + const run = toolRun(); + const graph = expandWorkflowGraph({ runs: [run], notices: [], version: 1 }, run.id); + const theme = deriveGraphTheme({}); + for (const status of ["completed", "failed", "cached", "cancelled"] as const) { + const card = graph.renderStages.find((stage) => stage.toolStatus === status); + assert.ok(card !== undefined); + const rendered = renderNodeCard(card, { theme }) + .join("\n") + .replace(/\x1b\[[0-9;]*m/g, ""); + assert.match(rendered, new RegExp(status)); + } + }); }); diff --git a/test/unit/workflows-authored-auto-group.test.ts b/test/unit/workflows-authored-auto-group.test.ts index d74027ce7..89fcc4d8e 100644 --- a/test/unit/workflows-authored-auto-group.test.ts +++ b/test/unit/workflows-authored-auto-group.test.ts @@ -1,66 +1,76 @@ -import { test } from "bun:test"; import assert from "node:assert/strict"; +import { test } from "vitest"; import { createStore, mockSession, run, workflow } from "./executor-shared.js"; type Group = string | true; -interface AuthoredSet { group?: Group; itemGroups?: readonly [Group, Group] } +interface AuthoredSet { + group?: Group; + itemGroups?: readonly [Group, Group]; +} function assertSharedUuid(groups: readonly string[]): void { - assert.equal(groups.length, 2); - assert.equal(groups[0], groups[1]); - assert.match(groups[0]!, /^[0-9a-f]{8}-[0-9a-f-]{27}$/i); + assert.equal(groups.length, 2); + assert.equal(groups[0], groups[1]); + assert.match(groups[0]!, /^[0-9a-f]{8}-[0-9a-f-]{27}$/i); } async function runAuthoredSets(sets: readonly AuthoredSet[]): Promise { - const groups: string[] = []; - const definition = workflow({ - name: "authored-auto-group", - description: "", - inputs: {}, - outputs: {}, - run: async (ctx) => { - for (const set of sets) { - await ctx.parallel([ - { name: "reviewer-a", task: "review A", ...(set.itemGroups ? { group: set.itemGroups[0] } : {}) }, - { name: "reviewer-b", task: "review B", ...(set.itemGroups ? { group: set.itemGroups[1] } : {}) }, - ], set.group === undefined ? {} : { group: set.group }); - } - return {}; - }, - }); - const result = await run(definition, {}, { - store: createStore(), - adapters: { - agentSession: { - async create(options) { - groups.push(options.orchestrationContext?.intercomGroup ?? "missing"); - return mockSession(); - }, - }, - }, - }); - assert.equal(result.status, "completed"); - return groups; + const groups: string[] = []; + const definition = workflow({ + name: "authored-auto-group", + description: "", + inputs: {}, + outputs: {}, + run: async (ctx) => { + for (const set of sets) { + await ctx.parallel( + [ + { name: "reviewer-a", task: "review A", ...(set.itemGroups ? { group: set.itemGroups[0] } : {}) }, + { name: "reviewer-b", task: "review B", ...(set.itemGroups ? { group: set.itemGroups[1] } : {}) }, + ], + set.group === undefined ? {} : { group: set.group }, + ); + } + return {}; + }, + }); + const result = await run( + definition, + {}, + { + store: createStore(), + adapters: { + agentSession: { + async create(options) { + groups.push(options.orchestrationContext?.intercomGroup ?? "missing"); + return mockSession(); + }, + }, + }, + }, + ); + assert.equal(result.status, "completed"); + return groups; } test("authored ctx.parallel normalizes a string auto group into one shared UUID", async () => { - assertSharedUuid(await runAuthoredSets([{ group: " AuTo " }])); + assertSharedUuid(await runAuthoredSets([{ group: " AuTo " }])); }); test("separate authored string-auto parallel sets receive different UUIDs", async () => { - const groups = await runAuthoredSets([{ group: "true" }, { group: " AUTO " }]); - assertSharedUuid(groups.slice(0, 2)); - assertSharedUuid(groups.slice(2, 4)); - assert.notEqual(groups[0], groups[2]); + const groups = await runAuthoredSets([{ group: "true" }, { group: " AUTO " }]); + assertSharedUuid(groups.slice(0, 2)); + assertSharedUuid(groups.slice(2, 4)); + assert.notEqual(groups[0], groups[2]); }); test("authored boolean true remains auto while a named group remains literal", async () => { - const booleanGroups = await runAuthoredSets([{ group: true }]); - assertSharedUuid(booleanGroups); - const namedGroups = await runAuthoredSets([{ group: "reviewers" }]); - assert.deepEqual(namedGroups, ["reviewers", "reviewers"]); + const booleanGroups = await runAuthoredSets([{ group: true }]); + assertSharedUuid(booleanGroups); + const namedGroups = await runAuthoredSets([{ group: "reviewers" }]); + assert.deepEqual(namedGroups, ["reviewers", "reviewers"]); }); test("authored per-step string sentinels normalize before shared UUID minting", async () => { - assertSharedUuid(await runAuthoredSets([{ itemGroups: [" TrUe ", " aUtO "] }])); + assertSharedUuid(await runAuthoredSets([{ itemGroups: [" TrUe ", " aUtO "] }])); }); diff --git a/test/unit/workflows-intercom-group.test.ts b/test/unit/workflows-intercom-group.test.ts index a498b25b5..b204113e5 100644 --- a/test/unit/workflows-intercom-group.test.ts +++ b/test/unit/workflows-intercom-group.test.ts @@ -1,46 +1,46 @@ -import { test } from "bun:test"; import assert from "node:assert/strict"; +import { test } from "vitest"; import { - DEFAULT_INTERCOM_GROUP, - normalizeGroup, - resolveStageGroup, - stageHasIntercomAccess, - workflowInvocationIntercomGroup, + DEFAULT_INTERCOM_GROUP, + normalizeGroup, + resolveStageGroup, + stageHasIntercomAccess, + workflowInvocationIntercomGroup, } from "../../packages/workflows/src/shared/intercom-group.js"; import type { StageOptions } from "../../packages/workflows/src/shared/types.js"; test("normalizeGroup collapses empties to the default group", () => { - assert.equal(normalizeGroup(), DEFAULT_INTERCOM_GROUP); - assert.equal(normalizeGroup(""), DEFAULT_INTERCOM_GROUP); - assert.equal(normalizeGroup(" x "), "x"); + assert.equal(normalizeGroup(), DEFAULT_INTERCOM_GROUP); + assert.equal(normalizeGroup(""), DEFAULT_INTERCOM_GROUP); + assert.equal(normalizeGroup(" x "), "x"); }); test("workflow invocation groups are stable, namespaced, and non-default", () => { - const group = workflowInvocationIntercomGroup("root-run-id"); - assert.equal(group, "workflow:root-run-id"); - assert.equal(workflowInvocationIntercomGroup("root-run-id"), group); - assert.notEqual(group, DEFAULT_INTERCOM_GROUP); + const group = workflowInvocationIntercomGroup("root-run-id"); + assert.equal(group, "workflow:root-run-id"); + assert.equal(workflowInvocationIntercomGroup("root-run-id"), group); + assert.notEqual(group, DEFAULT_INTERCOM_GROUP); }); test("resolveStageGroup preserves explicit precedence over a workflow group", () => { - assert.equal(resolveStageGroup(undefined), undefined); - assert.equal(resolveStageGroup({}, "workflow:root"), "workflow:root"); - assert.equal(resolveStageGroup({ group: " reviewers " }, "workflow:root"), "reviewers"); - assert.equal(resolveStageGroup({ group: "default" }, "workflow:root"), "default"); - assert.equal(resolveStageGroup({ group: "" }, "workflow:root"), undefined); + assert.equal(resolveStageGroup(undefined), undefined); + assert.equal(resolveStageGroup({}, "workflow:root"), "workflow:root"); + assert.equal(resolveStageGroup({ group: " reviewers " }, "workflow:root"), "reviewers"); + assert.equal(resolveStageGroup({ group: "default" }, "workflow:root"), "default"); + assert.equal(resolveStageGroup({ group: "" }, "workflow:root"), undefined); - const a = resolveStageGroup({ group: true }); - const b = resolveStageGroup({ group: true }); - assert.match(a ?? "", /^[0-9a-f-]{36}$/); - assert.notEqual(a, b, "each single-stage true mints its own uuid"); + const a = resolveStageGroup({ group: true }); + const b = resolveStageGroup({ group: true }); + assert.match(a ?? "", /^[0-9a-f-]{36}$/); + assert.notEqual(a, b, "each single-stage true mints its own uuid"); }); test("stageHasIntercomAccess gates on noTools / tools allowlist / excludedTools", () => { - assert.equal(stageHasIntercomAccess(undefined), true); - assert.equal(stageHasIntercomAccess({} as StageOptions), true); - assert.equal(stageHasIntercomAccess({ noTools: "all" } as StageOptions), false); - assert.equal(stageHasIntercomAccess({ noTools: "builtin" } as StageOptions), true); - assert.equal(stageHasIntercomAccess({ tools: ["bash", "read"] } as StageOptions), false); - assert.equal(stageHasIntercomAccess({ tools: ["bash", "intercom"] } as StageOptions), true); - assert.equal(stageHasIntercomAccess({ excludedTools: ["intercom"] } as StageOptions), false); + assert.equal(stageHasIntercomAccess(undefined), true); + assert.equal(stageHasIntercomAccess({} as StageOptions), true); + assert.equal(stageHasIntercomAccess({ noTools: "all" } as StageOptions), false); + assert.equal(stageHasIntercomAccess({ noTools: "builtin" } as StageOptions), true); + assert.equal(stageHasIntercomAccess({ tools: ["bash", "read"] } as StageOptions), false); + assert.equal(stageHasIntercomAccess({ tools: ["bash", "intercom"] } as StageOptions), true); + assert.equal(stageHasIntercomAccess({ excludedTools: ["intercom"] } as StageOptions), false); }); diff --git a/test/unit/worktree-git.test.ts b/test/unit/worktree-git.test.ts index 4d73346a5..558f80ecc 100644 --- a/test/unit/worktree-git.test.ts +++ b/test/unit/worktree-git.test.ts @@ -1,343 +1,372 @@ -import { describe, test } from "bun:test"; import assert from "node:assert/strict"; -import { mkdirSync, mkdtempSync, readFileSync, realpathSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs"; +import { + mkdirSync, + mkdtempSync, + readFileSync, + realpathSync, + renameSync, + rmSync, + statSync, + writeFileSync, +} from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { describe, test } from "vitest"; import { - createGitWorktreeSetupCache, - gitFailureMessage, - runGitChecked, - setupGitWorktree, - withGitRunnerForTest, - type GitRunner, -} from "../../packages/workflows/src/runs/shared/worktree-git.js"; -import type { GitResult } from "../../packages/workflows/src/runs/shared/worktree-types.js"; + findCanonicalGitRoot as findSubagentCanonicalGitRoot, + findGitRoot as findSubagentGitRoot, + resolveMainRepoRoot as resolveSubagentMainRepoRoot, +} from "../../packages/subagents/src/runs/shared/worktree-root.js"; import { createGitWorktreeSetupCacheOwner } from "../../packages/workflows/src/runs/shared/worktree-cache-lifecycle.js"; -import { findCanonicalGitRoot, findGitRoot, resolveMainRepoRoot } from "../../packages/workflows/src/runs/shared/worktree-root.js"; import { - findCanonicalGitRoot as findSubagentCanonicalGitRoot, - findGitRoot as findSubagentGitRoot, - resolveMainRepoRoot as resolveSubagentMainRepoRoot, -} from "../../packages/subagents/src/runs/shared/worktree-root.js"; + createGitWorktreeSetupCache, + type GitRunner, + gitFailureMessage, + runGitChecked, + setupGitWorktree, + withGitRunnerForTest, +} from "../../packages/workflows/src/runs/shared/worktree-git.js"; +import { + findCanonicalGitRoot, + findGitRoot, + resolveMainRepoRoot, +} from "../../packages/workflows/src/runs/shared/worktree-root.js"; +import type { GitResult } from "../../packages/workflows/src/runs/shared/worktree-types.js"; function successfulGit(stdout = ""): GitResult { - return { stdout, stderr: "", status: 0, signal: null, elapsedMs: 1 }; + return { stdout, stderr: "", status: 0, signal: null, elapsedMs: 1 }; } function timedOutGit(): GitResult { - const error = Object.assign(new Error("spawnSync git ETIMEDOUT"), { code: "ETIMEDOUT" }); - return { stdout: "", stderr: "", status: null, signal: null, elapsedMs: 60_001, error }; + const error = Object.assign(new Error("spawnSync git ETIMEDOUT"), { code: "ETIMEDOUT" }); + return { stdout: "", stderr: "", status: null, signal: null, elapsedMs: 60_001, error }; } function failingGit(args: readonly string[]): GitResult { - return { stdout: "", stderr: `unexpected fake git call: ${args.join(" ")}`, status: 1, signal: null, elapsedMs: 1 }; + return { stdout: "", stderr: `unexpected fake git call: ${args.join(" ")}`, status: 1, signal: null, elapsedMs: 1 }; } function createRepoShape(): { readonly root: string; readonly repo: string; readonly sourceCwd: string } { - const root = realpathSync.native(mkdtempSync(join(tmpdir(), "atomic-worktree-git-test-"))); - const repo = join(root, "repo"); - const sourceCwd = join(repo, "packages", "api"); - mkdirSync(sourceCwd, { recursive: true }); - mkdirSync(join(repo, ".git")); - return { root, repo, sourceCwd }; + const root = realpathSync.native(mkdtempSync(join(tmpdir(), "atomic-worktree-git-test-"))); + const repo = join(root, "repo"); + const sourceCwd = join(repo, "packages", "api"); + mkdirSync(sourceCwd, { recursive: true }); + mkdirSync(join(repo, ".git")); + return { root, repo, sourceCwd }; } function createGitRepository(): { readonly root: string; readonly repo: string; readonly worktree: string } { - const root = realpathSync.native(mkdtempSync(join(tmpdir(), "atomic-worktree-generation-test-"))); - const repo = join(root, "repo"); - const worktree = join(root, "worktree"); - mkdirSync(repo); - runGitChecked(repo, ["init", "-b", "main"]); - writeFileSync(join(repo, "tracked.txt"), "primary\n"); - runGitChecked(repo, ["add", "."]); - runGitChecked(repo, [ - "-c", "user.name=Atomic Test", "-c", "user.email=atomic-test@example.com", - "commit", "--no-gpg-sign", "-m", "initial", - ]); - return { root, repo, worktree }; + const root = realpathSync.native(mkdtempSync(join(tmpdir(), "atomic-worktree-generation-test-"))); + const repo = join(root, "repo"); + const worktree = join(root, "worktree"); + mkdirSync(repo); + runGitChecked(repo, ["init", "-b", "main"]); + writeFileSync(join(repo, "tracked.txt"), "primary\n"); + runGitChecked(repo, ["add", "."]); + runGitChecked(repo, [ + "-c", + "user.name=Atomic Test", + "-c", + "user.email=atomic-test@example.com", + "commit", + "--no-gpg-sign", + "-m", + "initial", + ]); + return { root, repo, worktree }; } function isArgs(args: readonly string[], expected: readonly string[]): boolean { - return args.length === expected.length && args.every((value, index) => value === expected[index]); + return args.length === expected.length && args.every((value, index) => value === expected[index]); } describe("canonical main Git root parser", () => { - test("finds a main checkout and handles cwd-is-a-file", () => { - const { root, repo } = createGitRepository(); - const file = join(repo, "tracked.txt"); - try { - assert.equal(findGitRoot(file), repo); - assert.equal(resolveMainRepoRoot(repo), repo); - assert.equal(findCanonicalGitRoot(file), repo); - } finally { rmSync(root, { recursive: true, force: true }); } - }); + test("finds a main checkout and handles cwd-is-a-file", () => { + const { root, repo } = createGitRepository(); + const file = join(repo, "tracked.txt"); + try { + assert.equal(findGitRoot(file), repo); + assert.equal(resolveMainRepoRoot(repo), repo); + assert.equal(findCanonicalGitRoot(file), repo); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); - test("resolves a linked pointer and nested cwd without invoking Git", () => { - const { root, repo, worktree } = createGitRepository(); - try { - runGitChecked(repo, ["worktree", "add", "--detach", worktree]); - const nested = join(worktree, "nested", "deeper"); - mkdirSync(nested, { recursive: true }); - assert.equal(findGitRoot(nested), worktree); - assert.equal(resolveMainRepoRoot(worktree), repo); - assert.equal(findCanonicalGitRoot(nested), repo); - } finally { rmSync(root, { recursive: true, force: true }); } - }); + test("resolves a linked pointer and nested cwd without invoking Git", () => { + const { root, repo, worktree } = createGitRepository(); + try { + runGitChecked(repo, ["worktree", "add", "--detach", worktree]); + const nested = join(worktree, "nested", "deeper"); + mkdirSync(nested, { recursive: true }); + assert.equal(findGitRoot(nested), worktree); + assert.equal(resolveMainRepoRoot(worktree), repo); + assert.equal(findCanonicalGitRoot(nested), repo); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); - test("rejects missing-prefix, empty, and non-worktrees pointers", () => { - const root = realpathSync.native(mkdtempSync(join(tmpdir(), "atomic-worktree-pointer-test-"))); - const checkout = join(root, "checkout"); - mkdirSync(checkout); - try { - writeFileSync(join(checkout, ".git"), "not-gitdir: ../foreign\n"); - assert.equal(resolveMainRepoRoot(checkout), undefined); - writeFileSync(join(checkout, ".git"), "gitdir: \n"); - assert.equal(resolveMainRepoRoot(checkout), undefined); - writeFileSync(join(checkout, ".git"), "gitdir: ../foreign/admin\n"); - assert.equal(resolveMainRepoRoot(checkout), undefined); - } finally { rmSync(root, { recursive: true, force: true }); } - }); + test("rejects missing-prefix, empty, and non-worktrees pointers", () => { + const root = realpathSync.native(mkdtempSync(join(tmpdir(), "atomic-worktree-pointer-test-"))); + const checkout = join(root, "checkout"); + mkdirSync(checkout); + try { + writeFileSync(join(checkout, ".git"), "not-gitdir: ../foreign\n"); + assert.equal(resolveMainRepoRoot(checkout), undefined); + writeFileSync(join(checkout, ".git"), "gitdir: \n"); + assert.equal(resolveMainRepoRoot(checkout), undefined); + writeFileSync(join(checkout, ".git"), "gitdir: ../foreign/admin\n"); + assert.equal(resolveMainRepoRoot(checkout), undefined); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); - test("rejects a pointer escaping to a foreign repository worktree", () => { - const first = createGitRepository(); - const foreign = createGitRepository(); - const fake = join(first.root, "fake-linked"); - mkdirSync(fake); - try { - runGitChecked(foreign.repo, ["worktree", "add", "--detach", foreign.worktree]); - writeFileSync(join(fake, ".git"), readFileSync(join(foreign.worktree, ".git"))); - assert.equal(resolveMainRepoRoot(fake), undefined); - } finally { - rmSync(first.root, { recursive: true, force: true }); - rmSync(foreign.root, { recursive: true, force: true }); - } - }); + test("rejects a pointer escaping to a foreign repository worktree", () => { + const first = createGitRepository(); + const foreign = createGitRepository(); + const fake = join(first.root, "fake-linked"); + mkdirSync(fake); + try { + runGitChecked(foreign.repo, ["worktree", "add", "--detach", foreign.worktree]); + writeFileSync(join(fake, ".git"), readFileSync(join(foreign.worktree, ".git"))); + assert.equal(resolveMainRepoRoot(fake), undefined); + } finally { + rmSync(first.root, { recursive: true, force: true }); + rmSync(foreign.root, { recursive: true, force: true }); + } + }); - test("subagent parser mirrors canonical-root edge cases", () => { - const { root, repo, worktree } = createGitRepository(); - const malformed = join(root, "malformed"); - const foreign = createGitRepository(); - mkdirSync(malformed); - try { - assert.equal(findSubagentGitRoot(join(repo, "tracked.txt")), repo); - assert.equal(resolveSubagentMainRepoRoot(repo), repo); - runGitChecked(repo, ["worktree", "add", "--detach", worktree]); - const nested = join(worktree, "nested", "deeper"); - mkdirSync(nested, { recursive: true }); - assert.equal(findSubagentCanonicalGitRoot(nested), repo); + test("subagent parser mirrors canonical-root edge cases", () => { + const { root, repo, worktree } = createGitRepository(); + const malformed = join(root, "malformed"); + const foreign = createGitRepository(); + mkdirSync(malformed); + try { + assert.equal(findSubagentGitRoot(join(repo, "tracked.txt")), repo); + assert.equal(resolveSubagentMainRepoRoot(repo), repo); + runGitChecked(repo, ["worktree", "add", "--detach", worktree]); + const nested = join(worktree, "nested", "deeper"); + mkdirSync(nested, { recursive: true }); + assert.equal(findSubagentCanonicalGitRoot(nested), repo); - writeFileSync(join(malformed, ".git"), "missing-prefix: ../foreign\n"); - assert.equal(resolveSubagentMainRepoRoot(malformed), undefined); - writeFileSync(join(malformed, ".git"), "gitdir: \n"); - assert.equal(resolveSubagentMainRepoRoot(malformed), undefined); - writeFileSync(join(malformed, ".git"), "gitdir: ../foreign/admin\n"); - assert.equal(resolveSubagentMainRepoRoot(malformed), undefined); + writeFileSync(join(malformed, ".git"), "missing-prefix: ../foreign\n"); + assert.equal(resolveSubagentMainRepoRoot(malformed), undefined); + writeFileSync(join(malformed, ".git"), "gitdir: \n"); + assert.equal(resolveSubagentMainRepoRoot(malformed), undefined); + writeFileSync(join(malformed, ".git"), "gitdir: ../foreign/admin\n"); + assert.equal(resolveSubagentMainRepoRoot(malformed), undefined); - runGitChecked(foreign.repo, ["worktree", "add", "--detach", foreign.worktree]); - writeFileSync(join(malformed, ".git"), readFileSync(join(foreign.worktree, ".git"))); - assert.equal(resolveSubagentMainRepoRoot(malformed), undefined); - } finally { - rmSync(root, { recursive: true, force: true }); - rmSync(foreign.root, { recursive: true, force: true }); - } - }); + runGitChecked(foreign.repo, ["worktree", "add", "--detach", foreign.worktree]); + writeFileSync(join(malformed, ".git"), readFileSync(join(foreign.worktree, ".git"))); + assert.equal(resolveSubagentMainRepoRoot(malformed), undefined); + } finally { + rmSync(root, { recursive: true, force: true }); + rmSync(foreign.root, { recursive: true, force: true }); + } + }); }); describe("workflow reusable git worktree git runner", () => { - test("formats timeout diagnostics with command cwd timeout elapsed status and signal", () => { - const message = gitFailureMessage({ - ...timedOutGit(), - argv: ["git", "-c", "core.hooksPath=/dev/null", "-c", "core.fsmonitor=false", "rev-parse", "--show-toplevel"], - cwd: "/repo with spaces", - timeoutMs: 60_000, - elapsedMs: 60_123, - }); + test("formats timeout diagnostics with command cwd timeout elapsed status and signal", () => { + const message = gitFailureMessage({ + ...timedOutGit(), + argv: ["git", "-c", "core.hooksPath=/dev/null", "-c", "core.fsmonitor=false", "rev-parse", "--show-toplevel"], + cwd: "/repo with spaces", + timeoutMs: 60_000, + elapsedMs: 60_123, + }); - assert.match(message, /git command timed out after 60000ms \(ETIMEDOUT\)/); - assert.match(message, /command: git -c core\.hooksPath=\/dev\/null -c core\.fsmonitor=false rev-parse --show-toplevel/); - assert.match(message, /cwd: \/repo with spaces/); - assert.match(message, /timeout: 60000ms/); - assert.match(message, /elapsed: 60123ms/); - assert.match(message, /status: null/); - assert.match(message, /signal: null/); - }); + assert.match(message, /git command timed out after 60000ms \(ETIMEDOUT\)/); + assert.match( + message, + /command: git -c core\.hooksPath=\/dev\/null -c core\.fsmonitor=false rev-parse --show-toplevel/, + ); + assert.match(message, /cwd: \/repo with spaces/); + assert.match(message, /timeout: 60000ms/); + assert.match(message, /elapsed: 60123ms/); + assert.match(message, /status: null/); + assert.match(message, /signal: null/); + }); - test("retries transient rev-parse timeouts before creating a missing worktree", () => { - const { root, repo, sourceCwd } = createRepoShape(); - const worktree = join(root, "transient-wt"); - const commonDir = join(root, "common.git"); - mkdirSync(commonDir); - let sourceTopLevelCalls = 0; - const runner: GitRunner = (cwd, args) => { - if (isArgs(args, ["rev-parse", "--show-toplevel"])) { - if (cwd === sourceCwd) { - sourceTopLevelCalls += 1; - return sourceTopLevelCalls === 1 ? timedOutGit() : successfulGit(`${repo}\n`); - } - return successfulGit(`${worktree}\n`); - } - if (isArgs(args, ["rev-parse", "--git-common-dir"])) return successfulGit(`${commonDir}\n`); - if (isArgs(args.slice(0, 3), ["worktree", "add", "--detach"])) { - mkdirSync(worktree); - return successfulGit(); - } - return failingGit(args); - }; + test("retries transient rev-parse timeouts before creating a missing worktree", () => { + const { root, repo, sourceCwd } = createRepoShape(); + const worktree = join(root, "transient-wt"); + const commonDir = join(root, "common.git"); + mkdirSync(commonDir); + let sourceTopLevelCalls = 0; + const runner: GitRunner = (cwd, args) => { + if (isArgs(args, ["rev-parse", "--show-toplevel"])) { + if (cwd === sourceCwd) { + sourceTopLevelCalls += 1; + return sourceTopLevelCalls === 1 ? timedOutGit() : successfulGit(`${repo}\n`); + } + return successfulGit(`${worktree}\n`); + } + if (isArgs(args, ["rev-parse", "--git-common-dir"])) return successfulGit(`${commonDir}\n`); + if (isArgs(args.slice(0, 3), ["worktree", "add", "--detach"])) { + mkdirSync(worktree); + return successfulGit(); + } + return failingGit(args); + }; - try { - const setup = withGitRunnerForTest(runner, () => setupGitWorktree({ - cwd: sourceCwd, - gitWorktreeDir: "../transient-wt", - baseBranch: "main", - })); + try { + const setup = withGitRunnerForTest(runner, () => + setupGitWorktree({ + cwd: sourceCwd, + gitWorktreeDir: "../transient-wt", + baseBranch: "main", + }), + ); - assert.equal(setup.created, true); - assert.equal(setup.repositoryRoot, repo); - assert.equal(setup.worktreeRoot, join(root, "transient-wt")); - assert.equal(setup.cwd, join(root, "transient-wt", "packages", "api")); - assert.equal(sourceTopLevelCalls, 2); - } finally { - rmSync(root, { recursive: true, force: true }); - } - }); + assert.equal(setup.created, true); + assert.equal(setup.repositoryRoot, repo); + assert.equal(setup.worktreeRoot, join(root, "transient-wt")); + assert.equal(setup.cwd, join(root, "transient-wt", "packages", "api")); + assert.equal(sourceTopLevelCalls, 2); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); - test("caches setup and revalidates checkout identity within a run", () => { - const { root, repo, sourceCwd } = createRepoShape(); - const worktree = join(root, "cached-wt"); - const commonDir = join(root, "common.git"); - const worktreeGitDir = join(root, "cached-wt.git"); - mkdirSync(commonDir); - mkdirSync(worktreeGitDir); - let worktreeAddCalls = 0; - let identityProbeCalls = 0; - const runner: GitRunner = (cwd, args) => { - if (isArgs(args, ["rev-parse", "--show-toplevel"])) { - return successfulGit(`${cwd === sourceCwd ? repo : worktree}\n`); - } - if (isArgs(args, ["rev-parse", "--git-common-dir"])) { - identityProbeCalls += 1; - return successfulGit(`${commonDir}\n`); - } - if (isArgs(args, ["rev-parse", "--absolute-git-dir"])) { - identityProbeCalls += 1; - return successfulGit(`${worktreeGitDir}\n`); - } - if (isArgs(args.slice(0, 3), ["worktree", "add", "--detach"])) { - worktreeAddCalls += 1; - mkdirSync(worktree); - writeFileSync(join(worktree, ".git"), "gitdir: ../cached-wt.git\n"); - return successfulGit(); - } - return failingGit(args); - }; + test("caches setup and revalidates checkout identity within a run", () => { + const { root, repo, sourceCwd } = createRepoShape(); + const worktree = join(root, "cached-wt"); + const commonDir = join(root, "common.git"); + const worktreeGitDir = join(root, "cached-wt.git"); + mkdirSync(commonDir); + mkdirSync(worktreeGitDir); + let worktreeAddCalls = 0; + let identityProbeCalls = 0; + const runner: GitRunner = (cwd, args) => { + if (isArgs(args, ["rev-parse", "--show-toplevel"])) { + return successfulGit(`${cwd === sourceCwd ? repo : worktree}\n`); + } + if (isArgs(args, ["rev-parse", "--git-common-dir"])) { + identityProbeCalls += 1; + return successfulGit(`${commonDir}\n`); + } + if (isArgs(args, ["rev-parse", "--absolute-git-dir"])) { + identityProbeCalls += 1; + return successfulGit(`${worktreeGitDir}\n`); + } + if (isArgs(args.slice(0, 3), ["worktree", "add", "--detach"])) { + worktreeAddCalls += 1; + mkdirSync(worktree); + writeFileSync(join(worktree, ".git"), "gitdir: ../cached-wt.git\n"); + return successfulGit(); + } + return failingGit(args); + }; - const cache = createGitWorktreeSetupCache(); - try { - withGitRunnerForTest(runner, () => { - const options = { cwd: sourceCwd, gitWorktreeDir: "../cached-wt", baseBranch: "main" }; - const setup = cache.get(options); - assert.equal(setup.cwd, join(root, "cached-wt", "packages", "api")); - assert.equal(cache.get(options), setup); - }); - assert.equal(worktreeAddCalls, 1); - assert.ok(identityProbeCalls > 0, "cache reuse should probe the selected checkout identity"); - } finally { - cache.dispose(); - rmSync(root, { recursive: true, force: true }); - } - }); + const cache = createGitWorktreeSetupCache(); + try { + withGitRunnerForTest(runner, () => { + const options = { cwd: sourceCwd, gitWorktreeDir: "../cached-wt", baseBranch: "main" }; + const setup = cache.get(options); + assert.equal(setup.cwd, join(root, "cached-wt", "packages", "api")); + assert.equal(cache.get(options), setup); + }); + assert.equal(worktreeAddCalls, 1); + assert.ok(identityProbeCalls > 0, "cache reuse should probe the selected checkout identity"); + } finally { + cache.dispose(); + rmSync(root, { recursive: true, force: true }); + } + }); - test("rejects cache reuse when a byte-identical .git file replaces the cached checkout generation", () => { - const { root, repo, worktree } = createGitRepository(); - const cache = createGitWorktreeSetupCache(); - try { - cache.get({ cwd: repo, gitWorktreeDir: worktree }); - const before = statSync(worktree); - const gitFile = join(worktree, ".git"); - const gitFileContents = readFileSync(gitFile); + test("rejects cache reuse when a byte-identical .git file replaces the cached checkout generation", () => { + const { root, repo, worktree } = createGitRepository(); + const cache = createGitWorktreeSetupCache(); + try { + cache.get({ cwd: repo, gitWorktreeDir: worktree }); + const before = statSync(worktree); + const gitFile = join(worktree, ".git"); + const gitFileContents = readFileSync(gitFile); - renameSync(gitFile, `${gitFile}.replaced`); - writeFileSync(gitFile, gitFileContents); + renameSync(gitFile, `${gitFile}.replaced`); + writeFileSync(gitFile, gitFileContents); - const after = statSync(worktree); - assert.equal(after.dev, before.dev, "replacement should retain the cached root device"); - assert.equal(after.ino, before.ino, "replacement should retain the cached root inode"); - assert.throws( - () => cache.get({ cwd: repo, gitWorktreeDir: worktree }), - /Cached gitWorktreeDir changed before reuse:/, - ); - } finally { - cache.dispose(); - rmSync(root, { recursive: true, force: true }); - } - }); + const after = statSync(worktree); + assert.equal(after.dev, before.dev, "replacement should retain the cached root device"); + assert.equal(after.ino, before.ino, "replacement should retain the cached root inode"); + assert.throws( + () => cache.get({ cwd: repo, gitWorktreeDir: worktree }), + /Cached gitWorktreeDir changed before reuse:/, + ); + } finally { + cache.dispose(); + rmSync(root, { recursive: true, force: true }); + } + }); - test("disposes cached checkout anchors idempotently before worktree cleanup", () => { - const { root, repo, worktree } = createGitRepository(); - const cache = createGitWorktreeSetupCache(); - try { - cache.get({ cwd: repo, gitWorktreeDir: worktree }); - cache.dispose(); - assert.doesNotThrow(() => cache.dispose()); - assert.throws( - () => cache.get({ cwd: repo, gitWorktreeDir: worktree }), - /cache is already disposed/, - ); - runGitChecked(repo, ["worktree", "remove", "--force", worktree]); - } finally { - cache.dispose(); - rmSync(root, { recursive: true, force: true }); - } - }); + test("disposes cached checkout anchors idempotently before worktree cleanup", () => { + const { root, repo, worktree } = createGitRepository(); + const cache = createGitWorktreeSetupCache(); + try { + cache.get({ cwd: repo, gitWorktreeDir: worktree }); + cache.dispose(); + assert.doesNotThrow(() => cache.dispose()); + assert.throws(() => cache.get({ cwd: repo, gitWorktreeDir: worktree }), /cache is already disposed/); + runGitChecked(repo, ["worktree", "remove", "--force", worktree]); + } finally { + cache.dispose(); + rmSync(root, { recursive: true, force: true }); + } + }); - test("leaves a supplied setup cache open when its engine owner is released", () => { - const { root, repo, worktree } = createGitRepository(); - const cache = createGitWorktreeSetupCache(); - const owner = createGitWorktreeSetupCacheOwner(cache); - let finalized = false; - try { - owner.release(() => { finalized = true; }); - assert.equal(finalized, true); - assert.equal(cache.get({ cwd: repo, gitWorktreeDir: worktree }).worktreeRoot, worktree); - } finally { - cache.dispose(); - rmSync(root, { recursive: true, force: true }); - } - }); + test("leaves a supplied setup cache open when its engine owner is released", () => { + const { root, repo, worktree } = createGitRepository(); + const cache = createGitWorktreeSetupCache(); + const owner = createGitWorktreeSetupCacheOwner(cache); + let finalized = false; + try { + owner.release(() => { + finalized = true; + }); + assert.equal(finalized, true); + assert.equal(cache.get({ cwd: repo, gitWorktreeDir: worktree }).worktreeRoot, worktree); + } finally { + cache.dispose(); + rmSync(root, { recursive: true, force: true }); + } + }); - test("supports the main checkout as a reusable target from a linked invocation", () => { - const { root, repo } = createGitRepository(); - const linkedSource = join(root, "linked-source"); - const cache = createGitWorktreeSetupCache(); - try { - runGitChecked(repo, ["worktree", "add", "--detach", linkedSource]); - const options = { cwd: linkedSource, gitWorktreeDir: repo }; - const setup = cache.get(options); - assert.equal(setup.created, false); - assert.equal(setup.worktreeRoot, repo); - assert.equal(cache.get(options), setup); - } finally { - cache.dispose(); - rmSync(root, { recursive: true, force: true }); - } - }); + test("supports the main checkout as a reusable target from a linked invocation", () => { + const { root, repo } = createGitRepository(); + const linkedSource = join(root, "linked-source"); + const cache = createGitWorktreeSetupCache(); + try { + runGitChecked(repo, ["worktree", "add", "--detach", linkedSource]); + const options = { cwd: linkedSource, gitWorktreeDir: repo }; + const setup = cache.get(options); + assert.equal(setup.created, false); + assert.equal(setup.worktreeRoot, repo); + assert.equal(cache.get(options), setup); + } finally { + cache.dispose(); + rmSync(root, { recursive: true, force: true }); + } + }); - test("creates a missing reusable target from the main root when invoked inside a linked worktree", () => { - const { root, repo } = createGitRepository(); - const linkedSource = join(root, "linked-source"); - const reusable = join(root, "reusable-from-linked"); - try { - runGitChecked(repo, ["worktree", "add", "--detach", linkedSource]); - const nested = join(linkedSource, "packages", "api"); - mkdirSync(nested, { recursive: true }); - const setup = setupGitWorktree({ cwd: nested, gitWorktreeDir: reusable, baseBranch: "main" }); - assert.equal(setup.created, true); - assert.equal(setup.repositoryRoot, linkedSource); - assert.equal(setup.worktreeRoot, reusable); - assert.equal(setup.cwd, join(reusable, "packages", "api")); - assert.equal(findCanonicalGitRoot(reusable), repo); - runGitChecked(repo, ["worktree", "remove", "--force", reusable]); - } finally { - rmSync(root, { recursive: true, force: true }); - } - }); + test("creates a missing reusable target from the main root when invoked inside a linked worktree", () => { + const { root, repo } = createGitRepository(); + const linkedSource = join(root, "linked-source"); + const reusable = join(root, "reusable-from-linked"); + try { + runGitChecked(repo, ["worktree", "add", "--detach", linkedSource]); + const nested = join(linkedSource, "packages", "api"); + mkdirSync(nested, { recursive: true }); + const setup = setupGitWorktree({ cwd: nested, gitWorktreeDir: reusable, baseBranch: "main" }); + assert.equal(setup.created, true); + assert.equal(setup.repositoryRoot, linkedSource); + assert.equal(setup.worktreeRoot, reusable); + assert.equal(setup.cwd, join(reusable, "packages", "api")); + assert.equal(findCanonicalGitRoot(reusable), repo); + runGitChecked(repo, ["worktree", "remove", "--force", reusable]); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); }); diff --git a/test/unit/worktree-lifecycle.test.ts b/test/unit/worktree-lifecycle.test.ts index bdeadfdc1..a29af7e8e 100644 --- a/test/unit/worktree-lifecycle.test.ts +++ b/test/unit/worktree-lifecycle.test.ts @@ -1,19 +1,30 @@ -import { test } from "bun:test"; +import { test } from "vitest"; // The setup-hook failure contract executes a bash-shebang script directly, // which Windows cannot spawn; the error-path contract runs on unix jobs. const unixTest = process.platform === "win32" ? test.skip : test; + import assert from "node:assert/strict"; -import { chmodSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs"; +import { + chmodSync, + existsSync, + lstatSync, + mkdirSync, + mkdtempSync, + readFileSync, + realpathSync, + rmSync, + writeFileSync, +} from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { cleanupWorktrees, createWorktrees } from "../../packages/workflows/src/runs/shared/worktree-setup.js"; -import { diffWorktrees } from "../../packages/workflows/src/runs/shared/worktree-diff.js"; -import { runGit, runGitChecked, runGitPlain } from "../../packages/workflows/src/runs/shared/worktree-git.js"; import { cleanupWorktrees as cleanupSubagentWorktrees, createWorktrees as createSubagentWorktrees, } from "../../packages/subagents/src/runs/shared/worktree.js"; +import { diffWorktrees } from "../../packages/workflows/src/runs/shared/worktree-diff.js"; +import { runGit, runGitChecked, runGitPlain } from "../../packages/workflows/src/runs/shared/worktree-git.js"; +import { cleanupWorktrees, createWorktrees } from "../../packages/workflows/src/runs/shared/worktree-setup.js"; function createRepository(ignoreSettings = true): { root: string; repo: string } { const root = realpathSync.native(mkdtempSync(join(tmpdir(), "atomic-worktree-lifecycle-"))); @@ -22,10 +33,15 @@ function createRepository(ignoreSettings = true): { root: string; repo: string } runGitChecked(repo, ["init", "-b", "main"]); runGitChecked(repo, ["config", "user.name", "Atomic Test"]); runGitChecked(repo, ["config", "user.email", "atomic@example.com"]); - writeFileSync(join(repo, ".gitignore"), [ - ...(ignoreSettings ? [".atomic/settings.local.json", ".atomic/settings.json"] : []), - "ignored/", "deps/", "", - ].join("\n")); + writeFileSync( + join(repo, ".gitignore"), + [ + ...(ignoreSettings ? [".atomic/settings.local.json", ".atomic/settings.json"] : []), + "ignored/", + "deps/", + "", + ].join("\n"), + ); writeFileSync(join(repo, ".worktreeinclude"), "ignored/**/*.txt\n"); mkdirSync(join(repo, "packages", "api"), { recursive: true }); writeFileSync(join(repo, "packages", "api", "tracked.txt"), "tracked\n"); @@ -37,14 +53,14 @@ function createRepository(ignoreSettings = true): { root: string; repo: string } test("temporary worktree uses main-root path, flattened branch, and post-creation setup", () => { const { root, repo } = createRepository(); mkdirSync(join(repo, ".atomic"), { recursive: true }); - writeFileSync(join(repo, ".atomic", "settings.local.json"), "{\"local\":true}\n"); - writeFileSync(join(repo, ".atomic", "settings.json"), "{\"shared\":true}\n"); + writeFileSync(join(repo, ".atomic", "settings.local.json"), '{"local":true}\n'); + writeFileSync(join(repo, ".atomic", "settings.json"), '{"shared":true}\n'); mkdirSync(join(repo, "ignored", "nested"), { recursive: true }); writeFileSync(join(repo, "ignored", "nested", "secret.txt"), "included\n"); writeFileSync(join(repo, "ignored", "skip.log"), "excluded\n"); mkdirSync(join(repo, "deps")); writeFileSync(join(repo, "deps", "module.txt"), "dependency\n"); - let setup; + let setup: ReturnType | undefined; try { setup = createWorktrees(join(repo, "packages", "api"), "feature/name", 1, { baseBranch: "main", @@ -55,8 +71,8 @@ test("temporary worktree uses main-root path, flattened branch, and post-creatio assert.equal(worktree.agentCwd, join(worktree.path, "packages", "api")); assert.equal(worktree.branch, "worktree-feature+name-0"); assert.equal(runGitChecked(worktree.path, ["branch", "--show-current"]).trim(), worktree.branch); - assert.equal(readFileSync(join(worktree.path, ".atomic", "settings.local.json"), "utf8"), "{\"local\":true}\n"); - assert.equal(readFileSync(join(worktree.path, ".atomic", "settings.json"), "utf8"), "{\"shared\":true}\n"); + assert.equal(readFileSync(join(worktree.path, ".atomic", "settings.local.json"), "utf8"), '{"local":true}\n'); + assert.equal(readFileSync(join(worktree.path, ".atomic", "settings.json"), "utf8"), '{"shared":true}\n'); assert.equal(readFileSync(join(worktree.path, "ignored", "nested", "secret.txt"), "utf8"), "included\n"); assert.equal(existsSync(join(worktree.path, "ignored", "skip.log")), false); assert.equal(lstatSync(join(worktree.path, "deps")).isSymbolicLink(), true); @@ -67,18 +83,20 @@ test("temporary worktree uses main-root path, flattened branch, and post-creatio } }); - test("non-ignored local settings propagate without leaking into patches and repeated creation stays usable", () => { const { root, repo } = createRepository(false); const diffs = join(root, "diffs"); mkdirSync(join(repo, ".atomic"), { recursive: true }); - writeFileSync(join(repo, ".atomic", "settings.local.json"), "{\"secret\":true}\n"); - writeFileSync(join(repo, ".atomic", "settings.json"), "{\"local\":true}\n"); + writeFileSync(join(repo, ".atomic", "settings.local.json"), '{"secret":true}\n'); + writeFileSync(join(repo, ".atomic", "settings.json"), '{"local":true}\n'); let first: ReturnType | undefined; let second: ReturnType | undefined; try { first = createWorktrees(repo, "settings/first", 1, { baseBranch: "main", symlinkDirectories: [] }); - assert.equal(readFileSync(join(first.worktrees[0]!.path, ".atomic", "settings.local.json"), "utf8"), "{\"secret\":true}\n"); + assert.equal( + readFileSync(join(first.worktrees[0]!.path, ".atomic", "settings.local.json"), "utf8"), + '{"secret":true}\n', + ); writeFileSync(join(first.worktrees[0]!.path, "agent-change.txt"), "agent\n"); const [diff] = diffWorktrees(first, ["worker"], diffs); assert.ok(diff); @@ -99,7 +117,7 @@ test("non-ignored local settings propagate without leaking into patches and repe test("linked-worktree invocation anchors temporary worktrees at the main root", () => { const { root, repo } = createRepository(); const linked = join(root, "linked-source"); - let setup; + let setup: ReturnType | undefined; try { runGitChecked(repo, ["worktree", "add", "--detach", linked]); setup = createWorktrees(join(linked, "packages", "api"), "inside/linked", 1, { baseBranch: "main" }); @@ -145,7 +163,10 @@ test("post-creation setup writes exact hooks paths and skips an already-correct try { mkdirSync(join(huskyRepo.repo, ".husky")); first = createWorktrees(huskyRepo.repo, "hooks/husky", 1, { baseBranch: "main", symlinkDirectories: [] }); - assert.equal(runGitPlain(huskyRepo.repo, ["config", "--get", "core.hooksPath"]).stdout.trim(), join(huskyRepo.repo, ".husky")); + assert.equal( + runGitPlain(huskyRepo.repo, ["config", "--get", "core.hooksPath"]).stdout.trim(), + join(huskyRepo.repo, ".husky"), + ); cleanupWorktrees(first); first = undefined; const configBefore = readFileSync(join(huskyRepo.repo, ".git", "config"), "utf8"); @@ -162,7 +183,10 @@ test("post-creation setup writes exact hooks paths and skips an already-correct try { writeFileSync(join(nativeRepo.repo, ".git", "hooks", "pre-commit"), "#!/bin/sh\n"); nativeSetup = createWorktrees(nativeRepo.repo, "hooks/native", 1, { baseBranch: "main", symlinkDirectories: [] }); - assert.equal(runGitPlain(nativeRepo.repo, ["config", "--get", "core.hooksPath"]).stdout.trim(), join(nativeRepo.repo, ".git", "hooks")); + assert.equal( + runGitPlain(nativeRepo.repo, ["config", "--get", "core.hooksPath"]).stdout.trim(), + join(nativeRepo.repo, ".git", "hooks"), + ); } finally { if (nativeSetup) cleanupWorktrees(nativeSetup); rmSync(nativeRepo.root, { recursive: true, force: true }); @@ -179,8 +203,14 @@ test("temporary base ref precedence is explicit then origin default then HEAD", runGitChecked(explicitRepo.repo, ["add", "explicit.txt"]); runGitChecked(explicitRepo.repo, ["commit", "--no-gpg-sign", "-m", "explicit"]); runGitChecked(explicitRepo.repo, ["checkout", "main"]); - explicitSetup = createWorktrees(explicitRepo.repo, "base/explicit", 1, { baseBranch: "explicit-base", symlinkDirectories: [] }); - assert.equal(runGitChecked(explicitSetup.worktrees[0]!.path, ["rev-parse", "HEAD"]).trim(), runGitChecked(explicitRepo.repo, ["rev-parse", "explicit-base"]).trim()); + explicitSetup = createWorktrees(explicitRepo.repo, "base/explicit", 1, { + baseBranch: "explicit-base", + symlinkDirectories: [], + }); + assert.equal( + runGitChecked(explicitSetup.worktrees[0]!.path, ["rev-parse", "HEAD"]).trim(), + runGitChecked(explicitRepo.repo, ["rev-parse", "explicit-base"]).trim(), + ); } finally { if (explicitSetup) cleanupWorktrees(explicitSetup); rmSync(explicitRepo.root, { recursive: true, force: true }); @@ -190,7 +220,10 @@ test("temporary base ref precedence is explicit then origin default then HEAD", let fallbackSetup: ReturnType | undefined; try { fallbackSetup = createWorktrees(fallbackRepo.repo, "base/head", 1, { symlinkDirectories: [] }); - assert.equal(runGitChecked(fallbackSetup.worktrees[0]!.path, ["rev-parse", "HEAD"]).trim(), runGitChecked(fallbackRepo.repo, ["rev-parse", "HEAD"]).trim()); + assert.equal( + runGitChecked(fallbackSetup.worktrees[0]!.path, ["rev-parse", "HEAD"]).trim(), + runGitChecked(fallbackRepo.repo, ["rev-parse", "HEAD"]).trim(), + ); } finally { if (fallbackSetup) cleanupWorktrees(fallbackSetup); rmSync(fallbackRepo.root, { recursive: true, force: true }); @@ -199,7 +232,7 @@ test("temporary base ref precedence is explicit then origin default then HEAD", test("subagent worktrees use the same linked-invocation lifecycle", () => { const { root, repo } = createRepository(); const linked = join(root, "subagent-linked"); - let setup; + let setup: ReturnType | undefined; try { runGitChecked(repo, ["worktree", "add", "--detach", linked]); setup = createSubagentWorktrees(linked, "subagent/nested", 1); @@ -218,11 +251,17 @@ unixTest("post-creation setup failure removes the worktree and branch", () => { writeFileSync(hook, "#!/bin/sh\nprintf 'not-json'\n"); chmodSync(hook, 0o755); try { - assert.throws(() => createWorktrees(repo, "failed/setup", 1, { - baseBranch: "main", - setupHook: { hookPath: hook }, - }), /invalid JSON/); + assert.throws( + () => + createWorktrees(repo, "failed/setup", 1, { + baseBranch: "main", + setupHook: { hookPath: hook }, + }), + /invalid JSON/, + ); assert.equal(existsSync(join(repo, ".atomic", "worktrees", "failed+setup-0")), false); assert.equal(runGitChecked(repo, ["branch", "--list", "worktree-failed+setup-0"]).trim(), ""); - } finally { rmSync(root, { recursive: true, force: true }); } + } finally { + rmSync(root, { recursive: true, force: true }); + } }); diff --git a/vitest.base.ts b/vitest.base.ts new file mode 100644 index 000000000..37de741c0 --- /dev/null +++ b/vitest.base.ts @@ -0,0 +1,57 @@ +/** + * Shared vitest resolution, in upstream pi's shape: `resolve.alias` and the path + * constants that build it, and nothing else. + * + * Deliberately absent: `pool`, `maxWorkers`, `poolOptions`, `fileParallelism`. + * pi sets none of them, and a suite that only passes with parallelism disabled + * is hiding a test that assumes an idle machine. Load-sensitive tests are fixed + * where they live instead (see test/unit/subagents-attempt-watchdog.test.ts). + */ +import { existsSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import type { Alias } from "vite"; + +/** Repository root, with a trailing separator. */ +export const repositoryRoot = fileURLToPath(new URL(".", import.meta.url)); + +/** + * `@bastani/atomic` resolves to its unbuilt source under test. Without this, + * packages/workflows/src/engine/workflow-activity.ts fails to resolve the host + * entry and takes test/unit/executor-concurrency-limiter.test.ts down with it. + */ +export const atomicSrcIndex = fileURLToPath( + new URL("./packages/coding-agent/src/index.ts", import.meta.url), +); + +const aiSrcIndex = fileURLToPath(new URL("./packages/ai/src/index.ts", import.meta.url)); +const aiSrcOAuth = fileURLToPath(new URL("./packages/ai/src/oauth.ts", import.meta.url)); +const agentSrcIndex = fileURLToPath(new URL("./packages/agent/src/index.ts", import.meta.url)); +const tuiSrcIndex = fileURLToPath(new URL("./packages/tui/src/index.ts", import.meta.url)); + +/** + * Prefer sibling pi sources when this repository is checked out beside them. + * The guard keeps the alias list empty in a normal checkout, where the published + * packages under node_modules are the right answer. + */ +const workspaceSourceAliases: readonly Alias[] = + existsSync(aiSrcIndex) && existsSync(aiSrcOAuth) && existsSync(agentSrcIndex) && existsSync(tuiSrcIndex) + ? [ + { find: /^@earendil-works\/pi-ai$/, replacement: aiSrcIndex }, + { find: /^@earendil-works\/pi-ai\/oauth$/, replacement: aiSrcOAuth }, + { find: /^@earendil-works\/pi-agent-core$/, replacement: agentSrcIndex }, + { find: /^@earendil-works\/pi-tui$/, replacement: tuiSrcIndex }, + { find: /^@mariozechner\/pi-ai$/, replacement: aiSrcIndex }, + { find: /^@mariozechner\/pi-ai\/oauth$/, replacement: aiSrcOAuth }, + { find: /^@mariozechner\/pi-agent-core$/, replacement: agentSrcIndex }, + { find: /^@mariozechner\/pi-tui$/, replacement: tuiSrcIndex }, + ] + : []; + +/** Every alias each vitest project shares. */ +export const sharedAliases: readonly Alias[] = [ + { find: /^@bastani\/atomic$/, replacement: atomicSrcIndex }, + ...workspaceSourceAliases, +]; + +/** The base every vitest config in the repository merges with. */ +export const baseConfig = { resolve: { alias: sharedAliases } }; diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 000000000..ce65557ea --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,42 @@ +import { defineConfig } from "vitest/config"; +import { sharedAliases } from "./vitest.base.js"; +import { TEST_TIMEOUT_MS } from "./test/helpers/test-timeout.js"; + +export { TEST_TIMEOUT_MS }; + +/** + * `bunfig.toml`'s `[test] preload` moved here verbatim. The file registers one + * `beforeEach` installing a fresh in-memory durable backend, and needs no edit: + * its `beforeEach` import resolves through the `bun:test` alias. + */ +const setupFiles = ["./test/setup-workflow-durability.ts"]; + +const project = (name: string, directory: string) => ({ + resolve: { alias: sharedAliases }, + test: { + name, + root: import.meta.dirname, + environment: "node" as const, + globals: true, + include: [`${directory}/**/*.test.ts`], + exclude: ["**/node_modules/**"], + setupFiles, + testTimeout: TEST_TIMEOUT_MS, + hookTimeout: TEST_TIMEOUT_MS, + }, +}); + +/** + * Three projects, one per suite directory, so the CI job split, the per-suite + * flake retry and the diagnostics artifact names all survive the move off + * `bun test ` unchanged. + * + * No `pool`, `maxWorkers`, `poolOptions` or `fileParallelism`: pi sets none, and + * a suite that only passes serialized is concealing a test that assumes an idle + * machine rather than fixing it. + */ +export default defineConfig({ + test: { + projects: [project("unit", "test/unit"), project("integration", "test/integration"), project("ci", "test/ci")], + }, +});