diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 12c2b01a..9f35f20c 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1,422 +1,15 @@ name: Tests - on: pull_request: branches: [main] - push: - branches: [main] - # Callable so release.yml gates on the REAL suite. Before this, a tag ran - # `npx vitest run` and nothing else — no lint, no typecheck, no coverage - # threshold, no e2e — so the only thing standing between a red commit and - # published installers was remembering to watch Tests before tagging. - workflow_call: - jobs: - # Fast static checks — run on every push/PR. - static: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6 - - uses: actions/setup-node@v6 - with: - node-version: 22 - cache: npm - - name: Install dependencies - shell: bash - run: | - for i in 1 2 3; do - npm install && break - echo "npm install failed (attempt $i), cleaning cache and retrying..." - npm cache clean --force || true - sleep 10 - done - - name: Lint - run: npm run lint - # The build is esbuild: it STRIPS types without checking them. So `Cannot find name - # 'memoryHostPid'` built clean, got signed, and shipped in v1.26.1 — the handler threw a - # ReferenceError on every call and the panel it fed reported "unknown" forever. Nothing in this - # repo ran tsc. Now something does, and it runs BEFORE the build that can't see this. - - name: Typecheck - run: npm run typecheck - - name: Build - run: npm run build - # Tailwind 4 runs behind a parity layer that makes it emit what Tailwind 3 did (see the top - # of src/renderer/src/index.css). Undo any part of it and the app looks different while every - # test still passes; tailwindParity.test.ts can only read the source, so a Tailwind or Vite - # upgrade that compiles the same source differently gets past it. This reads what was built. - - name: Verify the renderer CSS keeps Tailwind 3 parity - run: node scripts/verifyRendererCss.cjs out/renderer - - # The relay is a separate npm project with its own lockfile, and its tests run - # inside real workerd rather than a mock -- Durable Object storage, alarms and - # WebSocket semantics behave as they will in production. That makes a green - # suite meaningful evidence, so this job is blocking like the rest. - # - # Blocking matters more here than elsewhere: the relay is the one component - # that ships by `wrangler deploy` rather than in the installer, so a break - # reaches every paired phone at once and cannot be rolled back by asking users - # not to upgrade. - relay: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6 - - uses: actions/setup-node@v6 - with: - node-version: 22 - cache: npm - cache-dependency-path: relay/package-lock.json - - name: Install relay dependencies - run: npm ci --prefix relay - - name: Typecheck relay - run: npm --prefix relay run typecheck - - name: Test relay - run: npm --prefix relay test -- --coverage - - # The phone client. Pure TypeScript unit tests -- no simulator, no device, no - # Expo account. `src/wire/` is a second implementation of the same wire format - # the desktop speaks, and tests/electron/remoteMobileInterop.test.ts (in the - # `unit` job) is what keeps the two honest; this job only proves the phone - # tree builds and passes on its own. - mobile: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6 - - uses: actions/setup-node@v6 - with: - node-version: 22 - cache: npm - cache-dependency-path: mobile/package-lock.json - - name: Install mobile dependencies - run: npm ci --prefix mobile - - name: Typecheck mobile - run: npm --prefix mobile run typecheck - # src/wire/ has its own standalone tsconfig because the desktop's unit job - # transforms those files without mobile/node_modules. Compile it on its own - # here so that config cannot rot into something only Expo can load. - - name: Typecheck the wire format standalone - run: npm --prefix mobile run typecheck:wire - # src/net/ has one too, for remoteBlankTerminal.test.ts, which runs the - # phone's RemoteSession in that same job. - - name: Typecheck the network layer standalone - run: npm --prefix mobile run typecheck:net - - name: Test mobile - run: npm --prefix mobile run test:coverage - - # Unit tests across all three target platforms so we catch - # platform-specific regressions (path separators, shell detection, node-pty). - unit: - needs: static - strategy: - fail-fast: false - matrix: - os: [windows-latest, macos-latest, ubuntu-latest] - runs-on: ${{ matrix.os }} - steps: - - uses: actions/checkout@v6 - - uses: actions/setup-node@v6 - with: - node-version: 22 - cache: npm - - name: Install dependencies - shell: bash - run: | - for i in 1 2 3; do - npm install && break - echo "npm install failed (attempt $i), cleaning cache and retrying..." - npm cache clean --force || true - sleep 10 - done - # Populate resources/grammars so the code-graph tree-sitter loader resolves grammars from the - # SAME candidate dir CI-and-local (deterministic branch coverage — otherwise CI falls back to - # node_modules and lands a fraction lower on the razor-thin branch gate). - - name: Copy tree-sitter grammars - run: node scripts/copy-grammars.cjs - # The accounts tests (accountsChooserE2eMock, signInSessionPty, accountsSignIn, realPtyLoad, - # accountsUiE2eMock) start shells and mock CLIs on a REAL node-pty. On macOS node-pty forks - # through its prebuilt spawn-helper, which npm can extract without +x, and every spawn then - # fails with "posix_spawnp failed". Same step as e2e-macos and release.yml; Linux forks - # without a helper. - - name: Fix spawn-helper permissions (macOS) - if: matrix.os == 'macos-latest' - run: | - find node_modules/node-pty -name "spawn-helper" -type f -exec chmod 755 {} \; -print - # Coverage gates run on Windows only. src/main has ~19 - # `process.platform === 'win32'` checks whose win32 side never - # executes on Linux/macOS, so each platform lands at a slightly - # different coverage number. Gating on one platform keeps the - # 90/85 thresholds meaningful instead of flapping on ~1% drift. - # Ubuntu/macOS still run every test — they catch platform-specific - # regressions (path separators, shell detection, node-pty), just - # without the coverage denominator. - - name: Unit tests (with coverage gates) - if: matrix.os == 'windows-latest' - run: npx vitest run --coverage --retry=2 --reporter=verbose --reporter=json --outputFile.json=test-output.json - - name: Unit tests - if: matrix.os != 'windows-latest' - run: npx vitest run --retry=2 --reporter=verbose --reporter=json --outputFile.json=test-output.json - - name: Upload test output on failure - if: failure() - uses: actions/upload-artifact@v4 - with: - name: vitest-output-${{ matrix.os }} - path: | - test-output.json - coverage/ - retention-days: 7 - - # Packaging regression guard — builds a REAL unpacked app (no installer, - # no signing, ~1 min) and asserts the MCP stdio adapter was shipped. - # - # Why this exists: v1.11.5 shipped a broken installer where Claude Code's - # ~/.mcp.json pointed at an adapter file that did not physically exist. - # No unit test could catch it because the unit tests pin package.json - # config but not electron-builder's actual output. This job runs - # electron-builder --dir on Linux (cheapest), then runs our verifier - # against the produced dist-electron-builder/linux-unpacked tree. If - # the adapter is not shipped, this fails the PR. - package-verify: - needs: static - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6 - - uses: actions/setup-node@v6 - with: - node-version: 22 - cache: npm - - name: Install dependencies - shell: bash - run: | - for i in 1 2 3; do - npm install && break - echo "npm install failed (attempt $i), cleaning cache and retrying..." - npm cache clean --force || true - sleep 10 - done - - name: Install Linux build dependencies - run: sudo apt-get update && sudo apt-get install -y libarchive-tools - - name: Download bundled embedding model - run: bash scripts/download-embedding-model.sh - # Un-gates semantic recall in CI: with the real model now on disk - # (resources/models), the skipIf(!hasBundledModel) tests RUN here and prove - # real-embedding cross-agent recall — the plain unit jobs lack the model. - - name: Real-embedding semantic recall (model present) - run: npx vitest run tests/electron/localEmbedder.test.ts tests/electron/bertTokenizer.test.ts tests/electron/memorySemanticRecall.test.ts --reporter=verbose - # Retrieval-quality GATE: with the real bge model present, the recall benchmark asserts - # nDCG@10/recall@10/MRR absolute floors AND non-regression vs the committed baseline - # (tests/electron/fixtures/recall-baseline.json). A change that degrades recall fails CI. - - name: Recall benchmark GATE (real bge — floors + non-regression baseline) - run: npm run bench:recall - # PROOF GATE: the numbers published on the website (lookup latency, token leverage, learning - # from feedback, typed bug->fix relationship) are asserted here against the real model, so the - # marketing claims can never drift ahead of measured reality. - - name: Proof benchmark GATE (real bge — fast lookups / token leverage / learning / relationships) - run: npm run bench:proof - - name: Build app - run: npx electron-vite build - # The built main entries are ESM and electron-vite leaves real dependencies as - # runtime imports, so a CommonJS dep whose named exports Node cannot lex throws - # only when the built child STARTS. A utilityProcess that dies on its first line - # is silent -- v1.39.2 shipped a Remote bridge that never minted a pairing code - # and only an e2e shard noticed. This reads the build itself, before packaging. - - name: Verify child entry imports resolve at runtime - run: npm run verify:child-imports - - name: Package (unpacked, no installer, no signing) - run: npx electron-builder --dir --publish never - - name: Verify MCP adapter shipped in packaged resources - run: node scripts/verifyPackagedResources.cjs dist-electron-builder/linux-unpacked - - name: Verify embedding model + onnxruntime-web WASM bundled - run: node scripts/verifyPackagedResources.cjs --model dist-electron-builder/linux-unpacked - # `apt remove termpolis` runs the .deb's prerm (build/linux/before-remove.sh), which takes - # Termpolis back out of each user's Claude Code, Codex and Gemini CLI configs, as the - # Windows uninstaller does. Only a release builds a .deb, so this packs one from the - # unpacked app and checks what fpm made of it: the prerm is that script, the bundle it runs - # (resources/disconnect-agents.cjs) is in the package, and the packaged app does the work. - # Run as root, the way dpkg runs it, against a throwaway home: an upgrade (the in-app - # updater's too) must leave the agent connected, and a remove must take it out and leave - # the files it rewrote owned by the user. - - name: Verify the .deb prerm disconnects agents on remove, and only then - shell: bash - run: | - set -eu - npx electron-builder --linux deb --prepackaged dist-electron-builder/linux-unpacked --publish never - deb=$(ls dist-electron-builder/*.deb) - t=$RUNNER_TEMP/deb-prerm - me=$(id -un) - mkdir -p "$t/control" "$t/root/etc" "$t/home/.config/termpolis" - dpkg-deb -e "$deb" "$t/control" - if [ ! -x "$t/control/prerm" ] || ! cmp build/linux/before-remove.sh "$t/control/prerm"; then - echo "::error::The .deb's prerm is not build/linux/before-remove.sh"; exit 1 - fi - dpkg-deb -x "$deb" "$t/root" - if [ ! -f "$t/root/opt/Termpolis/resources/disconnect-agents.cjs" ]; then - echo "::error::The .deb does not ship resources/disconnect-agents.cjs"; exit 1 - fi - # The fake root's /etc/passwd lists only this runner's account, with the throwaway home, - # whose ~/.claude.json holds the MCP entry Termpolis writes. - printf '%s:x:%s:%s::%s:/bin/sh\n' "$me" "$(id -u)" "$(id -g)" "$t/home" >"$t/root/etc/passwd" - echo '{"mcpServers":{"termpolis":{"command":"node","args":["/opt/Termpolis/resources/mcp-adapter/stdio-adapter.cjs"]}}}' >"$t/home/.claude.json" - connected() { - node -e 'const c = JSON.parse(require("fs").readFileSync(process.argv[1], "utf8")); process.exit(c.mcpServers?.termpolis ? 0 : 1)' "$t/home/.claude.json" - } - sudo env TERMPOLIS_PRERM_ROOT="$t/root" "$t/control/prerm" upgrade 99.0.0 - if ! connected; then echo "::error::The prerm disconnected the agents on an upgrade"; exit 1; fi - sudo env TERMPOLIS_PRERM_ROOT="$t/root" "$t/control/prerm" remove - if connected; then echo "::error::The prerm left the agents connected on remove"; exit 1; fi - for f in "$t/home/.claude.json" "$t/home/.config/termpolis/agent-integration.json"; do - owner=$(stat -c %U "$f") - if [ "$owner" != "$me" ]; then echo "::error::$f is owned by $owner, not $me"; exit 1; fi - done - - name: Upload unpacked output on failure - if: failure() - uses: actions/upload-artifact@v4 - with: - name: dist-electron-builder-linux - path: dist-electron-builder/linux-unpacked/ - retention-days: 3 - - # The Windows installer, updating over a running Termpolis the way the in-app updater does. - # build/installer.nsh replaces electron-builder's "is the app running" check, which ran - # PowerShell's Get-CimInstance Win32_Process on every update: on a monitored work computer that - # process query, started under Termpolis.exe, reads as discovery to endpoint security. This - # builds an unsigned installer (a runner's Defender is off, so nothing quarantines it), installs - # it, starts the app and a Node-mode helper from its exe, updates with /S --updated, and fails - # unless the update closed both, finished, and started no PowerShell, tasklist or wmic - # (scripts/test-installer-update.ps1). release.yml runs this workflow, so it gates every release. - installer-update: - needs: static - runs-on: windows-latest - timeout-minutes: 45 - steps: - - uses: actions/checkout@v6 - - uses: actions/setup-node@v6 - with: - node-version: 22 - cache: npm - - name: Install dependencies - shell: bash - run: | - for i in 1 2 3; do - npm install && break - echo "npm install failed (attempt $i), cleaning cache and retrying..." - npm cache clean --force || true - sleep 10 - done - - name: Download bundled embedding model - shell: bash - run: bash scripts/download-embedding-model.sh - - name: Build app - run: npm run build - - name: Package an unsigned installer - shell: bash - env: - CSC_IDENTITY_AUTO_DISCOVERY: 'false' - run: npx electron-builder --win nsis --publish never - - name: Install, then update over the running app - shell: pwsh - run: | - $installer = (Get-ChildItem dist-electron-builder -Filter 'Termpolis*Setup*.exe' | Select-Object -First 1).FullName - if (-not $installer) { throw 'no installer was built' } - ./scripts/test-installer-update.ps1 -Installer $installer -UserDataDir (Join-Path $env:RUNNER_TEMP 'termpolis-profile') -Out (Join-Path $env:RUNNER_TEMP 'installer-update.json') - - name: Upload the result - if: always() - uses: actions/upload-artifact@v4 - with: - name: installer-update - path: ${{ runner.temp }}/installer-update.json - retention-days: 7 - if-no-files-found: ignore - - # THE Linux e2e gate: the WHOLE e2e directory, blocking, sharded 4 ways. - # - # Until v1.32.5 CI ran 18 named specs and 34 of the 52 on disk had never been - # executed at all. A spec that cannot fail rots silently: v1.30.3 removed a - # feature and the specs asserting it went stale with nothing to say so, and - # swarm-integration spent releases red on a mock file that had been deleted. - # Anything that genuinely cannot run on a hosted runner lives in e2e/manual/ - # (see its README) and is excluded by playwright.config.ts's testIgnore — so - # "not in the gate" is now a decision written down somewhere, not an accident. - e2e-full: - needs: static - runs-on: ubuntu-latest - timeout-minutes: 60 - strategy: - fail-fast: false - matrix: - shard: [1, 2, 3, 4] - steps: - - uses: actions/checkout@v6 - - uses: actions/setup-node@v6 - with: - node-version: 22 - cache: npm - - name: Install dependencies - shell: bash - run: | - for i in 1 2 3; do - npm install && break - echo "npm install failed (attempt $i), cleaning cache and retrying..." - npm cache clean --force || true - sleep 10 - done - - name: Install Playwright browsers - run: npx playwright install --with-deps chromium - # quitApp() (e2e/helpers/launch.ts) attaches gdb to a main process that won't quit, so the - # warning it leaves names the native frame the quit is stuck in. - - name: Install gdb - run: sudo apt-get install -y --no-install-recommends gdb || true - - name: Build - run: npm run build - # e2e/linux-sandbox.spec.ts launches with Chromium's sandbox ON (issue #32), which needs - # unprivileged user namespaces. Ubuntu 24.04 restricts them by default; lift that here. - - name: Allow the Chromium sandbox - run: | - sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 || true - if unshare -Ur true; then - echo "TERMPOLIS_E2E_SANDBOX=1" >> "$GITHUB_ENV" - else - echo "::warning::no unprivileged user namespaces on this runner; linux-sandbox.spec.ts will skip" - fi - # Sharded: Electron forces workers=1 inside a job, and once the 34 specs - # actually launched, the whole directory ran past the 90-minute cap and was - # cancelled mid-suite. Separate runners are the only parallelism available. - # TERMPOLIS_E2E_PREBUILT: the specs launch the Build step's out/ instead of each - # building it again (ensureE2eBuild, e2e/helpers/launch.ts). - # The step's own timeout sits under the job's on purpose. A step that runs out of time - # FAILS and the job goes on to the upload below; a job that runs out is CANCELLED, which - # skips the `!cancelled()` upload and loses the traces of exactly the run that needs them. - - name: Whole e2e suite - timeout-minutes: 52 - env: - TERMPOLIS_E2E_PREBUILT: '1' - run: xvfb-run --auto-servernum npx playwright test --shard=${{ matrix.shard }}/4 --reporter=list - - name: Upload failure traces - if: ${{ !cancelled() }} - uses: actions/upload-artifact@v4 - with: - name: playwright-traces-full-${{ matrix.shard }} - path: test-results/ - retention-days: 7 - - # THE Windows e2e gate: the whole e2e directory again, blocking, sharded 4 ways like e2e-full. - # - # ConPTY, NTFS ACLs and the hosted runner's 1024x768 desktop exist only here. Until this job, - # Windows e2e ran only when someone dispatched e2e-soak, and three Windows-only failures built - # up unseen, two of them product bugs: the prompt parse lost the prompt ConPTY sends after - # every Enter, and mcp-token's ACL lockdown could leave the file with an empty ACL. - # - # No Playwright browser install: Electron is the only browser a spec launches (e2e-full keeps - # the step on Linux, where --with-deps also installs system libraries). The gdb, xvfb and - # sandbox steps are Linux-only. If a runner-image bump destabilizes this job, flip - # continue-on-error to true while stabilizing, as with e2e-smoke-macos, rather than papering - # over a real regression. e2e-windows: - needs: static runs-on: windows-latest timeout-minutes: 45 strategy: fail-fast: false matrix: - shard: [1, 2, 3, 4] + shard: [1, 2] steps: - uses: actions/checkout@v6 - uses: actions/setup-node@v6 @@ -443,7 +36,7 @@ jobs: shell: bash env: TERMPOLIS_E2E_PREBUILT: '1' - run: npx playwright test --shard=${{ matrix.shard }}/4 --reporter=list + run: npx playwright test e2e/account-chooser.spec.ts -g "Codex at its usage limit" --repeat-each=3 --reporter=list --retries=0 - name: Upload failure traces if: ${{ !cancelled() }} uses: actions/upload-artifact@v4 @@ -451,77 +44,3 @@ jobs: name: playwright-traces-windows-${{ matrix.shard }} path: test-results/ retention-days: 7 - - # macOS e2e smoke: Darwin runner exercising the core boot + first-run - # flows. e2e-full above is Linux-only, so this is the sole Darwin coverage — - # a handful of specs intentionally, not the whole suite, because what it is - # here to catch is platform drift (node-pty rebuilds, shell detection, - # Electron-on-arm64), not app logic: - # - chrome-smoke: clicks every sidebar button, catches hooks/render - # regressions and node-pty rebuild drift. - # - first-run-zero-agents: proves a brand-new macOS user — no Claude, - # no Codex, no Gemini — can still boot the app and open a - # terminal. This is the path a first-time TestFlight reviewer hits. - # - linked-machines: Settings ▸ Linked machines through the forked - # bridge child (code minted, own code refused, switched off), so a - # Mac can be the computer a delegated job runs on. - # - # Required on main as of v1.11.15 — first green seen on 0342333. - # If macOS starts flaking after a runner-image bump or similar, flip - # continue-on-error back to true while we stabilize rather than - # papering over a real regression. - e2e-smoke-macos: - needs: static - runs-on: macos-latest - continue-on-error: false - # The Build step runs before any spec, so the specs launch its out/ instead of each - # building it again (ensureE2eBuild, e2e/helpers/launch.ts). - env: - TERMPOLIS_E2E_PREBUILT: '1' - steps: - - uses: actions/checkout@v6 - - uses: actions/setup-node@v6 - with: - node-version: 22 - cache: npm - - name: Install dependencies - shell: bash - run: | - for i in 1 2 3; do - npm install && break - echo "npm install failed (attempt $i), cleaning cache and retrying..." - npm cache clean --force || true - sleep 10 - done - - name: Install Playwright browsers - run: npx playwright install --with-deps chromium - - name: Build - run: npm run build - # node-pty spawns a PTY on macOS through a helper binary (spawn-helper) that - # must carry the executable bit; npm's tarball extraction on the macos runner - # can land it without +x, which surfaces only at run time as "posix_spawnp - # failed" when a Command step opens its terminal. node-pty 1.x ships PREBUILT - # binaries — the helper lives at prebuilds/-/spawn-helper (NOT - # build/Release, which doesn't exist for the prebuild), so `find` it wherever - # it lands and re-assert +x. Mirrors release.yml's proven step exactly so the - # e2e runner matches the shipped mac build; -print leaves evidence in the log. - - name: Fix spawn-helper permissions (macOS) - run: | - find node_modules/node-pty -name "spawn-helper" -type f -exec chmod 755 {} \; -print - - name: Smoke-click every sidebar button (macOS) - run: npx playwright test e2e/chrome-smoke.spec.ts - - name: First-run with zero AI agents installed (macOS) - run: npx playwright test e2e/first-run-zero-agents.spec.ts - - name: Report-a-Problem flow (macOS) - run: npx playwright test e2e/report-problem.spec.ts - - name: Workflow Orchestrator author→run→green (macOS) - run: npx playwright test e2e/workflow-orchestrator.spec.ts - - name: Linked machines settings (macOS) - run: npx playwright test e2e/linked-machines.spec.ts - - name: Upload failure traces - if: failure() - uses: actions/upload-artifact@v4 - with: - name: playwright-traces-macos - path: test-results/ - retention-days: 7 diff --git a/e2e/account-chooser.spec.ts b/e2e/account-chooser.spec.ts index 1dd1da46..30de94b6 100644 --- a/e2e/account-chooser.spec.ts +++ b/e2e/account-chooser.spec.ts @@ -168,7 +168,23 @@ const cardOf = (w: World, id: string) => paneOf(w, id).locator('[data-testid="li /** Waits for the card that offers a way out of a usage limit. */ async function expectCard(w: World, id: string) { const card = cardOf(w, id) - await expect(card).toBeVisible({ timeout: 60_000 }) + try { + await expect(card).toBeVisible({ timeout: 60_000 }) + } catch (e) { + // What main's look at the terminal says, a few times: which of live, turned and the status kept the card away. + const looks: unknown[] = [] + for (let i = 0; i < 4; i++) { + looks.push(await w.page.evaluate(async (terminalId) => { + try { return await (window as unknown as { accounts: { limitLook: (id: string) => Promise } }).accounts.limitLook(terminalId) } catch (err) { return String(err) } + }, id)) + await w.page.waitForTimeout(2000) + } + const raw = await w.page.evaluate(async (terminalId) => { + try { return await (window as unknown as { termpolis: { readTerminalBuffer: (id: string, from: number) => Promise } }).termpolis.readTerminalBuffer(terminalId, 0) } catch (err) { return String(err) } + }, id) + const st = await w.page.evaluate(() => { try { return (window as unknown as { __termpolis_test_state: () => { diag: unknown } }).__termpolis_test_state().diag } catch (err) { return String(err) } }) + throw new Error(`${(e as Error).message}\nSTORE-DIAG ${JSON.stringify(st)}\nLIMIT-LOOKS ${JSON.stringify(looks)}`) + } return card } diff --git a/src/main/limitLook.ts b/src/main/limitLook.ts index a6c18d18..a237d9f4 100644 --- a/src/main/limitLook.ts +++ b/src/main/limitLook.ts @@ -72,11 +72,22 @@ export { agentOfLaunch } export function agentGone(io: SwitchIO, id: string, start: LiveStart, now: number, l: Look = look(io, id)): boolean { if (start.ended) return true if (start.agent && hintAtFoot(l, start.agent) && HINT_HEAD[start.agent].test(plain(io.outputFrom(id, start.at)))) return true + const fresh = now - (start.time ?? 0) < FOREGROUND_GRACE_MS const from = indexOf(l, start.at) // The replay is read only once a shell reported a prompt since the start: David's Git Bash, zsh // and fish report none. - if (l.raw.slice(from).search(PROMPT_REPORT) >= 0 && sinceStarted(l.raw, from, false, io.size(id), start.commands).shell) return true - return now - (start.time ?? 0) >= FOREGROUND_GRACE_MS && io.atShell?.(id) === true + const since = l.raw.slice(from) + if (since.search(PROMPT_REPORT) >= 0 && !(fresh && quietUpToPrompt(since)) && sinceStarted(l.raw, from, false, io.size(id), start.commands).shell) return true + return !fresh && io.atShell?.(id) === true +} + +/** Nothing printed between the start and the shell's last prompt report since: the shell printed one more prompt + * after the line was entered and before it echoed that line (Git Bash on Windows CI did), not an agent that ran and + * exited. A look in that moment latched the agent gone before it started; in the moment the shell takes to start + * the agent (FOREGROUND_GRACE_MS) it is no exit. */ +function quietUpToPrompt(since: string): boolean { + const reports = [...since.matchAll(PROMPT_REPORT)] + return plain(since.slice(0, reports[reports.length - 1].index)).trim() === '' } /** Can this terminal ever show an agent's exit? A shell that reports its prompts, or a PTY whose foreground can be read diff --git a/src/main/mcpServer.ts b/src/main/mcpServer.ts index a9df3fba..20262116 100644 --- a/src/main/mcpServer.ts +++ b/src/main/mcpServer.ts @@ -880,6 +880,26 @@ function agentCommands(command: string): string[] { /** Whether `rows` show one of the commands, by its letters and digits. */ const showsCommand = (rows: readonly string[], commands: readonly string[]): boolean => commands.some((c) => bare(rows.join('')).includes(bare(c))) +/** How many of a command's letters and digits must end the rows before they read as its start (echoing): more than + * any folder a prompt ends in shares with a launch line by chance. */ +const ECHO_HEAD = 16 + +/** Whether `rows` end in the start of one of the commands, of more than one word: the shell still echoing the line + * typed at its prompt. Git Bash on Windows echoes a long launch line (Termpolis's own, with its instructions) in + * pieces, and a look between two of them saw the prompt, part of the line and nothing else (the CI run of + * 2026-11-07: the agent read as gone before it started). */ +function echoing(rows: readonly string[], commands: readonly string[]): boolean { + const text = bare(rows.join('')) + return commands.some((c) => { + const want = bare(c) + if (c.trim().split(/\s+/).length < 2 || want.length < ECHO_HEAD) return false + for (let at = text.indexOf(want.slice(0, ECHO_HEAD)); at >= 0; at = text.indexOf(want.slice(0, ECHO_HEAD), at + 1)) { + if (want.startsWith(text.slice(at))) return true + } + return false + }) +} + /** What the output since an agent was started says, `from` on in `raw` (the shell's very first * output when `whole`): whether its shell is back at its prompt (shell), and whether the agent drew * its input box (started). The prompts that count are the ones printed after the command was typed: @@ -907,7 +927,8 @@ export function sinceStarted(raw: string, from: number, whole: boolean, size: Sc } const switches = [...after.matchAll(ALT_SCREEN)] const onAlt = switches.length > 0 && switches[switches.length - 1][1] === 'h' - return { shell: !onAlt && inputBoxRow(upTo(REPAINT)) < 0 && !showsCommand(upTo(WIPE), commands), started } + const shown = upTo(WIPE) + return { shell: !onAlt && inputBoxRow(upTo(REPAINT)) < 0 && !showsCommand(shown, commands) && !echoing(shown, commands), started } } /** What a terminal shows, and what the agent in it is doing; null for a terminal with no output. diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 17f7c99d..581a9ac9 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -231,6 +231,12 @@ export default function App() { swarmAgents: s.swarmAgents, swarmCompletionSummary: s.swarmCompletionSummary, swarmNotification: s.swarmNotification, + diag: { + accountSwitch: s.accountSwitch, limitBanners: s.limitBanners, relaunching: s.relaunching, viewMode: s.viewMode, + activeTerminalId: s.activeTerminalId, showSettings: s.showSettings, focused: document.hasFocus(), + view: s.accountsView && { chooser: s.accountsView.chooser, hidden: s.accountsView.hidden, accounts: s.accountsView.accounts.map((r) => ({ id: r.id, agent: r.agent, label: r.label })) }, + trace: (window as any).__limitTrace ?? [], + }, } } // Bundled observability libs, exposed for e2e tests so they exercise @@ -718,7 +724,7 @@ export default function App() { lastDismissedPrompt.set(agent.terminalId, dismissKey) // Its agent as main recorded run_command's line, else as its name says (R10). const worker = store.terminals.find((t) => t.id === agent.terminalId) - const dismissChar = await replyToScreen( + const dismissChar = await replyToScreen( output, agent.agentName, () => mayTrustFolder(agent.terminalId), () => readScreen(agent.terminalId), !hasManyAccounts(store.accountsView, worker?.swarmAgent ?? agentOfName(agent.agentName)), @@ -825,10 +831,13 @@ export default function App() { // replyToScreen reads the screen again first, so an answered dialog gets nothing. lastDismissedPrompt.set(term.id, dismissKey) const agent = terminalAgent(term) + const tr = ((window as any).__limitTrace ??= []) as unknown[] const dismissChar = await replyToScreen( output, term.name, () => mayTrustFolder(term.id), () => readScreen(term.id), !hasManyAccounts(store.accountsView, agent), ) + tr.push({ at: Date.now(), id: term.id, agent, dismissChar, out: output.length }) + if (tr.length > 200) tr.splice(0, tr.length - 200) if (dismissChar) { window.termpolis.writeToTerminal(term.id, dismissChar) continue @@ -839,10 +848,12 @@ export default function App() { const looked = await watchTerminal(term.id, agent, store.accountsView, { accounts: window.accounts, now: Date.now, - onLimit: (note) => showLimit(note, { + screen: dismissKey, + onLimit: (note) => { tr.push({ at: Date.now(), onLimit: note.kind, ctx: note.ctx }); showLimit(note, { store: useTerminalStore.getState, focused: () => document.hasFocus(), now: Date.now, newId: uuid, notify: osNotify, - }), + }) }, }) + tr.push({ at: Date.now(), looked }) // A limit screen is still: a look that failed (main busy, an IPC error) must be tried again // on the next tick, not passed by as a screen already judged. if (!looked) lastDismissedPrompt.delete(term.id) diff --git a/src/renderer/src/lib/limitWatch.ts b/src/renderer/src/lib/limitWatch.ts index a9c72b4b..bda2bd24 100644 --- a/src/renderer/src/lib/limitWatch.ts +++ b/src/renderer/src/lib/limitWatch.ts @@ -92,6 +92,29 @@ export function noteLook(terminalId: string, agent: AccountAgent, look: LimitLoo /** Terminals closed since the last tick: their stops go with them. */ export function pruneStops(open: ReadonlySet): void { for (const id of stops.keys()) if (!open.has(id)) stops.delete(id) + for (const id of unsure.keys()) if (!open.has(id)) unsure.delete(id) +} + +/** How many more looks a still screen gets (2 s apart) while main has not counted a stop on it: main's word on the + * agent can change while the screen the loop keys on does not. Main judges its own parse of the screen, which can + * trail the output the loop reads, so the limit may show there only a look later; the prompt that led to the stop + * is a turn from the Enter, and a start main had not yet seen is recorded as the shell echoes the line, both + * possibly after the limit was drawn. A loop that judged that screen only once never opened the chooser (Windows + * CI, Codex, 2026-11-07). A new screen starts the count again. */ +export const UNSETTLED_LOOKS = 10 +const unsure = new Map() + +/** Is this look, of a screen main counted no stop on, one to take again on the next tick, though the screen stays + * the same? */ +function unsettled(terminalId: string, look: LimitLook, screen: string | undefined): boolean { + if (stopKind(look.status) !== null && look.live && look.turned) { + unsure.delete(terminalId) + return false + } + const last = unsure.get(terminalId) + const n = last && last.screen === screen ? last.n + 1 : 1 + unsure.set(terminalId, { screen, n }) + return n <= UNSETTLED_LOOKS } /** A banner's terminals that no longer show the stop it is about (the agent is at work again, or the @@ -119,6 +142,7 @@ export function pruneChooser(s: { accountSwitch: ShowStore['accountSwitch']; ter export function _resetLimitWatchForTest(): void { stops.clear() + unsure.clear() swarmOffer = null } @@ -312,6 +336,8 @@ export interface WatchDeps { now: () => number /** What follows a new stop: its record against the account, and what the user sees. */ onLimit: (note: Exclude) => void + /** The screen the loop judged (its key): a new one starts the count of looks again (UNSETTLED_LOOKS). */ + screen?: string } /** @@ -330,7 +356,8 @@ export async function watchTerminal(terminalId: string, agent: AccountAgent, vie return false } const note = noteLook(terminalId, agent, look, deps.now()) - if (note.kind === 'none') return true + if (note.kind === 'none') return !unsettled(terminalId, look, deps.screen) + unsure.delete(terminalId) const row = note.ctx.accountId ? view?.accounts.find((r) => r.id === note.ctx.accountId) : undefined if (note.kind === 'limit' && row) { const email = row.lastEmail ?? (agent === 'agy' ? row.email : undefined) diff --git a/tests/electron/limitLook.test.ts b/tests/electron/limitLook.test.ts index 4c59d057..b50ca87b 100644 --- a/tests/electron/limitLook.test.ts +++ b/tests/electron/limitLook.test.ts @@ -374,3 +374,49 @@ describe('where a turn began (R4): an old limit is not the answer to it', () => expect(noteEnteredLine(io, 't1', undefined, '/resume', 77, 1234)).toBeNull() }) }) + +describe('a long launch line Git Bash on Windows echoes late (the CI run of 2026-11-07)', () => { + const LINE = `unset CODEX_HOME && codex -c "developer_instructions='${'Termpolis project memory: saved background context exists. '.repeat(12)}'"` + const report = `${E}]7;file://host/tmp/project\x07` + const prompt = `${E}[32m\r\nrunner@host ${E}[35mUCRT64 ${E}[33m/tmp/project${E}[m\r\n$ ` + /** Bash at its prompt when the line is entered (at), one more prompt it prints before the echo, then the echo. */ + const BEFORE = `${report}${prompt}${E}[?2004h` + const AFTER = `${E}[?2004l${report}${prompt}` + function io(out: string): SwitchIO { + return { + output: () => ({ raw: out, end: out.length }), + outputFrom: (_id, from) => out.slice(from), + end: () => out.length, + size: () => ({ cols: 98, rows: 36 }), + write: () => undefined, + sleep: async () => undefined, + now: () => 0, + } + } + const start: LiveStart = { at: BEFORE.length, commands: [LINE], agent: 'codex', time: 0 } + + it('is no exit at the prompt printed before the echo, in the moment the shell takes to start the agent', () => { + expect(agentGone(io(BEFORE + AFTER), 't', start, FOREGROUND_GRACE_MS - 1)).toBe(false) + // Past it, a prompt and nothing more is the shell back: the line never ran. + expect(agentGone(io(BEFORE + AFTER), 't', start, FOREGROUND_GRACE_MS)).toBe(true) + }) + + it('is no exit with the line half echoed, at any moment', () => { + for (const cut of [40, 100, LINE.length - 5]) { + expect(agentGone(io(BEFORE + AFTER + LINE.slice(0, cut)), 't', start, FOREGROUND_GRACE_MS * 10)).toBe(false) + } + expect(agentGone(io(BEFORE + AFTER + LINE + '\r\n'), 't', start, FOREGROUND_GRACE_MS * 10)).toBe(false) + }) + + it('still reads an agent that printed something and exited in that moment as gone', () => { + expect(agentGone(io(`${BEFORE}${LINE}\r\nError: no such model${AFTER}`), 't', start, 0)).toBe(true) + }) + + it('reads a prompt that only ends like the line, or like a line too short to tell, as the shell back', () => { + expect(agentGone(io(BEFORE + AFTER + LINE.slice(0, 20)), 't', start, FOREGROUND_GRACE_MS)).toBe(true) + const brief: LiveStart = { ...start, commands: ['codex -m o3'] } + expect(agentGone(io(BEFORE + AFTER + 'codex -m'), 't', brief, FOREGROUND_GRACE_MS)).toBe(true) + // The line's head twice, the second time not followed by the rest of it. + expect(agentGone(io(BEFORE + AFTER + LINE.slice(0, 40) + ' ' + LINE.slice(0, 30) + 'zzz'), 't', start, FOREGROUND_GRACE_MS)).toBe(true) + }) +}) diff --git a/tests/lib/limitWatch.test.ts b/tests/lib/limitWatch.test.ts index 79c547ec..f061859c 100644 --- a/tests/lib/limitWatch.test.ts +++ b/tests/lib/limitWatch.test.ts @@ -6,7 +6,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' import { _resetLimitWatchForTest, bannerText, decideLimit, isTerminalVisible, noteLook, - noteSwarmStop, osNotify, pruneBanners, pruneChooser, pruneStops, sameGroup, setSwarmStopOffer, showLimit, watchTerminal, + noteSwarmStop, osNotify, pruneBanners, pruneChooser, pruneStops, sameGroup, setSwarmStopOffer, showLimit, UNSETTLED_LOOKS, watchTerminal, } from '../../src/renderer/src/lib/limitWatch' import type { DecideEnv, LimitBanner, LimitContext, LimitNote, ShowStore } from '../../src/renderer/src/lib/limitWatch' import type { LimitLook } from '../../src/shared/accountLimits' @@ -444,9 +444,9 @@ describe('watchTerminal: the view the next chooser reads (round 1)', () => { describe('watchTerminal: whether the look was answered (round 1)', () => { const lookApi = (limitLook: () => Promise) => ({ limitLook: vi.fn(limitLook as never), noteLimit: vi.fn(async () => ({ success: true })) }) - it('says true for a look main answered, whatever it saw, and false for one that failed', async () => { + it('says true for a stop main counted, and false for a look that failed or a screen it may not have caught up with', async () => { const idle = lookApi(async () => ({ success: true, data: look({ status: IDLE }) })) - expect(await watchTerminal('t1', 'claude', null, { accounts: idle, now: () => 1, onLimit: vi.fn() })).toBe(true) + expect(await watchTerminal('t1', 'claude', null, { accounts: idle, now: () => 1, onLimit: vi.fn() })).toBe(false) const stop = lookApi(async () => ({ success: true, data: look() })) expect(await watchTerminal('t1', 'claude', view([A, B]), { accounts: stop, now: () => Date.parse('2026-10-23T18:00:00Z'), onLimit: vi.fn() })).toBe(true) expect(await watchTerminal('t1', 'claude', null, { accounts: lookApi(async () => ({ success: false, error: 'busy' })), now: () => 1, onLimit: vi.fn() })).toBe(false) @@ -454,6 +454,51 @@ describe('watchTerminal: whether the look was answered (round 1)', () => { }) }) +describe('watchTerminal: a still limit screen main did not count yet (Windows CI, 2026-11-07)', () => { + const api = (data: () => LimitLook) => ({ limitLook: vi.fn(async () => ({ success: true, data: data() }) as never), noteLimit: vi.fn(async () => ({ success: true })) }) + + it('is looked at again until main counts it, then the chooser opens once', async () => { + let now = look({ turned: false }) + const accounts = api(() => now) + const onLimit = vi.fn() + const deps = { accounts, now: () => Date.parse('2026-10-23T18:00:00Z'), onLimit } + expect(await watchTerminal('t1', 'claude', view([A, B]), deps)).toBe(false) + now = look({ live: false }) + expect(await watchTerminal('t1', 'claude', view([A, B]), deps)).toBe(false) + now = look() + expect(await watchTerminal('t1', 'claude', view([A, B]), deps)).toBe(true) + expect(onLimit).toHaveBeenCalledTimes(1) + }) + + it('is looked at again while main still shows no stop on a screen that already holds the limit, its parse behind', async () => { + let now = look({ status: IDLE }) + const accounts = api(() => now) + const onLimit = vi.fn() + const deps = { accounts, now: () => Date.parse('2026-10-23T18:00:00Z'), onLimit, screen: 'k1' } + expect(await watchTerminal('t1', 'claude', view([A, B]), deps)).toBe(false) + now = look() + expect(await watchTerminal('t1', 'claude', view([A, B]), deps)).toBe(true) + expect(onLimit).toHaveBeenCalledTimes(1) + }) + + it('gives up after a few looks of one screen, a stop main never counts (the last frame of an agent that exited)', async () => { + const accounts = api(() => look({ live: false })) + const deps = { accounts, now: () => 1, onLimit: vi.fn(), screen: 'k1' } + for (let i = 0; i < UNSETTLED_LOOKS; i++) expect(await watchTerminal('t1', 'claude', null, deps)).toBe(false) + expect(await watchTerminal('t1', 'claude', null, deps)).toBe(true) + // A screen with no stop is judged a few times too, and passed by after. + const idle = { ...deps, accounts: api(() => look({ status: IDLE })) } + expect(await watchTerminal('t1', 'claude', null, idle)).toBe(true) + // A new screen starts the count again; so does a closed terminal; so does a counted stop. + expect(await watchTerminal('t1', 'claude', null, { ...idle, screen: 'k2' })).toBe(false) + pruneStops(new Set()) + for (let i = 0; i < UNSETTLED_LOOKS; i++) expect(await watchTerminal('t1', 'claude', null, idle)).toBe(false) + expect(await watchTerminal('t1', 'claude', null, { ...idle, accounts: api(() => look()) })).toBe(true) + expect(await watchTerminal('t1', 'claude', null, idle)).toBe(false) + expect(deps.onLimit).toHaveBeenCalledTimes(1) + }) +}) + describe('the one-line banners (round 1)', () => { const env = (over: Partial = {}): DecideEnv => ({ view: view([A]), dialog: null, banners: [], mayOpen: true, newId: () => 'b9', ...over }) const limit = (c: LimitContext): Exclude => ({ kind: 'limit', ctx: c })