From 7684c3f9a18b5de0d48cab37c28d29754041407c Mon Sep 17 00:00:00 2001 From: Igor Costa Date: Tue, 10 Nov 2026 13:12:39 +1300 Subject: [PATCH 01/19] Complete pending terminal, session, skills, and MCP changes Prevent resize frames from accumulating in terminal scrollback, repair compiled tmux startup, and add native terminal compatibility coverage. Preserve editable input during session restoration and select the latest nonempty project conversation. Finish sync opt-in and bounded command observations, reduce repeated skill metadata, and publish successful completions once their required review approves them. Integrate the pinned Squad bridge, restore interaction modes after Computer Use, and bound malformed MCP newline frames while preserving permission enforcement and resource cleanup. Protect the current Commander option contract and verify transported macOS notarization. Incorporate the remaining work from #745, #743, #742, #727, #690, and #669 without enabling release publication. Co-authored-by: Autohand Evolve --- .github/workflows/ci.yml | 12 +- .github/workflows/full-proof.yml | 5 +- .github/workflows/release.yml | 11 +- .github/workflows/terminal-compatibility.yml | 125 + CHANGELOG.md | 16 + README.md | 12 +- .../2026-11-05-built-in-squad-messaging.md | 136 ++ docs/compatibility/claude-code/README.md | 17 + docs/computer-control.md | 2 + docs/config-reference.md | 53 +- docs/config-reference_cs.md | 12 +- docs/config-reference_de.md | 12 +- docs/config-reference_es.md | 2 +- docs/config-reference_fr.md | 12 +- docs/config-reference_hi.md | 2 +- docs/config-reference_hu.md | 12 +- docs/config-reference_id.md | 2 +- docs/config-reference_it.md | 12 +- docs/config-reference_ja.md | 2 +- docs/config-reference_ko.md | 2 +- docs/config-reference_pl.md | 12 +- docs/config-reference_ptBR.md | 2 +- docs/config-reference_ru.md | 12 +- docs/config-reference_tr.md | 12 +- docs/config-reference_zh-tw.md | 12 +- docs/config-reference_zh.md | 2 +- docs/extension-authoring.md | 2 + .../cli-pr-integration-2026-11-10.md | 25 + docs/mcp.md | 88 + docs/terminal-compatibility.md | 46 + src/commands/README.md | 3 +- src/commands/login.ts | 1 + src/commands/message.ts | 22 +- src/commands/sessions.ts | 4 +- src/commands/settings.ts | 7 + src/commands/squad.ts | 26 +- src/commands/squadConnection.ts | 56 + src/commands/sync.ts | 30 +- src/computer/cuaDriver.ts | 5 +- src/config.ts | 11 +- src/core/actionExecutor.ts | 29 +- src/core/agent.ts | 21 +- src/core/agent/AgentDependencyComposer.ts | 9 +- src/core/agent/AgentLifecycleRunner.ts | 157 +- src/core/agent/AgentToolOutputRuntime.ts | 4 + src/core/agent/CodeModeRunner.ts | 7 + src/core/agent/InstructionRunner.ts | 28 +- src/core/agent/InteractionModeController.ts | 16 + src/core/agent/ReactLoopRunner.ts | 1 - src/core/agent/SystemPromptBuilder.ts | 13 +- src/core/slashCommandHandler.ts | 9 +- src/core/toolManager.ts | 32 +- src/index.ts | 30 +- src/mcp/McpClientManager.ts | 133 +- src/runtime/CliRuntimeResourceOwner.ts | 8 +- src/session/SessionManager.ts | 9 +- src/squad/SquadConnection.ts | 91 + src/squad/builtin.ts | 47 + src/squad/vendor/LICENSE | 21 + src/squad/vendor/SOURCE.md | 15 + src/squad/vendor/control-plane.d.mts | 3 + src/squad/vendor/control-plane.mjs | 366 +++ src/squad/vendor/management.mjs | 141 ++ src/squad/vendor/runtime.d.mts | 11 + src/squad/vendor/runtime.mjs | 525 +++++ src/startup/cliOptions.ts | 9 - src/sync/SyncService.ts | 44 +- src/sync/runtimeSyncService.ts | 27 +- src/sync/types.ts | 4 +- src/testing/drivers/native-terminal-driver.ts | 53 + .../drivers/native-terminal-lab/Host.swift | 8 + .../TerminalCompatibilityUITests.swift | 182 ++ .../drivers/native-terminal-lab/project.yml | 32 + src/testing/drivers/pty-driver.ts | 2 +- src/testing/drivers/tmux-driver.ts | 39 + src/testing/scenarios/fullScreenScenario.ts | 4 + src/testing/scenarios/resumeScenario.ts | 40 + .../scenarios/squadConnectionScenario.ts | 22 + src/types.ts | 13 +- src/ui/ink/InkRenderer.tsx | 37 +- src/ui/shellCommand.ts | 2 + src/utils/commandModelOutput.ts | 99 + src/utils/tmux.ts | 5 +- tests/ci/releaseWorkflow.test.ts | 2 + tests/ci/workflowCheckout.test.ts | 9 + tests/commands/squadConnection.test.ts | 55 + tests/commands/sync.test.ts | 23 +- tests/config.test.ts | 35 +- tests/config/toolsSettings.test.ts | 42 + .../core/actionExecutor.commandOutput.test.ts | 92 + ...AgentLifecycleRunner.fresh-session.test.ts | 20 + tests/core/agent/CodeModeRunner.test.ts | 16 + .../InstructionRunner.command-mode.test.ts | 101 +- .../agent/InteractionModeController.test.ts | 45 + .../core/agent/ReactLoopRunnerStatus.test.ts | 26 + tests/core/agent/SystemPromptBuilder.test.ts | 17 +- tests/core/agent/resumeSessionFreeze.test.ts | 86 +- tests/mcp/squadBuiltin.test.ts | 35 + tests/mcp/stdioConnectionEnv.test.ts | 190 +- tests/runtime/CliRuntimeResourceOwner.test.ts | 11 + tests/session/SessionManager.test.ts | 15 + tests/slashCommandHandler.spec.ts | 2 +- tests/startup/__snapshots__/cli-options.json | 2034 +++++++++++++++++ tests/startup/cliOptions.test.ts | 20 +- tests/startup/cliOptionsSnapshot.test.ts | 196 ++ tests/sync/SyncService.test.ts | 26 + tests/sync/syncOptIn.test.ts | 72 + tests/testing/nativeTerminal.test.ts | 59 + tests/toolManager.spec.ts | 101 + .../autohand-orchestration.tuistory.test.ts | 2 +- .../completion-review.tuistory.test.ts | 74 + .../computer-control.tuistory.test.ts | 79 +- .../continuation-output.tuistory.test.ts | 44 + tests/tuistory/full-screen.tuistory.test.ts | 29 +- .../tuistory/goal-continuity.tuistory.test.ts | 5 +- tests/tuistory/helpers/autohandTuistory.ts | 5 +- .../tuistory/native-terminal.tuistory.test.ts | 25 + .../tuistory/resize-flicker.tuistory.test.ts | 54 +- tests/tuistory/resume.tuistory.test.ts | 51 +- tests/tuistory/settings-sync.tuistory.test.ts | 29 +- .../squad-connection.tuistory.test.ts | 66 + tests/tuistory/tmux-startup.tuistory.test.ts | 77 + tests/ui/ink/InkRenderer.pause-resume.test.ts | 7 +- tests/ui/ink/InkRenderer.resize.test.ts | 63 +- tests/utils/commandModelOutput.test.ts | 76 + tests/utils/sessionZit.spec.ts | 4 +- tests/utils/tmux.spec.ts | 5 + tsup.config.ts | 1 + vitest.setup.ts | 1 + vitest.tuistory.config.ts | 2 + 130 files changed, 6840 insertions(+), 374 deletions(-) create mode 100644 .github/workflows/terminal-compatibility.yml create mode 100644 docs/adr/2026-11-05-built-in-squad-messaging.md create mode 100644 docs/compatibility/claude-code/README.md create mode 100644 docs/maintenance/cli-pr-integration-2026-11-10.md create mode 100644 docs/terminal-compatibility.md create mode 100644 src/commands/squadConnection.ts create mode 100644 src/squad/SquadConnection.ts create mode 100644 src/squad/builtin.ts create mode 100644 src/squad/vendor/LICENSE create mode 100644 src/squad/vendor/SOURCE.md create mode 100644 src/squad/vendor/control-plane.d.mts create mode 100644 src/squad/vendor/control-plane.mjs create mode 100644 src/squad/vendor/management.mjs create mode 100644 src/squad/vendor/runtime.d.mts create mode 100644 src/squad/vendor/runtime.mjs create mode 100644 src/testing/drivers/native-terminal-driver.ts create mode 100644 src/testing/drivers/native-terminal-lab/Host.swift create mode 100644 src/testing/drivers/native-terminal-lab/TerminalCompatibilityUITests.swift create mode 100644 src/testing/drivers/native-terminal-lab/project.yml create mode 100644 src/testing/drivers/tmux-driver.ts create mode 100644 src/testing/scenarios/squadConnectionScenario.ts create mode 100644 src/utils/commandModelOutput.ts create mode 100644 tests/commands/squadConnection.test.ts create mode 100644 tests/config/toolsSettings.test.ts create mode 100644 tests/core/actionExecutor.commandOutput.test.ts create mode 100644 tests/mcp/squadBuiltin.test.ts create mode 100644 tests/startup/__snapshots__/cli-options.json create mode 100644 tests/startup/cliOptionsSnapshot.test.ts create mode 100644 tests/sync/syncOptIn.test.ts create mode 100644 tests/testing/nativeTerminal.test.ts create mode 100644 tests/tuistory/completion-review.tuistory.test.ts create mode 100644 tests/tuistory/continuation-output.tuistory.test.ts create mode 100644 tests/tuistory/native-terminal.tuistory.test.ts create mode 100644 tests/tuistory/squad-connection.tuistory.test.ts create mode 100644 tests/tuistory/tmux-startup.tuistory.test.ts create mode 100644 tests/utils/commandModelOutput.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ca19a317a..87348d94b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,6 +13,7 @@ on: - beta - 'cto/**' - 'nikita/**' + - 'repair/**' workflow_dispatch: concurrency: @@ -81,6 +82,8 @@ jobs: shard: [1, 2, 3] steps: - uses: actions/checkout@v7 + with: + fetch-depth: 0 - name: Setup Node.js uses: actions/setup-node@v7 @@ -95,7 +98,7 @@ jobs: - name: Install build tools (Ubuntu) if: runner.os == 'Linux' - run: sudo apt-get update && sudo apt-get install -y build-essential python3 make g++ ripgrep + run: sudo apt-get update && sudo apt-get install -y build-essential python3 make g++ ripgrep tmux - name: Install dependencies run: bun install --frozen-lockfile @@ -131,7 +134,7 @@ jobs: bun-version-file: package.json - name: Install build tools (Ubuntu) - run: sudo apt-get update && sudo apt-get install -y build-essential python3 make g++ ripgrep + run: sudo apt-get update && sudo apt-get install -y build-essential python3 make g++ ripgrep tmux - name: Install dependencies run: bun install --frozen-lockfile @@ -139,6 +142,9 @@ jobs: - name: Build CLI for terminal tests run: bun run build + - name: Prepare pinned Zit for tmux isolation tests + run: node --input-type=module -e "import { installZit } from './scripts/install-zit.mjs'; await installZit({ force:true });" + - name: Run built terminal tests run: bun run test:tuistory -- --shard=${{ matrix.shard }}/4 @@ -164,7 +170,7 @@ jobs: - name: Install build tools (Ubuntu) if: runner.os == 'Linux' - run: sudo apt-get update && sudo apt-get install -y build-essential python3 make g++ ripgrep + run: sudo apt-get update && sudo apt-get install -y build-essential python3 make g++ ripgrep tmux - name: Install dependencies run: bun install --frozen-lockfile diff --git a/.github/workflows/full-proof.yml b/.github/workflows/full-proof.yml index 4746168c8..891a0b9fa 100644 --- a/.github/workflows/full-proof.yml +++ b/.github/workflows/full-proof.yml @@ -34,7 +34,7 @@ jobs: bun-version-file: package.json - name: Install native build tools - run: sudo apt-get update && sudo apt-get install -y build-essential python3 make g++ ripgrep + run: sudo apt-get update && sudo apt-get install -y build-essential python3 make g++ ripgrep tmux - name: Install dependencies run: bun install --frozen-lockfile @@ -42,5 +42,8 @@ jobs: - name: Prepare built CLI fixtures run: bun run build + - name: Prepare pinned Zit for tmux isolation tests + run: node --input-type=module -e "import { installZit } from './scripts/install-zit.mjs'; await installZit({ force:true });" + - name: Run complete proof run: bun run proof diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8d886a1b6..af6d96095 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -189,6 +189,8 @@ jobs: shard: [1, 2, 3] steps: - uses: actions/checkout@v7 + with: + fetch-depth: 0 - name: Setup Node.js uses: actions/setup-node@v7 @@ -202,7 +204,7 @@ jobs: bun-version-file: package.json - name: Install ripgrep - run: sudo apt-get update && sudo apt-get install -y ripgrep + run: sudo apt-get update && sudo apt-get install -y ripgrep tmux - name: Install dependencies run: bun install --frozen-lockfile @@ -240,7 +242,7 @@ jobs: bun-version-file: package.json - name: Install ripgrep - run: sudo apt-get update && sudo apt-get install -y ripgrep + run: sudo apt-get update && sudo apt-get install -y ripgrep tmux - name: Install dependencies run: bun install --frozen-lockfile @@ -248,6 +250,9 @@ jobs: - name: Build CLI for terminal tests run: bun run build + - name: Prepare pinned Zit for tmux isolation tests + run: node --input-type=module -e "import { installZit } from './scripts/install-zit.mjs'; await installZit({ force:true });" + - name: Run built terminal tests run: bun run test:tuistory -- --shard=${{ matrix.shard }}/4 @@ -572,10 +577,12 @@ jobs: chmod +x "$binary" file "$binary" codesign --verify --strict --verbose=4 "$binary" + codesign --verify --strict -R=notarized --check-notarization "$binary" "$binary" --version < /dev/null chmod +x "$ahtraces" file "$ahtraces" codesign --verify --strict --verbose=4 "$ahtraces" + codesign --verify --strict -R=notarized --check-notarization "$ahtraces" "$ahtraces" --version < /dev/null mkdir -p computer-use-verification tar -xzf "$computer_use_archive" -C computer-use-verification diff --git a/.github/workflows/terminal-compatibility.yml b/.github/workflows/terminal-compatibility.yml new file mode 100644 index 000000000..5dfb13629 --- /dev/null +++ b/.github/workflows/terminal-compatibility.yml @@ -0,0 +1,125 @@ +name: Terminal compatibility + +on: + pull_request: + paths: + - 'src/ui/**' + - 'src/testing/**' + - 'src/utils/tmux.ts' + - 'src/startup/cliOptions.ts' + - 'src/index.ts' + - 'package.json' + - 'bun.lock' + - 'tests/tuistory/*terminal*' + - 'tests/tuistory/*resize*' + - 'tests/tuistory/*tmux*' + - 'tests/testing/nativeTerminal.test.ts' + - '.github/workflows/terminal-compatibility.yml' + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: terminal-compatibility-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + tmux: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + - uses: actions/setup-node@v7 + with: + node-version: 24 + package-manager-cache: false + - uses: oven-sh/setup-bun@v2 + with: + bun-version-file: package.json + - run: sudo apt-get update && sudo apt-get install -y build-essential python3 ripgrep tmux + - run: bun install --frozen-lockfile + - run: bun run build + - name: Prepare pinned Zit for real isolation tests + run: node --input-type=module -e "import { installZit } from './scripts/install-zit.mjs'; await installZit({ force:true });" + - name: Test compiled startup and rapid resize through real tmux + run: bun run test:tuistory tests/tuistory/tmux-startup.tuistory.test.ts tests/tuistory/resize-flicker.tuistory.test.ts + + native-macos: + runs-on: macos-26 + timeout-minutes: 20 + strategy: + fail-fast: false + matrix: + terminal: [iterm2, ghostty, cmux, vscode] + env: + AUTOHAND_NATIVE_TERMINAL: ${{ matrix.terminal }} + HOMEBREW_NO_AUTO_UPDATE: '1' + AUTOHAND_SKIP_COMPUTER_CONTROL_INSTALL: '1' + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + - uses: actions/setup-node@v7 + with: + node-version: 24 + package-manager-cache: false + - uses: oven-sh/setup-bun@v2 + with: + bun-version-file: package.json + - name: Install native terminal and UI test build tools + shell: bash + run: | + set -euo pipefail + brew install xcodegen ripgrep tmux + case "$AUTOHAND_NATIVE_TERMINAL" in + iterm2) brew install --cask iterm2; terminal_app=/Applications/iTerm.app ;; + ghostty) brew install --cask ghostty; terminal_app=/Applications/Ghostty.app ;; + cmux) brew install --cask cmux; terminal_app=/Applications/cmux.app ;; + vscode) brew install --cask visual-studio-code; terminal_app='/Applications/Visual Studio Code.app' ;; + esac + codesign --verify --deep --strict "$terminal_app" + spctl --assess --type execute --verbose "$terminal_app" + # Approve only the verified app on this disposable VM for unattended launch. + xattr -dr com.apple.quarantine "$terminal_app" + /System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister -f "$terminal_app" + defaults read "$terminal_app/Contents/Info" CFBundleIdentifier + system_profiler SPDisplaysDataType + stat -f 'Console user: %Su' /dev/console + mkdir -p .artifacts/native-terminal + brew info --json=v2 --installed > .artifacts/native-terminal/installed-versions.json + # These preferences belong only to this disposable Actions VM. + defaults write com.googlecode.iterm2 EnableAPIServer -bool false + defaults write com.googlecode.iterm2 PromptOnQuit -bool false + defaults write com.googlecode.iterm2 SUEnableAutomaticChecks -bool false + defaults write com.googlecode.iterm2 NSQuitAlwaysKeepsWindows -bool false + defaults write com.googlecode.iterm2 ApplePersistenceIgnoreState -bool true + - run: bun install --frozen-lockfile + - name: Verify native automation boundary and disposable profiles + run: bun run test tests/testing/nativeTerminal.test.ts + - run: bun run build + - name: Run Tuistory with native XCTest window and accessibility checks + run: bun run test:tuistory tests/tuistory/native-terminal.tuistory.test.ts + - name: Export native screen captures + if: always() + shell: bash + run: | + result=".artifacts/native-terminal/$AUTOHAND_NATIVE_TERMINAL/NativeTerminal.xcresult" + if [ -d "$result" ]; then + xcrun xcresulttool export attachments --path "$result" --output-path ".artifacts/native-terminal/$AUTOHAND_NATIVE_TERMINAL/screenshots" + fi + - name: Preserve native screenshots and XCTest results + if: always() + uses: actions/upload-artifact@v7 + with: + name: native-terminal-${{ matrix.terminal }} + path: | + .artifacts/native-terminal/installed-versions.json + .artifacts/native-terminal/**/*.xcresult + .artifacts/native-terminal/**/screenshots + .artifacts/native-terminal/**/xcodebuild.log + .artifacts/native-terminal/**/vscode-profile/User/settings.json + if-no-files-found: error + retention-days: 7 diff --git a/CHANGELOG.md b/CHANGELOG.md index cd85338da..51f933f16 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,9 +2,25 @@ ## Unreleased +### Added + +- Squad MCP now also manages agent profiles, abilities, extra project folders, skills, group chats, channel membership and scheduled routines. +- Squad is built in as an MCP connection. Select an agent in the running app with `/squad`, send it direct messages with `/message`, and read conversation history and delivery receipts. The connection also reads Squad artifacts and memories. + ### Fixed - Start chat and file work in ordinary folders without initializing Git or creating an ignore file. Existing repositories still show their branch; initialize Git only when you choose to use it. +- Keep the composer editable while restoring saved conversations, preserve drafts and early submissions, and wait for restored history before executing queued work. +- Prevent full-screen resize frames from accumulating in iTerm2 scrollback, preserve drafts and cursor edits, and relaunch compiled executables correctly inside tmux. Plain `--tmux` uses the current folder; `--worktree` and `--zit` request isolation explicitly. +- Restore the previous interaction mode after a Computer Use task while preserving manual mode changes, explicit permission denials, hooks, and restricted mode. MCP startup waits for the child process and decodes fragmented UTF-8 responses correctly. +- Continue the latest nonempty conversation in the current project, and keep background sync disabled until explicitly enabled. Disabling sync cancels queued and active work immediately. +- Bound command output sent to the model while retaining complete oversized output in private session artifacts. Configure the limit with `tools.maxCommandOutputChars` or `AUTOHAND_MAX_COMMAND_OUTPUT_CHARS`. +- Discover installed skills through a compact name index and load descriptions on demand. Keep approved completions to one confirmed reply instead of displaying the candidate before its required review. +- Require notarization checks for transported macOS CLI and trace binaries, and protect the existing CLI option contract with a committed snapshot. + +### Testing + +- Exercise resize behavior through real tmux and native iTerm2, Ghostty, cmux, and VS Code windows in GitHub Actions, with the iTerm2 Python API disabled. ## 0.9.10 - 2026-11-04 diff --git a/README.md b/README.md index 131062c66..f0d4a3480 100644 --- a/README.md +++ b/README.md @@ -266,7 +266,7 @@ events and writes exactly one final `result` or `error` object to stdout. | `--permissions` | | Display current permission settings and exit | | `--login` | | Sign in to your Autohand Code account | | `--logout` | | Sign out of your Autohand Code account | -| `--sync-settings [bool]` | | Enable/disable settings sync (default: true for logged users) | +| `--sync-settings [bool]` | | Enable/disable settings sync (default: false; explicit opt-in required) | | `--patch` | | Generate git patch without applying changes | | `--output ` | | Output file for patch (default: stdout) | | `--mode ` | | Run mode: interactive (default), rpc, or acp | @@ -423,7 +423,7 @@ Node installs use `HTTPS_PROXY`, `HTTP_PROXY` and `NO_PROXY` for requests, inclu | `/goals complete` | Mark the current goal complete and advance queued work when completion requirements are met | | `/goals clear` | Clear this session's current goal while retaining queued work | | `/goals templates` | List reusable goal templates | -| `/squad` | Open/manage the local Autohand Squad runtime | +| `/squad` | Select an agent in the running Squad app; `/squad app` opens or installs it | | `/go` | Pair this session with the Autohand Code iOS app | | `/sync` | Sync settings across devices | | `/add-dir` | Add additional workspace directory | @@ -436,12 +436,18 @@ Node installs use `HTTPS_PROXY`, `HTTP_PROXY` and `NO_PROXY` for requests, inclu | `/mcp install` | Install validated Official MCP Registry servers (stdio and HTTP) | | `/team` | Manage team collaboration | | `/tasks` | List team tasks | -| `/message` | Send team message | +| `/message` | Message the selected Squad agent or a local CLI teammate | | `/import` | Import data from Claude, Codex, Gemini, Cursor, OpenCode, Kimi, Grok hooks, and other agents | | `/repeat` | Repeat previous actions | | `/browser` | Browser integration | | `/review` | Code review | +Squad MCP is built in. Select an agent with `/squad`, then use `/message ` +and `/squad history`. `/message squad ` addresses an agent +explicitly; `/message team ` addresses your local CLI team. +See [Squad connection and commands](docs/mcp.md#built-in-autohand-squad) for +delivery receipts, Library tools, bridge discovery and opt-out settings. + Persistent goal data remains in the workspace across conversations, but execution does not silently transfer work owned by another live session. Bare `/goal` inspects prior state and starts the next queued item when this session has no goal and no live peer owns the backlog. Use `/goal resume` to resume this session's paused goal or explicitly start queued work. Published CLI announcements appear as a cached launch block and a persistent line above the composer. Press `Ctrl+X` to dismiss the visible item, or use `/whatsnew` to review and dismiss all active announcements. Dismissal is per announcement; `--offline` keeps cached announcements visible without making announcement requests. diff --git a/docs/adr/2026-11-05-built-in-squad-messaging.md b/docs/adr/2026-11-05-built-in-squad-messaging.md new file mode 100644 index 000000000..3a1f9da5c --- /dev/null +++ b/docs/adr/2026-11-05-built-in-squad-messaging.md @@ -0,0 +1,136 @@ +# Built-in Squad messaging uses the running app's queues + +Date: 2026-11-05 + +## Context + +`/message` only addressed the active local CLI team, while `/squad` opened +or installed the app. Squad's external `send_message` started independent +Mission Control runs. Those paths could not select a desktop agent and send +a message into its actual direct conversation. The app already owns durable +message queues, saved agent permissions, transcripts, artifacts and memories. + +## Decision + +Add a session-owned Squad connection and a keyboard agent picker using the +existing Ink modal and input pause/resume hooks. A selected desktop agent +receives `/message `; `/message team` explicitly addresses the existing +local team. Explicit `/message squad ` is a one-off recipient. +IDs disambiguate duplicate names. Preserve top-level `autohand squad` app +launch and legacy standalone commands. No implicit app install/start is +performed when using the connection. + +Bundle the Squad operator MCP schemas/runtime from `autohandai/squad`, with +MIT attribution and source revision, instead of requiring another package or +an installed app executable. Inject it into the runtime MCP list without +persisting or syncing it. Node/Bun source installs launch their entrypoint +with the current runtime; standalone binaries launch themselves directly. +Honor disabled MCP, opt-out, bare, offline and no-daemon behavior. Configured +Squad entries win. Managed member environment/connection markers prevent +operator access from being added to an agent already inside Squad. + +Both slash commands and MCP use the same loopback bridge/tool implementation. +Direct sends enqueue in the actual member conversation; they preserve busy +work and paused queues, and return a durable receipt. The CLI sends no model, +engine or permission override. Library access is read-only. Existing normal +MCP discovery, permission checks and tool execution remain in the path. + +## Evidence and limits + +Focused command/MCP/installer/computer-use tests pass (41 tests), as do +type checking and lint. `tests/tuistory/squad-connection.tuistory.test.ts` +drives the built CLI through keyboard selection, direct delivery/history and +normal `tool_search`/`run_tool_script` invocation of the bundled MCP against +an isolated HTTP fixture. Squad's `scripts/check-control-plane.mjs` separately +checks actual bridge delivery and transcript persistence with a fake engine. +No live agent/provider is used as a test destination. Full `bun run proof` +is required before integration; its final result is recorded with the change. + +`AUTOHAND_TEST_WORKERS=1 bun run proof` passed lint, type checking and all +965 executed unit files: 13,786 tests passed and 80 were skipped. Earlier +parallel runs exposed an old exact-context assertion (updated for the new +connection) and intermittent cancellation/file-lock deadlines in unchanged +tests. Those timing tests pass in isolation; the memory-index test also +passes on unchanged `main`. Their implementation and timeout budgets were +left intact. The serial run removes competing test workers without skipping +coverage; terminal proof follows the rebuilt CLI in the same command. + +The complete terminal run executed 538 cases across 110 files: 532 passed, +four failed and two were skipped, so the overall proof command exited 1. +The orchestration-toggle and caret cases passed focused reruns; all 20 +resize cases passed a full-file rerun. Console connector synchronization +failed again at `toolsListed > 0` on both this branch and an independently +built, unchanged `9d10d59d` checkout. The baseline orchestration file (11 +cases), resize case and caret case passed. These results distinguish the +existing reproducible connector issue from intermittent terminal failures; +they do not establish that the full repository proof is green. No production +orchestration, resize, caret or Console-sync code was changed here. + +The new Squad scenarios pass in both full-screen and standard layouts on +the built Node CLI and compiled macOS ARM64 CLI. Their explicit fixture +connection also passes with the terminal runner's default opt-out enabled. + +Unit and terminal runners disable automatic Squad connections by default, +including scenarios that launch the CLI through a custom driver. The Squad +fixture explicitly opts back in against its isolated HTTP server, so generic +tests do not depend on an installed app or accidentally use its bridge. + +The same Tuistory flow also passes with the compiled macOS ARM64 binary +(`AUTOHAND_SQUAD_TEST_BINARY`), including its automatic MCP self-launch. +Before the management expansion, compiled stdio initialization/tool listing +succeeded offline with protocol-only stdout and 18 tools. A separate read-only roster +check against the installed Squad bridge recognized 18 members with presence; +it sent zero messages and made no app lifecycle calls. Local evidence is in +`.artifacts/squad-mcp/compiled-mcp-proof.json` and `live-readonly-proof.json`. + +## Expanded operator management + +The canonical Squad MCP now exposes 37 tools, adding saved member profiles, +abilities, directory grants, skill selections, channel settings/membership, +private groups and scheduled routines. Bundle its `management.mjs` alongside +the runtime and schemas. This keeps tool behavior identical between Squad +and CLI without introducing a second client implementation. Management +requires an updated bridge; a missing member-management endpoint explains +that the app must be updated, without starting an upgrade. + +Profile edits use canonical revisions. Folder grants and skill selections +apply during later engine preparation, preserving current work. Groups reuse +private channels and dispatch only explicit recipients; the bridge persists +replies after client disconnect. Routines require a timezone and are disabled +by default. Operator powers remain unavailable to managed Squad members. +The rationale and controlled bridge evidence are in +[Squad ADR-0181](https://github.com/autohandai/squad/blob/main/docs/adrs/ADR-0181-operator-mcp-manages-saved-members-and-work.md). + +Focused CLI validation: typecheck, 13 related unit tests, and the actual +Tuistory flow in both standard and fullscreen layouts pass. The terminal +scenario now discovers and calls the new `get_member_profile` tool through +the ordinary MCP tool-search/permission path, against an isolated fixture. +Both layouts also pass with the compiled macOS ARM64 executable. Its offline +stdio smoke now lists all 37 tools with protocol-only stdout. The +required full `AUTOHAND_TEST_WORKERS=1 AUTOHAND_TUISTORY_WORKERS=2 bun run proof` +completed: lint/typecheck and all unit files passed (965 files; 13,787 tests, +80 skipped). Terminal results were 108 passing files and two failing files +(527 passed tests, nine failed, two skipped). Console connector sync failed +at `toolsListed > 0`, previously reproduced on the unchanged base. The +extension file had one nonzero process exit and seven explicit `ENOSPC` +fixture-creation failures. This is not a green local aggregate result. + +All 24 reported CI checks on implementation commit `3478d59a` passed, +including the four terminal shards and Windows terminal checks; the optional +manual `proof` job was skipped. Local aggregate evidence is in +`.artifacts/squad-mcp/management-proof.log`. After removing an obsolete generated binary, the focused extension file +passed all 12 tests. The full local aggregate remains failed; this rerun does +not replace it. These results do not claim a published CLI or installed +Squad upgrade. + +## Consequences + +Desktop selection changes the unqualified `/message` recipient for this +session; the selection receipt documents the local-team escape and clearing +command. Selection is not persisted between sessions. There is no continuous +desktop roster polling or composer alias expansion in this change. A paused +queue requires a person in Squad. Send retries are not automatic/idempotent; +after a lost receipt inspect the queue before resending. Read history is +bounded to bridge chat turns; it does not load archived chat files or execute +artifact HTML. The vendored protocol needs deliberate synchronized updates; +no shared package or automatic Squad/CLI release is introduced. diff --git a/docs/compatibility/claude-code/README.md b/docs/compatibility/claude-code/README.md new file mode 100644 index 000000000..55109ecd1 --- /dev/null +++ b/docs/compatibility/claude-code/README.md @@ -0,0 +1,17 @@ +# Built-in CLI option compatibility guard + +The guard imports the real Commander program exported by `src/index.ts`. Importing it does not parse arguments or execute a command. The snapshot in `tests/startup/__snapshots__/cli-options.json` currently records 199 built-in option definitions across 52 command paths, including hidden options and commands with no options. + +The contract records flag spelling, required/optional/variadic arity, negation, and defaults. `hasDefault` distinguishes an omitted default from an explicit null or false value. User-installed extension flags are outside the built-in snapshot. + +Tests compare the current program against both the local snapshot and the snapshot committed on `origin/main`. Changing the golden alongside code cannot silently remove or change an existing option. Jobs running the unit inventory fetch full Git history so the committed comparison is available. The first integration bootstraps this guard because remote main has no snapshot yet. + +To record additive options or commands locally: + +```sh +git fetch origin main +UPDATE_CLI_OPTIONS_SNAPSHOT=1 bun run test -- tests/startup/cliOptionsSnapshot.test.ts +bun run test -- tests/startup/cliOptionsSnapshot.test.ts +``` + +The updater rejects removed or changed entries and refuses to write in CI. Existing semantics require an explicit compatibility decision; regenerating the golden alone does not approve them. This guard introduces no Claude Code compatibility flags and no provider or release changes. diff --git a/docs/computer-control.md b/docs/computer-control.md index b8bbc85b2..19518d719 100644 --- a/docs/computer-control.md +++ b/docs/computer-control.md @@ -11,6 +11,8 @@ scroll down in Chrome and tell me what is visible The built-in `computer-control` skill activates for native GUI requests. Autohand discovers the requested application and window, observes current state, performs the requested input through its local computer use server, and verifies the visible result. +Computer tasks temporarily enter auto mode, including when the composer was in plan mode. The previous mode returns when the task completes, fails, or is cancelled. Selecting a mode yourself during the task keeps your new choice. Explicit restricted and dry-run settings, denied tools, hooks, and operating-system permissions still apply. + ## Install and check The Unix and Windows Autohand installers attempt to install Autohand Computer Use automatically. If that optional step fails or an older CLI does not support it, installation continues and prints setup guidance. The npm package installs it during `postinstall` and reports a nonzero exit status on failure. An existing compatible engine is reused. Headless installations can explicitly opt out as described below. diff --git a/docs/config-reference.md b/docs/config-reference.md index 128183b9a..fe3cc7ab4 100644 --- a/docs/config-reference.md +++ b/docs/config-reference.md @@ -121,6 +121,7 @@ Precedence, lowest first: config file, workspace `.autohand` overlays, environme | `AUTOHAND_YES` | Auto-confirm all prompts | `1` | | `AUTOHAND_NO_BANNER` | Disable startup banner | `1` | | `AUTOHAND_STREAM_TOOL_OUTPUT` | Stream tool output in real-time | `1` | +| `AUTOHAND_MAX_COMMAND_OUTPUT_CHARS` | Override the model-visible command-output limit; integer of at least `1024` | `30000` | | `AUTOHAND_DEBUG` | Enable debug logging | `1` | | `AUTOHAND_THINKING_LEVEL` | Set reasoning depth level | `normal` | | `AUTOHAND_CLIENT_NAME` | Client/editor identifier (set by ACP extensions) | `zed` | @@ -1736,6 +1737,20 @@ git add -A && git commit -m "feat: add user dashboard with charts" --- +## Command Output Limits + +Foreground `run_command` and `shell` results sent to the model are limited to 30,000 characters by default, including combined stdout, stderr, and truncation details. Head and tail output retain the beginning and final result. The same limit applies inside Code Mode scripts and when live output streaming is enabled. + +```json +{ + "tools": { + "maxCommandOutputChars": 30000 + } +} +``` + +`tools.maxCommandOutputChars` accepts integers of at least `1024`. `AUTOHAND_MAX_COMMAND_OUTPUT_CHARS` takes precedence when it contains a valid limit. This is a ceiling: output that fits remains intact; after overflow, a compact head/tail preview uses half the ceiling (at least `1024` characters), including its header and archive marker. Larger configured limits scale that preview. Terminal display limits remain separate. Saved sessions retain the complete raw command output as a private session artifact when truncation is needed; ephemeral sessions create no output archive. + ## Shell Settings Choose the shell used by `run_command`, foreground and background `!` commands, and streaming shells, and control their environment. Hook commands and MCP server processes have their own launchers and are not affected. @@ -1960,6 +1975,8 @@ Load custom agent definitions from external directories. Skills are instruction packages that provide specialized instructions to the AI agent. They work like on-demand `AGENTS.md` files that can be activated for specific tasks. +The default CLI and ACP prompt lists installed skill names. Descriptions are available through `skill info` or `skill list`, and activating a skill loads its instructions. Active skill bodies and project instructions remain in the prompt. This avoids repeating the full description catalog on every provider request, including when adaptive skills are disabled. + ### Adaptive Skills Rollout Adaptive skills is an experimental, local opt-in. Enable it with `autohand experiments enable adaptive_skills` or `autohand config set features.adaptiveSkills true`, then restart the CLI. Disable it with `autohand experiments disable adaptive_skills` and restart to restore the existing skill prompt behavior. The switch does not enable automatic learning, remote decisions, cloud sharing, or a different inference provider. @@ -2355,7 +2372,7 @@ To enable, set share.enabled: true in your config file. ## Settings Sync -Autohand can sync your configuration across devices for logged-in users. Settings are stored securely in Cloudflare R2 and encrypted before upload. +Autohand can sync your configuration across devices for logged-in users. Background sync is off by default and requires explicit opt-in. Settings are stored securely in Cloudflare R2 and encrypted before upload. ```json { @@ -2371,7 +2388,7 @@ Autohand can sync your configuration across devices for logged-in users. Setting | Field | Type | Default | Description | | ------------------ | -------- | --------------- | -------------------------------------------------- | -| `enabled` | boolean | `true` (logged) | Enable/disable settings sync | +| `enabled` | boolean | `false` | Enable/disable settings sync | | `interval` | number | `300000` | Sync interval in milliseconds (default: 5 minutes) | | `exclude` | string[] | `[]` | Glob patterns to exclude from sync | | `includeTelemetry` | boolean | `false` | Sync telemetry data (requires user consent) | @@ -2380,6 +2397,8 @@ Autohand can sync your configuration across devices for logged-in users. Setting Open `/status` or `/sync` to inspect state and sync actions. Press Esc to return to the conversation. These panels retain visible output when launched through `bun run dev`, including after a renderer handoff. +Use `/sync on` to enable background sync in the current process and save the preference. `/sync off` immediately cancels the timer and pending sync work before saving the disabled preference. Turning sync back on starts it again in the same process. + A normal sync can continue transferring healthy settings when a remote saved-session object returns HTTP 404. It withholds the affected session's requested files, the session index, and uploads for that session; existing local files and the remote manifest records remain intact. The panel retains an incomplete-history warning. This does not recover missing cloud bytes or establish that the conversation was downloaded successfully. Retry from a device that still has the complete session, or restore its backup. Force operations, missing download URLs, non-session 404s, authentication failures, and other download failures remain errors. Session downloads are staged privately on disk with bounded storage before publication. Other sync roots have a 100 MiB budget; the aggregate limit is 2 GiB and 5,000 manifest files. ### CLI Flag @@ -2388,13 +2407,13 @@ A normal sync can continue transferring healthy settings when a remote saved-ses # Disable sync for this session autohand --sync-settings=false -# Enable sync (default for logged users) +# Explicitly enable sync for this session autohand --sync-settings ``` ### What Gets Synced -By default, these items are synced for logged-in users: +When sync is enabled, these items are included by default: - **Configuration** (`config.json`) - API keys are encrypted before upload - **Custom agents** (`agents/`) @@ -2500,6 +2519,13 @@ sync: Configure MCP (Model Context Protocol) servers to extend Autohand with external tools. +The effective runtime list also includes the built-in `autohand-squad` server; +it is not written into this configuration or synced to your account. Set +`AUTOHAND_DISABLE_SQUAD=1` to opt out, or `mcp.enabled: false` to disable all +MCP connections. Bare, offline and no-daemon sessions omit it, as do managed +Squad members with an existing identity-scoped connection. See +[built-in Squad commands and bridge discovery](mcp.md#built-in-autohand-squad). + ```json { "mcp": { @@ -3101,7 +3127,7 @@ When a noninteractive task needs approval, it stops at the first missing authori | Flag | Description | | ----------------------------- | ---------------------------------------------------------------------------------------------- | | `--worktree [name]` | Run session in isolated git worktree (optional worktree/branch name) | -| `--tmux` | Launch in a dedicated tmux session (implies `--worktree`; cannot be used with `--no-worktree`) | +| `--tmux` | Launch in a dedicated tmux session in the current directory; supports `--worktree` or `--zit` | | `--zit [intent]` | Run session in a disposable Zit workspace, recorded as a Zit change on exit (optional intent) | | `--no-worktree` | Disable git worktree isolation in auto-mode | | `-c, --continue` | Resume the latest session in this folder, including with `-p`; start fresh when none exists | @@ -3137,7 +3163,18 @@ What happens: Inspect the result with `zit status` and `zit show `; accepting the change into the current state is left to you (`zit accept`). -`--zit` cannot be combined with `--worktree`, `--tmux`, `--patch`, `--mode rpc|acp`, or a standalone `--auto-mode` loop. +`--zit` cannot be combined with `--worktree`, `--patch`, `--mode rpc|acp`, or a standalone `--auto-mode` loop. + +`--tmux` uses the current directory by default, including directories outside Git repositories. Workspace isolation is optional: + +```bash +autohand --tmux +autohand --tmux --worktree my-task +autohand --tmux --zit +``` + +Use `--worktree` or `--zit` when isolation is needed; `--tmux --no-worktree` is also supported. + ### Auto-Mode @@ -3168,7 +3205,7 @@ Claude imports count both flat skill files and folders containing `SKILL.md`. De | ----------------------------- | ---------------------------------------------------------------------------------------------- | | `--login` | Sign in to your Autohand account | | `--logout` | Sign out of your Autohand account | -| `--sync-settings` | Enable/disable settings sync (default: true for logged users) | +| `--sync-settings [bool]` | Explicitly enable or disable settings sync (default: false) | ### Setup & Info @@ -3288,6 +3325,8 @@ Type `/` in the REPL to browse commands with Up/Down. The menu scrolls through e From the shell, `autohand resume` opens a picker scoped to the current working directory. Use `--path ` to select another workspace, `--all` to browse every project, or `--last` to resume the most recently active session. `--last --all` selects the most recently active session across projects. Older metadata without a valid activity timestamp falls back to creation time. +The composer is editable while managers and saved history load, including when request queueing is disabled. Drafts survive restoration. If you submit before restoration finishes, that instruction waits and then runs once with the restored conversation; opening a saved session alone does not submit work. Saved goals still require explicit resumption. + `autohand resume ` accepts a saved session name (set with `/rename` or `--rename`, matched case-insensitively), a full session ID, a unique ID prefix, or a saved session directory/file. A name wins over an ID prefix. Ambiguous or missing references exit with an error, and a name shared by several sessions lists their ID prefixes. Explicit references cannot be combined with `--last` or `--all`. `--config`, `--model`, and `--offline` remain available. `-c` continues the latest session; use `--auto-commit` for automatic commits. Restored sessions become ready for input without repeating fresh-session initialization. Configured MCP servers connect in the background; a slow or failed server does not prevent the restored composer from accepting input. diff --git a/docs/config-reference_cs.md b/docs/config-reference_cs.md index 618dd1eec..993fc60d8 100644 --- a/docs/config-reference_cs.md +++ b/docs/config-reference_cs.md @@ -1370,7 +1370,7 @@ Autohand může synchronizovat vaši konfiguraci mezi zařízeními pro přihlá ``` | Pole | Typ | Výchozí | Popis | | ------------------- | -------- | ---------------- | --------------------------------------------------- | -| `enabled` | booleovský | `true` (přihlášeno) | Povolit/zakázat synchronizaci nastavení | +| `enabled` | booleovský | `false` | Povolit/zakázat synchronizaci nastavení | | `interval` | číslo | `300000` | Interval synchronizace v milisekundách (výchozí: 5 minut) | | `exclude` | řetězec[] | `[]` | Vzory globusů k vyloučení ze synchronizace | | `includeTelemetry` | booleovský | `false` | Synchronizace telemetrických dat (vyžaduje souhlas uživatele) | @@ -1381,12 +1381,12 @@ Autohand může synchronizovat vaši konfiguraci mezi zařízeními pro přihlá # Disable sync for this session autohand --sync-settings=false -# Enable sync (default for logged users) +# Explicitly enable sync (disabled by default) autohand --sync-settings ``` ### Co se synchronizuje -Ve výchozím nastavení se pro přihlášené uživatele synchronizují tyto položky: +Pokud je synchronizace povolena, synchronizují se tyto položky: - **Konfigurace** (`config.json`) - Klíče API jsou před nahráním zašifrovány – **Vlastní zástupci** (`agents/`) @@ -1439,7 +1439,7 @@ Přihlašovací token aplikace se odesílá v hlavičce `Authorization` pouze na ### Jak to funguje -1. **Při spuštění**: Pokud jste přihlášeni, služba synchronizace se spustí automaticky +1. **Při spuštění**: Pokud jste přihlášeni a výslovně jste povolili synchronizaci, služba se spustí automaticky 2. **Každých 5 minut**: Nastavení se porovnávají s cloudovým úložištěm 3. **Cloud vyhrává**: Vzdálené změny se stahují jako první 4. **Místní nahrání**: Nahrají se nové místní změny @@ -1931,7 +1931,7 @@ Tyto příznaky přepisují nastavení konfiguračního souboru: | Vlajka | Popis | | ------------------------------ | ---------------------------------------------------------------------------------------------- | | `--worktree [name]` | Spustit relaci v izolovaném pracovním stromu git (volitelný název pracovního stromu/větve) | -| `--tmux` | Spustit ve vyhrazené relaci tmux (předpokládá `--worktree`; nelze použít s `--no-worktree`) | +| `--tmux` | Spustit ve vyhrazené relaci tmux v aktuálním adresáři; podporuje `--worktree` nebo `--zit` | | `--no-worktree` | Zakázat izolaci pracovního stromu git v automatickém režimu | | `--auto-commit` | Automatické potvrzení změn po dokončení úkolů | | `--patch` | Vygenerujte git patch bez použití změn | @@ -1964,7 +1964,7 @@ Tyto příznaky přepisují nastavení konfiguračního souboru: | ------------------------------ | ---------------------------------------------------------------------------------------------- | | `--login` | Přihlaste se ke svému účtu Autohand | | `--logout` | Odhlaste se ze svého účtu Autohand | -| `--sync-settings` | Povolit/zakázat synchronizaci nastavení (výchozí: true pro přihlášené uživatele) | +| `--sync-settings` | Povolit/zakázat synchronizaci nastavení (výchozí: false) | ### Nastavení a informace diff --git a/docs/config-reference_de.md b/docs/config-reference_de.md index 9ab7e1d10..eb87571f6 100644 --- a/docs/config-reference_de.md +++ b/docs/config-reference_de.md @@ -1465,7 +1465,7 @@ Autohand kann Ihre Konfiguration über Geräte hinweg für angemeldete Benutzer | Feld | Typ | Standard | Beschreibung | | ------------------ | -------- | --------------- | -------------------------------------------------- | -| `enabled` | boolean | `true` (angemeldet) | Einstellungs-Synchronisierung aktivieren/deaktivieren | +| `enabled` | boolean | `false` | Einstellungs-Synchronisierung aktivieren/deaktivieren | | `interval` | number | `300000` | Synchronisierungsintervall in Millisekunden (Standard: 5 Minuten) | | `exclude` | string[] | `[]` | Glob-Muster, die von der Synchronisierung ausgeschlossen werden | | `includeTelemetry` | boolean | `false` | Telemetriedaten synchronisieren (erfordert Benutzereinwilligung) | @@ -1477,13 +1477,13 @@ Autohand kann Ihre Konfiguration über Geräte hinweg für angemeldete Benutzer # Synchronisierung für diese Sitzung deaktivieren autohand --sync-settings=false -# Synchronisierung aktivieren (Standard für angemeldete Benutzer) +# Synchronisierung ausdrücklich aktivieren (standardmäßig deaktiviert) autohand --sync-settings ``` ### Was wird synchronisiert -Standardmäßig werden diese Elemente für angemeldete Benutzer synchronisiert: +Wenn die Synchronisierung aktiviert ist, werden diese Elemente synchronisiert: - **Konfiguration** (`config.json`) - API-Schlüssel werden vor dem Upload verschlüsselt - **Benutzerdefinierte Agenten** (`agents/`) @@ -1538,7 +1538,7 @@ Das Anmeldetoken der Anwendung wird im `Authorization`-Header nur an Übertragun ### Wie es funktioniert -1. **Beim Start**: Wenn Sie angemeldet sind, startet der Synchronisierungsdienst automatisch +1. **Beim Start**: Wenn Sie angemeldet sind und die Synchronisierung ausdrücklich aktiviert haben, startet der Dienst automatisch 2. **Alle 5 Minuten**: Einstellungen werden mit dem Cloud-Speicher verglichen 3. **Cloud gewinnt**: Remote-Änderungen werden zuerst heruntergeladen 4. **Lokale Uploads**: Neue lokale Änderungen werden hochgeladen @@ -2054,7 +2054,7 @@ Diese Flags überschreiben Konfigurationsdatei-Einstellungen: | Flag | Beschreibung | | ----------------------------- | ---------------------------------------------------------------------------------------------- | | `--worktree [name]` | Sitzung in isoliertem Git-Worktree ausführen (optionaler Worktree-/Branch-Name) | -| `--tmux` | In dedizierter tmux-Sitzung starten (impliziert `--worktree`; kann nicht mit `--no-worktree` verwendet werden) | +| `--tmux` | In dedizierter tmux-Sitzung im aktuellen Verzeichnis starten; unterstützt `--worktree` oder `--zit` | | `--no-worktree` | Git-Worktree-Isolierung im Auto-Modus deaktivieren | | `--auto-commit` | Änderungen nach Abschluss der Aufgaben automatisch committen | | `--patch` | Git-Patch generieren, ohne Änderungen anzuwenden | @@ -2088,7 +2088,7 @@ Diese Flags überschreiben Konfigurationsdatei-Einstellungen: | ----------------------------- | ---------------------------------------------------------------------------------------------- | | `--login` | Bei Ihrem Autohand-Konto anmelden | | `--logout` | Von Ihrem Autohand-Konto abmelden | -| `--sync-settings` | Einstellungssynchronisierung aktivieren/deaktivieren (Standard: true für angemeldete Benutzer) | +| `--sync-settings` | Einstellungssynchronisierung aktivieren/deaktivieren (Standard: false) | ### Einrichtung & Info diff --git a/docs/config-reference_es.md b/docs/config-reference_es.md index 0b088f5e0..2ec753e74 100644 --- a/docs/config-reference_es.md +++ b/docs/config-reference_es.md @@ -1396,7 +1396,7 @@ Estos flags sobrescriben la configuración del archivo: | `--model ` | Sobrescribir modelo | | `--path ` | Sobrescribir raíz del espacio de trabajo | | `--worktree [nombre]` | Ejecutar sesión en un git worktree aislado (nombre opcional de worktree/rama) | -| `--tmux` | Iniciar en una sesión tmux dedicada (implica `--worktree`; no se puede usar con `--no-worktree`) | +| `--tmux` | Iniciar una sesión tmux dedicada en el directorio actual; admite `--worktree` o `--zit` | | `--add-dir ` | Agregar directorios adicionales al alcance del espacio de trabajo (se puede usar múltiples veces) | | `--config ` | Usar archivo de configuración personalizado | | `--temperature ` | Establecer temperatura (0-1) | diff --git a/docs/config-reference_fr.md b/docs/config-reference_fr.md index bc938ade1..25c2ab167 100644 --- a/docs/config-reference_fr.md +++ b/docs/config-reference_fr.md @@ -1352,7 +1352,7 @@ Autohand peut synchroniser votre configuration sur tous les appareils pour les u ``` | Champ | Tapez | Par défaut | Descriptif | | ------------------ | -------- | --------------- | -------------------------------------------------- | -| `enabled` | booléen | `true` (enregistré) | Activer/désactiver la synchronisation des paramètres | +| `enabled` | booléen | `false` | Activer/désactiver la synchronisation des paramètres | | `interval` | numéro | `300000` | Intervalle de synchronisation en millisecondes (par défaut : 5 minutes) | | `exclude` | chaîne[] | `[]` | Modèles Glob à exclure de la synchronisation | | `includeTelemetry` | booléen | `false` | Synchroniser les données de télémétrie (nécessite le consentement de l'utilisateur) | @@ -1363,12 +1363,12 @@ Autohand peut synchroniser votre configuration sur tous les appareils pour les u # Disable sync for this session autohand --sync-settings=false -# Enable sync (default for logged users) +# Explicitly enable sync (disabled by default) autohand --sync-settings ``` ### Ce qui est synchronisé -Par défaut, ces éléments sont synchronisés pour les utilisateurs connectés : +Lorsque la synchronisation est activée, ces éléments sont synchronisés : - **Configuration** (`config.json`) - Les clés API sont cryptées avant le téléchargement - **Agents personnalisés** (`agents/`) @@ -1421,7 +1421,7 @@ Le jeton de connexion de l’application n’est envoyé dans l’en-tête `Auth ### Comment ça marche -1. **Au démarrage** : si vous êtes connecté, le service de synchronisation démarre automatiquement +1. **Au démarrage** : le service démarre automatiquement si vous êtes connecté et avez explicitement activé la synchronisation 2. **Toutes les 5 minutes** : les paramètres sont comparés au stockage cloud 3. **Le cloud gagne** : les modifications à distance sont téléchargées en premier 4. **Téléchargements locaux** : les nouvelles modifications locales sont téléchargées @@ -1913,7 +1913,7 @@ Ces indicateurs remplacent les paramètres du fichier de configuration : | Drapeau | Descriptif | | ----------------------------- | ------------------------------------------------------------------------------------------------------------- | | `--worktree [name]` | Exécuter la session dans un arbre de travail git isolé (nom de l'arbre de travail/de la branche facultatif) | -| `--tmux` | Lancer dans une session tmux dédiée (implique `--worktree` ; ne peut pas être utilisé avec `--no-worktree`) | +| `--tmux` | Lancer une session tmux dédiée dans le répertoire courant ; accepte `--worktree` ou `--zit` | | `--no-worktree` | Désactiver l'isolation de git worktree en mode automatique | | `--auto-commit` | Valider automatiquement les modifications après avoir terminé les tâches | | `--patch` | Générer le patch git sans appliquer les modifications | @@ -1946,7 +1946,7 @@ Ces indicateurs remplacent les paramètres du fichier de configuration : | ----------------------------- | ------------------------------------------------------------------------------------------------------------- | | `--login` | Connectez-vous à votre compte Autohand | | `--logout` | Déconnectez-vous de votre compte Autohand | -| `--sync-settings` | Activer/désactiver la synchronisation des paramètres (par défaut : vrai pour les utilisateurs connectés) | +| `--sync-settings` | Activer/désactiver la synchronisation des paramètres (par défaut : false) | ### Configuration et informations diff --git a/docs/config-reference_hi.md b/docs/config-reference_hi.md index 997ed5348..2e310d7ec 100644 --- a/docs/config-reference_hi.md +++ b/docs/config-reference_hi.md @@ -1309,7 +1309,7 @@ Autohand `~/.autohand/` (या `$AUTOHAND_HOME`) में डेटा स् | `--model ` | मॉडल ओवरराइड करें | | `--path ` | वर्कस्पेस रूट ओवरराइड करें | | `--worktree [name]` | सेशन को अलग git worktree में चलाएँ (वैकल्पिक worktree/branch नाम) | -| `--tmux` | समर्पित tmux सेशन में शुरू करें (`--worktree` निहित; `--no-worktree` के साथ उपयोग नहीं कर सकते) | +| `--tmux` | वर्तमान निर्देशिका में समर्पित tmux सेशन शुरू करें; `--worktree` या `--zit` समर्थित हैं | | `--add-dir ` | वर्कस्पेस स्कोप में अतिरिक्त डायरेक्टरी जोड़ें (कई बार उपयोग किया जा सकता है) | | `--config ` | कस्टम कॉन्फिग फाइल का उपयोग करें | | `--temperature ` | टेम्परेचर सेट करें (0-1) | diff --git a/docs/config-reference_hu.md b/docs/config-reference_hu.md index 79d36139c..9300dd8ff 100644 --- a/docs/config-reference_hu.md +++ b/docs/config-reference_hu.md @@ -1352,7 +1352,7 @@ A Autohand szinkronizálhatja a konfigurációt az eszközök között a bejelen ``` | Mező | Típus | Alapértelmezett | Leírás | | ------------------- | -------- | ---------------- | --------------------------------------------------- | -| `enabled` | logikai | `true` (naplózva) | Beállítások szinkronizálásának engedélyezése/letiltása | +| `enabled` | logikai | `false` | Beállítások szinkronizálásának engedélyezése/letiltása | | `interval` | szám | `300000` | Szinkronizálási idő ezredmásodpercben (alapértelmezett: 5 perc) | | `exclude` | string[] | `[]` | Globális minták a szinkronizálásból kizárandó | | `includeTelemetry` | logikai | `false` | Telemetriai adatok szinkronizálása (felhasználói hozzájárulás szükséges) | @@ -1363,12 +1363,12 @@ A Autohand szinkronizálhatja a konfigurációt az eszközök között a bejelen # Disable sync for this session autohand --sync-settings=false -# Enable sync (default for logged users) +# Explicitly enable sync (disabled by default) autohand --sync-settings ``` ### Mi lesz szinkronizálva -Alapértelmezés szerint ezek az elemek szinkronizálva vannak a bejelentkezett felhasználók számára: +Ha a szinkronizálás engedélyezve van, a következő elemek szinkronizálódnak: - **Konfiguráció** (`config.json`) - Az API-kulcsok a feltöltés előtt titkosítva vannak - **Egyéni ügynökök** (`agents/`) @@ -1421,7 +1421,7 @@ Az alkalmazás bejelentkezési tokenje az `Authorization` fejlécben csak olyan ### Hogyan működik -1. **Indításkor**: Ha be van jelentkezve, a szinkronizálási szolgáltatás automatikusan elindul +1. **Indításkor**: A szolgáltatás akkor indul automatikusan, ha be van jelentkezve, és kifejezetten engedélyezte a szinkronizálást 2. **5 percenként**: A beállításokat összehasonlítja a felhőalapú tárolással 3. **A felhő nyer**: A távoli módosítások letöltése először történik meg 4. **Helyi feltöltések**: Új helyi módosítások kerülnek feltöltésre @@ -1913,7 +1913,7 @@ Ezek a jelzők felülírják a konfigurációs fájl beállításait: | zászló | Leírás | | ------------------------------ | ---------------------------------------------------------------------------------------------- | | `--worktree [name]` | Munkamenet futtatása elszigetelt git-munkafán (opcionális munkafa/ág neve) | -| `--tmux` | Indítás egy dedikált tmux munkamenetben (az `--worktree`-t jelenti; nem használható a `--no-worktree` kóddal) | +| `--tmux` | Indítás dedikált tmux munkamenetben az aktuális könyvtárban; támogatja a `--worktree` és `--zit` kapcsolókat | | `--no-worktree` | A git munkafa elkülönítésének letiltása automatikus módban | | `--auto-commit` | Változások automatikus véglegesítése a feladatok elvégzése után | | `--patch` | Git javítás generálása változtatások alkalmazása nélkül | @@ -1946,7 +1946,7 @@ Ezek a jelzők felülírják a konfigurációs fájl beállításait: | ------------------------------ | ---------------------------------------------------------------------------------------------- | | `--login` | Jelentkezzen be Autohand-fiókjába | | `--logout` | Jelentkezzen ki Autohand-fiókjából | -| `--sync-settings` | A beállítások szinkronizálásának engedélyezése/letiltása (alapértelmezett: igaz a bejelentkezett felhasználók számára) | +| `--sync-settings` | A beállítások szinkronizálásának engedélyezése/letiltása (alapértelmezett: false) | ### Beállítás és információ diff --git a/docs/config-reference_id.md b/docs/config-reference_id.md index 541177ca5..c71d8fcc9 100644 --- a/docs/config-reference_id.md +++ b/docs/config-reference_id.md @@ -1367,7 +1367,7 @@ Flag-flag ini mengganti pengaturan file konfigurasi: | `--model ` | Ganti model | | `--path ` | Ganti root workspace | | `--worktree [nama]` | Jalankan sesi di git worktree terisolasi (nama worktree/branch opsional) | -| `--tmux` | Jalankan dalam sesi tmux khusus (mengimplikasikan `--worktree`; tidak bisa dipakai dengan `--no-worktree`) | +| `--tmux` | Jalankan sesi tmux khusus di direktori saat ini; mendukung `--worktree` atau `--zit` | | `--add-dir ` | Tambahkan direktori tambahan ke lingkup workspace (dapat digunakan beberapa kali) | | `--config ` | Gunakan file konfigurasi kustom | | `--temperature ` | Atur temperature (0-1) | diff --git a/docs/config-reference_it.md b/docs/config-reference_it.md index 9690ad488..7f9d5724e 100644 --- a/docs/config-reference_it.md +++ b/docs/config-reference_it.md @@ -1352,7 +1352,7 @@ Autohand può sincronizzare la tua configurazione su tutti i dispositivi per gli ``` | Campo | Digitare | Predefinito | Descrizione | | ------------------ | -------- | --------------- | -------------------------------------------------- | -| `enabled` | booleano | `true` (registrato) | Abilita/disabilita la sincronizzazione delle impostazioni | +| `enabled` | booleano | `false` | Abilita/disabilita la sincronizzazione delle impostazioni | | `interval` | numero | `300000` | Intervallo di sincronizzazione in millisecondi (impostazione predefinita: 5 minuti) | | `exclude` | stringa[] | `[]` | Modelli globali da escludere dalla sincronizzazione | | `includeTelemetry` | booleano | `false` | Sincronizza i dati di telemetria (richiede il consenso dell'utente) | @@ -1363,12 +1363,12 @@ Autohand può sincronizzare la tua configurazione su tutti i dispositivi per gli # Disable sync for this session autohand --sync-settings=false -# Enable sync (default for logged users) +# Explicitly enable sync (disabled by default) autohand --sync-settings ``` ### Cosa viene sincronizzato -Per impostazione predefinita, questi elementi vengono sincronizzati per gli utenti che hanno effettuato l'accesso: +Quando la sincronizzazione è attivata, vengono sincronizzati questi elementi: - **Configurazione** (`config.json`) - Le chiavi API vengono crittografate prima del caricamento - **Agenti personalizzati** (`agents/`) @@ -1421,7 +1421,7 @@ Il token di accesso dell’applicazione viene inviato nell’intestazione `Autho ### Come funziona -1. **All'avvio**: se hai effettuato l'accesso, il servizio di sincronizzazione si avvia automaticamente +1. **All'avvio**: il servizio si avvia automaticamente se hai effettuato l’accesso e hai attivato esplicitamente la sincronizzazione 2. **Ogni 5 minuti**: le impostazioni vengono confrontate con l'archiviazione nel cloud 3. **Il cloud vince**: le modifiche remote vengono scaricate per prime 4. **Caricamenti locali**: vengono caricate nuove modifiche locali @@ -1913,7 +1913,7 @@ Questi flag sovrascrivono le impostazioni del file di configurazione: | Bandiera | Descrizione | | ----------------------- | ---------------------------------------------------------------------------------------- | | `--worktree [name]` | Esegui la sessione in un albero di lavoro git isolato (nome albero di lavoro/ramo opzionale) | -| `--tmux` | Avvia in una sessione tmux dedicata (implica `--worktree`; non può essere utilizzato con `--no-worktree`) | +| `--tmux` | Avvia una sessione tmux dedicata nella directory corrente; supporta `--worktree` o `--zit` | | `--no-worktree` | Disabilita l'isolamento di git worktree in modalità automatica | | `--auto-commit` | Effettua il commit automatico delle modifiche dopo aver completato le attività | | `--patch` | Genera patch git senza applicare modifiche | @@ -1946,7 +1946,7 @@ Questi flag sovrascrivono le impostazioni del file di configurazione: | ----------------------- | ---------------------------------------------------------------------------------------- | | `--login` | Accedi al tuo account Autohand | | `--logout` | Esci dal tuo account Autohand | -| `--sync-settings` | Abilita/disabilita la sincronizzazione delle impostazioni (impostazione predefinita: true per gli utenti registrati) | +| `--sync-settings` | Abilita/disabilita la sincronizzazione delle impostazioni (impostazione predefinita: false) | ### Configurazione e informazioni diff --git a/docs/config-reference_ja.md b/docs/config-reference_ja.md index 3a54d2fc5..81d1b3d71 100644 --- a/docs/config-reference_ja.md +++ b/docs/config-reference_ja.md @@ -1361,7 +1361,7 @@ Autohandは `~/.autohand/`(または `$AUTOHAND_HOME`)にデータを保存 | `--model ` | モデルをオーバーライド | | `--path ` | ワークスペースルートをオーバーライド | | `--worktree [name]` | セッションを分離されたgit worktreeで実行(worktree/ブランチ名は任意) | -| `--tmux` | 専用のtmuxセッションで起動(`--worktree`を含意。`--no-worktree`とは併用不可) | +| `--tmux` | 現在のディレクトリで専用の tmux セッションを起動。`--worktree` または `--zit` に対応 | | `--add-dir ` | ワークスペーススコープに追加ディレクトリを追加(複数回使用可能) | | `--config ` | カスタム設定ファイルを使用 | | `--temperature ` | 温度を設定(0-1) | diff --git a/docs/config-reference_ko.md b/docs/config-reference_ko.md index 40140e001..da122334b 100644 --- a/docs/config-reference_ko.md +++ b/docs/config-reference_ko.md @@ -892,7 +892,7 @@ Autohand는 `~/.autohand/` (또는 `$AUTOHAND_HOME`)에 데이터를 저장합 | `--model ` | 모델 덮어쓰기 | | `--path ` | 워크스페이스 루트 덮어쓰기 | | `--worktree [name]` | 세션을 격리된 git worktree에서 실행 (선택적으로 worktree/브랜치 이름 지정) | -| `--tmux` | 전용 tmux 세션에서 실행 (`--worktree` 포함, `--no-worktree`와 함께 사용 불가) | +| `--tmux` | 현재 디렉터리에서 전용 tmux 세션 실행; `--worktree` 또는 `--zit` 지원 | | `--add-dir ` | 워크스페이스 범위에 추가 디렉토리 추가 (여러 번 사용 가능) | | `--config ` | 사용자 지정 설정 파일 사용 | | `--temperature ` | 온도 설정 (0-1) | diff --git a/docs/config-reference_pl.md b/docs/config-reference_pl.md index 4a574db1e..c9e399db1 100644 --- a/docs/config-reference_pl.md +++ b/docs/config-reference_pl.md @@ -1352,7 +1352,7 @@ Autohand może zsynchronizować Twoją konfigurację na różnych urządzeniach ``` | Pole | Wpisz | Domyślne | Opis | | ------------------ | -------- | --------------- | -------------------------------------------------- | -| __AH_KOD_0__ | wartość logiczna | `true` (zalogowany) | Włącz/wyłącz synchronizację ustawień | +| __AH_KOD_0__ | wartość logiczna | `false` | Włącz/wyłącz synchronizację ustawień | | __AH_KOD_2__ | numer | __AH_KOD_3__ | Interwał synchronizacji w milisekundach (domyślnie: 5 minut) | | __AH_KOD_4__ | ciąg[] | __AH_KOD_5__ | Wzory globalne do wykluczenia z synchronizacji | | __AH_KOD_6__ | wartość logiczna | __AH_KOD_7__ | Synchronizuj dane telemetryczne (wymaga zgody użytkownika) | @@ -1363,12 +1363,12 @@ Autohand może zsynchronizować Twoją konfigurację na różnych urządzeniach # Disable sync for this session autohand --sync-settings=false -# Enable sync (default for logged users) +# Explicitly enable sync (disabled by default) autohand --sync-settings ``` ### Co jest synchronizowane -Domyślnie te elementy są synchronizowane dla zalogowanych użytkowników: +Po włączeniu synchronizacji synchronizowane są następujące elementy: - **Konfiguracja** (`config.json`) – klucze API są szyfrowane przed przesłaniem - **Agenci celni** (`agents/`) @@ -1421,7 +1421,7 @@ Token logowania aplikacji jest wysyłany w nagłówku `Authorization` wyłączni ### Jak to działa -1. **Przy uruchomieniu**: Jeśli jesteś zalogowany, usługa synchronizacji uruchomi się automatycznie +1. **Przy uruchomieniu**: Usługa uruchamia się automatycznie, jeśli jesteś zalogowany i jawnie włączysz synchronizację 2. **Co 5 minut**: Ustawienia są porównywane z danymi przechowywanymi w chmurze 3. **Chmura wygrywa**: Najpierw pobierane są zmiany zdalne 4. **Przesłanie lokalne**: Przesyłane są nowe zmiany lokalne @@ -1912,7 +1912,7 @@ Te flagi zastępują ustawienia pliku konfiguracyjnego: | Flaga | Opis | | ------------------------------ | -------------------------------------------------------------------------------------------------------- | | __AH_KOD_20__ | Uruchom sesję w izolowanym drzewie roboczym git (opcjonalna nazwa drzewa roboczego/oddziału) | -| __AH_KOD_21__ | Uruchom w dedykowanej sesji tmux (oznacza `--worktree`; nie można używać z `--no-worktree`) | +| `--tmux` | Uruchom dedykowaną sesję tmux w bieżącym katalogu; obsługuje `--worktree` lub `--zit` | | __AH_KOD_24__ | Wyłącz izolację drzewa roboczego git w trybie automatycznym | | __AH_KOD_25__ | Automatyczne zatwierdzanie zmian po ukończeniu zadań | | __AH_KOD_26__ | Wygeneruj łatkę git bez stosowania zmian | @@ -1945,7 +1945,7 @@ Te flagi zastępują ustawienia pliku konfiguracyjnego: | ------------------------------ | -------------------------------------------------------------------------------------------------------- | | __AH_KOD_14__ | Zaloguj się na swoje konto Autohand | | __AH_KOD_15__ | Wyloguj się ze swojego konta Autohand | -| __AH_KOD_16__ | Włącz/wyłącz synchronizację ustawień (domyślnie: true dla zalogowanych użytkowników) | +| __AH_KOD_16__ | Włącz/wyłącz synchronizację ustawień (domyślnie: false) | ### Konfiguracja i informacje diff --git a/docs/config-reference_ptBR.md b/docs/config-reference_ptBR.md index 3a0bca20c..3ee868a00 100644 --- a/docs/config-reference_ptBR.md +++ b/docs/config-reference_ptBR.md @@ -1411,7 +1411,7 @@ Estas flags sobrescrevem as configurações do arquivo: | `--model ` | Sobrescrever modelo | | `--path ` | Sobrescrever raiz do workspace | | `--worktree [nome]` | Executar sessão em git worktree isolado (nome opcional do worktree/branch) | -| `--tmux` | Iniciar em uma sessão tmux dedicada (implica `--worktree`; não pode ser usado com `--no-worktree`) | +| `--tmux` | Iniciar uma sessão tmux dedicada no diretório atual; aceita `--worktree` ou `--zit` | | `--add-dir ` | Adicionar diretórios adicionais ao escopo do workspace (pode ser usado múltiplas vezes) | | `--config ` | Usar arquivo de configuração personalizado | | `--temperature ` | Definir temperatura (0-1) | diff --git a/docs/config-reference_ru.md b/docs/config-reference_ru.md index 0111fc546..7bdb217dc 100644 --- a/docs/config-reference_ru.md +++ b/docs/config-reference_ru.md @@ -1352,7 +1352,7 @@ Autohand может синхронизировать вашу конфигура ``` | Поле | Тип | По умолчанию | Описание | | ------------------ | -------- | --------------- | -------------------------------------------------- | -| `enabled` | логическое | `true` (зарегистрировано) | Включить/выключить синхронизацию настроек | +| `enabled` | логическое | `false` | Включить/выключить синхронизацию настроек | | `interval` | номер | `300000` | Интервал синхронизации в миллисекундах (по умолчанию: 5 минут) | | `exclude` | строка[] | `[]` | Шаблоны Glob для исключения из синхронизации | | `includeTelemetry` | логическое | `false` | Синхронизировать данные телеметрии (требуется согласие пользователя) | @@ -1363,12 +1363,12 @@ Autohand может синхронизировать вашу конфигура # Disable sync for this session autohand --sync-settings=false -# Enable sync (default for logged users) +# Explicitly enable sync (disabled by default) autohand --sync-settings ``` ### Что синхронизируется -По умолчанию эти элементы синхронизируются для вошедших в систему пользователей: +Когда синхронизация включена, синхронизируются следующие элементы: - **Конфигурация** (`config.json`) — ключи API шифруются перед загрузкой. - **Пользовательские агенты** (`agents/`) @@ -1421,7 +1421,7 @@ autohand --sync-settings ### Как это работает -1. **При запуске**: если вы вошли в систему, служба синхронизации запускается автоматически. +1. **При запуске**: служба запускается автоматически, только если вы вошли в систему и явно включили синхронизацию 2. **Каждые 5 минут**: настройки сравниваются с облачным хранилищем. 3. **Облако побеждает**: удаленные изменения загружаются первыми. 4. **Локальные загрузки**: загружаются новые локальные изменения. @@ -1913,7 +1913,7 @@ Autohand хранит данные в `~/.autohand/` (или `$AUTOHAND_HOME`): | Флаг | Описание | | ----------------------------- | --------------------------------------------------------------------------------------------- | | `--worktree [name]` | Запустить сеанс в изолированном рабочем дереве git (необязательное рабочее дерево/имя ветки) | -| `--tmux` | Запуск в выделенном сеансе tmux (подразумевается `--worktree`; нельзя использовать с `--no-worktree`) | +| `--tmux` | Запуск выделенного сеанса tmux в текущем каталоге; поддерживает `--worktree` или `--zit` | | `--no-worktree` | Отключить изоляцию рабочего дерева git в автоматическом режиме | | `--auto-commit` | Автоматическое подтверждение изменений после выполнения задач | | `--patch` | Создать патч git без применения изменений | @@ -1946,7 +1946,7 @@ Autohand хранит данные в `~/.autohand/` (или `$AUTOHAND_HOME`): | ----------------------------- | --------------------------------------------------------------------------------------------- | | `--login` | Войдите в свою учетную запись Autohand | | `--logout` | Выйдите из своей учетной записи Autohand | -| `--sync-settings` | Включить/отключить синхронизацию настроек (по умолчанию: true для зарегистрированных пользователей) | +| `--sync-settings` | Включить/отключить синхронизацию настроек (по умолчанию: false) | ### Настройка и информация diff --git a/docs/config-reference_tr.md b/docs/config-reference_tr.md index d8f459e57..090110237 100644 --- a/docs/config-reference_tr.md +++ b/docs/config-reference_tr.md @@ -1352,7 +1352,7 @@ Autohand, oturum açmış kullanıcılar için yapılandırmanızı cihazlar ara ``` | Alan | Tür | Varsayılan | Açıklama | | ------------------ | -------- | --------------- | -------------------------------------------------- | -| `enabled` | boole | `true` (günlüğe kaydedildi) | Ayarların senkronizasyonunu etkinleştirme/devre dışı bırakma | +| `enabled` | boole | `false` | Ayarların senkronizasyonunu etkinleştirme/devre dışı bırakma | | `interval` | sayı | `300000` | Milisaniye cinsinden senkronizasyon aralığı (varsayılan: 5 dakika) | | `exclude` | dize[] | `[]` | Senkronizasyondan hariç tutulacak küre desenleri | | `includeTelemetry` | boole | `false` | Telemetri verilerini senkronize edin (kullanıcının iznini gerektirir) | @@ -1363,12 +1363,12 @@ Autohand, oturum açmış kullanıcılar için yapılandırmanızı cihazlar ara # Disable sync for this session autohand --sync-settings=false -# Enable sync (default for logged users) +# Explicitly enable sync (disabled by default) autohand --sync-settings ``` ### Neler Senkronize Edilir? -Varsayılan olarak bu öğeler oturum açmış kullanıcılar için senkronize edilir: +Senkronizasyon etkinleştirildiğinde şu öğeler senkronize edilir: - **Yapılandırma** (`config.json`) - API anahtarları yüklemeden önce şifrelenir - **Özel temsilciler** (`agents/`) @@ -1421,7 +1421,7 @@ Uygulama oturum açma belirteci, `Authorization` üstbilgisinde yalnızca yapıl ### Nasıl Çalışır? -1. **Başlangıçta**: Oturum açtıysanız senkronizasyon hizmeti otomatik olarak başlar +1. **Başlangıçta**: Hizmet yalnızca oturum açtıysanız ve senkronizasyonu açıkça etkinleştirdiyseniz otomatik başlar 2. **Her 5 dakikada bir**: Ayarlar, bulut depolama alanıyla karşılaştırılır 3. **Bulut kazanır**: Önce uzaktan yapılan değişiklikler indirilir 4. **Yerel yüklemeler**: Yeni yerel değişiklikler yüklendi @@ -1913,7 +1913,7 @@ Bu bayraklar yapılandırma dosyası ayarlarını geçersiz kılar: | Bayrak | Açıklama | | ----------------------------- | ---------------------------------------------------------------------------------------------- | | `--worktree [name]` | Oturumu yalıtılmış git çalışma ağacında çalıştırın (isteğe bağlı çalışma ağacı/dal adı) | -| `--tmux` | Özel bir tmux oturumunda başlat (`--worktree` anlamına gelir; `--no-worktree` ile kullanılamaz) | +| `--tmux` | Geçerli dizinde özel bir tmux oturumu başlat; `--worktree` veya `--zit` desteklenir | | `--no-worktree` | Otomatik modda git çalışma ağacı izolasyonunu devre dışı bırakın | | `--auto-commit` | Görevleri tamamladıktan sonra değişiklikleri otomatik olarak uygula | | `--patch` | Değişiklikleri uygulamadan git yamasını oluşturun | @@ -1946,7 +1946,7 @@ Bu bayraklar yapılandırma dosyası ayarlarını geçersiz kılar: | ----------------------------- | ---------------------------------------------------------------------------------------------- | | `--login` | Autohand hesabınızda oturum açın | | `--logout` | Autohand hesabınızdan çıkış yapın | -| `--sync-settings` | Ayarların senkronizasyonunu etkinleştirme/devre dışı bırakma (varsayılan: oturum açmış kullanıcılar için doğru) | +| `--sync-settings` | Ayarların senkronizasyonunu etkinleştirme/devre dışı bırakma (varsayılan: false) | ### Kurulum ve Bilgi diff --git a/docs/config-reference_zh-tw.md b/docs/config-reference_zh-tw.md index 52cca8ed4..88e8f6e2f 100644 --- a/docs/config-reference_zh-tw.md +++ b/docs/config-reference_zh-tw.md @@ -1352,7 +1352,7 @@ Autohand 可以為登入使用者跨裝置同步您的設定。設定安全性 ``` |領域|類型 |預設|描述 | | ------------------ | -------- | ---------------- | -------------------------------------------------- | -| `enabled` |布林 | `true`(已記錄)|啟用/停用設定同步 | +| `enabled` |布林 | `false` |啟用/停用設定同步 | | `interval` |數量 | `300000` |同步間隔(以毫秒為單位)(預設值:5 分鐘)| | `exclude` |字串[] | `[]` |從同步中排除的全域模式 | | `includeTelemetry` |布林 | `false` |同步遙測資料(需要使用者同意)| @@ -1363,12 +1363,12 @@ Autohand 可以為登入使用者跨裝置同步您的設定。設定安全性 # Disable sync for this session autohand --sync-settings=false -# Enable sync (default for logged users) +# Explicitly enable sync (disabled by default) autohand --sync-settings ``` ### 同步的內容 -預設情況下,這些項目會為登入使用者同步: +啟用同步後,會同步以下項目: - **設定** (`config.json`) - API 金鑰在上傳前加密 - **自訂代理程式** (`agents/`) @@ -1421,7 +1421,7 @@ autohand --sync-settings ### 它是如何運作的 -1. **啟動時**:如果您已登錄,同步服務將自動啟動 +1. **啟動時**:只有在您已登入並明確啟用同步時,服務才會自動啟動 2. **每5分鐘**:設定與雲端儲存進行比較 3. **雲端獲勝**:首先下載遠端更改 4. **本地上傳**:上傳新的本地更改 @@ -1913,7 +1913,7 @@ Autohand 將資料儲存在 `~/.autohand/` (或 `$AUTOHAND_HOME`): |旗幟|描述 | | -------------------------------------- |---------------------------------------------------------------------------------------------------------------- | | `--worktree [name]` |在隔離的 git 工作樹中執行會話(可選工作樹/分支名稱)| -| `--tmux` |在專用 tmux 會話中啟動(意味著 `--worktree`;不能與 `--no-worktree` 一起使用)| +| `--tmux` | 在目前目錄中啟動專用 tmux 工作階段;支援 `--worktree` 或 `--zit` | | `--no-worktree` |在自動模式下停用 git worktree 隔離 | | `--auto-commit` |完成任務後自動提交變更 | | `--patch` |產生 git 補丁而不套用變更 | @@ -1946,7 +1946,7 @@ Autohand 將資料儲存在 `~/.autohand/` (或 `$AUTOHAND_HOME`): | -------------------------------------- |---------------------------------------------------------------------------------------------------------------- | | `--login` |登入您的 Autohand 帳戶 | | `--logout` |退出您的 Autohand 帳戶 | -| `--sync-settings` |啟用/停用設定同步(預設值:對於登入使用者為 true)| +| `--sync-settings` |啟用/停用設定同步(預設值:false)| ### 設定和訊息 diff --git a/docs/config-reference_zh.md b/docs/config-reference_zh.md index cce42f9fe..29bf586ca 100644 --- a/docs/config-reference_zh.md +++ b/docs/config-reference_zh.md @@ -1395,7 +1395,7 @@ Autohand 将数据存储在 `~/.autohand/`(或 `$AUTOHAND_HOME`): | `--model ` | 覆盖模型 | | `--path ` | 覆盖工作区根目录 | | `--worktree [name]` | 在隔离的 git worktree 中运行会话(可选 worktree/分支名称) | -| `--tmux` | 在专用 tmux 会话中启动(隐含 `--worktree`;不能与 `--no-worktree` 一起使用) | +| `--tmux` | 在当前目录中启动专用 tmux 会话;支持 `--worktree` 或 `--zit` | | `--add-dir ` | 添加额外目录到工作区范围(可多次使用) | | `--config ` | 使用自定义配置文件 | | `--temperature ` | 设置温度(0-1) | diff --git a/docs/extension-authoring.md b/docs/extension-authoring.md index fc72cca45..d0bb80fcb 100644 --- a/docs/extension-authoring.md +++ b/docs/extension-authoring.md @@ -84,6 +84,8 @@ Trust is stored in Autohand state outside the package, survives disable/enable, Install with `--link --trust` while developing, then rebuild the JavaScript file declared in `contributes.runtime` after editing TypeScript. The REPL watches installed and linked packages, coalesces changes, and reloads when the current turn and any modal finish. Installs and enable/disable changes made by another CLI process also refresh automatically. Commands, views, lines, shortcuts, hooks, providers, policies, tools, agents, and skills update without losing your draft or conversation. +The extension builder must run the package's build and validation steps; the watcher reloads the resulting JavaScript and does not compile TypeScript itself. This updates the current REPL session in place. It does not restart the CLI or select a different saved chat. Changes to Autohand's core implementation still require rebuilding and restarting the CLI. + Use `/extensions reload` to retry explicitly and `/extensions doctor` to inspect failures. Failed live edits retain the last working package until a repaired build succeeds. Release resources from activation cleanup or `deactivate`. Bundle dependencies or import them statically for live reload; computed imports and native modules retain normal runtime caching. Newly registered CLI flag defaults work immediately; argv values are parsed at startup. `--bare` and `--no-daemon` do not run a watcher. Extension API v1 is versioned independently of CLI releases. Keep `extensionApi: 1` and use the public `api` surface instead of CLI internals. Upgrades preserve `.autohand/extensions` and `$AUTOHAND_HOME/extensions`, with their enabled/trusted state. Compatible JavaScript extensions need no reinstall or rebuild after replacing the CLI. Unsupported API versions produce diagnostics. Verify a copied install and discovery in a new CLI process before distributing a package. diff --git a/docs/maintenance/cli-pr-integration-2026-11-10.md b/docs/maintenance/cli-pr-integration-2026-11-10.md new file mode 100644 index 000000000..8b0897c35 --- /dev/null +++ b/docs/maintenance/cli-pr-integration-2026-11-10.md @@ -0,0 +1,25 @@ +# CLI pull-request integration + +This candidate combines the remaining Igor-authored work from [#745](https://github.com/autohandai/code-cli/pull/745), [#743](https://github.com/autohandai/code-cli/pull/743), [#742](https://github.com/autohandai/code-cli/pull/742), [#727](https://github.com/autohandai/code-cli/pull/727), [#690](https://github.com/autohandai/code-cli/pull/690), and [#669](https://github.com/autohandai/code-cli/pull/669), together with the saved-session input fix from `df44f764`. + +The integration starts from main at `150be8938f3bacd4264f0622e88dcffc465bfc8f`. It preserves the intervening explicit Git-initialization change, atomic configuration writes, partial cloud-history protection, extension reload coordination, and permission enforcement. Provider PR #747 is outside this batch. + +| Area | Resulting behavior | Reference | +| --- | --- | --- | +| Terminal rendering | Full-screen resize clears addressed rows without archiving stale frames in iTerm2; compiled tmux startup uses the real executable. Plain `--tmux` uses the current folder. | [Terminal compatibility](../terminal-compatibility.md) | +| Terminal CI | Real tmux and native iTerm2, Ghostty, cmux, and VS Code scenarios retain resize, streaming, draft, cursor, and exit assertions. iTerm2's Python API stays disabled. | [Compatibility workflow](../../.github/workflows/terminal-compatibility.yml) | +| Squad | Built-in MCP uses the pinned canonical Squad bridge; `/squad` selects an agent and `/message` uses durable delivery. Companion Squad PR #52 is merged. | [Squad ADR](../adr/2026-11-05-built-in-squad-messaging.md) | +| Continuation and sync | Resume the most recently active nonempty conversation in the project. Background sync defaults off; disabling it cancels queued and active work. | [Configuration reference](../config-reference.md) | +| Command output | Cap model observations while preserving complete oversized output in private session artifacts; streamed and nested command paths use the same cap. | [Configuration reference](../config-reference.md) | +| Computer Use | Temporarily enter AUTO for the computer task and restore the preceding mode, preserving subsequent manual selections, explicit denials, hooks, and restricted mode. | [Computer control](../computer-control.md) | +| Saved sessions | Accept composer edits while restoring history and defer submitted work until restoration completes. | [Resume terminal tests](../../tests/tuistory/resume.tuistory.test.ts) | +| CLI contract | Bootstrap the current 199 option definitions across 52 command paths, and compare future changes with the committed main snapshot. | [Option contract](../compatibility/claude-code/README.md) | +| macOS distribution | Check transported CLI and trace binaries for notarization in addition to existing signature checks. | [Release workflow](../../.github/workflows/release.yml) | + +Independent review identified two follow-ups. Newline MCP frames now have a 64 MiB bound with pending-request and listener cleanup. F-0402 now uses the existing names-only skill discovery path by default, keeps normal-sized command observations intact, and uses a compact preview only after output exceeds its configured ceiling. Approved completions hold their candidate while review is pending, then publish one confirmed reply. Explicitly labeled rejected or interrupted drafts retain their existing behavior. + +The live `seq 1 400000` command probes reported 15,539 total tokens for Terra and 24,483 for Fantail, each with one command execution and one answer. Each private raw archive retained 2,688,895 bytes and 400,000 lines. These probes used the existing Free account, which correctly cannot invoke paid Moa review. Required-review approval, rejection, cancellation, and delayed-answer behavior have separate deterministic unit and built-terminal coverage; these checks do not establish paid live-provider review qualification. + +Validation is recorded against the final candidate before merge. Focused checks, complete proof, native application execution, and live-provider acceptance are separate evidence boundaries. The root execution sandbox denies local loopback listeners with `listen EPERM`, so the combined full proof runs on the hosted GitHub runner. A successful headless terminal test or XCTest compilation does not establish native application success. The candidate must pass the full proof and all four native terminal jobs before merging. + +This integration does not publish a release or enable the manually disabled release workflow. Existing primary-checkout working changes are retained separately. diff --git a/docs/mcp.md b/docs/mcp.md index 09f858711..ea699bee6 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -4,6 +4,7 @@ Autohand includes a built-in MCP client that connects to external MCP servers, e ## Table of Contents +- [Built-in Autohand Squad](#built-in-autohand-squad) - [Overview](#overview) - [Quick Start](#quick-start) - [Configuration](#configuration) @@ -25,8 +26,95 @@ MCP is an open protocol for connecting AI agents to external tools. Autohand's M - **Automatic tool discovery** -- discovers and registers tools from connected servers - **Namespaced tools** -- MCP tools are prefixed to avoid collisions with built-in tools - **Non-blocking startup** -- servers connect in the background without delaying the prompt + +Stdio startup waits for the child process and the protocol handshake. Newline-framed responses decode complete byte frames, preserving UTF-8 characters split across reads. A newline frame exceeding 64 MiB closes the connection and rejects its pending requests with a size error; normal-sized screenshot and tool responses remain supported. - **Interactive management** -- toggle servers on/off with the `/mcp` command +## Built-in Autohand Squad + +Autohand Code adds `autohand-squad` to its runtime MCP servers automatically. +It uses this CLI's bundled stdio server, so no separate MCP install or saved +configuration is needed. Tools are available through normal tool discovery +and permission checks, under `mcp__autohand-squad__`. + +Open the Squad app, then in an interactive CLI session: + +```text +/squad choose an agent with the keyboard +/squad agents list agent IDs and presence +/squad select kai select by ID or exact name +/message Please review this message the selected agent +/message squad iris Review this message another agent explicitly +/squad history read the selected agent's replies +/squad queue inspect delivery receipts and pause state +/message team local Check this message a local CLI teammate +/squad clear clear the selected desktop recipient +/squad app open or install the app +``` + +Selection belongs to the current CLI session. An explicit one-off recipient +does not change it. Local `/message ` behavior continues when no +desktop recipient is selected. Escape cancels the picker and restores input. +Top-level `autohand squad` still opens the app; `autohand squad agents`, +`autohand squad message ` and `autohand squad history ` +work without an interactive agent session. Legacy standalone runtime actions +remain available, including top-level `autohand squad queue`. + +The server exposes 37 tools: the existing channel/run/goal tools plus direct messaging +(`message_member`, `get_conversation`, `get_member_queue`, `wait_for_message`), +published artifacts (`list_artifacts`, `get_artifact`) and the knowledge +library (`list_memories`, `read_memory`). These direct messages enter Squad's +durable conversation queue, wait behind busy work and retain saved member +permissions. A receipt means queued, not completed. Paused queues stay paused. +Repeat `wait_for_message` using the same receipt after a timeout; inspect the +queue before retrying a send whose response was lost. Creating members and +changing their models still happens in the app. + +Member management is also built in: `get_member_profile`, +`update_member_profile`, `set_member_abilities`, `add_member_directory`, +`remove_member_directory`, `list_skills` and `set_member_skills`. Read the +saved revision before editing; pass `expected_revision` to reject stale +changes. Folder grants use the existing engine directory settings on later +turns; current work continues. Skill selection is distinct from installation +on the next turn. Policy edits preserve other settings and OS grants. + +Channels support `get_channel`, `update_channel`, `set_channel_members`, +`list_channel_threads`, `create_group_chat` and `send_channel_message`. +Groups are private channels with at least two existing members. Creation +does not dispatch work. Sending dispatches only explicit `member_ids`; +omitting them posts without starting agents. Replies persist on the bridge +even after this CLI disconnects. Check per-recipient receipts before retrying. + +Use `list_routines`, `get_routine`, `preview_routine`, `create_routine`, +`update_routine` and `run_routine` for scheduled work. Schedules require an +explicit IANA timezone. New routines default to disabled; set `enabled:true` +to schedule, `enabled:false` to pause, or run a saved routine manually. +These tools require an updated Squad bridge; older versions return an +upgrade instruction. The CLI never upgrades or restarts the app automatically. +The [canonical tool reference](https://github.com/autohandai/squad/blob/main/docs/integration/control-plane.md) +contains arguments and examples. + +Use a current Squad release for Library tools; older app versions may lack +the artifact or knowledge-library endpoints. Updating the app does not +require reinstalling the CLI's built-in MCP. + +Bridge discovery: explicit `AUTOHAND_SQUAD_BRIDGE_URL`, then +`$AUTOHAND_SQUAD_HOME/web-server.json` (default `~/.autohand/squad`), then +`http://127.0.0.1:19821`. Only loopback origins are accepted; redirects are +refused. MCP initialization succeeds while the app is offline, and calls +explain how to open it. Startup does not install or start Squad. + +Disable with `AUTOHAND_DISABLE_SQUAD=1` or `mcp.enabled: false`. Bare, offline +and no-daemon sessions do not add this built-in server. An existing configured +`autohand-squad`/`autohand_squad` entry wins. A CLI launched as a managed Squad +member keeps its identity-scoped connection and never receives operator +access to another member's private memories. + +For another MCP client, use `autohand squad mcp` as the command/arguments +(`--bridge http://127.0.0.1:19821` is optional). It emits protocol messages +only on stdout. See the [Squad control-plane reference](https://github.com/autohandai/squad/blob/main/docs/integration/control-plane.md) +for the complete tool schemas and scope. + --- ## Quick Start diff --git a/docs/terminal-compatibility.md b/docs/terminal-compatibility.md new file mode 100644 index 000000000..1f35ccd74 --- /dev/null +++ b/docs/terminal-compatibility.md @@ -0,0 +1,46 @@ +# Terminal compatibility + +Autohand uses Ink 7 and React 19. Full-screen output owns the alternate screen; inline output retains the terminal's normal scrollback. Terminal transport and workspace isolation are independent: + +```bash +autohand --tmux +autohand --tmux --no-worktree +autohand --tmux --worktree my-task +autohand --tmux --zit +``` + +Plain `--tmux` runs in the current directory and does not require Git. `--worktree` and `--zit` opt into their respective workspace isolation. `--no-daemon` remains incompatible with `--tmux`. + +## Resize regression + +iTerm2 archives an erase-display operation into scrollback. Sending `CSI 2 J` before a full-screen repaint therefore accumulated entire old frames. Moving the cursor home without erasing rows prevented that accumulation but left stale composer rows when the window grew. The renderer now clears each visible row with cursor-addressed erase-line operations, then returns home and paints in the same synchronized write. It also translates Ink overflow resets into row erases and advances full-screen lines with cursor movement that clamps at the bottom row. This prevents a frame arriving before SIGWINCH has settled from scrolling into history. See [iTerm2's erase-display implementation](https://github.com/gnachman/iTerm2/blob/master/sources/VT100Screen/VT100ScreenMutableState.m). + +The regression was reproduced in native iTerm2 3.7.7 with its Python API disabled: resizing increased the header, history marker, and draft counts from one to four. With the corrected renderer, each remained exactly one through four window sizes, including shrinking and growing. Screenshots and accessibility text were checked separately from the headless tests. + +Compiled Bun executables also need to relaunch their real `process.execPath` inside tmux. Their virtual entrypoint (`/$bunfs/...` or `~BUN/...`) is internal to the executable and cannot be passed to a separate Bun process. Startup tests exercise a real compiled CLI rather than mocking this argument layout alone. + +Startup authentication and Console connector synchronization can save configuration concurrently. Those writes wait up to five seconds for the configuration lock, so a brief competing write does not discard connector updates. Connector startup and later removal are checked through the running CLI even when file synchronization is unavailable. + +## Automated coverage + +Install tmux locally, build the CLI, and run: + +```bash +bun run build +node --input-type=module -e "import { installZit } from './scripts/install-zit.mjs'; await installZit({ force: true });" +bun run test:tuistory tests/tuistory/resize-flicker.tuistory.test.ts tests/tuistory/tmux-startup.tuistory.test.ts +``` + +The resize suite checks published terminal frames, idle and streaming output, full-screen and inline operation, multiline drafts, cursor retention, navigation, and clean Ctrl+C exits. Its terminal-name matrix checks protocol compatibility; it does not claim to run the named native application. + +The tmux suite launches a compiled CLI through a real tmux server on a unique test socket. It checks current-directory, worktree, and Zit startup, retained input during resize, pane contents and scrollback size, and clean exit. It never uses or stops the user's tmux server. The suite is skipped on Windows, where tmux is not native. `AUTOHAND_TUISTORY_EXECUTABLE` can select an already compiled candidate; otherwise the test compiles the current source. + +`.github/workflows/terminal-compatibility.yml` adds a separate macOS matrix for actual iTerm2, Ghostty, cmux, and VS Code applications. Tuistory runs an XCTest UI scenario against the shared renderer fixture. It resizes real windows, checks accessible terminal contents for duplicate transcript and composer markers, retains screenshots in an `.xcresult` bundle, and checks idle and streaming execution. The workflow records installed application versions alongside those results. Native GUI cases run only on disposable GitHub Actions macOS runners; the driver refuses to launch or terminate applications on a developer machine. iTerm2's Python API remains disabled. + +First-run dialogs are handled before fixture input. The offline fixture denies only the macOS request to find devices on local networks through XCTest's interruption handler, then focuses the terminal before pasting the command. VS Code uses a disposable profile with [`chat.disableAIFeatures`](https://code.visualstudio.com/docs/agents/reference/ai-settings) enabled to suppress built-in Copilot onboarding. If the specific Copilot sign-in dialog still appears, the scenario closes that dialog and opens the integrated terminal. It does not sign in or grant network access. Window, workbench, and terminal-view failures preserve bounded accessibility diagnostics and screenshots; the workflow also retains the Xcode output and generated VS Code settings. + +Native CI availability and per-application results must be assessed from that workflow's completed jobs. A passing PTY suite or a successful XCTest build alone does not prove a native application passed. Failures in app launch, accessibility access, or window resizing fail the native job and preserve its XCTest evidence; they are not silently converted to headless passes. + +Development postinstall intentionally skips Zit downloads. The command above prepares its pinned, checksum-verified binary for the isolation tests; an existing installation can also be selected with `AUTOHAND_TUISTORY_ZIT_BIN`. + +The regular CI, full proof, and release terminal-test jobs install tmux and prepare Zit explicitly. The native matrix registers the installed applications with Launch Services and targets their actual application URLs. It runs for terminal-related pull requests and can also be invoked manually. It is separate from package publication. diff --git a/src/commands/README.md b/src/commands/README.md index 1fc9b4c8d..944db6cec 100644 --- a/src/commands/README.md +++ b/src/commands/README.md @@ -40,7 +40,8 @@ Each command is a separate TypeScript file that exports: | `/experiments` | `features.ts` | List and toggle experiments | | `/goal` | `goal.ts` | Manage session-attached persistent goals, budgets, templates, and queued work; bare `/goal` recovers a stranded queue only when no live peer owns it. Enabled by default from 0.9.9; explicit `slash_goal` settings are preserved. | | `/goals` | `goal.ts` | Open the live goal queue with keyboard and mouse editing. Accepts `/goal` subcommands and returns the queue as text without an interactive panel. Shares `slash_goal`; `/experiments enable slash_goals` enables the same local feature. | -| `/squad` | `squad.ts`, `squadDesktop.ts` | Open the Autohand Squad app, installing the latest release when missing; subcommands manage the standalone runtime. | +| `/squad` | `squad.ts`, `squadConnection.ts` | Select an agent in the running Squad app; `/squad agents`, `select`, `history`, `queue`, and `clear` manage the desktop recipient. `/squad app` opens or installs the app. | +| `/message` | `message.ts` | Send text to the selected Squad agent, `/message squad ` to an explicit agent, or `/message team ` to a local CLI teammate. Without a desktop selection, existing `/message ` team behavior remains. | | `/squad view` | `squad.ts` | Inspect recorded independent Squad sessions for the current workspace | | `/usage` | `usage.ts` | Show Autohand plan limits and project token activity | | `/statusline` | `statusline.ts` | Configure composer status-line fields | diff --git a/src/commands/login.ts b/src/commands/login.ts index 31eee0e53..8e57c3ccb 100644 --- a/src/commands/login.ts +++ b/src/commands/login.ts @@ -370,6 +370,7 @@ async function checkAndRestoreSyncData( userId: string, config: LoadedConfig ): Promise { + if (config.sync?.enabled !== true) return; try { // Create sync service to check for cloud data const syncService = createSyncService({ diff --git a/src/commands/message.ts b/src/commands/message.ts index 6c7a058f4..b7999876a 100644 --- a/src/commands/message.ts +++ b/src/commands/message.ts @@ -7,18 +7,27 @@ import chalk from 'chalk'; import type { SlashCommand } from '../core/slashCommandTypes.js'; import type { TeamManager } from '../core/teams/TeamManager.js'; +import type { SquadConnection } from '../squad/SquadConnection.js'; export const metadata: SlashCommand = { command: '/message', - description: 'Send a direct message to a teammate', + description: 'Message a local teammate or a selected Squad agent', implemented: true, }; interface MessageCommandContext { - teamManager?: TeamManager; + teamManager?: Pick; + squadConnection?: SquadConnection; } export async function message(ctx: MessageCommandContext, args: string[]): Promise { + if (args[0] === 'squad' || (ctx.squadConnection?.selectedMemberId && args[0] !== 'team')) { + try { + if (!ctx.squadConnection) return 'Squad connection unavailable.'; + return await sendSquadMessage(ctx.squadConnection, args); + } catch (error) { return chalk.red(error instanceof Error ? error.message : String(error)); } + } + if (args[0] === 'team') args = args.slice(1); if (!ctx.teamManager) { return chalk.yellow('Team manager not available.'); } @@ -47,3 +56,12 @@ export async function message(ctx: MessageCommandContext, args: string[]): Promi return chalk.red((err as Error).message); } } + +export async function sendSquadMessage(connection: SquadConnection, args: string[]): Promise { + const explicit = args[0] === 'squad'; + const target = explicit ? args[1] : undefined; + const text = (explicit ? args.slice(2) : args).join(' ').trim(); + if (!text || (explicit && !target)) throw new Error('Usage: /message squad , or select an agent with /squad select then /message .'); + const receipt = target ? await connection.sendTo(target, text) : await connection.send(text); + return `Message queued for Squad agent ${receipt.member_id}. Receipt: ${receipt.message_id}.${receipt.paused ? ` Queue paused: ${receipt.pauseReason} Resume it in Squad when ready.` : ''}`; +} diff --git a/src/commands/sessions.ts b/src/commands/sessions.ts index ac7430081..71cb588e2 100644 --- a/src/commands/sessions.ts +++ b/src/commands/sessions.ts @@ -23,10 +23,12 @@ export async function sessions(ctx: { : undefined; try { - const allSessions = await ctx.sessionManager.listSessions( + const storedSessions = await ctx.sessionManager.listSessions( projectFilter ? { project: projectFilter } : undefined ); + const allSessions = storedSessions.filter(session => session.messageCount > 0); + if (allSessions.length === 0) { console.log(chalk.gray(t('commands.sessions.noSessions'))); return null; diff --git a/src/commands/settings.ts b/src/commands/settings.ts index f3da70bd2..9dbb43311 100644 --- a/src/commands/settings.ts +++ b/src/commands/settings.ts @@ -375,6 +375,13 @@ export function setConfigSetting(config: LoadedConfig, keyInput: string, rawValu return { key: 'provider', value: provider }; } + if (key === 'tools.maxCommandOutputChars') { + const value = Number(rawValue); + if (!Number.isSafeInteger(value) || value < 1024) throw new Error('tools.maxCommandOutputChars must be an integer of at least 1024.'); + config.tools = { ...config.tools, maxCommandOutputChars: value }; + return { key, value }; + } + const providerConfigKey = normalizeProviderConfigKey(key); if (providerConfigKey) { const current = config[providerConfigKey.provider]; diff --git a/src/commands/squad.ts b/src/commands/squad.ts index e0b3ebd9b..3b126c445 100644 --- a/src/commands/squad.ts +++ b/src/commands/squad.ts @@ -18,6 +18,8 @@ import { readSquadRunSnapshot } from '../core/agents/SquadRunSnapshot.js'; import { openSquadDesktop } from './squadDesktop.js'; import type { SquadDesktopDeps } from './squadDesktop.js'; import type { AgentRunSource } from '../core/agents/AgentRunStore.js'; +import { SquadConnection } from '../squad/SquadConnection.js'; +import { runDesktopSquadCommand, type DesktopSquadContext } from './squadConnection.js'; const DEFAULT_API_BASE_URL = 'https://api.autohand.ai'; const DEFAULT_CHANNEL = 'stable'; @@ -29,6 +31,10 @@ const START_ACTIONS = new Set(['start', 'open', 'restart']); type SquadAction = 'start' | 'status' | 'restart' | 'stop' | 'queue' | 'open' | 'config'; interface SquadContext { + squadConnection?: SquadConnection; + onBeforeModal?: DesktopSquadContext['onBeforeModal']; + onAfterModal?: DesktopSquadContext['onAfterModal']; + isNonInteractive?: boolean; config?: LoadedConfig; workspaceRoot: string; onToggleAgentRunsView?: (visible: boolean, source?: AgentRunSource) => void; @@ -111,9 +117,17 @@ interface RuntimeConfig { export const metadata: SlashCommand = { command: '/squad', - description: 'open the Autohand Squad app, installing it if needed', + description: 'Select a Squad agent, read its conversation, or open the app', implemented: true, - subcommands: [{ name: 'view', description: 'inspect recorded workspace runs from independent Squad sessions' }], + subcommands: [ + { name: 'agents', description: 'list agents in the running Squad app' }, + { name: 'select', description: 'select a Squad message recipient' }, + { name: 'history', description: 'read the selected agent conversation' }, + { name: 'queue', description: 'read the selected agent message queue' }, + { name: 'clear', description: 'clear the selected Squad recipient' }, + { name: 'app', description: 'open or install the Squad app' }, + { name: 'view', description: 'inspect recorded independent Squad runs' }, + ], }; export async function squad( @@ -121,6 +135,10 @@ export async function squad( args: string[] = [], deps: SquadDeps = {}, ): Promise { + if (!args.length && ctx.squadConnection) { + try { return await runDesktopSquadCommand({ ...ctx, squadConnection: ctx.squadConnection }, ['select']); } + catch (error) { return `${error instanceof Error ? error.message : String(error)}\nUse /squad app to open Autohand Squad.`; } + } const result = await runSquadCommand(ctx, args, deps); return result.output; } @@ -130,6 +148,10 @@ export async function runSquadCommand( args: string[] = [], deps: SquadDeps = {}, ): Promise { + if (['agents', 'select', 'history', 'clear', 'help', 'message'].includes(args[0]) || (args[0] === 'queue' && ctx.squadConnection)) { + try { return { code: 0, output: await runDesktopSquadCommand({ ...ctx, squadConnection: ctx.squadConnection ?? new SquadConnection({ env: deps.env }) }, args) }; } + catch (error) { return { code: 1, output: error instanceof Error ? error.message : String(error) }; } + } if (args.length === 1 && args[0] === 'view') { const snapshot = await readSquadRunSnapshot({ workspaceRoot: ctx.workspaceRoot, env: deps.env }); ctx.onToggleAgentRunsView?.(true, 'squad'); diff --git a/src/commands/squadConnection.ts b/src/commands/squadConnection.ts new file mode 100644 index 000000000..8e18355b7 --- /dev/null +++ b/src/commands/squadConnection.ts @@ -0,0 +1,56 @@ +/** + * @license + * Copyright 2026 Autohand AI LLC + * SPDX-License-Identifier: Apache-2.0 + */ +import { SquadConnection, type SquadMember } from '../squad/SquadConnection.js'; +import { redactSecrets } from '../squad/vendor/control-plane.mjs'; + +export interface DesktopSquadContext { + squadConnection: SquadConnection; + pickMember?: (members: SquadMember[]) => Promise; + onBeforeModal?: () => Promise | void; + onAfterModal?: () => Promise | void; + isNonInteractive?: boolean; +} + +const HELP = '/squad agents\n/squad select [agent-id|name]\n/squad message \n/squad history [agent-id]\n/squad queue [agent-id]\n/squad clear\n/squad app\n/message (selected Squad agent)\n/message squad \n/message team (local CLI team)'; + +export async function runDesktopSquadCommand(ctx: DesktopSquadContext, args: string[]): Promise { + const [action = 'select', ...rest] = args; + const connection = ctx.squadConnection; + if (action === 'help') return HELP; + if (action === 'message') { + const { sendSquadMessage } = await import('./message.js'); + return sendSquadMessage(connection, ['squad', ...rest]); + } + if (action === 'clear') { connection.clearSelection(); return 'Squad recipient cleared. /message now uses your local CLI team.'; } + if (action === 'agents') { + const members = await connection.listMembers(); + return members.length ? members.map(member => `${member.id === connection.selectedMemberId ? '› ' : ' '}${member.name || member.id} · ${member.id} · ${member.presence}${member.role ? ` · ${member.role}` : ''}`).join('\n') : 'No agents found. Create an agent in Autohand Squad first.'; + } + if (action === 'history') { + const data = await connection.history(rest[0]); + return data.messages.map(message => `${message.role}: ${redactSecrets(message.body)}`).join('\n') || 'No direct messages yet.'; + } + if (action === 'queue') return redactSecrets(JSON.stringify(await connection.queue(rest[0]), null, 2)); + if (action !== 'select') return HELP; + let target = rest.join(' '); + if (!target) { + const members = await connection.listMembers(); + if (!members.length) return 'No agents found. Create an agent in Autohand Squad first.'; + if (ctx.isNonInteractive || (!ctx.pickMember && (!process.stdin.isTTY || !process.stdout.isTTY))) return `Choose an agent with /squad select .\n${await runDesktopSquadCommand(ctx, ['agents'])}`; + await ctx.onBeforeModal?.(); + try { + if (ctx.pickMember) target = await ctx.pickMember(members) || ''; + else { + const { showModal } = await import('../ui/ink/components/Modal.js'); + const choice = await showModal({ title: 'Select a Squad agent', filterable: true, options: members.map(member => ({ label: member.name || member.id, value: member.id, description: `${member.presence} · ${member.role || member.id}` })) }); + target = choice?.value || ''; + } + } finally { await ctx.onAfterModal?.(); } + if (!target) return 'Squad selection cancelled.'; + } + const member = await connection.select(target); + return `Selected Squad agent ${member.name || member.id} (${member.id}).\n/message sends to this agent. Use /message team for a local teammate, or /squad clear to clear the selection.`; +} diff --git a/src/commands/sync.ts b/src/commands/sync.ts index 082e9200e..60c6360ab 100644 --- a/src/commands/sync.ts +++ b/src/commands/sync.ts @@ -8,12 +8,13 @@ import readline from 'node:readline'; import { StdinBuffer } from '../ui/StdinBuffer.js'; import type { SlashCommandContext } from '../core/slashCommandTypes.js'; import { loadConfig, saveConfig } from '../config.js'; -import type { AutohandConfig } from '../types.js'; +import type { AutohandConfig, LoadedConfig } from '../types.js'; import { formatSyncSkippedFiles } from '../sync/skipReport.js'; import type { SyncSkippedFiles } from '../sync/rootBudgets.js'; import type { SyncService } from '../sync/SyncService.js'; import { getSyncService as getRuntimeSyncService, + setBackgroundSyncEnabled, setSyncService as setRuntimeSyncService, } from '../sync/runtimeSyncService.js'; import { createCommandTheme } from './commandTheme.js'; @@ -56,6 +57,11 @@ export function getSyncService(): SyncService | null { export async function sync(ctx: SlashCommandContext, args: string[] = []): Promise { const config = await loadConfig(); const isLoggedIn = Boolean(config.auth?.token && config.auth?.user); + if (args[0] === 'off') { + await updateSyncEnabled(config, false); + console.log(createCommandTheme().accent(t('commands.sync.disabled'))); + return null; + } if (!isLoggedIn) { const theme = createCommandTheme(); @@ -64,6 +70,12 @@ export async function sync(ctx: SlashCommandContext, args: string[] = []): Promi return null; } + if (args[0] === 'on') { + await updateSyncEnabled(config, true); + console.log(createCommandTheme().accent(t('commands.sync.enabled'))); + return null; + } + // Gather sync data const data = await gatherSyncData(ctx, config); @@ -78,6 +90,14 @@ export async function sync(ctx: SlashCommandContext, args: string[] = []): Promi return null; } +async function updateSyncEnabled(config: LoadedConfig, enabled: boolean): Promise { + // Stop the timer and in-flight work before the asynchronous config write. + if (!enabled) setBackgroundSyncEnabled(false); + config.sync = { ...config.sync, enabled }; + await saveConfig(config); + if (enabled) setBackgroundSyncEnabled(true); +} + async function gatherSyncData(ctx: SlashCommandContext, config: AutohandConfig): Promise { const syncService = getRuntimeSyncService(); let status: Awaited> = { @@ -97,7 +117,7 @@ async function gatherSyncData(ctx: SlashCommandContext, config: AutohandConfig): } return { - enabled: config.sync?.enabled !== false, + enabled: syncService ? status.enabled : config.sync?.enabled === true, isRunning: syncService?.isRunning ?? false, lastSync: status.lastSync, lastError: status.lastError, @@ -198,11 +218,11 @@ function renderSyncUI(data: SyncData, ctx: SlashCommandContext): Promise { // Toggle sync enabled try { const config = await loadConfig(); - const newEnabled = config.sync?.enabled === false; - config.sync = { ...config.sync, enabled: newEnabled }; - await saveConfig(config); + const newEnabled = config.sync?.enabled !== true; + await updateSyncEnabled(config, newEnabled); if (closed) return; data.enabled = newEnabled; + data.isRunning = data.syncService?.isRunning ?? false; console.log(createCommandTheme().accent(`\n${newEnabled ? t('commands.sync.enabled') : t('commands.sync.disabled')}`)); await sleep(1000); render(); diff --git a/src/computer/cuaDriver.ts b/src/computer/cuaDriver.ts index fb26bd364..624dde4de 100644 --- a/src/computer/cuaDriver.ts +++ b/src/computer/cuaDriver.ts @@ -3,6 +3,7 @@ * Copyright 2026 Autohand AI LLC * SPDX-License-Identifier: Apache-2.0 */ +import { ensureSquadMcpServer, type SquadMcpOptions } from '../squad/builtin.js'; import { execFile } from 'node:child_process'; import { accessSync, constants as fsConstants, statSync } from 'node:fs'; import os from 'node:os'; @@ -268,7 +269,7 @@ export function ensureCuaMcpServer( /** Build the effective MCP list without persisting Autohand's built-in entry. */ export function resolveRuntimeMcpServers( config: LoadedConfig, - options: Parameters[1] = {}, + options: Parameters[1] & SquadMcpOptions = {}, ): McpServerConfigEntry[] { const runtimeConfig = { ...config, @@ -277,6 +278,8 @@ export function resolveRuntimeMcpServers( : {}, } as LoadedConfig; ensureCuaMcpServer(runtimeConfig, options); + const squad = ensureSquadMcpServer(runtimeConfig, options); + if (squad) runtimeConfig.mcp = { ...runtimeConfig.mcp, servers: [...(runtimeConfig.mcp?.servers ?? []), squad] }; return runtimeConfig.mcp?.servers ?? []; } diff --git a/src/config.ts b/src/config.ts index b55627e16..fb6f67ec5 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1493,6 +1493,13 @@ function validateShellSettings(config: AutohandConfig, configPath: string): void function validateConfig(config: AutohandConfig, configPath: string): void { validateShellSettings(config, configPath); + if (config.tools !== undefined) { + if (!isPlainObject(config.tools)) throw new Error(`tools must be an object in ${configPath}`); + const limit = config.tools.maxCommandOutputChars; + if (limit !== undefined && (!Number.isSafeInteger(limit) || limit < 1024)) { + throw new Error(`tools.maxCommandOutputChars must be an integer of at least 1024 in ${configPath}`); + } + } if (config.traces !== undefined) { if (!isPlainObject(config.traces)) { throw new Error(`traces must be an object in ${configPath}`); @@ -2196,13 +2203,13 @@ export async function saveValidatedAuth(config: LoadedConfig, validatedToken: st if (config.auth) persisted.auth = config.auth; else delete persisted.auth; await writeConfigData(config.configPath, persisted as Record, {}, writePath); - }); + }, { waitTimeoutMs: 5_000 }); } export async function saveConfig(config: LoadedConfig, options: SaveConfigOptions = {}): Promise { const writePath = await resolveConfigWritePath(config.configPath); await fs.ensureDir(path.dirname(writePath)); - await withFileLock(`${writePath}.lock`, () => saveConfigSnapshot(config, options, writePath)); + await withFileLock(`${writePath}.lock`, () => saveConfigSnapshot(config, options, writePath), { waitTimeoutMs: 5_000 }); } async function saveConfigSnapshot( diff --git a/src/core/actionExecutor.ts b/src/core/actionExecutor.ts index a246215a2..69875771c 100644 --- a/src/core/actionExecutor.ts +++ b/src/core/actionExecutor.ts @@ -4,6 +4,8 @@ * SPDX-License-Identifier: Apache-2.0 */ import chalk from 'chalk'; +import { CommandModelOutput, resolveMaxCommandOutputChars } from '../utils/commandModelOutput.js'; +import { formatToolObservation } from './agent/AgentToolOutputRuntime.js'; import { setBounded } from '../utils/bounded.js'; import path from 'node:path'; import fse from 'fs-extra'; @@ -176,6 +178,7 @@ export interface ActionExecutorOptions { projectManager?: ProjectManager; sessionId?: string; getCurrentSessionId?: () => string | undefined; + getCommandOutputDirectory?: () => string | undefined; onExploration?: (entry: ExplorationEvent) => void; toolsRegistry?: ToolsRegistry; metaToolService?: MetaToolService; @@ -260,6 +263,7 @@ const READ_FILE_MAX_BYTES = 128 * 1024; const READ_FILE_MAX_LINE_CHARACTERS = 2_000; interface ToolOutcomeCapture { + commandOutput?: CommandModelOutput; failure?: ToolFailureOutcome; imagePaths?: string[]; } @@ -922,9 +926,20 @@ export class ActionExecutor { } const capture: ToolOutcomeCapture = {}; - const withImages = (outcome: ToolActionOutcome): ToolActionOutcome => capture.imagePaths?.length - ? { ...outcome, imagePaths: capture.imagePaths } - : outcome; + if (action.type === 'run_command' || action.type === 'shell') { + capture.commandOutput = new CommandModelOutput( + resolveMaxCommandOutputChars(this.runtime.config.tools?.maxCommandOutputChars), + this.deps.getCommandOutputDirectory?.(), + ); + } + const withImages = (outcome: ToolActionOutcome): ToolActionOutcome => { + if (capture.commandOutput) { + const observation = formatToolObservation({ tool: action.type, ...outcome }); + const modelOutput = capture.commandOutput.finish(observation); + if (modelOutput !== observation) outcome = { ...outcome, modelOutput }; + } + return capture.imagePaths?.length ? { ...outcome, imagePaths: capture.imagePaths } : outcome; + }; try { const output = await this.withToolActivity( action, @@ -2016,10 +2031,12 @@ export class ActionExecutor { shell: true, signal: context?.signal, onStdout: (chunk) => { + capture?.commandOutput?.append(chunk); emitOutput('stdout', chunk); emitLiveOutput('stdout', chunk); }, onStderr: (chunk) => { + capture?.commandOutput?.append(chunk); emitOutput('stderr', chunk); emitLiveOutput('stderr', chunk); }, @@ -2123,8 +2140,8 @@ export class ActionExecutor { cmdStr, this.runtime.workspaceRoot, { - onStdout: (chunk) => this.onLiveCommandOutput!(liveId, 'stdout', chunk), - onStderr: (chunk) => this.onLiveCommandOutput!(liveId, 'stderr', chunk), + onStdout: (chunk) => { capture?.commandOutput?.append(chunk); this.onLiveCommandOutput!(liveId, 'stdout', chunk); }, + onStderr: (chunk) => { capture?.commandOutput?.append(chunk); this.onLiveCommandOutput!(liveId, 'stderr', chunk); }, preferPty: process.stdin.isTTY && process.stdout.isTTY, columns: process.stdout.columns, rows: process.stdout.rows, @@ -2195,6 +2212,8 @@ export class ActionExecutor { shell: true, background: action.background, signal: context?.signal, + onStdout: chunk => capture?.commandOutput?.append(chunk), + onStderr: chunk => capture?.commandOutput?.append(chunk), ...(action.background ? { onBackgroundExit: () => { if (fallbackBackgroundRegistryId !== undefined) { diff --git a/src/core/agent.ts b/src/core/agent.ts index 478bc676e..99e875e9b 100644 --- a/src/core/agent.ts +++ b/src/core/agent.ts @@ -210,6 +210,7 @@ import { type FreshAgentSessionRecord, type FreshAgentSessionStateHost, type RunAgentCommandModeOptions, + type RestoreAgentSessionOptions, } from './agent/AgentLifecycleRunner.js'; import { promptForAgentInstruction, type AgentPromptInstructionHost } from './agent/PromptInstructionReader.js'; import { @@ -828,8 +829,8 @@ export class AutohandAgent { }; } - private async restoreSessionState(session: string | import('../session/SessionManager.js').Session) { - return restoreAgentSessionState(this, session); + private async restoreSessionState(session: string | import('../session/SessionManager.js').Session, options?: RestoreAgentSessionOptions) { + return restoreAgentSessionState(this, session, options); } async attachSession(sessionId: string): Promise<{ sessionId: string; model: string; workspaceRoot: string; messageCount: number }> { @@ -2380,6 +2381,21 @@ export class AutohandAgent { this.syncInteractiveAutomodePermissions(); } + private activateComputerUseAutoMode(): boolean { + return this.instructionRunner.activateComputerUseAutoMode(); + } + + private beginComputerUseAutoMode(): () => void { + const restore = this.interactionModeController.enterTemporaryMode('automode'); + this.inkRenderer?.setInteractionMode?.(this.getInteractionMode()); + this.persistentInput?.setStatusLine(this.formatStatusLine()); + return () => { + restore(); + this.inkRenderer?.setInteractionMode?.(this.getInteractionMode()); + this.persistentInput?.setStatusLine(this.formatStatusLine()); + }; + } + private getInteractionMode(): InteractionMode { return this.interactionModeController.getMode(); } @@ -2513,6 +2529,7 @@ export class AutohandAgent { this.runtime.config.mcp = mcp; this.runtimeMcpServers = resolveRuntimeMcpServers(this.runtime.config, { bare: this.runtime.options.bare || this.runtime.options.daemon === false, + offline: this.runtime.options.offline, }); await this.mcpManager.disconnectAll(); if (mcp?.enabled !== false) { diff --git a/src/core/agent/AgentDependencyComposer.ts b/src/core/agent/AgentDependencyComposer.ts index a32916458..9e260d21d 100644 --- a/src/core/agent/AgentDependencyComposer.ts +++ b/src/core/agent/AgentDependencyComposer.ts @@ -80,7 +80,7 @@ import { fetchRegistryWithFallback, installSkillWithSecurity } from '../../skill import { McpClientManager } from '../../mcp/McpClientManager.js'; import { compactComputerUseResult, COMPUTER_USE_TOOL_PREFIX } from '../../computer/computerUseOutput.js'; import { ComputerUseLifecycle } from '../../computer/ComputerUseLifecycle.js'; -import { resolveRuntimeMcpServers } from '../../computer/cuaDriver.js'; +import { isComputerUseMcpServer, resolveRuntimeMcpServers } from '../../computer/cuaDriver.js'; import { findCommunityMcpServers, installCommunityMcpServer, @@ -484,6 +484,7 @@ export function initializeAgentDependencies( runtime.config = applyNoDaemonConfig(runtime.config, runtime.options); host.runtimeMcpServers = resolveRuntimeMcpServers(runtime.config, { bare: runtime.options.bare || runtime.options.daemon === false, + offline: runtime.options.offline, }); const initialProvider = runtime.config.provider ?? 'openrouter'; const providerSettings = getProviderConfig(runtime.config, initialProvider); @@ -854,6 +855,7 @@ export function initializeAgentDependencies( toolsRegistry: host.toolsRegistry, getRegisteredTools: () => host.toolManager?.listDefinitions() ?? [], getCurrentSessionId: () => host.sessionManager?.getCurrentSession?.()?.metadata?.sessionId, + getCommandOutputDirectory: () => host.sessionManager?.getCurrentSession?.()?.getArtifactDirectory?.(), memoryManager: host.memoryManager, permissionManager: host.permissionManager, onFileModified: (filePath, changeType, toolCallId) => { @@ -1472,6 +1474,11 @@ export function initializeAgentDependencies( const customPolicy = blockedTools.length > 0 ? { blockedTools } : undefined; host.toolManager = new ToolManager({ + onToolRequested: tool => { + const parsed = McpClientManager.parseMcpToolName(tool); + const server = parsed && host.runtimeMcpServers?.find((entry: { name: string }) => entry.name === parsed.serverName); + return server && isComputerUseMcpServer(server) ? host.activateComputerUseAutoMode?.() === true : false; + }, getActiveProvider: () => host.activeProvider ?? runtime.config.provider ?? 'openrouter', maxConcurrency: runtime.config.agent?.parallelToolConcurrency ?? 5, executor: async (action, context) => { diff --git a/src/core/agent/AgentLifecycleRunner.ts b/src/core/agent/AgentLifecycleRunner.ts index 84532cf47..96e3d1c26 100644 --- a/src/core/agent/AgentLifecycleRunner.ts +++ b/src/core/agent/AgentLifecycleRunner.ts @@ -1415,7 +1415,11 @@ async function restoreHostGoalCheckpoint(host: AgentLifecycleHost, sessionId: st if (!restored.ok) throw new Error(restored.message ?? 'Goal checkpoint recovery was refused.'); } -export async function restoreAgentSessionState(host: AgentLifecycleHost, sessionOrId: string | Session) { +export interface RestoreAgentSessionOptions { + preserveVisibleMessages?: boolean; +} + +export async function restoreAgentSessionState(host: AgentLifecycleHost, sessionOrId: string | Session, options: RestoreAgentSessionOptions = {}) { resetSessionGitSnapshot(host.runtime); const session = typeof sessionOrId === 'string' ? await host.sessionManager.loadSession(sessionOrId) @@ -1474,7 +1478,10 @@ export async function restoreAgentSessionState(host: AgentLifecycleHost, session await host.injectProjectKnowledge(); host.updateContextUsage(host.conversation.history()); if (host.inkRenderer?.setChatMessages) { - host.inkRenderer.setChatMessages(host.restoredChatMessages); + const visibleMessages = options.preserveVisibleMessages + ? host.inkRenderer.getState?.().chatMessages ?? [] + : []; + host.inkRenderer.setChatMessages([...host.restoredChatMessages, ...visibleMessages]); } if (host.goalActivityManager?.getSessionSnapshot) { host.goalActivitySnapshot = await host.goalActivityManager.getSessionSnapshot(); @@ -1510,90 +1517,80 @@ export async function attachAgentSession( } export async function resumeAgentSession(host: AgentLifecycleHost, sessionId: string): Promise { - // Initialize managers and pre-load files in parallel - await host.initializeManagers(); - - // Prepare MCP startup coordinator so auto-connect servers are identified - // before the interactive loop begins (mirrors runAgentInteractive). - host.mcpStartupCoordinator.prepareForInteractiveStartup(); - - // Start MCP connections in background (non-blocking, like fresh session startup). - // Guarded on mcpManager existence so a missing manager or a synchronous - // connectAll throw cannot escape before the try/finally below installs and - // removes the exit signal handlers. - if (host.mcpManager && host.runtime.config.mcp?.enabled !== false) { - try { - host.mcpStartupCoordinator.markConnectStarted(); - host.mcpReady = host.mcpManager - .connectAll(host.runtimeMcpServers ?? host.runtime.config.mcp?.servers ?? []) - .then(() => { if (!isRuntimeResourceShutdownStarted(host)) host.syncMcpTools(); }) - .catch(() => { /* individual server errors already captured by connectAll */ }) - .finally(() => { if (!isRuntimeResourceShutdownStarted(host)) host.mcpStartupCoordinator.markSummaryPending(); }); - } catch { - // A synchronous connectAll throw must not escape before the try/finally - // below installs and removes the exit signal handlers. + host.installExitSignalHandlers(); + try { + await initializeInteractiveComposer(host); + if (host.useInkRenderer && host.inkRenderer) host.setComposerIdle(); + + await host.initializeManagers(); + if (host.shouldExit || isRuntimeResourceShutdownStarted(host)) return; + + // Prepare MCP startup coordinator so auto-connect servers are identified + // before the interactive loop begins (mirrors runAgentInteractive). + host.mcpStartupCoordinator.prepareForInteractiveStartup(); + + if (host.mcpManager && host.runtime.config.mcp?.enabled !== false) { + try { + host.mcpStartupCoordinator.markConnectStarted(); + host.mcpReady = host.mcpManager + .connectAll(host.runtimeMcpServers ?? host.runtime.config.mcp?.servers ?? []) + .then(() => { if (!isRuntimeResourceShutdownStarted(host)) host.syncMcpTools(); }) + .catch(() => { /* individual server errors already captured by connectAll */ }) + .finally(() => { if (!isRuntimeResourceShutdownStarted(host)) host.mcpStartupCoordinator.markSummaryPending(); }); + } catch { + // A failed optional handshake must not prevent restoring the conversation. + } } - } - // Mark init as complete BEFORE the try so both the success path and the - // fallback path (which creates a new session) leave initDone true and - // initReady awaitable. Without this, describeStartupWait() returns - // STARTUP_WAIT_STATUS and ensureInitComplete() hangs, freezing the composer. - // - // initReady is a resolved promise rather than performBackgroundInit() on - // purpose: the resumed session is already restored, and the background-init - // pipeline calls resetConversationContext() and sessionManager.createSession(), - // which would wipe the restored conversation and double-create a session. - host.initDone = true; - host.initReady = Promise.resolve(); - - // Install exit signal handlers to stop queue processing immediately on SIGINT/SIGTERM. - // Installed before the try so both the success path and the fallback path - // (which creates a new session) have handlers active during the interactive - // loop. Removed in the finally block after the loop exits. - host.installExitSignalHandlers(); + // The interactive loop starts only after restoration. Fresh-session background + // initialization would clear that history and create an unwanted second session. + host.initDone = true; + host.initReady = Promise.resolve(); - try { - const session = await host.restoreSessionState(sessionId); - host.sessionStartedAt = Date.now(); - const providerSettings = getHostProviderSettings(host); - await startHostActiveAgentHeartbeat(host); + try { + const session = await host.restoreSessionState(sessionId, { preserveVisibleMessages: true }); + if (host.shouldExit || isRuntimeResourceShutdownStarted(host)) return; + host.sessionStartedAt = Date.now(); + const providerSettings = getHostProviderSettings(host); + await startHostActiveAgentHeartbeat(host); - console.log(chalk.cyan(`\n📂 Resumed session ${sessionId}`)); + console.log(chalk.cyan(`\n📂 Resumed session ${sessionId}`)); - // Start telemetry for resumed session - await host.telemetryManager.startSession( - sessionId, - session.metadata.model, - host.activeProvider, - host.sessionStartedAt, - buildProviderTelemetryMetadata(providerSettings) - ); + // Start telemetry for resumed session + await host.telemetryManager.startSession( + sessionId, + session.metadata.model, + host.activeProvider, + host.sessionStartedAt, + buildProviderTelemetryMetadata(providerSettings) + ); - // Start interactive loop - await host.runInteractiveLoop(); - } catch (error) { - console.error(chalk.red(`Failed to resume session: ${(error as Error).message}`)); - await host.telemetryManager.trackError({ - type: 'session_resume_failed', - message: (error as Error).message, - context: 'resumeSession' - }); - // Fallback to new session - const providerSettings = getHostProviderSettings(host); - const model = host.runtime?.options?.model ?? providerSettings?.model ?? 'unconfigured'; - host.sessionStartedAt = Date.now(); - const workspaceRoot = host.runtime?.workspaceRoot ?? process.cwd(); - const fallbackSession = await host.sessionManager.createSession(workspaceRoot, model); - await startHostActiveAgentHeartbeat(host); - await host.telemetryManager.startSession( - fallbackSession.metadata.sessionId, - model, - host.activeProvider, - host.sessionStartedAt, - buildProviderTelemetryMetadata(providerSettings) - ); - await host.runInteractiveLoop(); + // Start interactive loop + await host.runInteractiveLoop(); + } catch (error) { + if (host.shouldExit || isRuntimeResourceShutdownStarted(host)) return; + console.error(chalk.red(`Failed to resume session: ${(error as Error).message}`)); + await host.telemetryManager.trackError({ + type: 'session_resume_failed', + message: (error as Error).message, + context: 'resumeSession' + }); + // Fallback to new session + const providerSettings = getHostProviderSettings(host); + const model = host.runtime?.options?.model ?? providerSettings?.model ?? 'unconfigured'; + host.sessionStartedAt = Date.now(); + const workspaceRoot = host.runtime?.workspaceRoot ?? process.cwd(); + const fallbackSession = await host.sessionManager.createSession(workspaceRoot, model); + await startHostActiveAgentHeartbeat(host); + await host.telemetryManager.startSession( + fallbackSession.metadata.sessionId, + model, + host.activeProvider, + host.sessionStartedAt, + buildProviderTelemetryMetadata(providerSettings) + ); + await host.runInteractiveLoop(); + } } finally { // Clean up signal handlers on success, error, or fallback path. // They are re-installed by runAgentInteractive on the next fresh session. diff --git a/src/core/agent/AgentToolOutputRuntime.ts b/src/core/agent/AgentToolOutputRuntime.ts index 3d0aa4ba6..97f0c39cd 100644 --- a/src/core/agent/AgentToolOutputRuntime.ts +++ b/src/core/agent/AgentToolOutputRuntime.ts @@ -24,6 +24,8 @@ export function handleAgentToolOutput( if (process.env.AUTOHAND_STREAM_TOOL_OUTPUT !== '1') { return; } + // Commands supply one bounded final observation; live chunks remain terminal-only. + if (chunk.tool === 'run_command' || chunk.tool === 'shell') return; if (!chunk.toolCallId || !chunk.data) { return; } @@ -37,6 +39,7 @@ export function queueAgentToolMessageChunk( toolCallId: string, stream?: 'stdout' | 'stderr' ): void { + if (name === 'run_command' || name === 'shell') return; const session = host.sessionManager.getCurrentSession(); if (!session) return; @@ -76,6 +79,7 @@ export async function saveAgentToolMessage( } export function formatToolObservation(result: ToolExecutionResult): string { + if (result.modelOutput !== undefined) return result.modelOutput; if (result.success) return result.output ?? '(no output)'; if (!result.output) return result.error ?? 'Tool failed without error message'; if (!result.error || result.output.includes(result.error)) return result.output; diff --git a/src/core/agent/CodeModeRunner.ts b/src/core/agent/CodeModeRunner.ts index 8e0200f12..777baa746 100644 --- a/src/core/agent/CodeModeRunner.ts +++ b/src/core/agent/CodeModeRunner.ts @@ -95,6 +95,13 @@ function isArgumentObject(value: unknown): value is NonNullable void; toolManager?: { resetUnattendedDenials?(): void; getUnattendedDenials?(): string[] }; extensionReloadController?: Pick; repositoryReadiness?: Pick | null; @@ -225,17 +227,35 @@ interface DeepResearchInstructionState { type FinalizeResearch = (turnSucceeded: boolean) => Promise; export class InstructionRunner { + private computerUseModeScope?: { restore?: () => void }; + constructor(private readonly host: AgentInstructionHost) {} async run(instruction: string, options: RunInstructionOptions = {}): Promise { + const parentScope = this.computerUseModeScope; + const scope: { restore?: () => void } = {}; + this.computerUseModeScope = scope; const execute = async (): Promise => { while (true) { const result = await this.runAttempt(instruction, options); if (result !== 'retry-provider') return result; } }; - return this.host.extensionReloadController - ? this.host.extensionReloadController.withStableExtensions(execute) : execute(); + try { + return await (this.host.extensionReloadController + ? this.host.extensionReloadController.withStableExtensions(execute) : execute()); + } finally { + try { scope.restore?.(); } + finally { this.computerUseModeScope = parentScope; } + } + } + + activateComputerUseAutoMode(): boolean { + const scope = this.computerUseModeScope; + if (!scope || scope.restore || this.host.runtime.options.restricted || this.host.runtime.options.dryRun + || this.host.permissionManager?.getMode() === 'restricted') return false; + scope.restore = this.host.beginComputerUseAutoMode?.(); + return scope.restore !== undefined; } private async runAttempt(instruction: string, options: RunInstructionOptions): Promise { @@ -407,6 +427,10 @@ export class InstructionRunner { if (!host.runtime.isRpcMode) console.log(chalk.yellow('Request canceled.')); return false; } + if (matchesComputerControlIntent(instruction) + && host.runtimeMcpServers?.some(server => server.autoConnect !== false && isComputerUseMcpServer(server))) { + this.activateComputerUseAutoMode(); + } if (deepResearch.runId) { await markDeepResearchRunStarted(host.runtime.workspaceRoot, deepResearch.runId); } diff --git a/src/core/agent/InteractionModeController.ts b/src/core/agent/InteractionModeController.ts index d670dad3f..fb45a5e65 100644 --- a/src/core/agent/InteractionModeController.ts +++ b/src/core/agent/InteractionModeController.ts @@ -28,6 +28,8 @@ export interface InteractionModeAdapter { * Owns the mutually-exclusive session interaction modes selected by Shift+Tab. */ export class InteractionModeController { + private revision = 0; + constructor(private readonly adapter: InteractionModeAdapter) {} getMode(): InteractionMode { @@ -58,6 +60,7 @@ export class InteractionModeController { } setMode(mode: InteractionMode): InteractionMode { + this.revision++; if (mode !== 'plan') { this.adapter.setPlanEnabled(false); } @@ -82,6 +85,19 @@ export class InteractionModeController { return mode; } + enterTemporaryMode(mode: InteractionMode): () => void { + const previous = this.getMode(); + if (previous === mode) return () => {}; + this.setMode(mode); + const revision = this.revision; + let restored = false; + return () => { + if (restored) return; + restored = true; + if (this.revision === revision) this.setMode(previous); + }; + } + cycle(): InteractionMode { const currentIndex = INTERACTION_MODE_SEQUENCE.indexOf(this.getMode()); const nextIndex = (currentIndex + 1) % INTERACTION_MODE_SEQUENCE.length; diff --git a/src/core/agent/ReactLoopRunner.ts b/src/core/agent/ReactLoopRunner.ts index b8de18684..337100014 100644 --- a/src/core/agent/ReactLoopRunner.ts +++ b/src/core/agent/ReactLoopRunner.ts @@ -1905,7 +1905,6 @@ export async function runAgentReactLoop( return { status: 'incomplete', reason: 'pending_todos' }; } if (advisor && hadToolWork) { - await renderProvisionalResponse(turnOutcome.response, 'completion review is pending.'); let review: AdvisorReview; try { review = await consultAdvisor('completion', turnOutcome.response); diff --git a/src/core/agent/SystemPromptBuilder.ts b/src/core/agent/SystemPromptBuilder.ts index 0ea1cd89f..377efb06d 100644 --- a/src/core/agent/SystemPromptBuilder.ts +++ b/src/core/agent/SystemPromptBuilder.ts @@ -46,7 +46,7 @@ export interface SystemPromptBuilderOptions { getContextMemories: () => Promise; loadInstructionFiles: () => Promise; listSkills: () => PromptSkillSummary[]; - /** Names-only skill index; defaults to on for ACP clients (see isAcpClient). */ + /** Names-only discovery by default; false explicitly requests descriptions. */ compactSkillIndex?: boolean; getActiveSkills: () => PromptSkillSummary[]; getTeam: () => PromptTeam | null; @@ -72,11 +72,6 @@ function shouldAddAutohandSkillCompatibilityOverride(skill: PromptSkillSummary): || hasCodexSkillInstallerMarkers(skill); } -/** True inside `autohand --mode acp` (runAcpMode sets AUTOHAND_CLIENT_NAME). */ -function isAcpClient(): boolean { - return process.env.AUTOHAND_CLIENT_NAME === 'acp'; -} - /** Compact, stable skill index for the system prompt: sorted names, active ones marked. */ export function formatSkillNameIndex(skills: readonly PromptSkillSummary[]): string { return skills @@ -492,11 +487,7 @@ export class SystemPromptBuilder { const allSkills = adaptiveSkills ? [] : this.options.listSkills(); if (allSkills.length > 0) { parts.push('', '## Available Skills'); - if (this.options.compactSkillIndex ?? isAcpClient()) { - // Protocol hosts (the desktop app over ACP) get names only: the full - // catalog is ~60 KB (~15k tokens) per request and dominated time-to-first- - // token for simple prompts. The model pulls details on demand. Terminal - // users keep the full catalog below. + if (this.options.compactSkillIndex !== false) { parts.push( 'Skills are specialized instruction packages. When one of these names looks relevant, use the `skill` tool with command `info` to read its description, then `activate` to load it (`list` shows every skill with its description).' ); diff --git a/src/core/slashCommandHandler.ts b/src/core/slashCommandHandler.ts index 8495a70f1..b97e80d98 100644 --- a/src/core/slashCommandHandler.ts +++ b/src/core/slashCommandHandler.ts @@ -3,6 +3,7 @@ * Copyright 2025 Autohand AI LLC * SPDX-License-Identifier: Apache-2.0 */ +import { SquadConnection } from '../squad/SquadConnection.js'; import chalk from 'chalk'; import terminalLink from 'terminal-link'; import type { SlashCommand } from './slashCommands.js'; @@ -17,6 +18,7 @@ import { export class SlashCommandHandler { private readonly commandMap = new Map(); + private readonly squadConnection = new SquadConnection(); constructor( private readonly ctx: SlashCommandContext, @@ -786,7 +788,8 @@ export class SlashCommandHandler { } case '/message': { const { message } = await import('../commands/message.js'); - return message({ teamManager: this.ctx.teamManager }, args); + if (this.ctx.backgroundServicesEnabled === false && args[0] === 'squad') return 'Squad messaging requires background services. Use /message team for a local teammate.'; + return message({ teamManager: this.ctx.teamManager, squadConnection: this.ctx.backgroundServicesEnabled === false ? undefined : this.squadConnection }, args); } case '/import': { const { execute } = await import('../commands/import.js'); @@ -863,6 +866,10 @@ export class SlashCommandHandler { case '/squad': { const { squad } = await import('../commands/squad.js'); return squad({ + squadConnection: this.squadConnection, + onBeforeModal: this.ctx.onBeforeModal, + onAfterModal: this.ctx.onAfterModal, + isNonInteractive: this.ctx.isNonInteractive, workspaceRoot: this.ctx.workspaceRoot, config: this.ctx.config, onToggleAgentRunsView: this.ctx.onToggleAgentRunsView, diff --git a/src/core/toolManager.ts b/src/core/toolManager.ts index 0548c4057..710f56474 100644 --- a/src/core/toolManager.ts +++ b/src/core/toolManager.ts @@ -131,6 +131,7 @@ export interface ToolDefinition { } export interface ToolManagerOptions { + onToolRequested?: (toolName: string) => boolean; getActiveProvider?: () => string; executor: (action: AgentAction, context?: ToolExecutionContext) => Promise; confirmApproval: (message: string, context?: { tool?: string; path?: string; command?: string }) => Promise; @@ -2526,6 +2527,7 @@ export const EXIT_PLAN_MODE_TOOL_DEFINITION: ToolDefinition = { }; export class ToolManager { + private readonly onToolRequested?: ToolManagerOptions['onToolRequested']; private readonly unattendedApprovals: UnattendedApprovalTracker; getUnattendedDenials(): string[] { return this.unattendedApprovals.tools; } @@ -2546,6 +2548,7 @@ export class ToolManager { private readonly onAdditionalContext?: ToolAuthorizationOptions['onAdditionalContext']; constructor(options: ToolManagerOptions) { + this.onToolRequested = options.onToolRequested; this.unattendedApprovals = options.authorization?.unattendedApprovals ?? new UnattendedApprovalTracker(); this.getActiveProvider = options.getActiveProvider; this.executor = options.executor; @@ -2765,8 +2768,7 @@ export class ToolManager { // Get plan mode manager to check read-only enforcement const planModeManager = getPlanModeManager(); - const isInPlanningPhase = planModeManager.isEnabled() && planModeManager.getPhase() === 'planning'; - const readOnlyTools = isInPlanningPhase ? new Set(planModeManager.getReadOnlyTools()) : null; + const readOnlyTools = new Set(planModeManager.getReadOnlyTools()); // Phase 1: Pre-flight + Approval (sequential) // Categorize each call as rejected, denied, or ready-to-execute @@ -2815,12 +2817,6 @@ export class ToolManager { continue; } - // Check plan mode restrictions - only read-only tools allowed during planning phase - if (readOnlyTools && !readOnlyTools.has(call.tool)) { - reject(`Tool '${call.tool}' is not available in plan mode. Only read-only tools are allowed during planning. Use 'plan' tool to create a plan, then accept it to execute write operations.`, 'authorization'); - continue; - } - const definition = this.definitions.get(call.tool); if (!definition) { reject(`Tool '${call.tool}' is not available. Use tool_search or tools_registry to find an available tool.`, 'validation'); @@ -2940,6 +2936,22 @@ export class ToolManager { } } + if (this.onToolRequested?.(call.tool)) { + policyEvaluation = this.evaluatePermissionContexts(permissionContexts); + if (policyEvaluation.denied) { + reject(`Tool '${call.tool}' was denied by the permission policy.`); + continue; + } + permissionContext = policyEvaluation.promptContext; + policyRequiresPrompt = policyEvaluation.requiresPrompt + && shouldPromptForToolPermission(call.tool, requiresApproval, permissionContext.tool); + } + if (planModeManager.isEnabled() && planModeManager.getPhase() === 'planning' + && !readOnlyTools.has(call.tool)) { + reject(`Tool '${call.tool}' is not available in plan mode. Only read-only tools are allowed during planning. Use 'plan' tool to create a plan, then accept it to execute write operations.`, 'authorization'); + continue; + } + let shouldPrompt = hookPromptOverride ?? policyRequiresPrompt; if (shouldPrompt && this.runPermissionRequestHooks) { const promptContext = this.toPromptContext(permissionContext); @@ -3600,7 +3612,11 @@ export class ToolManager { || outcome.imageRefs.some(value => typeof value !== 'string' || !TOOL_IMAGE_REF.test(value)))) { throw new Error('Tool executor returned malformed runtime image references.'); } + if (outcome.modelOutput !== undefined && typeof outcome.modelOutput !== 'string') { + throw new Error('Tool executor returned malformed model output.'); + } const images = { + ...(outcome.modelOutput === undefined ? {} : { modelOutput: outcome.modelOutput }), ...(outcome.imagePaths === undefined ? {} : { imagePaths: [...outcome.imagePaths] }), ...(outcome.imageRefs === undefined ? {} : { imageRefs: [...outcome.imageRefs] }), }; diff --git a/src/index.ts b/src/index.ts index f741e3339..60d1768c6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -9,6 +9,8 @@ const requestsStructuredCommandOutput = process.argv.some((arg) => ( || arg.startsWith('--output-format=') )); const requestsProtocolOutput = process.argv.some((arg, index, argv) => ( + (arg === 'squad' && argv[index + 1] === 'mcp') + || arg === '--answer-only' || arg === '--setup-only' || arg === '--acp' @@ -338,7 +340,7 @@ import { isDiscoveryInvocation, registerDiscoveryCommand } from './discovery/cli installProcessErrorHandlers(); -const program = new Command(); +export const program = new Command(); const collectRepeatable = (value: string, previous: string[] = []): string[] => [...previous, value]; @@ -364,6 +366,10 @@ program.hook('preAction', async (thisCommand, actionCommand) => { configureRunConfigOverlay({ profile, sets: set, ...(sdkSettings ? { sdkSettings } : {}) }); const commandPath = commandPathOf(actionCommand); + if (commandPath[0] === 'squad' && actionCommand.args[0] === 'mcp') { + if (daemon === false || bare) actionCommand.error(NO_DAEMON_MESSAGE); + return; + } if (daemon === false && (tracesOn || commandPath[0] === 'traces' || commandPath[0] === 'computer' || commandPath[0] === 'squad')) { actionCommand.error(NO_DAEMON_MESSAGE); } @@ -465,7 +471,7 @@ program .conflicts('tracesOn')) .option('--login', 'Sign in to your Autohand account', false) .option('--logout', 'Sign out of your Autohand account', false) - .option('--sync-settings [bool]', 'Enable/disable settings sync (default: true for logged users)') + .option('--sync-settings [bool]', 'Enable/disable settings sync (default: false)') .option('--patch', 'Generate git patch without applying changes (requires --prompt)', false) .option('--output ', 'Output file for patch (default: stdout, used with --patch)') .option('--mode ', 'Run mode: interactive (default), rpc, or acp', 'interactive') @@ -480,7 +486,7 @@ program .addOption(new Option('--sdk-resume-last').hideHelp()) .option('--teammate-mode ', 'Legacy team display preference; live team view stays in the lead terminal') .option('--worktree [name]', 'Run session in isolated git worktree (optional name)') - .option('--tmux', 'Launch in a dedicated tmux session (implies --worktree)') + .option('--tmux', 'Launch in a dedicated tmux session (supports --worktree or --zit)') .option('--zit [intent]', 'Run session in a disposable Zit workspace, recorded as a Zit change on exit (optional intent)') // Auto-mode options .option('--auto-mode [prompt]', 'Enable interactive auto-mode, or start a standalone loop with an inline task') @@ -621,8 +627,6 @@ program process.exit(1); } - // tmux sessions are intended to run with isolated worktrees by default. - // Respect explicit --no-worktree (opts.worktree === false) as invalid with --tmux. const tmuxOptionError = normalizeTmuxWorktreeOption(opts); if (tmuxOptionError) { console.error(chalk.red(tmuxOptionError)); @@ -991,10 +995,15 @@ program program .command('squad [args...]') - .description('Open the Autohand Squad app (installs it if missing); subcommands manage the standalone runtime') + .description('Open Squad, list agents, send messages, read history, or serve the built-in MCP') .allowUnknownOption(true) .allowExcessArguments(true) .action(async (args: string[] = []) => { + if (args[0] === 'mcp') { + try { await (await import('./squad/builtin.js')).runSquadMcp(args.slice(1)); } + catch (error) { console.error(error instanceof Error ? error.message : String(error)); process.exit(1); } + process.exit(0); + } const rootOptions = program.opts(); const config = await loadConfig(rootOptions.config, process.cwd()); const workspaceRoot = resolveWorkspaceRoot(config, rootOptions.path); @@ -2053,11 +2062,8 @@ async function runCLI(options: InternalCLIOptions): Promise { onVersionResult: (versionResult) => { agentHolder.current?.setVersionCheckResult(versionResult); }, - shouldStartSync: () => Boolean( - config.auth?.token - && options.syncSettings !== false - && config.sync?.enabled !== false - ), + shouldCreateSync: () => Boolean(config.auth?.token), + shouldStartSync: () => Boolean(config.auth?.token && (options.syncSettings ?? config.sync?.enabled ?? false)), createSyncService: async (authUser) => { const { createSyncService, DEFAULT_SYNC_CONFIG, SyncApiClient } = await import('./sync/index.js'); let lastUnavailableCount = 0; @@ -2077,7 +2083,7 @@ async function runCLI(options: InternalCLIOptions): Promise { config: { ...DEFAULT_SYNC_CONFIG, ...config.sync, - enabled: true, + enabled: options.syncSettings ?? config.sync?.enabled ?? false, }, onSyncFailure: async (reason) => { const oneLine = reason.replace(/[\u0000-\u001f\u007f-\u009f]/gu, ' ').slice(0, 300).replace(/[.\s]+$/u, ''); diff --git a/src/mcp/McpClientManager.ts b/src/mcp/McpClientManager.ts index e75fd9be1..7b8ff6ca9 100644 --- a/src/mcp/McpClientManager.ts +++ b/src/mcp/McpClientManager.ts @@ -84,6 +84,8 @@ class McpConnectionCancelledError extends Error { const MCP_STOP_GRACE_MS = 1_000; const MCP_STOP_FORCE_WAIT_MS = 1_000; +// Allow large base64 screenshots while bounding malformed, unterminated frames. +export const MAX_MCP_NEWLINE_FRAME_BYTES = 64 * 1024 * 1024; /** * Budget for every request, the initialize handshake included. Cold `npx` * servers can spend most of it installing on first launch, so it stays generous; @@ -173,7 +175,10 @@ function waitForChildClose(child: ChildProcess, timeoutMs: number): Promise void; private frameBuffer = Buffer.alloc(0); private nextId = 1; private pendingRequests = new Map< @@ -210,38 +215,52 @@ export class McpStdioConnection extends EventEmitter { // Server-specific env still wins; the base follows the shell.env policy. env: buildAutohandChildProcessEnv(this.config.env), }); + const child = this.process; + let startupSettled = false; + const finishStartup = (error?: Error): void => { + if (startupSettled) return; + startupSettled = true; + child.off('spawn', onSpawn); + if (error) reject(error); + else resolve(); + }; + const onSpawn = (): void => finishStartup(); - const stdin = this.process.stdin; - if (stdin) { - const onStdinError = (error: Error): void => { - this.rejectPendingRequests(error); - if (this.listenerCount('error') > 0) this.emit('error', error); - }; - stdin.on('error', onStdinError); - stdin.once('close', () => stdin.off('error', onStdinError)); - } - - this.process.stdout?.on('data', (data: Buffer) => { - this.handleStdoutData(data); - }); - - this.process.stderr?.on('data', (data: Buffer) => { - this.emit('stderr', data.toString()); - }); - - this.process.on('error', (err) => { - this.emit('error', err); - reject(err); - }); - - this.process.on('close', (code) => { + const stdin = child.stdin; + const onStdinError = (error: Error): void => { + this.rejectPendingRequests(error); + if (this.listenerCount('error') > 0) this.emit('error', error); + }; + const onStdinClose = (): void => { stdin?.off('error', onStdinError); }; + const onStdoutData = (data: Buffer): void => { this.handleStdoutData(data); }; + const onStderrData = (data: Buffer): void => { this.emit('stderr', data.toString()); }; + const onProcessError = (error: Error): void => { + finishStartup(error); + this.emit('error', error); + }; + const onProcessClose = (code: number | null): void => { + finishStartup(new Error(`MCP server "${this.config.name}" exited before spawning`)); this.cleanup(); this.emit('close', code); - }); - - // Give the process a moment to start, then resolve - // The actual readiness is determined by the initialize handshake - setTimeout(() => resolve(), 100); + }; + this.detachProcessListeners = () => { + stdin?.off('error', onStdinError); + stdin?.off('close', onStdinClose); + child.stdout?.off('data', onStdoutData); + child.stderr?.off('data', onStderrData); + child.off('error', onProcessError); + child.off('close', onProcessClose); + child.off('spawn', onSpawn); + }; + stdin?.on('error', onStdinError); + stdin?.once('close', onStdinClose); + child.stdout?.on('data', onStdoutData); + child.stderr?.on('data', onStderrData); + child.on('error', onProcessError); + child.on('close', onProcessClose); + + // The protocol handshake determines readiness; spawning needs no grace delay. + this.process.once('spawn', onSpawn); } catch (err) { reject(err instanceof Error ? err : new Error(String(err))); } @@ -259,6 +278,7 @@ export class McpStdioConnection extends EventEmitter { if (options.signal?.aborted) { throw new McpRequestAbortedError(); } + if (this.framingError) throw this.framingError; if (!this.process?.stdin?.writable) { throw new Error(`MCP server "${this.config.name}" is not connected`); } @@ -380,6 +400,7 @@ export class McpStdioConnection extends EventEmitter { * Parses incoming stdout data as newline-delimited JSON-RPC messages. */ private handleStdoutData(data: Buffer): void { + if (this.framingError) return; if (this.framing === 'newline') { this.parseLineDelimitedData(data); return; @@ -429,8 +450,11 @@ export class McpStdioConnection extends EventEmitter { */ private cleanup(): void { this.rejectPendingRequests(new Error('MCP connection closed')); + this.detachProcessListeners?.(); + this.detachProcessListeners = undefined; this.process = null; - this.lineBuffer = ''; + this.lineBuffer = Buffer.alloc(0); + this.lineLength = 0; this.frameBuffer = Buffer.alloc(0); } @@ -451,16 +475,47 @@ export class McpStdioConnection extends EventEmitter { * Parses newline-delimited JSON-RPC messages (legacy mode). */ private parseLineDelimitedData(data: Buffer): void { - this.lineBuffer += data.toString(); - - let newlineIndex: number; - while ((newlineIndex = this.lineBuffer.indexOf('\n')) !== -1) { - const line = this.lineBuffer.slice(0, newlineIndex).trim(); - this.lineBuffer = this.lineBuffer.slice(newlineIndex + 1); + let offset = 0; + let newlineIndex = data.indexOf(0x0a, offset); + while (newlineIndex !== -1) { + if (!this.appendLineData(data.subarray(offset, newlineIndex))) return; + // Decode the complete frame once, preserving UTF-8 split across pipe reads. + const line = this.lineBuffer.toString('utf8', 0, this.lineLength).trim(); + this.lineBuffer = Buffer.alloc(0); + this.lineLength = 0; + if (line.length > 0) this.handleJsonPayload(line); + offset = newlineIndex + 1; + newlineIndex = data.indexOf(0x0a, offset); + } + if (offset < data.length) this.appendLineData(data.subarray(offset)); + } - if (line.length === 0) continue; - this.handleJsonPayload(line); + private appendLineData(data: Buffer): boolean { + const length = this.lineLength + data.length; + if (length > MAX_MCP_NEWLINE_FRAME_BYTES) { + const error = new Error(`MCP server "${this.config.name}" sent a newline frame exceeding 64 MiB; closing the connection.`); + this.framingError = error; + this.lineBuffer = Buffer.alloc(0); + this.lineLength = 0; + this.rejectPendingRequests(error); + try { + if (this.listenerCount('error') > 0) this.emit('error', error); + } finally { + void this.stop(); + } + return false; + } + if (length > this.lineBuffer.length) { + // Geometric growth bounds metadata and avoids copying on every small read. + const capacity = Math.min(MAX_MCP_NEWLINE_FRAME_BYTES, + Math.max(length, this.lineBuffer.length * 2, 4096)); + const buffer = Buffer.allocUnsafe(capacity); + this.lineBuffer.copy(buffer, 0, 0, this.lineLength); + this.lineBuffer = buffer; } + data.copy(this.lineBuffer, this.lineLength); + this.lineLength = length; + return true; } /** diff --git a/src/runtime/CliRuntimeResourceOwner.ts b/src/runtime/CliRuntimeResourceOwner.ts index 4f9fcf642..da90e8f45 100644 --- a/src/runtime/CliRuntimeResourceOwner.ts +++ b/src/runtime/CliRuntimeResourceOwner.ts @@ -25,6 +25,8 @@ export interface CliBackgroundStartup< versionResult: VersionResult | null; }>; onVersionResult(result: VersionResult): void; + /** Prepare a disabled service so the user can opt in during this process. */ + shouldCreateSync?(authUser: AuthUser): boolean; shouldStartSync(authUser: AuthUser): boolean; createSyncService(authUser: AuthUser): Promise; } @@ -168,7 +170,9 @@ export class CliRuntimeResourceOwner< if (versionResult) { startup.onVersionResult(versionResult); } - if (!authUser || !startup.shouldStartSync(authUser)) return; + if (!authUser) return; + const shouldStart = startup.shouldStartSync(authUser); + if (!shouldStart && !startup.shouldCreateSync?.(authUser)) return; const service = await startup.createSyncService(authUser); if (!this.isGenerationActive(generation)) { @@ -178,7 +182,7 @@ export class CliRuntimeResourceOwner< this.syncService = service; try { - service.start(); + if (shouldStart) service.start(); if (!this.isGenerationActive(generation)) { this.syncService = null; await this.stopService(service); diff --git a/src/session/SessionManager.ts b/src/session/SessionManager.ts index dca9af7d2..8cb16df64 100644 --- a/src/session/SessionManager.ts +++ b/src/session/SessionManager.ts @@ -372,7 +372,9 @@ export class SessionManager { const activeAt = Date.parse(metadata.lastActiveAt); return Number.isFinite(activeAt) ? activeAt : Date.parse(metadata.createdAt) || 0; }; - return sessions.reduce((latest, session) => { + const conversations = sessions.filter(session => session.messageCount > 0); + const candidates = conversations.length > 0 ? conversations : sessions; + return candidates.reduce((latest, session) => { return !latest || activityTime(session) > activityTime(latest) ? session : latest; }, null); } @@ -584,6 +586,11 @@ export class Session { return !this.persist; } + /** Artifact persistence follows the same boundary as the session transcript. */ + getArtifactDirectory(): string | undefined { + return this.persist ? this.sessionDir : undefined; + } + private async ensureSessionDir(): Promise { await fs.ensureDir(this.sessionDir); } diff --git a/src/squad/SquadConnection.ts b/src/squad/SquadConnection.ts new file mode 100644 index 000000000..d65d15f1e --- /dev/null +++ b/src/squad/SquadConnection.ts @@ -0,0 +1,91 @@ +/** + * @license + * Copyright 2026 Autohand AI LLC + * SPDX-License-Identifier: Apache-2.0 + */ +import { readFile } from 'node:fs/promises'; +import path from 'node:path'; +import { z } from 'zod'; +import { resolveAutohandHome } from '../constants.js'; +import { createBridgeClient, createTools, type SquadBridge } from './vendor/runtime.mjs'; +import { DEFAULT_BRIDGE_URL, parseBridgeUrl } from './vendor/control-plane.mjs'; + +const MemberSchema = z.object({ id: z.string(), name: z.string().nullable().optional(), role: z.string().nullable().optional(), presence: z.string(), lastWorkspace: z.string().nullable().optional() }); +export type SquadMember = z.infer; +const ReceiptSchema = z.object({ member_id: z.string(), message_id: z.string(), status: z.string(), paused: z.boolean(), pauseReason: z.string(), workspace: z.string() }); +const ConversationSchema = z.object({ messages: z.array(z.object({ role: z.string(), body: z.string() })), live: z.boolean() }); + +export async function resolveSquadBridgeUrl(env: NodeJS.ProcessEnv = process.env): Promise { + if (env.AUTOHAND_SQUAD_BRIDGE_URL) return parseBridgeUrl(env.AUTOHAND_SQUAD_BRIDGE_URL); + const home = env.AUTOHAND_SQUAD_HOME || path.join(resolveAutohandHome({ environment: env }), 'squad'); + let text: string; + try { text = await readFile(path.join(home, 'web-server.json'), 'utf8'); } + catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; + return DEFAULT_BRIDGE_URL; + } + const record = z.object({ url: z.string() }).parse(JSON.parse(text)); + return parseBridgeUrl(record.url); +} + +export class SquadConnection { + private selectedId?: string; + private readonly bridge?: SquadBridge; + private readonly env: NodeJS.ProcessEnv; + + constructor(options: { bridge?: SquadBridge; env?: NodeJS.ProcessEnv } = {}) { + this.bridge = options.bridge; + this.env = options.env ?? process.env; + } + + get selectedMemberId(): string | undefined { return this.selectedId; } + + private async tools() { + if (this.env.AUTOHAND_SQUAD_TEAM_TOKEN || this.env.AUTOHAND_SQUAD_MEMBER || this.env.AUTOHAND_SQUAD_TEAM_URL) throw new Error('This CLI is a managed Squad member. Use its existing member MCP connection.'); + return createTools(this.bridge ?? createBridgeClient(await resolveSquadBridgeUrl(this.env))); + } + + async listMembers(): Promise { + const tools = await this.tools(); + return z.object({ members: z.array(MemberSchema) }).parse(await tools.list_members()).members; + } + + private async resolveMember(query: string): Promise { + const members = await this.listMembers(); + const exact = members.find(member => member.id === query); + const matches = exact ? [exact] : members.filter(member => member.name?.toLowerCase() === query.toLowerCase()); + if (matches.length !== 1) throw new Error(matches.length ? 'Several agents have that name. Select by member ID.' : `Squad agent "${query}" was not found. Use /squad agents.`); + return matches[0]; + } + + async select(query: string): Promise { + const member = await this.resolveMember(query); + this.selectedId = member.id; + return member; + } + + async sendTo(query: string, text: string) { + const member = await this.resolveMember(query); + return this.send(text, member.id); + } + + clearSelection(): void { this.selectedId = undefined; } + + async send(text: string, memberId = this.selectedId) { + if (!memberId) throw new Error('Select a Squad agent with /squad select, or use /message squad .'); + const tools = await this.tools(); + return ReceiptSchema.parse(await tools.message_member({ member_id: memberId, text })); + } + + async history(memberId = this.selectedId) { + if (!memberId) throw new Error('Select a Squad agent with /squad select first.'); + const tools = await this.tools(); + return ConversationSchema.parse(await tools.get_conversation({ member_id: memberId })); + } + + async queue(memberId = this.selectedId) { + if (!memberId) throw new Error('Select a Squad agent with /squad select first.'); + const tools = await this.tools(); + return tools.get_member_queue({ member_id: memberId }); + } +} diff --git a/src/squad/builtin.ts b/src/squad/builtin.ts new file mode 100644 index 000000000..6e60c1a22 --- /dev/null +++ b/src/squad/builtin.ts @@ -0,0 +1,47 @@ +/** + * @license + * Copyright 2026 Autohand AI LLC + * SPDX-License-Identifier: Apache-2.0 + */ +import type { LoadedConfig, McpServerConfigEntry } from '../types.js'; +import { resolveSquadBridgeUrl } from './SquadConnection.js'; +import { createBridgeClient, createTools, createRpcHandler, serveStdio } from './vendor/runtime.mjs'; + +export interface SquadMcpOptions { + env?: NodeJS.ProcessEnv; + bare?: boolean; + offline?: boolean; + execPath?: string; + entryPath?: string; + compiled?: boolean; +} + +export function ensureSquadMcpServer(config: LoadedConfig, options: SquadMcpOptions = {}): McpServerConfigEntry | undefined { + const env = options.env ?? process.env; + if (options.bare || options.offline || config.mcp?.enabled === false || env.AUTOHAND_SQUAD_TEAM_TOKEN || env.AUTOHAND_SQUAD_MEMBER || env.AUTOHAND_SQUAD_TEAM_URL || /^(1|true|yes)$/i.test(env.AUTOHAND_DISABLE_SQUAD || '')) return undefined; + if (config.mcp?.servers?.some(server => server.name.replace(/_/g, '-').toLowerCase() === 'autohand-squad')) return undefined; + const entry = options.entryPath ?? process.argv[1]; + const compiled = options.compiled ?? /(?:\$bunfs|~BUN)/.test(entry || ''); + if (!compiled && !entry) return undefined; + return { + name: 'autohand-squad', transport: 'stdio', stdioFraming: 'newline', + command: options.execPath ?? process.execPath, + args: [...(!compiled && entry ? [entry] : []), 'squad', 'mcp'], + autoConnect: true, + localOnly: true, + }; +} + +export async function runSquadMcp(argv: string[] = [], env: NodeJS.ProcessEnv = process.env): Promise { + if (env.AUTOHAND_SQUAD_TEAM_TOKEN || env.AUTOHAND_SQUAD_MEMBER || env.AUTOHAND_SQUAD_TEAM_URL) throw new Error('Managed Squad members must use their existing member MCP connection.'); + let bridge: string | undefined; + for (let index = 0; index < argv.length; index++) { + const value = argv[index]; + if (value === '--bridge' && argv[index + 1]) bridge = argv[++index]; + else if (value.startsWith('--bridge=')) bridge = value.slice('--bridge='.length); + else throw new Error('Usage: autohand squad mcp [--bridge http://127.0.0.1:19821]'); + } + const origin = await resolveSquadBridgeUrl({ ...env, ...(bridge ? { AUTOHAND_SQUAD_BRIDGE_URL: bridge } : {}) }); + const handle = createRpcHandler(createTools(createBridgeClient(origin)), { bridgeUrl: origin }); + await serveStdio({ handle }); +} diff --git a/src/squad/vendor/LICENSE b/src/squad/vendor/LICENSE new file mode 100644 index 000000000..e135d88f7 --- /dev/null +++ b/src/squad/vendor/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Autohand AI Ltd. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/src/squad/vendor/SOURCE.md b/src/squad/vendor/SOURCE.md new file mode 100644 index 000000000..75de2b38d --- /dev/null +++ b/src/squad/vendor/SOURCE.md @@ -0,0 +1,15 @@ +# Squad MCP source + +Canonical repository: https://github.com/autohandai/squad + +Revision: `4056752878c9319ed31f5ba05466254dfcde5d40` + +`control-plane.mjs`, `runtime.mjs` and `management.mjs` are unchanged copies of +`server/control-plane/` at that revision, under the accompanying MIT license. +The `.d.mts` files are CLI type declarations. The bundle uses the runtime +library, never the app's executable stdio entry point. + +To update, copy all three canonical modules and `LICENSE` together, update this +revision, run Squad's `node scripts/check-control-plane.mjs`, and run the CLI +`bun run proof`. Keep operator and managed-member connections separate. +Do not add an automatic download or execute an app-side server on startup. diff --git a/src/squad/vendor/control-plane.d.mts b/src/squad/vendor/control-plane.d.mts new file mode 100644 index 000000000..8fea9fcfa --- /dev/null +++ b/src/squad/vendor/control-plane.d.mts @@ -0,0 +1,3 @@ +export const DEFAULT_BRIDGE_URL: string; +export function parseBridgeUrl(input: string): string; +export function redactSecrets(text: string): string; diff --git a/src/squad/vendor/control-plane.mjs b/src/squad/vendor/control-plane.mjs new file mode 100644 index 000000000..49300cc91 --- /dev/null +++ b/src/squad/vendor/control-plane.mjs @@ -0,0 +1,366 @@ +// Pure control-plane logic (ADR-0109): tool definitions, argument validation, +// loopback checks, secret redaction, outcome mapping and the two stdio +// framings. No I/O here so scripts/check-control-plane.mjs can import it +// without a bridge. The stdio entry point is mcp-server.mjs. + +import { MANAGEMENT_TOOLS } from './management.mjs'; + +export const SERVER_NAME = 'autohand-squad-control-plane'; +export const SERVER_VERSION = '1'; +export const PROTOCOL_VERSIONS = ['2024-11-05', '2025-03-26', '2025-06-18']; +export const DEFAULT_BRIDGE_URL = 'http://127.0.0.1:19821'; +export const NOT_AVAILABLE_CODE = 'not_available_from_bridge'; +export const MAX_MESSAGES = 200; +export const MAX_TAIL_LINES = 60; +export const MAX_WAIT_MS = 600_000; +export const DEFAULT_WAIT_MS = 60_000; +export const POLL_INTERVAL_MS = 400; + +const LOOPBACK_HOSTS = new Set(['127.0.0.1', 'localhost', '::1', '[::1]']); + +/** Parse and accept only a loopback bridge origin; throws with the reason otherwise. */ +export function parseBridgeUrl(input) { + const raw = String(input || '').trim(); + if (!raw) throw new Error('bridge URL is required'); + let url; + try { url = new URL(raw); } catch { throw new Error(`bridge URL is not a valid URL: ${raw}`); } + if (!['http:', 'https:'].includes(url.protocol)) throw new Error(`bridge URL must be http or https: ${raw}`); + const host = url.hostname.toLowerCase(); + if (!LOOPBACK_HOSTS.has(host)) { + throw new Error(`bridge URL must point at this machine (127.0.0.1, localhost or ::1); refusing ${host}. The control plane never exposes a remote bridge.`); + } + if (url.username || url.password) throw new Error('bridge URL must not carry credentials'); + return url.origin; +} + +// Anything that looks like a credential is replaced before it leaves the +// bridge: vendor API keys, GitHub and Slack tokens, AWS access keys, JWTs, +// bearer headers, key=value secrets and long bare hex or base64 runs. Paths +// are kept (the long-run patterns exclude '/'). +const SECRET_PATTERNS = [ + /\b(?:sk|rk|pk)-[A-Za-z0-9_-]{16,}/g, + /\bgh[pousr]_[A-Za-z0-9]{20,}\b/g, + /\bgithub_pat_[A-Za-z0-9_]{20,}/g, + /\bxox[abprs]-[A-Za-z0-9-]{10,}/g, + /\bAKIA[0-9A-Z]{16}\b/g, + /\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{5,}/g, + /\bBearer\s+[A-Za-z0-9._~+/-]{16,}=*/gi, + /\b[A-Fa-f0-9]{40,}\b/g, + /\b[A-Za-z0-9_-]{48,}={0,2}(?![A-Za-z0-9_\/-])/g, +]; +const KEY_VALUE_SECRET = /\b(api[_-]?key|secret|token|password|passwd|authorization|credential)(s?["']?\s*[:=]\s*["']?)([^\s"',;]{6,})/gi; + +export function redactSecrets(text) { + let value = String(text ?? ''); + for (const pattern of SECRET_PATTERNS) value = value.replace(pattern, '[redacted]'); + value = value.replace(KEY_VALUE_SECRET, (_match, key, separator) => `${key}${separator}[redacted]`); + return value; +} + +/** Last `lines` log lines of a run summary, redacted. */ +export function redactedTail(logs, lines = 20) { + const take = Math.max(1, Math.min(Number(lines) || 20, MAX_TAIL_LINES)); + return (Array.isArray(logs) ? logs : []) + .map((entry) => (typeof entry === 'string' ? entry : `${entry?.source ? `[${entry.source}] ` : ''}${entry?.line || ''}`)) + .filter(Boolean) + .slice(-take) + .map((line) => redactSecrets(line).slice(0, 500)); +} + +/** Map a bridge run summary to a wait outcome, or null while it is still live. */ +export function runOutcome(run) { + const status = String(run?.status || ''); + if (['queued', 'launching', 'running'].includes(status)) return null; + const actions = Array.isArray(run?.channelActions) ? run.channelActions : []; + if (actions.some((action) => action?.status === 'proposed')) { + return { outcome: 'needs-you', reason: 'The member proposed a channel action that is waiting for your approval in the app.' }; + } + if (status === 'completed') return { outcome: 'settled', reason: '' }; + if (status === 'stopped') return { outcome: 'failed', reason: 'The run was stopped.' }; + if (status === 'failed') return { outcome: 'failed', reason: lastErrorLine(run?.logs) || 'The run failed.' }; + return { outcome: 'failed', reason: `Unknown run status ${status || '(empty)'}.` }; +} + +/** Map a bridge goal record to a wait outcome, or null while it is running. */ +export function goalOutcome(goal) { + const status = String(goal?.status || ''); + if (status === 'running') return null; + if (status === 'completed') return { outcome: 'settled', reason: '' }; + if (status === 'paused') return { outcome: 'needs-you', reason: goal?.pauseReason || 'The goal is paused and waits for you to resume it.' }; + if (status === 'blocked') return { outcome: 'needs-you', reason: 'The member reported the goal as blocked.' }; + if (status === 'failed') return { outcome: 'failed', reason: goal?.error || 'The goal failed.' }; + if (status === 'cancelled') return { outcome: 'failed', reason: 'The goal was cancelled.' }; + return { outcome: 'failed', reason: `Unknown goal status ${status || '(empty)'}.` }; +} + +function lastErrorLine(logs) { + const entries = Array.isArray(logs) ? logs : []; + for (let index = entries.length - 1; index >= 0; index -= 1) { + const entry = entries[index]; + if (entry?.source === 'stderr' && entry.line) return redactSecrets(entry.line).slice(0, 300); + } + return ''; +} + +// ---- tool schemas --------------------------------------------------------- + +const id = (description) => ({ type: 'string', minLength: 1, maxLength: 200, description }); + +export const TOOLS = [ + { + name: 'list_members', + description: 'List Squad agents by id, name, role, presence, harness and workspace. Uses registered profiles and live presence, supplemented by the app\'s status mirror. Use member ids to address messages unambiguously.', + inputSchema: { type: 'object', properties: {}, additionalProperties: false }, + }, + { + name: 'list_channels', + description: 'Channels with their members, projects and thread and message counts from the bridge\'s channel state.', + inputSchema: { type: 'object', properties: {}, additionalProperties: false }, + }, + { + name: 'get_messages', + description: 'Read a channel\'s threads and messages, or a member\'s bridge-owned run and goal summaries with redacted replies and log tails. Use get_conversation for the actual direct-message transcript.', + inputSchema: { + type: 'object', + properties: { + channel_id: id('Channel to read.'), + thread_id: id('Only this thread of the channel.'), + member_id: id('Member whose bridge-owned runs and goals to read.'), + limit: { type: 'integer', minimum: 1, maximum: MAX_MESSAGES, description: `Newest items to return (default 50, max ${MAX_MESSAGES}).` }, + before: { type: 'string', maxLength: 40, description: 'ISO timestamp; only items created before it.' }, + }, + additionalProperties: false, + }, + }, + { + name: 'search', + description: 'Search the bridge index: channel messages, channels, runs, and whatever the app has pushed (members, tasks, handoffs, canvases, workflow runs). Snippets wrap hits in .', + inputSchema: { + type: 'object', + properties: { + query: { type: 'string', minLength: 1, maxLength: 500 }, + types: { type: 'string', maxLength: 200, description: 'Comma list of message,channel,member,run,task,handoff,canvas,workflow.' }, + limit: { type: 'integer', minimum: 1, maximum: 100 }, + }, + required: ['query'], + additionalProperties: false, + }, + }, + { + name: 'create_member', + description: 'Not available from the bridge: members are created and stored by the Squad app. This tool always returns the documented error so clients learn where to create one.', + inputSchema: { + type: 'object', + properties: { name: { type: 'string', maxLength: 120 }, role: { type: 'string', maxLength: 120 } }, + additionalProperties: false, + }, + }, + { + name: 'create_channel', + description: 'Create a channel on the bridge. It appears in the app on its next channel reload.', + inputSchema: { + type: 'object', + properties: { + name: { type: 'string', minLength: 1, maxLength: 80 }, + visibility: { type: 'string', enum: ['public', 'private'] }, + member_ids: { type: 'array', items: id('Member id.'), maxItems: 50 }, + section: { type: 'string', maxLength: 80 }, + }, + required: ['name'], + additionalProperties: false, + }, + }, + { + name: 'send_message', + description: 'Post text to a channel (channel_id) and, when member_id is also given, start a bridge-owned run for that member in the new thread. Or send directly to a member (member_id only): starts a bridge-owned run (mode "run", default) or a durable goal (mode "goal") and returns its id for wait_for_reply. The bridge has no automatic channel responder: a channel message without member_id is posted but nobody is dispatched. Runs need a workspace folder; when omitted, the member\'s last bridge-owned workspace is reused.', + inputSchema: { + type: 'object', + properties: { + text: { type: 'string', minLength: 1, maxLength: 20000 }, + channel_id: id('Channel to post in.'), + thread_id: id('Reply inside this existing thread instead of opening one.'), + member_id: id('Member to dispatch.'), + workspace: { type: 'string', maxLength: 1000, description: 'Absolute project folder the member works in.' }, + mode: { type: 'string', enum: ['run', 'goal'] }, + author: { type: 'string', maxLength: 80, description: 'Display name recorded on the channel message (default "Control plane").' }, + }, + required: ['text'], + additionalProperties: false, + }, + }, + { + name: 'wait_for_reply', + description: `Wait for a run (run_id) or goal (goal_id) started by send_message. Returns settled, needs-you, failed or timed-out with the reply and a redacted log tail; keys and tokens are replaced with [redacted]. A settled channel run also posts its reply into the thread.`, + inputSchema: { + type: 'object', + properties: { + run_id: id('Run to wait for.'), + goal_id: id('Goal to wait for.'), + timeout_ms: { type: 'integer', minimum: 1000, maximum: MAX_WAIT_MS, description: `Default ${DEFAULT_WAIT_MS}.` }, + tail_lines: { type: 'integer', minimum: 1, maximum: MAX_TAIL_LINES, description: 'Log lines in the tail (default 20).' }, + }, + additionalProperties: false, + }, + }, + { + name: 'interrupt', + description: 'Stop a run (run_id), pause a goal (goal_id), or stop everything the bridge holds for a member (member_id): its warm sessions and live runs, and pause its goals.', + inputSchema: { + type: 'object', + properties: { run_id: id('Run to stop.'), goal_id: id('Goal to pause.'), member_id: id('Member to stop.') }, + additionalProperties: false, + }, + }, + { + name: 'set_model', + description: 'Not available from the bridge: a member\'s harness and model are chosen in the app and sent with each turn; the bridge only holds the global provider settings. This tool always returns the documented error.', + inputSchema: { + type: 'object', + properties: { member_id: id('Member.'), model: { type: 'string', maxLength: 120 }, provider: { type: 'string', maxLength: 60 } }, + additionalProperties: false, + }, + }, + { + name: 'message_member', + description: 'Send a real direct message to an existing Squad member. Returns a durable message_id immediately. Messages run in queue order without interrupting current work, reuse the member\'s saved workspace when omitted, and appear in the app conversation. Does not resume a paused queue. Use wait_for_message for the reply.', + inputSchema: { type: 'object', properties: { member_id: id('Recipient member id from list_members.'), text: { type: 'string', minLength: 1, maxLength: 20000 }, workspace: { type: 'string', minLength: 1, maxLength: 1000, description: 'Explicit project override; otherwise reuse the member workspace.' } }, required: ['member_id', 'text'], additionalProperties: false }, + }, + { + name: 'get_conversation', + description: 'Read recent direct-message turns, including messages sent from the app or CLI, from the bridge history. Up to 60 turns; use since to filter a conversation boundary. Does not read archived chat files.', + inputSchema: { type: 'object', properties: { member_id: id('Member id.'), limit: { type: 'integer', minimum: 1, maximum: 60 }, since: { type: 'string', maxLength: 40, description: 'Only turns after this ISO timestamp.' } }, required: ['member_id'], additionalProperties: false }, + }, + { + name: 'get_member_queue', + description: 'Read a member\'s queued messages and delivery receipts, replies, failures and pause reason. Does not change queue state.', + inputSchema: { type: 'object', properties: { member_id: id('Member id.'), limit: { type: 'integer', minimum: 1, maximum: 200 } }, required: ['member_id'], additionalProperties: false }, + }, + { + name: 'wait_for_message', + description: 'Wait for a direct-message receipt from message_member. Returns settled, failed, needs-you (paused queue), or timed-out; waiting never cancels or resends the message.', + inputSchema: { type: 'object', properties: { member_id: id('Recipient member id.'), message_id: id('Durable message receipt id.'), timeout_ms: { type: 'integer', minimum: 1000, maximum: 25000, description: 'Default 20000; repeat with the same receipt if timed out.' } }, required: ['member_id', 'message_id'], additionalProperties: false }, + }, + { + name: 'list_artifacts', + description: 'Find published agent artifacts in the Squad Library. Returns metadata and local preview/download links, without executing artifact content.', + inputSchema: { type: 'object', properties: { member_id: id('Filter by owner.'), query: { type: 'string', maxLength: 500 }, limit: { type: 'integer', minimum: 1, maximum: 100 } }, additionalProperties: false }, + }, + { + name: 'get_artifact', + description: 'Read metadata for one published artifact, including its local preview/download links. Does not read arbitrary workspace files.', + inputSchema: { type: 'object', properties: { artifact_id: id('Artifact id from list_artifacts.') }, required: ['artifact_id'], additionalProperties: false }, + }, + { + name: 'list_memories', + description: 'Search the Squad knowledge library, optionally by member. This operator connection reads memories visible to the person; it is never supplied to a managed Squad member.', + inputSchema: { type: 'object', properties: { member_id: id('Filter by owner.'), query: { type: 'string', maxLength: 500 }, limit: { type: 'integer', minimum: 1, maximum: 100 }, offset: { type: 'integer', minimum: 0, maximum: 100000 } }, additionalProperties: false }, + }, + { + name: 'read_memory', + description: 'Read one entry from the Squad knowledge library. Read-only operator access; does not edit or share memories.', + inputSchema: { type: 'object', properties: { memory_id: id('Memory id from list_memories.') }, required: ['memory_id'], additionalProperties: false }, + }, +]; + +TOOLS.push(...MANAGEMENT_TOOLS); +export const TOOL_NAMES = TOOLS.map((tool) => tool.name); + +/** Small JSON-schema subset validator: types, enums, bounds, required, unknown fields. */ +export function validateArgs(schema, input, path = 'arguments') { + const errors = []; + const check = (node, value, at) => { + if (node.anyOf) { + if (!node.anyOf.some(branch => validateArgs(branch, value, at).length === 0)) errors.push(`${at} must match one of the allowed types`); + return; + } + if (node.type === 'object') { + if (value === undefined || value === null) value = {}; + if (typeof value !== 'object' || Array.isArray(value)) { errors.push(`${at} must be an object`); return; } + const properties = node.properties || {}; + for (const key of Object.keys(value)) { + if (!(key in properties)) { if (node.additionalProperties === false) errors.push(`${at}.${key} is not a known field`); } + else if (value[key] !== undefined) check(properties[key], value[key], `${at}.${key}`); + } + for (const key of node.required || []) { + if (value[key] === undefined || value[key] === null || value[key] === '') errors.push(`${at}.${key} is required`); + } + return; + } + if (node.type === 'array') { + if (!Array.isArray(value)) { errors.push(`${at} must be an array`); return; } + if (node.maxItems !== undefined && value.length > node.maxItems) errors.push(`${at} has more than ${node.maxItems} items`); + value.forEach((item, index) => check(node.items || {}, item, `${at}[${index}]`)); + return; + } + if (node.type === 'string') { + if (typeof value !== 'string') { errors.push(`${at} must be a string`); return; } + if (node.minLength !== undefined && value.trim().length < node.minLength) errors.push(`${at} must not be empty`); + if (node.maxLength !== undefined && value.length > node.maxLength) errors.push(`${at} is longer than ${node.maxLength} characters`); + if (node.enum && !node.enum.includes(value)) errors.push(`${at} must be one of ${node.enum.join(', ')}`); + return; + } + if (node.type === 'integer' || node.type === 'number') { + if (typeof value !== 'number' || !Number.isFinite(value) || (node.type === 'integer' && !Number.isInteger(value))) { errors.push(`${at} must be ${node.type === 'integer' ? 'an integer' : 'a number'}`); return; } + if (node.minimum !== undefined && value < node.minimum) errors.push(`${at} must be at least ${node.minimum}`); + if (node.maximum !== undefined && value > node.maximum) errors.push(`${at} must be at most ${node.maximum}`); + return; + } + if (node.type === 'boolean' && typeof value !== 'boolean') errors.push(`${at} must be true or false`); + }; + check(schema, input, path); + return errors; +} + +// ---- stdio framing -------------------------------------------------------- + +/** Encode one JSON-RPC message in the given framing ('newline' | 'header'). */ +export function encodeFrame(message, framing) { + const encoded = Buffer.from(JSON.stringify(message)); + return framing === 'header' + ? Buffer.concat([Buffer.from(`Content-Length: ${encoded.length}\r\n\r\n`), encoded]) + : Buffer.concat([encoded, Buffer.from('\n')]); +} + +/** + * Pull every complete frame off `buffer`. Returns `{ frames: [{ body, framing }], rest }`. + * A frame that starts with a Content-Length header is read by length; anything + * else is one JSON document per line. Blank lines are skipped. + */ +export function decodeFrames(buffer) { + const frames = []; + let rest = buffer; + for (;;) { + while (rest.length && (rest[0] === 0x0a || rest[0] === 0x0d || rest[0] === 0x20 || rest[0] === 0x09)) rest = rest.subarray(1); + if (!rest.length) break; + if (/^content-length:/i.test(rest.subarray(0, 15).toString())) { + const headerEnd = rest.indexOf('\r\n\r\n'); + const looseEnd = headerEnd < 0 ? rest.indexOf('\n\n') : -1; + const end = headerEnd >= 0 ? headerEnd : looseEnd; + const separator = headerEnd >= 0 ? 4 : 2; + if (end < 0) break; + const match = rest.subarray(0, end).toString().match(/content-length:\s*(\d+)/i); + const size = Number(match?.[1]); + if (!Number.isFinite(size)) { rest = rest.subarray(end + separator); continue; } + if (rest.length < end + separator + size) break; + frames.push({ body: rest.subarray(end + separator, end + separator + size), framing: 'header' }); + rest = rest.subarray(end + separator + size); + continue; + } + const lineEnd = rest.indexOf('\n'); + if (lineEnd < 0) break; + const body = rest.subarray(0, lineEnd); + rest = rest.subarray(lineEnd + 1); + if (body.toString().trim()) frames.push({ body, framing: 'newline' }); + } + return { frames, rest }; +} + +/** Choose the protocol version to answer initialize with. */ +export function negotiateProtocol(requested) { + return PROTOCOL_VERSIONS.includes(requested) ? requested : PROTOCOL_VERSIONS.at(-1); +} + +/** Documented refusal for tools the bridge cannot honour. */ +export function notAvailable(tool, why, next) { + return Object.assign(new Error(`${tool} is not available from the bridge: ${why} ${next}`), { code: NOT_AVAILABLE_CODE }); +} diff --git a/src/squad/vendor/management.mjs b/src/squad/vendor/management.mjs new file mode 100644 index 000000000..d3b9ac387 --- /dev/null +++ b/src/squad/vendor/management.mjs @@ -0,0 +1,141 @@ +import { randomUUID } from 'node:crypto'; + +const text = (maxLength = 2000) => ({ type: 'string', minLength: 1, maxLength }); +const id = () => text(200); +const ids = (minimum = 0) => ({ type: 'array', items: id(), minItems: minimum, maxItems: 20 }); +const revision = { type: 'integer', minimum: 0 }; +const mode = { type: 'string', enum: ['allow', 'ask', 'block'] }; +const policies = { type: 'array', maxItems: 200, items: { type: 'object', properties: { id: id(), mode }, required: ['id', 'mode'], additionalProperties: false } }; +const schedule = { + type: 'object', additionalProperties: false, required: ['kind', 'timeZone'], + properties: { + kind: { type: 'string', enum: ['once', 'weekdays', 'interval', 'monthly', 'yearly', 'cron'] }, + timeZone: text(100), time: text(5), date: text(10), + days: { type: 'array', maxItems: 7, items: { type: 'integer', minimum: 0, maximum: 6 } }, + everyMinutes: { type: 'integer', minimum: 1, maximum: 1440 }, startDate: text(10), startTime: text(5), + month: { type: 'integer', minimum: 1, maximum: 12 }, + day: { anyOf: [{ type: 'integer', minimum: 1, maximum: 31 }, { type: 'string', enum: ['last'] }] }, + cron: text(120), + }, +}; +const tool = (name, description, properties, required = [], readOnly = false) => ({ name, description, inputSchema: { type: 'object', properties, required, additionalProperties: false }, annotations: { readOnlyHint: readOnly, destructiveHint: !readOnly, openWorldHint: ['send_channel_message', 'run_routine'].includes(name) } }); +const member = { member_id: id(), expected_revision: revision }; +const routineFields = { title: text(160), instructions: text(20000), workspace: text(1000), schedule, enabled: { type: 'boolean' }, max_run_minutes: { type: 'integer', minimum: 1, maximum: 1440 } }; + +export const MANAGEMENT_TOOLS = [ + tool('get_member_profile', 'Read a member\'s saved name, personality, operating instructions, skills, folders and abilities, with its revision and available ability IDs. Operator access.', { member_id: id() }, ['member_id'], true), + tool('update_member_profile', 'Change a saved member name, role, personality or operating instructions. Preserves other fields and current work. expected_revision rejects stale edits; changes apply to later turns.', { ...member, name: text(60), role: text(80), description: { type: 'string', maxLength: 600 }, instructions: { type: 'string', maxLength: 1200 }, personality: { type: 'string', maxLength: 600 } }, ['member_id']), + tool('set_member_abilities', 'Set specific capability/tool policies to allow, ask or block. Get supported IDs from get_member_profile. Preserves unmentioned policies and workspace overrides. Optional workspace edits that folder\'s override. Does not grant OS permissions or disable safety guards.', { ...member, capabilities: policies, tool_policies: policies, workspace: text(1000) }, ['member_id']), + tool('add_member_directory', 'Grant an existing project folder to a member, equivalent to --add-dir on later turns. Persists the folder on the member, validates its real path, and preserves other folders. Does not restart current work.', { ...member, path: text(1000) }, ['member_id', 'path']), + tool('remove_member_directory', 'Remove an extra folder grant for later turns. Does not delete files or interrupt current work. The current primary workspace cannot be removed here.', { ...member, path: text(1000) }, ['member_id', 'path']), + tool('list_skills', 'Discover local and registry skill packages available for a member\'s workspace. Choose package IDs for set_member_skills; offset supports catalog pagination.', { member_id: id(), query: { type: 'string', maxLength: 500 }, offset: { type: 'integer', minimum: 0, maximum: 100000 } }, ['member_id'], true), + tool('set_member_skills', 'Replace a member\'s selected skill-package IDs (empty clears selections). Validates references against the catalog. Packages are installed/pinned by the existing engine preparation on the next turn; this receipt is assignment, not installation.', { ...member, skill_ids: { type: 'array', maxItems: 32, items: text(500) } }, ['member_id', 'skill_ids']), + tool('get_channel', 'Read one channel\'s saved settings, member IDs and projects.', { channel_id: id() }, ['channel_id'], true), + tool('update_channel', 'Change a channel name, purpose or section without replacing its members or history.', { channel_id: id(), name: text(80), purpose: { type: 'string', maxLength: 20000 }, section: { type: 'string', maxLength: 80 } }, ['channel_id']), + tool('set_channel_members', 'Add/remove existing members atomically from a channel roster. Does not dispatch work or delete messages.', { channel_id: id(), add: ids(), remove: ids() }, ['channel_id']), + tool('list_channel_threads', 'List a channel\'s newest conversation threads. Use get_messages with thread_id to read replies.', { channel_id: id(), limit: { type: 'integer', minimum: 1, maximum: 100 } }, ['channel_id'], true), + tool('create_group_chat', 'Create a persistent private channel for two or more existing agents. Does not send a message or start work until explicitly requested.', { name: text(80), member_ids: ids(2) }, ['name', 'member_ids']), + tool('send_channel_message', 'Post into a channel/group thread and optionally dispatch the explicitly named channel members. Omit member_ids to publish only. Replies persist in the thread even if this MCP client disconnects. Returns per-member run receipts/errors for wait_for_reply.', { channel_id: id(), thread_id: id(), text: text(20000), member_ids: ids() }, ['channel_id', 'text']), + tool('list_routines', 'Read scheduled routines, pause/enabled state, next occurrences and recent runs; optionally filter by member.', { member_id: id() }, [], true), + tool('get_routine', 'Read one routine and its run history.', { routine_id: id() }, ['routine_id'], true), + tool('preview_routine', 'Validate an explicit IANA-timezone schedule and show its next three occurrences without saving or running it.', { schedule }, ['schedule'], true), + tool('create_routine', 'Save a routine for an existing member. Disabled by default; enabled:true explicitly schedules it. Uses the member workspace unless overridden. Does not run it immediately.', { member_id: id(), ...routineFields }, ['member_id', 'title', 'instructions', 'schedule']), + tool('update_routine', 'Edit a routine or set enabled:false/true to pause/resume scheduling. Preserves unmentioned fields, member ownership and history; does not stop an active run.', { routine_id: id(), ...routineFields }, ['routine_id']), + tool('run_routine', 'Explicitly run a saved routine now using its saved member, workspace and permissions. Returns a run receipt; refuses overlap with an already active run.', { routine_id: id() }, ['routine_id']), +]; + +export function createManagementTools(bridge, legacy) { + const profile = memberId => bridge.get(`/api/control-plane/members/${encodeURIComponent(memberId)}`); + async function patchMember(memberId, patch, expectedRevision) { + const current = await profile(memberId); + return bridge.patch(`/api/control-plane/members/${encodeURIComponent(memberId)}`, { ...patch, expectedRevision: expectedRevision ?? current.revision }); + } + async function known(idsToCheck) { + const roster = await legacy.list_members(); + for (const memberId of idsToCheck) if (!roster.members.some(item => item.id === memberId)) throw new Error(`Member ${memberId} was not found. Choose an ID from list_members.`); + } + async function channelFor(channelId) { + const state = await bridge.get('/api/channels'); + const channel = (state.channels || []).find(item => item.id === channelId); + if (!channel) throw Object.assign(new Error('Channel not found.'), { status: 404 }); + return { channel, state }; + } + const routineBody = args => Object.fromEntries(Object.entries(args).filter(([key]) => !['member_id', 'routine_id', 'max_run_minutes'].includes(key)).concat(args.max_run_minutes !== undefined ? [['maxRunMinutes', args.max_run_minutes]] : [])); + + return { + get_member_profile: ({ member_id }) => profile(member_id), + update_member_profile: ({ member_id, expected_revision, ...patch }) => patchMember(member_id, patch, expected_revision), + set_member_abilities: ({ member_id, expected_revision, ...abilities }) => patchMember(member_id, { abilities }, expected_revision), + async add_member_directory({ member_id, path, expected_revision }) { + const current = await profile(member_id); + return bridge.post(`/api/control-plane/members/${encodeURIComponent(member_id)}/directories`, { operation: 'add', path, expectedRevision: expected_revision ?? current.revision }); + }, + async remove_member_directory({ member_id, path, expected_revision }) { + const current = await profile(member_id); + return bridge.post(`/api/control-plane/members/${encodeURIComponent(member_id)}/directories`, { operation: 'remove', path, expectedRevision: expected_revision ?? current.revision }); + }, + async list_skills({ member_id, query = '', offset = 0 }) { + const current = await profile(member_id); + return bridge.get(`/api/skills/catalog?${new URLSearchParams({ q: query, workspace: current.workspace || '', offset: String(offset) })}`); + }, + set_member_skills: ({ member_id, expected_revision, skill_ids }) => patchMember(member_id, { skills: skill_ids }, expected_revision), + async get_channel({ channel_id }) { return (await channelFor(channel_id)).channel; }, + async update_channel({ channel_id, ...patch }) { + if (!Object.keys(patch).length) throw new Error('Provide at least one channel setting to change.'); + return bridge.patch(`/api/channels/${encodeURIComponent(channel_id)}`, patch); + }, + async set_channel_members({ channel_id, add = [], remove = [] }) { + await channelFor(channel_id); + if (!add.length && !remove.length) throw new Error('Choose members to add or remove.'); + if (add.some(id => remove.includes(id))) throw new Error('A member cannot be both added and removed.'); + await known(add); + return bridge.post(`/api/channels/${encodeURIComponent(channel_id)}/members`, { add, remove }); + }, + async list_channel_threads({ channel_id, limit = 50 }) { + await channelFor(channel_id); + const threads = await bridge.get(`/api/channels/${encodeURIComponent(channel_id)}/threads`); + return { threads: [...threads].sort((a, b) => String(b.updatedAt || '').localeCompare(String(a.updatedAt || ''))).slice(0, limit) }; + }, + async create_group_chat({ name, member_ids }) { + const members = [...new Set(member_ids)]; + if (members.length < 2) throw new Error('Choose at least two different members.'); + await known(members); + return { channel: await bridge.post('/api/channels', { name, visibility: 'private', memberIds: members }) }; + }, + async send_channel_message({ channel_id, thread_id, text, member_ids = [] }) { + const { channel, state } = await channelFor(channel_id); + const recipients = [...new Set(member_ids)]; + if (recipients.some(id => !channel.memberIds.includes(id))) throw new Error('Every recipient must belong to the channel. Add them with set_channel_members first.'); + await known(recipients); + if (thread_id && !(state.threads || []).some(thread => thread.id === thread_id && thread.channelId === channel_id)) throw new Error('The thread does not belong to this channel.'); + const profiles = await Promise.all(recipients.map(id => profile(id))); + if (profiles.some(item => !item.workspace)) throw new Error('Each recipient needs a saved workspace before dispatch.'); + const messageId = `operator-${randomUUID()}`; + const threadId = thread_id || `thread-${randomUUID()}`; + const at = new Date().toISOString(); + await bridge.put('/api/channels/sync', { + ...(!thread_id ? { threads: [{ id: threadId, channelId: channel_id, parentMessageId: messageId, title: text.slice(0, 160), creatorId: 'person', memberIds: channel.memberIds, createdAt: at, updatedAt: at }] } : {}), + messages: { [channel_id]: [{ id: messageId, role: 'user', body: text, authorName: 'You', threadId, status: 'complete', createdAt: at, updatedAt: at }] }, + }); + const deliveries = await Promise.all(recipients.map(async (memberId, index) => { + try { + const run = await bridge.post('/api/runs', { agentId: memberId, prompt: text, workspace: profiles[index].workspace, title: text.slice(0, 120), channel: { channelId: channel_id, channelName: channel.name, threadId, parentMessageId: messageId, operatorReply: true } }); + return { member_id: memberId, run_id: run.id, status: run.status }; + } catch (error) { return { member_id: memberId, status: 'failed', error: String(error.message || error) }; } + })); + return { channel_id, thread_id: threadId, message_id: messageId, deliveries, note: recipients.length ? 'Dispatched only the requested members. Check each run receipt; retrying this send creates a new message.' : 'Published without dispatching any member.' }; + }, + list_routines: ({ member_id } = {}) => bridge.get(`/api/routines${member_id ? `?memberId=${encodeURIComponent(member_id)}` : ''}`), + get_routine: ({ routine_id }) => bridge.get(`/api/routines/${encodeURIComponent(routine_id)}`), + preview_routine: ({ schedule }) => bridge.post('/api/routines/preview', { schedule }), + async create_routine({ member_id, ...args }) { + const current = await profile(member_id); + return bridge.post('/api/routines', { ...routineBody(args), memberId: member_id, workspace: args.workspace || current.workspace, enabled: args.enabled === true }); + }, + async update_routine({ routine_id, ...args }) { + if (!Object.keys(args).length) throw new Error('Provide at least one routine setting to change.'); + return bridge.patch(`/api/routines/${encodeURIComponent(routine_id)}`, routineBody(args)); + }, + run_routine: ({ routine_id }) => bridge.post(`/api/routines/${encodeURIComponent(routine_id)}/run`, {}), + }; +} diff --git a/src/squad/vendor/runtime.d.mts b/src/squad/vendor/runtime.d.mts new file mode 100644 index 000000000..6df113e40 --- /dev/null +++ b/src/squad/vendor/runtime.d.mts @@ -0,0 +1,11 @@ +import type { Readable, Writable } from 'node:stream'; +export interface SquadBridge { + get(path: string): Promise; + post(path: string, body?: Record): Promise; + patch(path: string, body?: Record): Promise; + put(path: string, body?: Record): Promise; +} +export function createBridgeClient(origin: string, options?: { fetchImpl?: typeof fetch }): SquadBridge; +export function createTools(bridge: SquadBridge): Record, options?: { signal?: AbortSignal }) => Promise>; +export function createRpcHandler(tools: ReturnType, options?: { bridgeUrl?: string }): (request: unknown) => Promise; +export function serveStdio(options: { input?: Readable; output?: Writable; handle: ReturnType }): Promise; diff --git a/src/squad/vendor/runtime.mjs b/src/squad/vendor/runtime.mjs new file mode 100644 index 000000000..483f1bbd0 --- /dev/null +++ b/src/squad/vendor/runtime.mjs @@ -0,0 +1,525 @@ +#!/usr/bin/env node +// Autohand Squad control plane (ADR-0109): a dependency-free stdio MCP server +// that lets Claude Desktop, Cursor or any MCP client coordinate this Squad +// through the local bridge. JSON-RPC 2.0 over stdin/stdout in either newline +// or Content-Length framing; each reply uses the framing of its request. +// +// node server/control-plane/mcp-server.mjs [--bridge http://127.0.0.1:19821] +// AUTOHAND_SQUAD_BRIDGE_URL=http://127.0.0.1:19821 node server/control-plane/mcp-server.mjs +// +// Only a loopback bridge is accepted. Approvals, deletion, credentials and +// computer lifecycle are never exposed; see docs/integration/control-plane.md. + +import { randomUUID } from 'node:crypto'; +import { createManagementTools } from './management.mjs'; + +import { + DEFAULT_WAIT_MS, MAX_MESSAGES, MAX_WAIT_MS, NOT_AVAILABLE_CODE, POLL_INTERVAL_MS, SERVER_NAME, SERVER_VERSION, TOOLS, + decodeFrames, encodeFrame, goalOutcome, negotiateProtocol, notAvailable, parseBridgeUrl, redactSecrets, redactedTail, runOutcome, validateArgs, +} from './control-plane.mjs'; + +const HTTP_TIMEOUT_MS = 30_000; +const REPLY_CHARS = 8000; +const BODY_CHARS = 4000; +const DEFAULT_AUTHOR = 'Control plane'; + +// ---- bridge client -------------------------------------------------------- + +export function createBridgeClient(origin, { fetchImpl = fetch } = {}) { + async function call(path, { method = 'GET', body, timeoutMs = HTTP_TIMEOUT_MS, signal } = {}) { + let response; + try { + response = await fetchImpl(`${origin}${path}`, { + method, + redirect: 'error', + headers: body === undefined ? {} : { 'content-type': 'application/json' }, + body: body === undefined ? undefined : JSON.stringify(body), + signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(timeoutMs)]) : AbortSignal.timeout(timeoutMs), + }); + } catch (error) { + throw new Error(`The Squad bridge at ${origin} did not answer (${error?.message || error}). Start Autohand Squad or pass --bridge.`); + } + let envelope = null; + try { envelope = await response.json(); } catch { envelope = null; } + if (!envelope || envelope.success !== true) { + const unavailableManagement = response.status === 404 && path.startsWith('/api/control-plane/') && envelope?.error !== 'Member not found.'; + const message = unavailableManagement ? 'This Squad bridge does not support member management. Update Autohand Squad, then reconnect.' : envelope?.error || `${method} ${path} returned HTTP ${response.status}`; + throw Object.assign(new Error(message), { status: response.status }); + } + return envelope.data; + } + return { + get: (path, options) => call(path, options), + post: (path, body = {}) => call(path, { method: 'POST', body }), + put: (path, body = {}) => call(path, { method: 'PUT', body }), + patch: (path, body = {}) => call(path, { method: 'PATCH', body }), + }; +} + +// ---- tools ---------------------------------------------------------------- + +const clip = (text, limit) => { const value = String(text ?? ''); return value.length > limit ? `${value.slice(0, limit)}…` : value; }; +const byTime = (key) => (a, b) => String(a[key] || '').localeCompare(String(b[key] || '')); + +function latestByMember(runs, goals) { + const latest = new Map(); + const consider = (memberId, record) => { + if (!memberId) return; + const previous = latest.get(memberId); + if (!previous || String(record.at || '') > String(previous.at || '')) latest.set(memberId, record); + }; + for (const run of runs) consider(run.agentId, { at: run.startedAt, harness: run.harness || null, workspace: run.workspace || '' }); + for (const goal of goals) consider(goal.agentId, { at: goal.createdAt || goal.updatedAt, harness: goal.harness || null, workspace: goal.workspace || '' }); + return latest; +} + +export function createTools(bridge) { + async function channelsState() { + return bridge.get('/api/channels'); + } + + async function runsFor(memberId = '') { + const runs = await bridge.get('/api/runs'); + return (Array.isArray(runs) ? runs : []).filter((run) => !memberId || run.agentId === memberId); + } + + async function goalsFor(memberId = '') { + const goals = await bridge.get(`/api/goals${memberId ? `?agentId=${encodeURIComponent(memberId)}` : ''}`); + return Array.isArray(goals) ? goals : []; + } + + async function postChannelRecords(channelId, { messages = [], threads = [] }) { + await bridge.put('/api/channels/sync', { + ...(threads.length ? { threads } : {}), + ...(messages.length ? { messages: { [channelId]: messages } } : {}), + }); + } + + async function lastWorkspace(memberId) { + const [runs, goals, state, queue] = await Promise.all([runsFor(memberId), goalsFor(memberId), channelsState(), bridge.get(`/api/queues/${encodeURIComponent(memberId)}`)]); + const agent = state?.agentChannelAccess?.[memberId]?.agent || {}; + const latestQueue = [...(queue?.items || [])].reverse().find(item => item.workspace); + const last = latestByMember(runs, goals).get(memberId); + return (latestQueue && (!last || latestQueue.createdAt > last.at) ? latestQueue.workspace : last?.workspace) + || agent.workspace || agent.projects?.[0]?.path || ''; + } + + async function startWork({ memberId, text, workspace, mode, channel }) { + const folder = String(workspace || '').trim() || await lastWorkspace(memberId); + if (!folder) { + throw new Error(`workspace is required: pass the absolute project folder ${memberId} should work in. The bridge has no earlier run or goal for this member to reuse one from.`); + } + const title = clip(text.replace(/\s+/g, ' ').trim(), 120); + if (mode === 'goal') { + if (channel) throw new Error('mode "goal" is only available for a direct message to a member, not inside a channel.'); + const data = await bridge.post('/api/chat', { agentId: memberId, prompt: text, workspace: folder, mode: 'goal', title }); + const goal = data?.goal || {}; + return { kind: 'goal', goal_id: goal.id || data?.goalId || '', status: goal.status || 'running', workspace: folder }; + } + const run = await bridge.post('/api/runs', { + agentId: memberId, + prompt: text, + title, + workspace: folder, + ...(channel ? { channelId: channel.channelId, threadId: channel.threadId, parentMessageId: channel.parentMessageId, channelName: channel.channelName } : {}), + }); + return { kind: 'run', run_id: run?.id || '', status: run?.status || 'running', workspace: folder, transport: run?.transport || '', harness: run?.harness || '' }; + } + + async function mirrorChannelReply(run, outcome, reply) { + const channel = run?.channel; + if (!channel?.channelId || !channel.threadId) return false; + const text = reply || (outcome.outcome === 'failed' ? `Run ${outcome.reason}` : ''); + if (!text) return false; + const at = run.finishedAt || new Date().toISOString(); + try { + await postChannelRecords(channel.channelId, { + messages: [{ + id: `${run.id}-reply`, + channelId: channel.channelId, + role: 'agent', + agentId: run.agentId, + authorName: run.agentId, + body: text, + status: outcome.outcome === 'failed' ? 'error' : 'complete', + threadId: channel.threadId, + parentMessageId: channel.parentMessageId || '', + workspace: run.workspace || '', + transport: run.transport || '', + runId: run.id, + createdAt: at, + updatedAt: at, + }], + }); + return true; + } catch { + return false; + } + } + + const tools = { + async list_members() { + const [snapshot, presence, runs, goals, state] = await Promise.all([ + bridge.get('/api/status/snapshot').catch(() => null), + bridge.get('/api/members/presence').catch(() => ({ presence: {} })), + runsFor(), + goalsFor(), + channelsState(), + ]); + const latest = latestByMember(runs, goals); + const rows = new Map(); + for (const member of Array.isArray(snapshot?.members) ? snapshot.members : []) { + if (!member?.id) continue; + rows.set(member.id, { id: member.id, name: member.name || member.id, role: member.role || null, status: member.status || 'online', lastActivityAt: member.lastActivityAt || null }); + } + for (const memberId of [...Object.keys(presence?.presence || {}), ...latest.keys()]) { + if (!rows.has(memberId)) rows.set(memberId, { id: memberId, name: null, role: null, status: null, lastActivityAt: null }); + } + for (const [memberId, access] of Object.entries(state?.agentChannelAccess || {})) { + if (access?.removed) { rows.delete(memberId); continue; } + if (!access?.agent) continue; + const agent = access.agent; + rows.set(memberId, { ...rows.get(memberId), id: memberId, name: agent.name || memberId, role: agent.role || null, status: rows.get(memberId)?.status || 'online' }); + } + const members = [...rows.values()].map((row) => { + const current = presence?.presence?.[row.id] || {}; + const agent = state?.agentChannelAccess?.[row.id]?.agent || {}; + const last = latest.get(row.id) || {}; + return { ...row, presence: current.state || 'unknown', since: current.since || null, activeRuns: current.activeRuns || 0, harness: last.harness || agent.harness?.id || null, lastWorkspace: last.workspace || agent.workspace || agent.projects?.[0]?.path || null }; + }); + return { + members, + snapshotAt: snapshot?.generatedAt || snapshot?.at || null, + source: { + roster: 'registered member profiles, supplemented by web-status.json and live presence', + harness: 'the member\'s latest bridge-owned work, falling back to its registered profile', + }, + }; + }, + + async list_channels() { + const state = await channelsState(); + const channels = (state?.channels || []).map((channel) => ({ + id: channel.id, + name: channel.name, + visibility: channel.visibility, + memberIds: channel.memberIds || [], + projects: (channel.projects || []).map((project) => project.name || project.path), + section: channel.section || '', + threads: (state.threads || []).filter((thread) => thread.channelId === channel.id).length, + messages: (state.messages || []).filter((message) => message.channelId === channel.id).length, + createdAt: channel.createdAt, + updatedAt: channel.updatedAt, + })); + return { channels, revision: state?.revision ?? null }; + }, + + async get_messages({ channel_id, thread_id, member_id, limit, before } = {}) { + const take = Math.min(Math.max(Number(limit) || 50, 1), MAX_MESSAGES); + const cutoff = before ? String(before) : ''; + if (channel_id && member_id) throw new Error('pass channel_id or member_id, not both'); + if (channel_id) { + const state = await channelsState(); + const channel = (state?.channels || []).find((item) => item.id === channel_id); + if (!channel) throw Object.assign(new Error(`channel ${channel_id} was not found`), { status: 404 }); + const threads = (state.threads || []).filter((thread) => thread.channelId === channel_id && (!thread_id || thread.id === thread_id)) + .map((thread) => ({ id: thread.id, title: thread.title, creatorId: thread.creatorId, memberIds: thread.memberIds || [], replyCount: thread.replyCount || 0, createdAt: thread.createdAt, updatedAt: thread.updatedAt })); + const all = (state.messages || []) + .filter((message) => message.channelId === channel_id && (!thread_id || message.threadId === thread_id) && (!cutoff || String(message.createdAt) < cutoff)) + .sort(byTime('createdAt')); + const messages = all.slice(-take).map((message) => ({ + id: message.id, threadId: message.threadId || '', parentMessageId: message.parentMessageId || '', role: message.role, agentId: message.agentId || '', + authorName: message.authorName || '', body: clip(message.body, BODY_CHARS), status: message.status, createdAt: message.createdAt, updatedAt: message.updatedAt, + })); + return { channel: { id: channel.id, name: channel.name }, threads, messages, total: all.length, truncated: all.length > messages.length }; + } + if (member_id) { + const [runs, goals] = await Promise.all([runsFor(member_id), goalsFor(member_id)]); + const items = [ + ...runs.map((run) => ({ + kind: 'run', id: run.id, title: run.title, status: run.status, harness: run.harness, workspace: run.workspace, channel: run.channel || null, + reply: clip(redactSecrets(run.reply || ''), REPLY_CHARS), tail: redactedTail(run.logs, 10), createdAt: run.startedAt, finishedAt: run.finishedAt || null, + })), + ...goals.map((goal) => ({ + kind: 'goal', id: goal.id, title: goal.title || goal.prompt || '', status: goal.status, harness: goal.harness, workspace: goal.workspace, + summary: clip(redactSecrets(goal.summary || ''), REPLY_CHARS), latestReply: clip(redactSecrets(goal.latestReply || ''), REPLY_CHARS), turnCount: goal.turnCount || 0, + createdAt: goal.createdAt, updatedAt: goal.updatedAt, finishedAt: goal.finishedAt || null, + })), + ].filter((item) => !cutoff || String(item.createdAt) < cutoff).sort(byTime('createdAt')); + const page = items.slice(-take); + return { + member_id, + items: page, + total: items.length, + truncated: items.length > page.length, + note: 'These are run and goal summaries. Use get_conversation for the actual direct-message transcript.', + }; + } + throw new Error('channel_id or member_id is required'); + }, + + async search({ query, types, limit } = {}) { + const params = new URLSearchParams({ q: String(query || '') }); + if (types) params.set('types', String(types)); + if (limit) params.set('limit', String(Math.min(Number(limit) || 40, 100))); + const data = await bridge.get(`/api/search?${params}`); + return { query: data?.query ?? query, mode: data?.mode || '', results: (data?.results || []).slice(0, 100), tookMs: data?.tookMs ?? null }; + }, + + async create_member() { + throw notAvailable('create_member', 'members are created and stored by the Squad app and only mirrored to the bridge as a read-only status snapshot.', 'Create the member in the app (New member); it then appears in list_members.'); + }, + + async create_channel({ name, visibility, member_ids, section } = {}) { + const channel = await bridge.post('/api/channels', { + name, + ...(visibility ? { visibility } : {}), + ...(Array.isArray(member_ids) ? { memberIds: member_ids } : {}), + ...(section ? { section } : {}), + creatorId: '', + }); + return { channel: { id: channel.id, name: channel.name, visibility: channel.visibility, memberIds: channel.memberIds || [], createdAt: channel.createdAt } }; + }, + + async send_message({ text, channel_id, thread_id, member_id, workspace, mode, author } = {}) { + const body = String(text || '').trim(); + if (!channel_id && !member_id) throw new Error('channel_id or member_id is required'); + if (!channel_id && thread_id) throw new Error('thread_id needs a channel_id'); + if (!channel_id) { + const work = await startWork({ memberId: member_id, text: body, workspace, mode: mode || 'run' }); + return { ...work, note: `The ${work.kind} is bridge-owned: it appears in the app's Mission Control${work.kind === 'goal' ? ' and the member\'s goals' : ''}, not as a direct-chat message. Use wait_for_reply with its id.` }; + } + const state = await channelsState(); + const channel = (state?.channels || []).find((item) => item.id === channel_id); + if (!channel) throw Object.assign(new Error(`channel ${channel_id} was not found`), { status: 404 }); + const parentThread = thread_id ? (state.threads || []).find((thread) => thread.id === thread_id && thread.channelId === channel_id) : null; + if (thread_id && !parentThread) throw Object.assign(new Error(`thread ${thread_id} was not found in channel ${channel_id}`), { status: 404 }); + const at = new Date().toISOString(); + const messageId = `cp-${randomUUID()}`; + const threadId = thread_id || messageId; + const message = { + id: messageId, channelId: channel_id, role: 'user', agentId: '', authorName: String(author || '').trim() || DEFAULT_AUTHOR, body, + status: 'complete', threadId, parentMessageId: parentThread ? parentThread.parentMessageId || parentThread.id : '', createdAt: at, updatedAt: at, + }; + const threads = thread_id ? [] : [{ id: threadId, channelId: channel_id, title: body, creatorId: '', memberIds: member_id ? [member_id] : [], createdAt: at, updatedAt: at }]; + await postChannelRecords(channel_id, { messages: [message], threads }); + let dispatched = null; + if (member_id) { + dispatched = await startWork({ memberId: member_id, text: body, workspace, mode: mode || 'run', channel: { channelId: channel_id, threadId, parentMessageId: messageId, channelName: channel.name } }); + } + return { + message: { id: messageId, channel_id, thread_id: threadId }, + ...(dispatched ? { run_id: dispatched.run_id, status: dispatched.status, workspace: dispatched.workspace, harness: dispatched.harness } : {}), + dispatched: Boolean(dispatched), + note: dispatched + ? 'A bridge-owned run was started for the member in this thread; wait_for_reply posts its reply into the thread when it settles.' + : 'The message was posted. Nobody was dispatched: the bridge has no automatic channel responder; pass member_id to start a run for a member.', + }; + }, + + async wait_for_reply({ run_id, goal_id, timeout_ms, tail_lines } = {}, { signal } = {}) { + if (!run_id && !goal_id) throw new Error('run_id or goal_id is required'); + if (run_id && goal_id) throw new Error('pass run_id or goal_id, not both'); + const budget = Math.min(Math.max(Number(timeout_ms) || DEFAULT_WAIT_MS, 1000), MAX_WAIT_MS); + const started = Date.now(); + for (;;) { + if (signal?.aborted) throw new Error('wait_for_reply was cancelled'); + if (run_id) { + const run = await bridge.get(`/api/runs/${encodeURIComponent(run_id)}`); + const outcome = runOutcome(run); + if (outcome) { + const reply = clip(redactSecrets(run.reply || ''), REPLY_CHARS); + const mirrored = outcome.outcome === 'settled' || outcome.outcome === 'failed' ? await mirrorChannelReply(run, outcome, reply) : false; + return { kind: 'run', id: run.id, ...outcome, status: run.status, reply, tail: redactedTail(run.logs, tail_lines), finishedAt: run.finishedAt || null, waitedMs: Date.now() - started, ...(run.channel ? { channel: run.channel, replyPostedToThread: mirrored } : {}) }; + } + if (Date.now() - started >= budget) { + return { kind: 'run', id: run.id, outcome: 'timed-out', reason: `Still ${run.status} after ${budget} ms.`, status: run.status, reply: '', tail: redactedTail(run.logs, tail_lines), finishedAt: null, waitedMs: Date.now() - started }; + } + } else { + const goal = await bridge.get(`/api/goals/${encodeURIComponent(goal_id)}`); + const outcome = goalOutcome(goal); + const reply = clip(redactSecrets(goal.summary || goal.latestReply || ''), REPLY_CHARS); + const tail = (Array.isArray(goal.evidence) ? goal.evidence : []).slice(-(Number(tail_lines) || 20)).map((line) => clip(redactSecrets(typeof line === 'string' ? line : JSON.stringify(line)), 500)); + if (outcome) return { kind: 'goal', id: goal.id, ...outcome, status: goal.status, reply, tail, turnCount: goal.turnCount || 0, finishedAt: goal.finishedAt || null, waitedMs: Date.now() - started }; + if (Date.now() - started >= budget) return { kind: 'goal', id: goal.id, outcome: 'timed-out', reason: `Still running after ${budget} ms.`, status: goal.status, reply, tail, turnCount: goal.turnCount || 0, finishedAt: null, waitedMs: Date.now() - started }; + } + await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS)); + } + }, + + async interrupt({ run_id, goal_id, member_id } = {}) { + const given = [run_id, goal_id, member_id].filter(Boolean).length; + if (given !== 1) throw new Error('pass exactly one of run_id, goal_id or member_id'); + if (run_id) { + const run = await bridge.post(`/api/runs/${encodeURIComponent(run_id)}/stop`, {}); + return { kind: 'run', id: run_id, stopped: run?.stopped === true, status: run?.status || '' }; + } + if (goal_id) { + const goal = await bridge.post(`/api/goals/${encodeURIComponent(goal_id)}/pause`, {}); + return { kind: 'goal', id: goal_id, status: goal?.status || '', note: 'The goal is paused, not cancelled; resume it in the app.' }; + } + const result = await bridge.post(`/api/members/${encodeURIComponent(member_id)}/stop`, {}); + return { kind: 'member', id: member_id, stoppedRuns: result?.stoppedRuns || 0, closedSessions: result?.closedSessions || 0, pausedGoals: Array.isArray(result?.pausedGoals) ? result.pausedGoals.length : 0 }; + }, + + async set_model() { + throw notAvailable('set_model', 'a member\'s harness and model are chosen in the app and sent with each turn; the bridge only holds the global provider settings.', 'Change the model in the member\'s settings in the app.'); + }, + + async message_member({ member_id, text, workspace } = {}) { + const roster = await tools.list_members(); + if (!roster.members.some(member => member.id === member_id)) throw Object.assign(new Error(`Member ${member_id} was not found. Use list_members to choose a recipient.`), { status: 404 }); + if (!text.trim()) throw new Error('Message text is required.'); + const folder = workspace || await lastWorkspace(member_id); + if (!folder) throw new Error(`Choose a workspace for ${member_id} in Squad, or pass workspace explicitly.`); + const item = await bridge.post(`/api/queues/${encodeURIComponent(member_id)}/items`, { text, workspace: folder }); + const queue = await bridge.get(`/api/queues/${encodeURIComponent(member_id)}`); + return { member_id, message_id: item.id, status: item.status, workspace: folder, paused: queue.paused === true, pauseReason: queue.pauseReason || '' }; + }, + + async get_conversation({ member_id, limit = 30, since = '' } = {}) { + const query = new URLSearchParams({ limit: String(limit), ...(since ? { since } : {}) }); + return bridge.get(`/api/members/${encodeURIComponent(member_id)}/transcript?${query}`); + }, + + async get_member_queue({ member_id, limit = 50 } = {}) { + const queue = await bridge.get(`/api/queues/${encodeURIComponent(member_id)}`); + return { ...queue, items: (queue.items || []).slice(-limit) }; + }, + + async wait_for_message({ member_id, message_id, timeout_ms = 20000 } = {}, { signal } = {}) { + const deadline = Date.now() + Math.min(timeout_ms, 25000); + let previousQueue; + while (true) { + if (signal?.aborted) throw new Error('Wait cancelled; the message remains queued.'); + const remaining = deadline - Date.now(); + const queue = remaining <= 0 && previousQueue ? previousQueue : await bridge.get(`/api/queues/${encodeURIComponent(member_id)}`, { signal, timeoutMs: Math.max(1, Math.min(HTTP_TIMEOUT_MS, remaining)) }); + previousQueue = queue; + const item = (queue.items || []).find(item => item.id === message_id); + if (!item) throw Object.assign(new Error('Message receipt not found for this member.'), { status: 404 }); + const outcome = item.status === 'done' ? 'settled' : ['failed', 'cancelled'].includes(item.status) ? 'failed' : queue.paused ? 'needs-you' : Date.now() >= deadline ? 'timed-out' : ''; + if (outcome) return { member_id, message_id, outcome, status: item.status, reply: clip(item.reply, REPLY_CHARS), reason: item.error || queue.pauseReason || '', finishedAt: item.finishedAt }; + await new Promise(resolve => setTimeout(resolve, Math.min(POLL_INTERVAL_MS, Math.max(1, deadline - Date.now())))); + } + }, + + async list_artifacts({ member_id, query = '', limit = 50 } = {}) { + const data = await bridge.get(`/api/artifacts?${new URLSearchParams({ owner: member_id || '', query })}`); + return { artifacts: (data.artifacts || []).slice(0, limit) }; + }, + + async get_artifact({ artifact_id } = {}) { return bridge.get(`/api/artifacts/${encodeURIComponent(artifact_id)}`); }, + + async list_memories({ member_id, query = '', limit = 50, offset = 0 } = {}) { + return bridge.get(`/api/memory?${new URLSearchParams({ owner: member_id || '', query, limit: String(limit), offset: String(offset) })}`); + }, + + async read_memory({ memory_id } = {}) { return bridge.get(`/api/memory/${encodeURIComponent(memory_id)}`); }, + }; + return { ...tools, ...createManagementTools(bridge, tools) }; +} + +// ---- JSON-RPC ------------------------------------------------------------- + +const TOOL_BY_NAME = new Map(TOOLS.map((tool) => [tool.name, tool])); + +export function createRpcHandler(tools, { bridgeUrl } = {}) { + const inflight = new Map(); + async function callTool(params, id) { + const name = String(params?.name || ''); + const tool = TOOL_BY_NAME.get(name); + if (!tool) throw Object.assign(new Error(`unknown tool ${name || '(empty)'}`), { rpc: -32602 }); + const args = params?.arguments ?? {}; + const problems = validateArgs(tool.inputSchema, args); + if (problems.length) throw Object.assign(new Error(`invalid arguments: ${problems.join('; ')}`), { rpc: -32602 }); + if (inflight.size >= 64) throw Object.assign(new Error('Too many active tool requests; wait for an existing request to finish.'), { rpc: -32000 }); + const controller = new AbortController(); + if (id !== undefined && id !== null) inflight.set(id, controller); + try { + const redact = value => typeof value === 'string' ? redactSecrets(value) : Array.isArray(value) ? value.map(redact) : value && typeof value === 'object' ? Object.fromEntries(Object.entries(value).map(([key, entry]) => [key, redact(entry)])) : value; + const result = redact(await tools[name](args, { signal: controller.signal })); + return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }], structuredContent: result, isError: false }; + } catch (error) { + const message = redactSecrets(error?.code === NOT_AVAILABLE_CODE ? error.message : `${name} failed: ${error?.message || error}`); + const payload = { error: message, ...(error?.code ? { code: error.code } : {}), ...(error?.status ? { status: error.status } : {}) }; + return { content: [{ type: 'text', text: JSON.stringify(payload) }], structuredContent: payload, isError: true }; + } finally { + if (id !== undefined && id !== null) inflight.delete(id); + } + } + + return async function handle(request) { + if (!request || typeof request !== 'object' || request.jsonrpc !== '2.0' || typeof request.method !== 'string') { + return { jsonrpc: '2.0', id: request?.id ?? null, error: { code: -32600, message: 'invalid request' } }; + } + const { id, method, params } = request; + const notification = id === undefined; + if (method === 'notifications/cancelled') { + inflight.get(params?.requestId)?.abort(); + return null; + } + if (method.startsWith('notifications/')) return null; + try { + let result; + if (method === 'initialize') { + result = { + protocolVersion: negotiateProtocol(params?.protocolVersion), + capabilities: { tools: { listChanged: false } }, + serverInfo: { name: SERVER_NAME, version: SERVER_VERSION }, + instructions: `Coordinates the Autohand Squad bridge at ${bridgeUrl}. Use list_members to select an agent, message_member for a queued direct conversation, get_conversation for history and wait_for_message for its reply. Use get_member_profile before revision-checked profile, ability, folder or skill edits. Channels support private groups and explicit recipient dispatch; routines support timezone previews, saved schedules and manual runs. Also reads Library artifacts and memories and independent runs/goals. Approvals, deletion, credentials and computer lifecycle stay in the app.`, + }; + } else if (method === 'ping') { + result = {}; + } else if (method === 'tools/list') { + result = { tools: TOOLS }; + } else if (method === 'tools/call') { + result = await callTool(params, id); + } else { + throw Object.assign(new Error(`method not found: ${method}`), { rpc: -32601 }); + } + return notification ? null : { jsonrpc: '2.0', id, result }; + } catch (error) { + if (notification) return null; + return { jsonrpc: '2.0', id, error: { code: error?.rpc || -32000, message: String(error?.message || error) } }; + } + }; +} + +// ---- stdio loop ----------------------------------------------------------- + +export async function serveStdio({ input = process.stdin, output = process.stdout, handle }) { + let buffer = Buffer.alloc(0); + const pending = new Set(); + const write = (message, framing) => { if (message) output.write(encodeFrame(message, framing)); }; + const receive = async (body, framing) => { + let parsed; + try { parsed = JSON.parse(body.toString()); } + catch { write({ jsonrpc: '2.0', id: null, error: { code: -32700, message: 'parse error' } }, framing); return; } + if (Array.isArray(parsed)) { + const responses = (await Promise.all(parsed.map((item) => handle(item)))).filter(Boolean); + if (responses.length) write(responses, framing); + return; + } + write(await handle(parsed), framing); + }; + await new Promise((resolve) => { + const onData = (chunk) => { + buffer = Buffer.concat([buffer, chunk]); + const { frames, rest } = decodeFrames(buffer); + buffer = rest; + for (const frame of frames) { + const task = receive(frame.body, frame.framing).catch(error => write({ jsonrpc: '2.0', id: null, error: { code: -32000, message: redactSecrets(String(error?.message || error)) } }, frame.framing)); + pending.add(task); + task.finally(() => pending.delete(task)); + } + }; + const finish = () => { + input.off('data', onData); input.off('end', finish); input.off('error', finish); + Promise.allSettled([...pending]).then(resolve); + }; + input.on('data', onData); + input.once('end', finish); + input.once('error', finish); + }); +} + diff --git a/src/startup/cliOptions.ts b/src/startup/cliOptions.ts index 468ff7155..95cc35fb5 100644 --- a/src/startup/cliOptions.ts +++ b/src/startup/cliOptions.ts @@ -135,12 +135,6 @@ export function normalizeTmuxWorktreeOption(options: RootCliOptions): string | n return null; } if (options.daemon === false) return '--tmux cannot be used with --no-daemon'; - if (options.worktree === false) { - return '--tmux cannot be used with --no-worktree'; - } - if (options.worktree === undefined) { - options.worktree = true; - } return null; } @@ -151,9 +145,6 @@ export function validateZitOption(options: RootCliOptions): string | null { if (options.worktree !== undefined && options.worktree !== false) { return '--zit cannot be used with --worktree; choose one isolation mode'; } - if (isTmuxEnabled(options.tmux)) { - return '--zit cannot be used with --tmux (--tmux implies --worktree)'; - } if (options.patch) { return '--zit cannot be used with --patch'; } diff --git a/src/sync/SyncService.ts b/src/sync/SyncService.ts index 15d2425e7..525544d1e 100644 --- a/src/sync/SyncService.ts +++ b/src/sync/SyncService.ts @@ -165,13 +165,14 @@ export class SyncService { private generation = 0; private operationController: AbortController | null = null; private activeOperation: Promise | null = null; + private pendingStartGeneration: number | null = null; private shutdownPromise: Promise | null = null; private lastAppliedMcpFingerprint: string | undefined; constructor(options: SyncServiceOptions) { this.authToken = options.authToken; this.userId = options.userId; - this.config = options.config; + this.config = { ...options.config }; this.client = options.apiClient || getSyncApiClient(); this.onEvent = options.onEvent || (() => {}); this.onAuthFailure = options.onAuthFailure; @@ -185,17 +186,14 @@ export class SyncService { * Start the background sync timer */ start(): void { - if (this.started || this.stopped) return; + if (this.started || this.stopped || !this.config.enabled) return; this.started = true; - // Run initial sync with proper error handling - this.sync().then(result => { - if (!result.success && this.isAuthError(result.error)) { - // Auth failure already handled in sync() - } - }).catch(() => { - // Unexpected error - already logged - }); + if (this.operationController?.signal.aborted && this.activeOperation) { + this.pendingStartGeneration = this.generation; + } else { + void this.sync().catch(() => {}); + } // Set up periodic sync this.timer = setInterval(() => { @@ -210,11 +208,21 @@ export class SyncService { * Stop the background sync timer */ stop(): void { - if (!this.stopped) { - this.stopped = true; - this.generation++; - this.operationController?.abort(new SyncOperationStoppedError()); - } + this.stopped = true; + this.pause(); + } + + /** Pause immediately without making an explicit later opt-in impossible. */ + setEnabled(enabled: boolean): void { + this.config.enabled = enabled; + if (enabled) this.start(); + else this.pause(); + } + + private pause(): void { + this.pendingStartGeneration = null; + this.generation++; + this.operationController?.abort(new SyncOperationStoppedError()); if (this.timer) { clearInterval(this.timer); this.timer = null; @@ -261,6 +269,7 @@ export class SyncService { }); } + this.pendingStartGeneration = null; this.skipped = emptySyncSkippedFiles(); this.remoteSkipped = emptySyncSkippedFiles(); this.operationFailureRecorded = false; @@ -279,6 +288,11 @@ export class SyncService { if (this.operationController === controller) { this.operationController = null; } + if (this.activeOperation === null && this.pendingStartGeneration === this.generation + && this.started && !this.stopped) { + this.pendingStartGeneration = null; + void this.sync().catch(() => {}); + } }; void activeOperation.then(clearActiveOperation, clearActiveOperation); return activeOperation; diff --git a/src/sync/runtimeSyncService.ts b/src/sync/runtimeSyncService.ts index d41b16aaf..ba9bb83e9 100644 --- a/src/sync/runtimeSyncService.ts +++ b/src/sync/runtimeSyncService.ts @@ -6,10 +6,21 @@ import type { SyncService } from './SyncService.js'; let globalSyncService: SyncService | null = null; -let pendingBackgroundSync = false; +let pendingBackgroundSync: ReturnType | undefined; +let enabledOverride: boolean | undefined; export function setSyncService(service: SyncService | null): void { + if (globalSyncService !== service) clearPendingBackgroundSync(); globalSyncService = service; + if (!service) enabledOverride = undefined; + else if (enabledOverride !== undefined) service.setEnabled(enabledOverride); +} + +/** Remember an explicit /sync choice even while startup authentication is pending. */ +export function setBackgroundSyncEnabled(enabled: boolean): void { + enabledOverride = enabled; + if (!enabled) clearPendingBackgroundSync(); + globalSyncService?.setEnabled(enabled); } export function getSyncService(): SyncService | null { @@ -18,13 +29,19 @@ export function getSyncService(): SyncService | null { export function scheduleBackgroundSync(): void { const syncService = globalSyncService; - if (!syncService?.isRunning || pendingBackgroundSync) return; + if (!syncService?.isRunning || pendingBackgroundSync !== undefined) return; - pendingBackgroundSync = true; - setTimeout(() => { - pendingBackgroundSync = false; + pendingBackgroundSync = setTimeout(() => { + pendingBackgroundSync = undefined; + if (globalSyncService !== syncService || !syncService.isRunning) return; void syncService.sync().catch(() => { // Background sync is opportunistic; explicit /sync still reports errors. }); }, 0); } + +function clearPendingBackgroundSync(): void { + if (pendingBackgroundSync === undefined) return; + clearTimeout(pendingBackgroundSync); + pendingBackgroundSync = undefined; +} diff --git a/src/sync/types.ts b/src/sync/types.ts index 03c290b05..b096ca487 100644 --- a/src/sync/types.ts +++ b/src/sync/types.ts @@ -10,7 +10,7 @@ * Sync configuration stored in config.json */ export interface SyncConfig { - /** Enable/disable sync (default: true for logged users) */ + /** Enable/disable sync (default: false; explicit opt-in required) */ enabled: boolean; /** Sync interval in ms (default: 300000 = 5 min) */ interval: number; @@ -30,7 +30,7 @@ export interface SyncConfig { * Default sync configuration */ export const DEFAULT_SYNC_CONFIG: SyncConfig = { - enabled: true, + enabled: false, interval: 5 * 60 * 1000, // 5 minutes includeTelemetry: false, includeFeedback: false, diff --git a/src/testing/drivers/native-terminal-driver.ts b/src/testing/drivers/native-terminal-driver.ts new file mode 100644 index 000000000..f1998fcaa --- /dev/null +++ b/src/testing/drivers/native-terminal-driver.ts @@ -0,0 +1,53 @@ +/** @license Apache-2.0 */ +import { execFileSync } from 'node:child_process'; +import { copyFile, mkdir, readdir, writeFile } from 'node:fs/promises'; +import path from 'node:path'; + +export async function prepareNativeTerminalTest(): Promise<{ args: string[]; resultBundle: string }> { + if (process.platform !== 'darwin' || process.env.GITHUB_ACTIONS !== 'true' || process.env.CI !== 'true') { + throw new Error('Native terminal UI automation runs only on an isolated macOS GitHub Actions runner.'); + } + const terminal = process.env.AUTOHAND_NATIVE_TERMINAL; + const applications: Record = { iterm2: '/Applications/iTerm.app', ghostty: '/Applications/Ghostty.app', + cmux: '/Applications/cmux.app', vscode: '/Applications/Visual Studio Code.app' }; + const application = terminal ? applications[terminal] : undefined; + if (!terminal || !application) { + throw new Error('Choose iterm2, ghostty, cmux, or vscode for AUTOHAND_NATIVE_TERMINAL.'); + } + const root = process.cwd(); + const output = path.resolve('.artifacts', 'native-terminal', terminal); + await mkdir(output, { recursive: true }); + const fixture = path.join(output, 'Autohand Terminal Fixture.command'); + const quote = (value: string) => `'${value.replaceAll("'", "'\\''")}'`; + const bun = execFileSync('/bin/sh', ['-c', 'command -v bun'], { encoding: 'utf8' }).trim(); + await writeFile(fixture, `#!/bin/sh\nset -eu\ncd ${quote(root)}\nexport AUTOHAND_HOME=${quote(path.join(output, 'home'))}\nexport CI=false\nexec ${quote(bun)} src/testing/scenarios/resizeFlickerScenario.tsx --full-screen --multiline "$@"\n`, { mode: 0o755 }); + const profile = path.join(output, 'vscode-profile'); + await mkdir(path.join(profile, 'User'), { recursive: true }); + await writeFile(path.join(profile, 'User', 'settings.json'), JSON.stringify({ + 'chat.disableAIFeatures': true, + 'editor.accessibilitySupport': 'on', 'workbench.startupEditor': 'none', 'window.restoreWindows': 'none', + 'workbench.panel.opensMaximized': 'always', + 'update.mode': 'none', 'security.workspace.trust.enabled': false, + 'terminal.integrated.enablePersistentSessions': false, 'terminal.integrated.scrollback': 5000, + })); + const project = path.resolve('src/testing/drivers/native-terminal-lab'); + for (const source of ['Host.swift', 'TerminalCompatibilityUITests.swift']) { + await copyFile(path.join(project, source), path.join(output, source)); + } + execFileSync('xcodegen', ['generate', '--spec', path.join(project, 'project.yml'), '--project', output], { stdio: 'pipe', timeout: 30_000 }); + const derived = path.join(output, 'DerivedData'); + execFileSync('/usr/bin/xcodebuild', ['build-for-testing', '-project', path.join(output, 'TerminalLab.xcodeproj'), + '-scheme', 'TerminalLab', '-destination', 'platform=macOS', '-derivedDataPath', derived, 'CODE_SIGNING_ALLOWED=NO'], + { stdio: 'pipe', timeout: 120_000 }); + const products = path.join(derived, 'Build', 'Products'); + const runFile = (await readdir(products)).find(file => file.endsWith('.xctestrun')); + if (!runFile) throw new Error('Xcode did not produce the native UI test configuration.'); + const testRun = path.join(products, runFile); + execFileSync('/usr/bin/python3', ['-c', + 'import json,plistlib,sys\np=sys.argv[1]\nd=plistlib.load(open(p,"rb"))\ne=json.loads(sys.argv[2])\ntargets=[t for c in d.get("TestConfigurations",[]) for t in c["TestTargets"]] if "TestConfigurations" in d else [v for k,v in d.items() if k!="__xctestrun_metadata__" and isinstance(v,dict)]\nfor t in targets: t.setdefault("EnvironmentVariables",{}).update(e)\nplistlib.dump(d,open(p,"wb"))', + testRun, JSON.stringify({ AUTOHAND_NATIVE_TERMINAL: terminal, AUTOHAND_NATIVE_FIXTURE: fixture, + AUTOHAND_NATIVE_PROFILE: profile, AUTOHAND_NATIVE_APPLICATION: application, GITHUB_ACTIONS: 'true' })], { timeout: 10_000 }); + const resultBundle = path.join(output, 'NativeTerminal.xcresult'); + return { resultBundle, args: ['test-without-building', '-xctestrun', testRun, '-destination', 'platform=macOS', + '-parallel-testing-enabled', 'NO', '-resultBundlePath', resultBundle] }; +} diff --git a/src/testing/drivers/native-terminal-lab/Host.swift b/src/testing/drivers/native-terminal-lab/Host.swift new file mode 100644 index 000000000..23d70ec98 --- /dev/null +++ b/src/testing/drivers/native-terminal-lab/Host.swift @@ -0,0 +1,8 @@ +import SwiftUI + +@main +struct TerminalLab: App { + var body: some Scene { + WindowGroup { Text("Autohand terminal compatibility test host") } + } +} diff --git a/src/testing/drivers/native-terminal-lab/TerminalCompatibilityUITests.swift b/src/testing/drivers/native-terminal-lab/TerminalCompatibilityUITests.swift new file mode 100644 index 000000000..941dbc35f --- /dev/null +++ b/src/testing/drivers/native-terminal-lab/TerminalCompatibilityUITests.swift @@ -0,0 +1,182 @@ +import AppKit +import XCTest + +final class TerminalCompatibilityUITests: XCTestCase { + private var environment: [String: String] { ProcessInfo.processInfo.environment } + + @MainActor func testIdleResize() throws { try resizeScenario(streaming: false) } + @MainActor func testStreamingResize() throws { try resizeScenario(streaming: true) } + + @MainActor + private func resizeScenario(streaming: Bool) throws { + guard environment["GITHUB_ACTIONS"] == "true", + let terminal = environment["AUTOHAND_NATIVE_TERMINAL"], + let application = environment["AUTOHAND_NATIVE_APPLICATION"], + let fixture = environment["AUTOHAND_NATIVE_FIXTURE"], + let profile = environment["AUTOHAND_NATIVE_PROFILE"] else { + XCTFail("This scenario requires an isolated GitHub Actions macOS runner") + return + } + continueAfterFailure = false + let interruption = addUIInterruptionMonitor(withDescription: "Deny local network access for the offline terminal fixture") { alert in + let text = ([alert.label] + alert.staticTexts.allElementsBoundByIndex.map(\.label)).joined(separator: "\n") + guard text.contains("find devices on local networks") else { return false } + for label in ["Don’t Allow", "Don't Allow"] { + let deny = alert.buttons[label] + if deny.exists { + deny.click() + print("NATIVE_TERMINAL_DENIED_LOCAL_NETWORK") + return true + } + } + return false + } + defer { removeUIInterruptionMonitor(interruption) } + let app = XCUIApplication(url: URL(fileURLWithPath: application)) + defer { if app.state != .notRunning { app.terminate() } } + let command = "'" + fixture.replacingOccurrences(of: "'", with: "'\\''") + "'" + (streaming ? " --streaming" : "") + if terminal == "ghostty" { + app.launchArguments = ["--config-default-files=false", "--confirm-close-surface=false"] + } else if terminal == "vscode" { + app.launchArguments = ["--user-data-dir", profile, "--extensions-dir", profile + "/extensions", "--disable-extensions", + "--disable-workspace-trust", "--skip-welcome", "--skip-release-notes", "--new-window"] + } + let launch = expectation(description: "Launch the installed terminal through macOS") + let configuration = NSWorkspace.OpenConfiguration() + configuration.arguments = app.launchArguments + configuration.environment = app.launchEnvironment + configuration.createsNewApplicationInstance = true + var launchError: Error? + NSWorkspace.shared.openApplication(at: URL(fileURLWithPath: application), configuration: configuration) { _, error in + launchError = error + launch.fulfill() + } + wait(for: [launch], timeout: 30) + let desktop = XCTAttachment(screenshot: XCUIScreen.main.screenshot()) + desktop.name = "\(terminal)-launch" + desktop.lifetime = .keepAlways + add(desktop) + XCTAssertNil(launchError) + app.activate() + let window = app.windows.firstMatch + guard window.waitForExistence(timeout: 30) else { + attachStartupFailure(app, terminal: terminal, phase: "window") + XCTFail("The native terminal did not create a window") + return + } + if terminal == "vscode" { + guard app.webViews.firstMatch.waitForExistence(timeout: 30) else { + attachStartupFailure(app, terminal: terminal, phase: "workbench") + XCTFail("VS Code did not display its workbench") + return + } + dismissCopilotSignIn(app) + app.typeKey("`", modifierFlags: [.control, .shift]) + } + let terminalView = app.textViews.firstMatch + let terminalReady = NSPredicate { _, _ in + if terminal == "vscode", self.dismissCopilotSignIn(app) { + app.typeKey("`", modifierFlags: [.control, .shift]) + return false + } + return terminalView.exists + } + guard XCTWaiter.wait(for: [XCTNSPredicateExpectation(predicate: terminalReady, object: app)], timeout: 20) == .completed else { + attachStartupFailure(app, terminal: terminal, phase: "terminal-view") + XCTFail("The native terminal did not expose its terminal text view") + return + } + terminalView.click() + NSPasteboard.general.clearContents() + XCTAssertTrue(NSPasteboard.general.writeObjects([command as NSString])) + app.typeKey("v", modifierFlags: .command) + app.typeKey(.return, modifierFlags: []) + var startupValue = "" + let ready = NSPredicate { _, _ in + startupValue = self.terminalValue(app) + return startupValue.contains("RESIZE_HISTORY retained text") + } + guard XCTWaiter.wait(for: [XCTNSPredicateExpectation(predicate: ready, object: app)], timeout: 30) == .completed else { + let text = XCTAttachment(string: String(startupValue.suffix(16_000))) + text.name = "\(terminal)-startup-terminal" + text.lifetime = .keepAlways + add(text) + let screen = XCTAttachment(screenshot: XCUIScreen.main.screenshot()) + screen.name = "\(terminal)-startup-screen" + screen.lifetime = .keepAlways + add(screen) + XCTFail("The native terminal did not display the fixture history marker") + return + } + for (index, delta) in [CGVector(dx: -160, dy: -100), CGVector(dx: 240, dy: 160), + CGVector(dx: -200, dy: -120), CGVector(dx: 160, dy: 100)].enumerated() { + let frame = window.frame + let corner = window.coordinate(withNormalizedOffset: CGVector(dx: 1, dy: 1)).withOffset(CGVector(dx: -2, dy: -2)) + corner.press(forDuration: 0.1, thenDragTo: corner.withOffset(delta)) + XCTAssertNotEqual(window.frame.size, frame.size, "The native window did not resize") + let settled = NSPredicate { _, _ in + let value = self.terminalValue(app) + return value.contains("RESIZE_HISTORY retained text") && value.contains("preserved draft") + } + expectation(for: settled, evaluatedWith: app) + waitForExpectations(timeout: 10) + let content = terminalValue(app) + XCTAssertEqual(content.components(separatedBy: "Autohand Code").count - 1, 1, content) + XCTAssertEqual(content.components(separatedBy: "RESIZE_HISTORY retained text").count - 1, 1, content) + XCTAssertEqual(content.components(separatedBy: "preserved draft").count - 1, 1, content) + if streaming { XCTAssertTrue(content.contains("RESIZE_STREAM "), content) } + let image = XCTAttachment(screenshot: window.screenshot()) + image.name = "\(terminal)-\(streaming ? "streaming" : "idle")-\(index)" + image.lifetime = .keepAlways + add(image) + } + app.typeKey(.leftArrow, modifierFlags: []) + app.typeKey(.leftArrow, modifierFlags: []) + app.typeKey("x", modifierFlags: .shift) + let edited = NSPredicate { _, _ in self.terminalValue(app).contains("taXil") } + expectation(for: edited, evaluatedWith: app) + waitForExpectations(timeout: 10) + app.typeKey(.escape, modifierFlags: []) + app.typeKey("c", modifierFlags: .control) + app.typeKey("c", modifierFlags: .control) + app.typeKey("c", modifierFlags: .control) + let exit = NSPredicate { _, _ in self.terminalValue(app).contains("RESIZE_PTY_EXIT") || app.state == .notRunning } + expectation(for: exit, evaluatedWith: app) + waitForExpectations(timeout: 15) + print(streaming ? "NATIVE_TERMINAL_STREAMING_PASS" : "NATIVE_TERMINAL_IDLE_PASS") + } + + @discardableResult + private func dismissCopilotSignIn(_ app: XCUIApplication) -> Bool { + let dialog = app.groups.matching(NSPredicate(format: "label == %@", "Info Sign in to use GitHub Copilot")).firstMatch + guard dialog.exists else { return false } + let close = dialog.buttons["Close Dialog"] + guard close.exists, close.isHittable else { return false } + close.click() + print("NATIVE_TERMINAL_DISMISSED_COPILOT_SIGN_IN") + return true + } + + @MainActor + private func attachStartupFailure(_ app: XCUIApplication, terminal: String, phase: String) { + let hierarchy = XCTAttachment(string: String(app.debugDescription.suffix(16_000))) + hierarchy.name = "\(terminal)-startup-\(phase)-hierarchy" + hierarchy.lifetime = .keepAlways + add(hierarchy) + let screen = XCTAttachment(screenshot: XCUIScreen.main.screenshot()) + screen.name = "\(terminal)-startup-\(phase)-screen" + screen.lifetime = .keepAlways + add(screen) + } + + private func terminalValue(_ app: XCUIApplication) -> String { + let values = app.textViews.allElementsBoundByIndex.compactMap { $0.value as? String } + if let terminal = values.filter({ $0.contains("RESIZE_HISTORY retained text") }).max(by: { $0.count < $1.count }) { + return terminal + } + if environment["AUTOHAND_NATIVE_TERMINAL"] == "vscode" { + return app.staticTexts.allElementsBoundByIndex.map(\.label).joined(separator: "\n") + } + return values.max(by: { $0.count < $1.count }) ?? "" + } +} diff --git a/src/testing/drivers/native-terminal-lab/project.yml b/src/testing/drivers/native-terminal-lab/project.yml new file mode 100644 index 000000000..e3bbebf43 --- /dev/null +++ b/src/testing/drivers/native-terminal-lab/project.yml @@ -0,0 +1,32 @@ +name: TerminalLab +options: + bundleIdPrefix: ai.autohand.testing + deploymentTarget: + macOS: '14.0' +settings: + CODE_SIGNING_ALLOWED: NO + SWIFT_VERSION: '5.0' +targets: + TerminalLab: + type: application + platform: macOS + sources: [Host.swift] + settings: + GENERATE_INFOPLIST_FILE: YES + TerminalLabUITests: + type: bundle.ui-testing + platform: macOS + sources: [TerminalCompatibilityUITests.swift] + dependencies: + - target: TerminalLab + settings: + GENERATE_INFOPLIST_FILE: YES + TEST_TARGET_NAME: TerminalLab +schemes: + TerminalLab: + build: + targets: + TerminalLab: all + TerminalLabUITests: [test] + test: + targets: [TerminalLabUITests] diff --git a/src/testing/drivers/pty-driver.ts b/src/testing/drivers/pty-driver.ts index a6ebbd629..839a0f7fb 100644 --- a/src/testing/drivers/pty-driver.ts +++ b/src/testing/drivers/pty-driver.ts @@ -20,7 +20,7 @@ export class PtyDriver { down(): void { this.type('\x1b[B'); } ctrlC(): void { this.type('\x03'); } resize(cols: number, rows: number): void { this.terminal?.resize(cols, rows); } - snapshot(): string { return stripVTControlCharacters(this.output); } + snapshot(): string { return stripVTControlCharacters(this.output.replaceAll('\x1b[1E', '\n')); } close(): void { this.terminal?.kill(); } async waitFor(expected: string | RegExp, timeout = 10_000, after = 0): Promise { diff --git a/src/testing/drivers/tmux-driver.ts b/src/testing/drivers/tmux-driver.ts new file mode 100644 index 000000000..9ee98556b --- /dev/null +++ b/src/testing/drivers/tmux-driver.ts @@ -0,0 +1,39 @@ +import { spawnSync } from 'node:child_process'; +import { randomUUID } from 'node:crypto'; +import { mkdir, writeFile } from 'node:fs/promises'; +import path from 'node:path'; + +export class TmuxTestServer { + private readonly socketName = `autohand-test-${randomUUID()}`; + private readonly executable: string; + + constructor() { + const found = spawnSync('/bin/sh', ['-c', 'command -v tmux'], { encoding: 'utf8' }); + if (found.status !== 0 || !found.stdout.trim()) throw new Error('Terminal integration tests require tmux on PATH.'); + this.executable = found.stdout.trim(); + } + + async prepare(directory: string): Promise { + await mkdir(directory, { recursive: true }); + const quote = (text: string) => `'${text.replaceAll("'", "'\\''")}'`; + const config = path.join(directory, 'tmux.conf'); + await writeFile(config, 'set -g remain-on-exit failed\n'); + await writeFile(path.join(directory, 'tmux'), + `#!/bin/sh\nexec ${quote(this.executable)} -L ${quote(this.socketName)} -f ${quote(config)} "$@"\n`, { mode: 0o755 }); + return `${directory}${path.delimiter}${process.env.PATH ?? ''}`; + } + + stop(): void { + spawnSync(this.executable, ['-L', this.socketName, 'kill-server'], { stdio: 'ignore', timeout: 5_000 }); + } + + snapshot(): { text: string; historySize: number } { + const capture = (args: string[]) => { + const result = spawnSync(this.executable, ['-L', this.socketName, ...args], { encoding: 'utf8', timeout: 5_000 }); + if (result.status !== 0) throw new Error(`Could not inspect the test tmux pane: ${result.stderr}`); + return result.stdout; + }; + return { text: capture(['capture-pane', '-p', '-S', '-']), + historySize: Number(capture(['display-message', '-p', '#{history_size}']).trim()) }; + } +} diff --git a/src/testing/scenarios/fullScreenScenario.ts b/src/testing/scenarios/fullScreenScenario.ts index 569415d9e..bb2001561 100644 --- a/src/testing/scenarios/fullScreenScenario.ts +++ b/src/testing/scenarios/fullScreenScenario.ts @@ -25,6 +25,10 @@ export async function requestFullScreenStartupWrite(session: TerminalSession): P await session.type('Create the requested startup-check file.'); await session.press('enter'); await session.waitForText('AUTO_STARTUP_COMPLETE', { timeout: 20_000 }); + await session.text({ timeout: 5_000, waitFor: () => { + const viewport = outputViewport(session); + return viewport.includes('AUTO_STARTUP_COMPLETE') && viewport.includes('Completed in') && viewport.includes('● AUTO'); + } }); } export async function startFullScreenHeaderProbe(session: TerminalSession): Promise { diff --git a/src/testing/scenarios/resumeScenario.ts b/src/testing/scenarios/resumeScenario.ts index 02c934f63..ce01e9ce9 100644 --- a/src/testing/scenarios/resumeScenario.ts +++ b/src/testing/scenarios/resumeScenario.ts @@ -1,4 +1,5 @@ import path from 'node:path'; +import { pathToFileURL } from 'node:url'; import fs from 'fs-extra'; import type { Session as TerminalSession } from 'tuistory'; import { Session, SessionManager } from '../../session/SessionManager.js'; @@ -35,6 +36,45 @@ export async function inspectSdkSessionResume(session: TerminalSession): Promise interface FixtureMessage { role: 'user' | 'assistant'; content: string; } +export async function createResumeLoadGate(autohandHome: string, sessionId: string) { + const releasePath = path.join(autohandHome, 'resume.release'); + const startedPath = path.join(autohandHome, 'resume.loading'); + const preloadPath = path.join(autohandHome, 'resume-load-gate.mjs'); + const conversationPath = path.join(autohandHome, 'sessions', sessionId, 'conversation.jsonl'); + await fs.writeFile(preloadPath, ` +import fs from 'node:fs'; +const readFile = fs.readFile; +fs.readFile = function(file, ...args) { + if (String(file) !== ${JSON.stringify(conversationPath)}) return readFile.call(this, file, ...args); + fs.writeFileSync(${JSON.stringify(startedPath)}, 'loading'); + const signal = AbortSignal.timeout(20000); + let released = false; + const watcher = fs.watch(${JSON.stringify(autohandHome)}, check); + function release() { + if (released) return; + released = true; + watcher.close(); + signal.removeEventListener('abort', release); + readFile.call(fs, file, ...args); + } + function check() { if (fs.existsSync(${JSON.stringify(releasePath)})) release(); } + signal.addEventListener('abort', release, { once: true }); + check(); +}; +`); + return { + importSpecifier: pathToFileURL(preloadPath).href, + startedPath, + release: () => fs.writeFile(releasePath, 'ready'), + }; +} + +export async function typeDuringResumeLoad(session: TerminalSession): Promise { + await session.waitForText('❯', { timeout: 5_000 }); + await session.type('draft typed during restore'); + return session.text({ timeout: 5_000, waitFor: text => text.includes('❯ draft typed during restore') }); +} + interface FixtureSession { sessionId: string; createdAt: string; diff --git a/src/testing/scenarios/squadConnectionScenario.ts b/src/testing/scenarios/squadConnectionScenario.ts new file mode 100644 index 000000000..87c8fb180 --- /dev/null +++ b/src/testing/scenarios/squadConnectionScenario.ts @@ -0,0 +1,22 @@ +import type { Session } from 'tuistory'; + +export async function selectSquadAgentAndMessage(session: Session): Promise { + await session.type('/squad'); + await session.press('enter'); + await session.waitForText('Select a Squad agent', { timeout: 15000 }); + await session.type('Kai'); + await session.press('enter'); + await session.waitForText('Selected Squad agent Kai (kai)', { timeout: 10000 }); + await session.type('/message Review the CLI fixture'); + await session.press('enter'); + await session.waitForText('Receipt: receipt-1', { timeout: 10000 }); + await session.type('/squad history'); + await session.press('enter'); + return session.text({ timeout: 10000, waitFor: text => text.includes('SQUAD_DIRECT_REPLY') }); +} + +export async function invokeBuiltinSquadMcp(session: Session): Promise { + await session.type("Read Kai's saved Squad profile using the built-in MCP."); + await session.press('enter'); + return session.text({ timeout: 20000, waitFor: text => text.includes('BUILTIN_SQUAD_MCP_VERIFIED') }); +} diff --git a/src/types.ts b/src/types.ts index db15893a3..9b829049b 100644 --- a/src/types.ts +++ b/src/types.ts @@ -838,7 +838,7 @@ export interface ShareSettings { /** Settings sync configuration */ export interface SyncSettings { - /** Enable/disable sync (default: true for logged users) */ + /** Enable/disable background sync (default: false; explicitly opt in). */ enabled?: boolean; /** Sync interval in milliseconds (default: 300000 = 5 min) */ interval?: number; @@ -1114,6 +1114,8 @@ export interface AutohandConfig { network?: NetworkSettings; /** Child-process controls for shell tools and `!` commands. */ shell?: ShellSettings; + /** Model-facing command output; terminal capture retains its independent limit. */ + tools?: { maxCommandOutputChars?: number }; /** Named run profiles selected with `--profile `; each is a partial config layered for that run only. */ profiles?: Record; externalAgents?: ExternalAgentsConfig; @@ -1377,7 +1379,7 @@ export interface CLIOptions { logout?: boolean; /** Submit feedback */ feedback?: boolean; - /** Enable/disable settings sync (default: true for logged users, false otherwise) */ + /** Enable/disable settings sync (default: false; explicit opt-in required) */ syncSettings?: boolean; /** Generate git patch without applying changes */ patch?: boolean; @@ -2155,7 +2157,10 @@ export interface ToolImageRegistrationResult { error?: string; } -export type ToolActionOutcome = +export type ToolActionOutcome = { + /** Bounded model observation, separate from terminal and diagnostic output. */ + modelOutput?: string; +} & ( | { success: true; output?: string; @@ -2172,7 +2177,7 @@ export type ToolActionOutcome = exitCode?: number | null; imagePaths?: string[]; imageRefs?: string[]; - }; + }); export type ToolExecutionResult = { tool: AgentAction['type']; diff --git a/src/ui/ink/InkRenderer.tsx b/src/ui/ink/InkRenderer.tsx index 7ec91dc42..2e4c5f413 100644 --- a/src/ui/ink/InkRenderer.tsx +++ b/src/ui/ink/InkRenderer.tsx @@ -401,7 +401,7 @@ const AgentUIWrapper = forwardRef( * On unsupported terminals the CSI sequences are silently ignored, so this * is safe to enable unconditionally. */ -function patchStdoutForSyncOutput(framePrefix: (paint: string) => string, shouldBatchOutput: () => boolean): () => void { +function patchStdoutForSyncOutput(preparePaint: (paint: string) => string, shouldBatchOutput: () => boolean): () => void { const originalWrite = process.stdout.write; const chunks: Buffer[] = []; let pendingBytes = 0; @@ -438,15 +438,10 @@ function patchStdoutForSyncOutput(framePrefix: (paint: string) => string, should if (str === '\x1b[?2026h') inkFrameActive = true; else if (str === '\x1b[?2026l') inkFrameActive = false; else { - const prefix = inkFrameActive ? framePrefix(str) : ''; - const content = typeof chunk === 'string' - ? Buffer.from(chunk, typeof encodingOrCallback === 'string' ? encodingOrCallback : 'utf8') + const prepared = inkFrameActive ? preparePaint(str) : str; + const content = prepared !== str || typeof chunk === 'string' + ? Buffer.from(prepared, typeof encodingOrCallback === 'string' ? encodingOrCallback : 'utf8') : Buffer.from(chunk); - if (prefix) { - const origin = Buffer.from(prefix); - chunks.push(origin); - pendingBytes += origin.length; - } chunks.push(content); pendingBytes += content.length; } @@ -531,9 +526,8 @@ export class InkRenderer { }; /** - * A resize may reflow the previous frame. Reset its origin in the same write - * as Ink's next paint, since its resize listener can defer the actual output. - * Clearing from a resize listener would publish an empty intermediate frame. + * Erase reflowed rows inside the next paint. iTerm2 archives erase-display + * operations into scrollback; cursor-addressed line erases avoid that behavior. */ private resizeFramePrefix = (paint: string): string => { if (!this.fullScreenTranscript) return ''; @@ -541,7 +535,22 @@ export class InkRenderer { if (this.paintedViewport?.columns === viewport.columns && this.paintedViewport.rows === viewport.rows) return ''; if (!stripAnsiCodes(paint).trim()) return ''; this.paintedViewport = viewport; - return '\x1b[2J\x1b[H'; + return this.eraseFullScreenRows(); + }; + + private eraseFullScreenRows(): string { + return Array.from({ length: Math.max(1, process.stdout.rows ?? 24) }, (_, row) => `\x1b[${row + 1};1H\x1b[2K`).join('') + '\x1b[H'; + } + + private prepareFramePaint = (paint: string): string => { + if (!this.fullScreenTranscript || !this.instance) return paint; + const prefix = this.resizeFramePrefix(paint); + const inkOverflowClear = '\x1b[2J\x1b[3J\x1b[H'; + // A frame can arrive before SIGWINCH updates React. Cursor motion clamps at + // the new bottom row; line feeds would scroll the stale frame into history. + const safePaint = paint.replaceAll(inkOverflowClear, '\x1b[H').replaceAll(/\r?\n/gu, '\x1b[1E'); + if (!paint.includes(inkOverflowClear)) return prefix + safePaint; + return (prefix || this.eraseFullScreenRows()) + safePaint; }; /** @@ -561,7 +570,7 @@ export class InkRenderer { try { // Enable synchronized output wrapping to eliminate flicker from partial // frame updates. Must happen before Ink starts writing to stdout. - this.unpatchedStdout = patchStdoutForSyncOutput(this.resizeFramePrefix, () => this.instance !== null); + this.unpatchedStdout = patchStdoutForSyncOutput(this.prepareFramePaint, () => this.instance !== null); // Shift+Enter, Alt+Enter and Esc are only distinguishable when the // terminal encodes modified keys. Ink parses the kitty CSI u form; iTerm2, diff --git a/src/ui/shellCommand.ts b/src/ui/shellCommand.ts index 4d698d205..24451b838 100644 --- a/src/ui/shellCommand.ts +++ b/src/ui/shellCommand.ts @@ -636,6 +636,7 @@ export async function executeShellCommandAsync( } finish({ success: false, + ...(stdout.length > 0 ? { output: stdout.toString() } : {}), error: stderr.toString() || error.stderr?.toString() || error.message || 'Unknown error' }); }); @@ -659,6 +660,7 @@ export async function executeShellCommandAsync( finish({ success: false, + ...(stdout.length > 0 ? { output: stdout.toString() } : {}), error: errorMessage }); }); diff --git a/src/utils/commandModelOutput.ts b/src/utils/commandModelOutput.ts new file mode 100644 index 000000000..bc70f4adb --- /dev/null +++ b/src/utils/commandModelOutput.ts @@ -0,0 +1,99 @@ +import { closeSync, mkdirSync, openSync, writeSync } from 'node:fs'; +import path from 'node:path'; +import { randomUUID } from 'node:crypto'; +import { CommandOutputCapture } from './commandOutputCapture.js'; + +export const DEFAULT_MAX_COMMAND_OUTPUT_CHARS = 30_000; + +export function resolveMaxCommandOutputChars(configured?: number): number { + const environment = Number(process.env.AUTOHAND_MAX_COMMAND_OUTPUT_CHARS); + if (Number.isSafeInteger(environment) && environment >= 1024) return environment; + return configured ?? DEFAULT_MAX_COMMAND_OUTPUT_CHARS; +} + +/** Spools raw output without retaining it in memory; UI capture remains independent. */ +export class CommandModelOutput { + private pending = ''; + private totalLength = 0; + private descriptor: number | undefined; + private closed = false; + private archiveFailed = false; + private head = ''; + private tail = ''; + filePath: string | undefined; + + constructor(private readonly limit: number, private readonly sessionDirectory?: string) { + if (!Number.isSafeInteger(limit) || limit < 1024) throw new RangeError('Command model output limit must be an integer of at least 1024'); + } + + append(chunk: string): void { + if (this.closed) return; + this.totalLength += chunk.length; + this.head += chunk.slice(0, Math.max(0, this.limit - this.head.length)); + this.tail = (this.tail + chunk.slice(-this.limit)).slice(-this.limit); + if (this.archiveFailed) return; + if (this.totalLength <= this.limit) { this.pending += chunk; return; } + if (!this.sessionDirectory) { this.pending = ''; return; } + try { + if (this.descriptor === undefined) { + const directory = path.join(this.sessionDirectory, 'command-output'); + mkdirSync(directory, { recursive: true, mode: 0o700 }); + const filename = path.join(directory, `${randomUUID()}.log`); + this.descriptor = openSync(filename, 'wx', 0o600); + this.filePath = filename; + this.write(this.pending); + this.pending = ''; + } + this.write(chunk); + } catch { + this.archiveFailed = true; + this.filePath = undefined; + this.pending = ''; + this.close(); + } + } + + finish(observation: string): string { + this.closed = true; + this.close(); + if (this.totalLength <= this.limit && observation.length <= this.limit) return observation; + const observationLimit = this.totalLength > this.limit + ? Math.max(1024, Math.floor(this.limit / 2)) + : this.limit; + let location = this.filePath + ? `Full command output saved to ${this.filePath}. Use run_command with grep to inspect it.` + : 'Full command output was not saved (session persistence disabled or archive unavailable).'; + let firstLine = observation.split('\n', 1)[0].slice(0, 512); + if (/[\uD800-\uDBFF]$/.test(firstLine)) firstLine = firstLine.slice(0, -1); + const prefix = firstLine + '\n'; + const marker = (omitted: number): string => `\n[output truncated: ${omitted} characters omitted. ${location}]\n`; + if (this.filePath && prefix.length + marker(this.totalLength).length + 2 > observationLimit) { + location = `Full command output saved as session artifact command-output/${path.basename(this.filePath)}.`; + } + if (this.totalLength > this.limit) { + const room = Math.max(2, observationLimit - prefix.length - marker(this.totalLength).length); + let head = this.head.slice(0, Math.floor(room / 2)); + let tail = this.tail.slice(-Math.ceil(room / 2)); + if (/[\uD800-\uDBFF]$/.test(head)) head = head.slice(0, -1); + if (/^[\uDC00-\uDFFF]/.test(tail)) tail = tail.slice(1); + return prefix + head + marker(this.totalLength - head.length - tail.length) + tail; + } + const suffix = `\n[${location}]`; + const capture = new CommandOutputCapture(Math.max(2, this.limit - suffix.length - 96)); + capture.append(observation); + return capture.toString() + suffix; + } + + private write(contents: string): void { + const bytes = Buffer.from(contents); + let offset = 0; + while (offset < bytes.length) offset += writeSync(this.descriptor!, bytes, offset); + } + + private close(): void { + if (this.descriptor === undefined) return; + const descriptor = this.descriptor; + this.descriptor = undefined; + try { closeSync(descriptor); } catch { this.filePath = undefined; } + } +} diff --git a/src/utils/tmux.ts b/src/utils/tmux.ts index 42445fa4a..23e23fd65 100644 --- a/src/utils/tmux.ts +++ b/src/utils/tmux.ts @@ -39,8 +39,9 @@ function shellQuote(arg: string): string { return `'${arg.replace(/'/g, `'\"'\"'`)}'`; } -export function buildTmuxLaunchCommand(argv: string[]): string { - const cleaned = stripTmuxArgs(argv); +export function buildTmuxLaunchCommand(argv: string[], executable = process.execPath): string { + const launch = /(?:\$bunfs|~BUN)/.test(argv[1] ?? '') ? [executable, ...argv.slice(2)] : argv; + const cleaned = stripTmuxArgs(launch); const quoted = cleaned.map(shellQuote).join(' '); return `AUTOHAND_TMUX_LAUNCHED=1 ${quoted}`; } diff --git a/tests/ci/releaseWorkflow.test.ts b/tests/ci/releaseWorkflow.test.ts index e1c501524..55432bc06 100644 --- a/tests/ci/releaseWorkflow.test.ts +++ b/tests/ci/releaseWorkflow.test.ts @@ -568,6 +568,8 @@ describe('release workflow', () => { path: 'binaries', }); expect(verifyStep?.run).toContain('codesign --verify --strict --verbose=4'); + expect(verifyStep?.run).toContain('codesign --verify --strict -R=notarized --check-notarization "$binary"'); + expect(verifyStep?.run).toContain('codesign --verify --strict -R=notarized --check-notarization "$ahtraces"'); expect(verifyStep?.run).toContain('"$binary" --version < /dev/null'); expect(verifyStep?.run).toContain('ahtraces'); expect(verifyStep?.run).toContain('Autohand Computer Use.app'); diff --git a/tests/ci/workflowCheckout.test.ts b/tests/ci/workflowCheckout.test.ts index 9106b52d3..10b968b66 100644 --- a/tests/ci/workflowCheckout.test.ts +++ b/tests/ci/workflowCheckout.test.ts @@ -51,6 +51,15 @@ function hasFullHistoryCheckout(job: WorkflowJob): boolean { * release workflow kept failing because it runs the same suite from its own job. */ describe('CI workflow checkout', () => { + it('fetches committed main for every job running the CLI option contract guard', () => { + const jobs = readWorkflowJobs(); + for (const [workflow, name] of [['ci.yml', 'test'], ['release.yml', 'test'], ['full-proof.yml', 'proof']]) { + const job = jobs.find(job => job.workflow === workflow && job.name === name); + expect(job, `${workflow}:${name}`).toBeDefined(); + expect(hasFullHistoryCheckout(job!), `${workflow}:${name}`).toBe(true); + } + }); + it('runs managed branches through PR validation once and cancels superseded revisions', () => { const workflow = parse(readFileSync(path.join(WORKFLOW_DIR, 'ci.yml'), 'utf8')) as { on: { push: { 'branches-ignore': string[] }; pull_request: { branches: string[] }; workflow_dispatch?: unknown }; diff --git a/tests/commands/squadConnection.test.ts b/tests/commands/squadConnection.test.ts new file mode 100644 index 000000000..3e792d764 --- /dev/null +++ b/tests/commands/squadConnection.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it, vi } from 'vitest'; +import { SquadConnection } from '../../src/squad/SquadConnection.js'; +import { message } from '../../src/commands/message.js'; +import { runDesktopSquadCommand } from '../../src/commands/squadConnection.js'; +import type { Team } from '../../src/core/teams/types.js'; + +function fixture() { + const post = vi.fn(async () => ({ id: 'receipt-1', status: 'queued' })); + const get = vi.fn(async (path: string) => { + if (path === '/api/status/snapshot') return { members: [{ id: 'kai', name: 'Kai', role: 'Engineer' }] }; + if (path === '/api/members/presence') return { presence: { kai: { state: 'working' } } }; + if (path === '/api/runs') return [{ agentId: 'kai', workspace: '/member/project', startedAt: '2026-11-01' }]; + if (path.startsWith('/api/goals')) return []; + if (path === '/api/channels') return {}; + if (path.startsWith('/api/queues/')) return { items: [], paused: false }; + if (path.includes('/transcript')) return { messages: [{ role: 'agent', body: 'Ready for review.' }], live: false }; + throw new Error(`Unexpected request ${path}`); + }); + return { connection: new SquadConnection({ bridge: { get, post, put: vi.fn(), patch: vi.fn() } }), post, get }; +} + +describe('desktop Squad commands', () => { + it('selects a running Squad agent and sends /message to its durable direct queue', async () => { + const { connection, post } = fixture(); + const pickMember = vi.fn(async () => 'kai'); + const selected = await runDesktopSquadCommand({ squadConnection: connection, pickMember }, ['select']); + expect(selected).toContain('Kai'); + expect(pickMember.mock.calls[0]?.[0]).toEqual(expect.arrayContaining([expect.objectContaining({ id: 'kai', presence: 'working' })])); + expect(await message({ squadConnection: connection }, ['Please', 'review', 'this.'])).toContain('receipt-1'); + expect(post).toHaveBeenCalledWith('/api/queues/kai/items', { text: 'Please review this.', workspace: '/member/project' }); + }); + + it('keeps explicit local team messages working while Squad is selected', async () => { + const { connection } = fixture(); + await connection.select('Kai'); + const sendMessageTo = vi.fn(); + const teamManager = { getTeam: (): Team => ({ name: 'fixture', leadSessionId: 'lead', createdAt: '', status: 'active', members: [] }), sendMessageTo }; + const response = await message({ squadConnection: connection, teamManager }, ['team', 'local', 'Review', 'this']); + expect(response).toContain('Message sent to local'); + expect(sendMessageTo).toHaveBeenCalledWith('local', 'lead', 'Review this'); + }); + + it('sends to an explicit desktop recipient without requiring a local team', async () => { + const { connection, post } = fixture(); + expect(await message({ squadConnection: connection }, ['squad', 'kai', 'Hello'])).toContain('receipt-1'); + expect(post).toHaveBeenCalledOnce(); + expect(await runDesktopSquadCommand({ squadConnection: connection }, ['history', 'kai'])).toContain('Ready for review.'); + }); + + it('rejects an unknown recipient without queuing anything', async () => { + const { connection, post } = fixture(); + await expect(connection.select('unknown')).rejects.toThrow(/not found/i); + expect(post).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/commands/sync.test.ts b/tests/commands/sync.test.ts index 7f33902fa..7c97942a7 100644 --- a/tests/commands/sync.test.ts +++ b/tests/commands/sync.test.ts @@ -7,6 +7,7 @@ import { PassThrough } from 'node:stream'; import { describe, expect, it, vi } from 'vitest'; import { getSyncService } from '../../src/sync/runtimeSyncService.js'; import { SyncService } from '../../src/sync/SyncService.js'; +import { loadConfig, saveConfig } from '../../src/config.js'; import { sync } from '../../src/commands/sync.js'; import type { SlashCommandContext } from '../../src/core/slashCommandTypes.js'; @@ -14,7 +15,10 @@ vi.mock('../../src/config.js', () => ({ loadConfig: vi.fn().mockResolvedValue({ auth: { token: 'fixture-token', user: { id: 'fixture-user' } } }), saveConfig: vi.fn(), })); -vi.mock('../../src/sync/runtimeSyncService.js', () => ({ getSyncService: vi.fn(() => null), setSyncService: vi.fn() })); +vi.mock('../../src/sync/runtimeSyncService.js', () => { + const getSyncService = vi.fn<() => SyncService | null>(() => null); + return { getSyncService, setSyncService: vi.fn(), setBackgroundSyncEnabled: vi.fn((enabled: boolean) => getSyncService()?.setEnabled(enabled)) }; +}); describe('sync screen input ownership', () => { it('clears an incomplete extended-key timer when the panel closes', async () => { @@ -153,3 +157,20 @@ describe('/sync status', () => { } finally { vi.mocked(getSyncService).mockReturnValue(null); stdin.mockRestore(); log.mockRestore(); input.destroy(); } }); }); + + +describe('/sync on and off', () => { + it('stops the live service before saving off, even without valid authentication', async () => { + vi.mocked(loadConfig).mockResolvedValueOnce({ configPath: '/fixture/config.json', sync: { enabled: true } }); + const service = new SyncService({ authToken: 'fixture', userId: 'fixture', config: { enabled: true, interval: 300000 } }); + const toggle = vi.spyOn(service, 'setEnabled'); + vi.mocked(getSyncService).mockReturnValueOnce(service); + const log = vi.spyOn(console, 'log').mockImplementation(() => {}); + try { + await sync({} as SlashCommandContext, ['off']); + expect(toggle).toHaveBeenCalledWith(false); + expect(saveConfig).toHaveBeenCalledWith(expect.objectContaining({ sync: { enabled: false } })); + expect(toggle.mock.invocationCallOrder[0]).toBeLessThan(vi.mocked(saveConfig).mock.invocationCallOrder.at(-1)!); + } finally { service.stop(); log.mockRestore(); toggle.mockRestore(); } + }); +}); diff --git a/tests/config.test.ts b/tests/config.test.ts index 9226af37b..ad3844a70 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -9,12 +9,45 @@ import path from 'node:path'; import os from 'node:os'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import * as atomicFiles from '../src/utils/atomicFile.js'; -import { applyTrustedWorkspaceEntries, getProviderConfig, loadConfig, resolveRequestedWorkspaceRoot, saveConfig } from '../src/config'; +import { applyTrustedWorkspaceEntries, getProviderConfig, loadConfig, resolveRequestedWorkspaceRoot, saveConfig, saveValidatedAuth } from '../src/config'; import { trustWorkspace } from '../src/permissions/workspaceTrust'; import type { AutohandConfig } from '../src/types'; const originalApiUrl = process.env.AUTOHAND_API_URL; +describe('configuration write contention', () => { + it.each(['configuration', 'validated authentication'] as const)('waits for a brief competing %s write', async (operation) => { + const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'autohand-config-contention-')); + const configPath = path.join(directory, 'config.json'); + await fs.writeJson(configPath, { auth: { token: 'fixture-token' }, mcp: { servers: [] } }); + const config = await loadConfig(configPath); + const lease = await atomicFiles.acquireFileLock(`${configPath}.lock`); + expect(lease).not.toBeNull(); + vi.useFakeTimers(); + const timerBaseline = vi.getTimerCount(); + const retryTimer = vi.spyOn(globalThis, 'setTimeout'); + try { + const result = (operation === 'configuration' + ? saveConfig({ ...config, mcp: { servers: [{ name: 'fixture', transport: 'http', url: 'https://example.com/mcp' }] } }) + : saveValidatedAuth({ ...config, auth: { token: 'fixture-token', user: { id: 'updated', email: 'fixture@example.com' } } }, 'fixture-token')) + .then(() => undefined, (error: unknown) => error); + await vi.waitFor(() => expect(retryTimer.mock.calls.some(([, delay]) => delay === 25)).toBe(true)); + await lease?.release(); + await vi.runAllTimersAsync(); + await expect(result).resolves.toBeUndefined(); + const saved = await fs.readJson(configPath); + if (operation === 'configuration') expect(saved.mcp.servers[0].name).toBe('fixture'); + else expect(saved.auth.user.id).toBe('updated'); + expect(vi.getTimerCount()).toBe(timerBaseline); + } finally { + retryTimer.mockRestore(); + vi.useRealTimers(); + await lease?.release(); + await fs.remove(directory); + } + }); +}); + afterEach(() => { if (originalApiUrl === undefined) delete process.env.AUTOHAND_API_URL; else process.env.AUTOHAND_API_URL = originalApiUrl; diff --git a/tests/config/toolsSettings.test.ts b/tests/config/toolsSettings.test.ts new file mode 100644 index 000000000..5aaedc752 --- /dev/null +++ b/tests/config/toolsSettings.test.ts @@ -0,0 +1,42 @@ +/** + * @license + * Copyright 2026 Autohand AI LLC + * SPDX-License-Identifier: Apache-2.0 + */ +import fse from 'fs-extra'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { loadConfig } from '../../src/config.js'; + +describe('command model output configuration', () => { + let directory: string; + + beforeEach(async () => { + directory = await fse.mkdtemp(path.join(os.tmpdir(), 'autohand-tools-settings-')); + vi.spyOn(console, 'warn').mockImplementation(() => {}); + }); + + afterEach(async () => { + vi.restoreAllMocks(); + await fse.remove(directory); + }); + + async function load(tools: unknown) { + const configPath = path.join(directory, 'config.json'); + await fse.writeJson(configPath, { provider: 'openrouter', openrouter: { apiKey: 'fixture-key', model: 'fixture-model' }, tools }); + return loadConfig(configPath); + } + + it.each([1024, 30000, 100000])('accepts a safe character limit of %s', async limit => { + expect((await load({ maxCommandOutputChars: limit })).tools?.maxCommandOutputChars).toBe(limit); + }); + + it.each([0, 1023, -1, 1024.5, '30000', null, Number.MAX_SAFE_INTEGER + 1])('rejects an invalid character limit of %j', async limit => { + await expect(load({ maxCommandOutputChars: limit })).rejects.toThrow('tools.maxCommandOutputChars must be an integer of at least 1024'); + }); + + it.each([false, [], '30000'])('rejects a non-object tools setting %j', async tools => { + await expect(load(tools)).rejects.toThrow('tools must be an object'); + }); +}); diff --git a/tests/core/actionExecutor.commandOutput.test.ts b/tests/core/actionExecutor.commandOutput.test.ts new file mode 100644 index 000000000..7012faf83 --- /dev/null +++ b/tests/core/actionExecutor.commandOutput.test.ts @@ -0,0 +1,92 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { ToolManager } from '../../src/core/toolManager.js'; +import { setConfigSetting } from '../../src/commands/settings.js'; +import { ActionExecutor } from '../../src/core/actionExecutor.js'; +import { FileActionManager } from '../../src/actions/filesystem.js'; +import { formatToolObservation, handleAgentToolOutput, queueAgentToolMessageChunk } from '../../src/core/agent/AgentToolOutputRuntime.js'; +const directories: string[] = []; +afterEach(async () => { vi.unstubAllEnvs(); await Promise.all(directories.splice(0).map(dir => rm(dir, { recursive: true, force: true }))); }); +describe('command output at the model boundary', () => { + it('preserves the model observation through tool-result normalization', async () => { + const manager = new ToolManager({ executor: async () => ({ success: true, output: 'terminal log', modelOutput: 'model log' }), + definitions: [{ name: 'run_command', description: 'Run a command' }], confirmApproval: async () => true }); + const [result] = await manager.execute([{ tool: 'run_command', args: { command: 'true' } }]); + expect(result.output).toBe('terminal log'); + expect(formatToolObservation(result)).toBe('model log'); + }); + it('accepts the public config key and rejects invalid limits', () => { + const config = { configPath: '/fixture/config.json' }; + expect(setConfigSetting(config, 'tools.maxCommandOutputChars', '4096').value).toBe(4096); + expect(() => setConfigSetting(config, 'tools.maxCommandOutputChars', '0')).toThrow('at least 1024'); + }); + + it.each([ + { type: 'run_command', live: false, exit: 0 }, + { type: 'run_command', live: false, exit: 7 }, + { type: 'shell', live: false, exit: 0 }, + { type: 'shell', live: false, exit: 7 }, + { type: 'shell', live: true, exit: 0 }, + { type: 'shell', live: true, exit: 7 }, + ] as const)('bounds $type live=$live exit=$exit and saves output beyond the UI capture limit', async ({ type, live, exit }) => { + const directory = await mkdtemp(path.join(tmpdir(), 'ah-command-model-')); directories.push(directory); + const script = path.join(directory, 'output.cjs'); + const stdout = 'HEAD\n' + 'x'.repeat(1024 * 1024 + 20000) + '\n400000\n'; + await writeFile(script, `process.stdout.write(${JSON.stringify(stdout)}, () => { process.stderr.write(${JSON.stringify('STDERR_FINAL\n')}, () => { process.exitCode = ${exit}; }); });`); + const actions = new ActionExecutor({ + runtime: { workspaceRoot: directory, config: { configPath: '' }, options: { yes: true } }, + files: new FileActionManager(directory), resolveWorkspacePath: file => path.resolve(directory, file), + confirmDangerousAction: async () => true, getCommandOutputDirectory: () => path.join(directory, 'session'), + ...(live ? { onLiveCommandStart: () => 'live', onLiveCommandOutput: vi.fn(), onLiveCommandRemove: vi.fn(), onLiveCommandFinish: vi.fn() } : {}), + }); + const outcome = await actions.executeForTool({ type, command: `"${process.execPath}" "${script}"` }, { approvalHandled: true }); + expect(outcome.success).toBe(exit === 0); + expect(outcome.output!.length).toBeGreaterThan(30000); + const model = formatToolObservation({ tool: type, ...outcome }); + expect(model.length).toBeLessThanOrEqual(30000); + expect(model).toContain('400000'); expect(model).toContain('STDERR_FINAL'); + const files = await readdir(path.join(directory, 'session', 'command-output')); + const filename = path.join(directory, 'session', 'command-output', files[0]); + const archived = await readFile(filename, 'utf8'); + expect(archived.replace('STDERR_FINAL\n', '')).toBe(stdout); + expect(model).toContain(filename); + }); + it('keeps a 20k log whole and does not create an unnecessary archive', async () => { + const directory = await mkdtemp(path.join(tmpdir(), 'ah-command-short-')); directories.push(directory); + const actions = new ActionExecutor({ runtime: { workspaceRoot: directory, config: { configPath: '' }, options: { yes: true } }, + files: new FileActionManager(directory), resolveWorkspacePath: file => path.resolve(directory, file), + confirmDangerousAction: async () => true, getCommandOutputDirectory: () => directory }); + const outcome = await actions.executeForTool({ type: 'run_command', command: `"${process.execPath}" -e 'process.stdout.write("Q".repeat(20000))'` }, { approvalHandled: true }); + expect(formatToolObservation({ tool: 'run_command', ...outcome })).toContain('Q'.repeat(20000)); + expect(await readdir(directory)).not.toContain('command-output'); + }); + it.each([ + { type: 'run_command', background: true }, + { type: 'run_command', interactive: true }, + { type: 'shell', background: true }, + ] as const)('bounds the $type observation envelope with %j', async action => { + const directory = await mkdtemp(path.join(tmpdir(), 'ah-command-envelope-')); directories.push(directory); + const actions = new ActionExecutor({ runtime: { workspaceRoot: directory, config: { configPath: '', tools: { maxCommandOutputChars: 1024 } }, options: { yes: true } }, + files: new FileActionManager(directory), resolveWorkspacePath: file => path.resolve(directory, file), + confirmDangerousAction: async () => true }); + vi.spyOn(actions as unknown as { executeLegacy: () => Promise }, 'executeLegacy') + .mockResolvedValue('$ command\n' + 'metadata'.repeat(1000)); + const outcome = await actions.executeForTool({ ...action, command: 'fixture' }, { approvalHandled: true }); + expect(outcome.success).toBe(true); + expect(outcome.output!.length).toBeGreaterThan(1024); + expect(formatToolObservation({ tool: action.type, ...outcome }).length).toBeLessThanOrEqual(1024); + }); + it('keeps streamed command chunks out of the conversation even when streaming is enabled', async () => { + vi.stubEnv('AUTOHAND_STREAM_TOOL_OUTPUT', '1'); + const appendTransient = vi.fn(); const queueToolMessageChunk = vi.fn(); + const host = { sessionManager: { getCurrentSession: () => ({ append: vi.fn(), appendTransient }) }, toolOutputQueue: Promise.resolve(), queueToolMessageChunk }; + for (const tool of ['run_command', 'shell'] as const) { + handleAgentToolOutput(host, { tool, toolCallId: 'command', data: 'unbounded', stream: 'stdout' }); + queueAgentToolMessageChunk(host, tool, 'unbounded', 'command', 'stdout'); + } + await host.toolOutputQueue; + expect(queueToolMessageChunk).not.toHaveBeenCalled(); expect(appendTransient).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/core/agent/AgentLifecycleRunner.fresh-session.test.ts b/tests/core/agent/AgentLifecycleRunner.fresh-session.test.ts index 1fbf3a78d..eac474232 100644 --- a/tests/core/agent/AgentLifecycleRunner.fresh-session.test.ts +++ b/tests/core/agent/AgentLifecycleRunner.fresh-session.test.ts @@ -313,6 +313,26 @@ describe('startFreshAgentSession', () => { }); describe('restoreAgentSessionState', () => { + it.each([false, true])('preserves visible submissions only when requested during startup resume: %s', async preserveVisibleMessages => { + const setChatMessages = vi.fn(); + const host = { + sessionManager: { loadSession: vi.fn(async () => ({ getMessages: () => [ + { role: 'user', content: 'Saved question' }, + { role: 'assistant', content: 'Saved answer' }, + ] })) }, + resetConversationContext: vi.fn(), injectSessionBootstrap: vi.fn(), injectProjectKnowledge: vi.fn(), + conversation: { addSystemNote: vi.fn(), addMessage: vi.fn(), history: () => [] }, + updateContextUsage: vi.fn(), + inkRenderer: { setChatMessages, getState: () => ({ chatMessages: [{ role: 'user', content: 'New submission' }] }) }, + }; + await restoreAgentSessionState(host, 'saved-session', { preserveVisibleMessages }); + expect(setChatMessages).toHaveBeenCalledWith([ + { role: 'user', content: 'Saved question' }, + { role: 'assistant', content: 'Saved answer' }, + ...(preserveVisibleMessages ? [{ role: 'user', content: 'New submission' }] : []), + ]); + }); + it('restores only the latest compaction checkpoint and subsequent messages', async () => { const addSystemNote = vi.fn(); const addMessage = vi.fn(); diff --git a/tests/core/agent/CodeModeRunner.test.ts b/tests/core/agent/CodeModeRunner.test.ts index 9e471d710..f358fe34b 100644 --- a/tests/core/agent/CodeModeRunner.test.ts +++ b/tests/core/agent/CodeModeRunner.test.ts @@ -329,3 +329,19 @@ describe('feature gating of the script tool', () => { }); }); + + +describe('bounded command observations through code mode', () => { + it.each([true, false])('passes the bounded command observation to the guest, success=%s', async success => { + const { host } = createHost(call => success + ? { tool: call.tool, success: true, output: 'RAW'.repeat(100000), modelOutput: 'BOUNDED command output' } + : { tool: call.tool, success: false, kind: 'command', error: 'RAW'.repeat(100000), output: 'RAW'.repeat(100000), modelOutput: 'BOUNDED command failure' }); + const result = await runCodeModeScript(host, INPUT, sandbox(async call => { + const [outcome] = await call([{ tool: 'run_command', args: { command: 'seq 1 400000' } }]); + expect(JSON.stringify(outcome)).not.toContain('RAW'); + expect(JSON.stringify(outcome)).toContain('BOUNDED'); + return { result: JSON.stringify(outcome) }; + })); + expect(result.success).toBe(true); + }); +}); diff --git a/tests/core/agent/InstructionRunner.command-mode.test.ts b/tests/core/agent/InstructionRunner.command-mode.test.ts index 67e4112ff..2d9c58d5e 100644 --- a/tests/core/agent/InstructionRunner.command-mode.test.ts +++ b/tests/core/agent/InstructionRunner.command-mode.test.ts @@ -125,6 +125,101 @@ function createHost(): AgentInstructionHost { } describe('InstructionRunner command mode UI', () => { + it.each(['finished', 'failed', 'cancelled'] as const)('uses computer auto mode and restores it when %s', async status => { + const host = createHost(); + let mode = 'default'; + const restore = vi.fn(() => { mode = 'default'; }); + const begin = vi.fn(() => { mode = 'automode'; return restore; }); + Object.assign(host, { beginComputerUseAutoMode: begin }); + host.runtimeMcpServers = [{ name: 'autohand-computer-use', transport: 'stdio', command: 'fixture-driver' }]; + host.runReactLoop = vi.fn(async controller => { + expect(mode).toBe('automode'); + if (status === 'failed') throw new Error('fixture failed'); + if (status === 'cancelled') controller.abort(); + return { status: status === 'cancelled' ? 'aborted' : 'completed' }; + }); + expect(await new InstructionRunner(host).run('open my browser')).toBe(status === 'finished'); + expect(begin).toHaveBeenCalledOnce(); + expect(restore).toHaveBeenCalledOnce(); + expect(mode).toBe('default'); + }); + + it.each(['restricted', 'dryRun'] as const)('preserves explicit %s for a computer task', async flag => { + const host = createHost(); + host.runtime.options[flag] = true; + host.runtimeMcpServers = [{ name: 'autohand-computer-use', transport: 'stdio', command: 'fixture-driver' }]; + const begin = vi.fn(() => vi.fn()); + Object.assign(host, { beginComputerUseAutoMode: begin }); + expect(await new InstructionRunner(host).run('open my browser')).toBe(true); + expect(begin).not.toHaveBeenCalled(); + }); + + it.each(['recognized intent', 'discovered action'])('preserves restricted permission configuration for %s', async kind => { + const host = createHost(); + host.permissionManager = new PermissionManager({ mode: 'restricted' }); + host.runtimeMcpServers = [{ name: 'autohand-computer-use', transport: 'stdio', command: 'fixture-driver' }]; + const begin = vi.fn(() => vi.fn()); + Object.assign(host, { beginComputerUseAutoMode: begin }); + const runner = new InstructionRunner(host); + host.runReactLoop = vi.fn(async () => { + expect(runner.activateComputerUseAutoMode()).toBe(false); + return { status: 'completed' }; + }); + expect(await runner.run(kind === 'recognized intent' ? 'open my browser' : 'inspect the fixture')).toBe(true); + expect(begin).not.toHaveBeenCalled(); + expect(host.permissionManager.getMode()).toBe('restricted'); + }); + + it('keeps temporary auto mode until provider setup retries finish', async () => { + const host = createHost(); + host.runtime.isCommandMode = false; + host.runtime.options.prompt = undefined; + host.runtimeMcpServers = [{ name: 'autohand-computer-use', transport: 'stdio', command: 'fixture-driver' }]; + const restore = vi.fn(); + const begin = vi.fn(() => restore); + Object.assign(host, { beginComputerUseAutoMode: begin }); + vi.mocked(host.providerConfigManager.promptModelSelection).mockResolvedValue(true); + vi.mocked(host.runReactLoop) + .mockRejectedValueOnce(new ProviderNotConfiguredError('openai')) + .mockImplementationOnce(async () => { + expect(begin).toHaveBeenCalledOnce(); + expect(restore).not.toHaveBeenCalled(); + return { status: 'completed' }; + }); + expect(await new InstructionRunner(host).run('open my browser')).toBe(true); + expect(host.runReactLoop).toHaveBeenCalledTimes(2); + expect(begin).toHaveBeenCalledOnce(); + expect(restore).toHaveBeenCalledOnce(); + }); + + it('enters auto mode once for a native action discovered during an ordinary turn', async () => { + const host = createHost(); + const restore = vi.fn(); + const begin = vi.fn(() => restore); + Object.assign(host, { beginComputerUseAutoMode: begin }); + const runner = new InstructionRunner(host); + host.runReactLoop = vi.fn(async () => { + runner.activateComputerUseAutoMode(); + runner.activateComputerUseAutoMode(); + expect(begin).toHaveBeenCalledOnce(); + return { status: 'completed' }; + }); + expect(await runner.run('inspect the current state')).toBe(true); + expect(restore).toHaveBeenCalledOnce(); + runner.activateComputerUseAutoMode(); + expect(begin).toHaveBeenCalledOnce(); + }); + + it('does not enter computer auto mode for source work or absent native control', async () => { + const host = createHost(); + const begin = vi.fn(() => vi.fn()); + Object.assign(host, { beginComputerUseAutoMode: begin }); + const runner = new InstructionRunner(host); + expect(await runner.run('build a browser extension')).toBe(true); + expect(await runner.run('open my browser')).toBe(true); + expect(begin).not.toHaveBeenCalled(); + }); + it('captures host learning baselines before work and finishes without waiting for extraction', async () => { const host = createHost(); const sequence: string[] = []; @@ -282,10 +377,14 @@ describe('InstructionRunner command mode UI', () => { it('runs pre-prompt before side effects and respects hook denial', async () => { const host = createHost(); + host.runtimeMcpServers = [{ name: 'autohand-computer-use', transport: 'stdio', command: 'fixture-driver' }]; + const begin = vi.fn(() => vi.fn()); + Object.assign(host, { beginComputerUseAutoMode: begin }); host.hookManager = new HookManager({ workspaceRoot: '/tmp', settings: { hooks: [{ event: 'pre-prompt', command: `printf '%s' '{"decision":"block","reason":"PROMPT_BLOCKED"}'`, }] } }); - expect(await new InstructionRunner(host).run('blocked prompt')).toBe(false); + expect(await new InstructionRunner(host).run('open my browser')).toBe(false); + expect(begin).not.toHaveBeenCalled(); expect(host.buildTurnContext).not.toHaveBeenCalled(); expect(host.runReactLoop).not.toHaveBeenCalled(); expect(host.runEnvironmentBootstrap).not.toHaveBeenCalled(); diff --git a/tests/core/agent/InteractionModeController.test.ts b/tests/core/agent/InteractionModeController.test.ts index 842afebe5..a4bc3f78a 100644 --- a/tests/core/agent/InteractionModeController.test.ts +++ b/tests/core/agent/InteractionModeController.test.ts @@ -8,9 +8,54 @@ import { describe, expect, it, vi } from 'vitest'; import { InteractionModeController, INTERACTION_MODE_SEQUENCE, + type InteractionMode, } from '../../../src/core/agent/InteractionModeController.js'; describe('InteractionModeController', () => { + function createController(initial: InteractionMode) { + const state = { plan: false, yolo: false, automode: false, profile: 'baseline' }; + const controller = new InteractionModeController({ + isPlanEnabled: () => state.plan, + isYoloEnabled: () => state.yolo, + isAutomodeEnabled: () => state.automode, + setPlanEnabled: enabled => { state.plan = enabled; }, + setYoloEnabled: enabled => { state.yolo = enabled; }, + setAutomodeEnabled: enabled => { state.automode = enabled; }, + setPermissionProfile: profile => { state.profile = profile; }, + }); + controller.setMode(initial); + return { controller, state }; + } + + it.each(INTERACTION_MODE_SEQUENCE)('restores %s after temporary computer auto mode', previous => { + const { controller, state } = createController(previous); + const restore = controller.enterTemporaryMode('automode'); + expect(controller.getMode()).toBe('automode'); + expect(state.profile).toBe('unrestricted'); + restore(); + restore(); + expect(controller.getMode()).toBe(previous); + expect(state.profile).toBe(previous === 'automode' || previous === 'yolo' ? 'unrestricted' : 'baseline'); + }); + + it.each(INTERACTION_MODE_SEQUENCE)('preserves an explicit %s selection during computer auto mode', selected => { + const { controller } = createController('default'); + const restore = controller.enterTemporaryMode('automode'); + controller.setMode(selected); + restore(); + expect(controller.getMode()).toBe(selected); + }); + + it('keeps an outer temporary mode active through a nested turn', () => { + const { controller } = createController('plan'); + const restoreOuter = controller.enterTemporaryMode('automode'); + const restoreInner = controller.enterTemporaryMode('automode'); + restoreInner(); + expect(controller.getMode()).toBe('automode'); + restoreOuter(); + expect(controller.getMode()).toBe('plan'); + }); + it('cycles default, plan, automode, yolo, then back to default', () => { const state = { plan: false, diff --git a/tests/core/agent/ReactLoopRunnerStatus.test.ts b/tests/core/agent/ReactLoopRunnerStatus.test.ts index c9ea61284..445133552 100644 --- a/tests/core/agent/ReactLoopRunnerStatus.test.ts +++ b/tests/core/agent/ReactLoopRunnerStatus.test.ts @@ -43,6 +43,32 @@ import { ReactionParser } from '../../../src/core/agent/ReactionParser.js'; import { PromptCacheToolSet } from '../../../src/core/agent/PromptCache.js'; describe('ReactLoopRunner composer status', () => { + it('publishes one confirmed reply only after the required completion review approves it', async () => { + const complete = vi.fn().mockResolvedValueOnce({ content: JSON.stringify({ toolCalls: [ + { tool: 'run_command', args: { command: 'seq 1 400000' } }, + ] }) }).mockResolvedValue({ content: '400000' }); + const host = createReactLoopTestHost(complete, new ReactionParser()); + host.runtime.commandOutputCaptured = true; + host.toolManager.execute = vi.fn().mockResolvedValue([{ tool: 'run_command', success: true, output: '400000' }]); + const pending = Promise.withResolvers<{ verdict: 'approved'; feedback: string }>(); + const review = vi.fn(() => pending.promise); + host.createCheckpointAdvisor = () => ({ observeFailure: () => false, review }); + host.captureAdvisorDiff = async () => 'No workspace changes'; + const turn = runAgentReactLoop(host, new AbortController()); + try { + await vi.waitFor(() => expect(review).toHaveBeenCalledOnce()); + expect(vi.mocked(host.emitOutput).mock.calls.filter(([event]) => event.type === 'message')).toEqual([]); + pending.resolve({ verdict: 'approved', feedback: 'Command evidence verified.' }); + expect(await turn).toEqual({ status: 'completed' }); + const messages = vi.mocked(host.emitOutput).mock.calls.filter(([event]) => event.type === 'message'); + expect(messages.map(([event]) => event.content)).toEqual(['400000']); + expect(review).toHaveBeenCalledWith('completion', expect.any(Object)); + } finally { + pending.resolve({ verdict: 'approved', feedback: 'Command evidence verified.' }); + await turn; + } + }); + it('refuses an oversized prepared request before spending a provider call', async () => { const complete = vi.fn().mockResolvedValue({ id: 'too-large', created: 1, content: 'Done.', raw: {} }); const host = createReactLoopTestHost(complete, new ReactionParser()); diff --git a/tests/core/agent/SystemPromptBuilder.test.ts b/tests/core/agent/SystemPromptBuilder.test.ts index 91ea46a81..d4fb44781 100644 --- a/tests/core/agent/SystemPromptBuilder.test.ts +++ b/tests/core/agent/SystemPromptBuilder.test.ts @@ -346,7 +346,7 @@ describe('SystemPromptBuilder', () => { expect(prompt).toContain('canonical memory event history'); }); - it('keeps the full skill catalog for terminal (non-ACP) sessions', async () => { + it('preserves an explicit request for the full skill catalog', async () => { const prompt = await createBuilder({ compactSkillIndex: false, listSkills: vi.fn(() => [{ name: 'alpha-docs', description: 'Documentation helper' }]), @@ -355,6 +355,21 @@ describe('SystemPromptBuilder', () => { expect(prompt).toContain('- **alpha-docs**: Documentation helper'); }); + it('defaults to names-only discovery in CLI sessions while preserving active skill instructions', async () => { + const description = 'CATALOG_DESCRIPTION '.repeat(1000); + const prompt = await createBuilder({ + listSkills: () => [ + { name: 'zeta-review', description }, + { name: 'alpha-docs', description, isActive: true }, + ], + getActiveSkills: () => [{ name: 'alpha-docs', description, body: 'MANDATORY_ACTIVE_SKILL_RULE' }], + }).build(); + expect(prompt).toContain('alpha-docs [ACTIVE], zeta-review'); + expect(prompt).toContain('command `info`'); + expect(prompt).toContain('MANDATORY_ACTIVE_SKILL_RULE'); + expect(prompt).not.toContain('CATALOG_DESCRIPTION'); + }); + it('keeps adaptive discovery independent of catalog size and active skill text', async () => { const listSkills = vi.fn(() => Array.from({ length: 10_000 }, (_, index) => ({ name: `private-skill-${index}`, description: 'Private catalog instructions'.repeat(20) }))); const getActiveSkills = vi.fn(() => [{ name: 'private-active', description: 'Selected', body: 'Full active instructions live in task context.' }]); diff --git a/tests/core/agent/resumeSessionFreeze.test.ts b/tests/core/agent/resumeSessionFreeze.test.ts index 52d6b38b0..66a1a33e3 100644 --- a/tests/core/agent/resumeSessionFreeze.test.ts +++ b/tests/core/agent/resumeSessionFreeze.test.ts @@ -98,6 +98,90 @@ function makeResumeHost() { afterEach(() => vi.restoreAllMocks()); describe('resumeAgentSession freeze fix', () => { + it('mounts input before manager initialization and waits before restoring or executing', async () => { + const initializing = Promise.withResolvers(); + const original = makeResumeHost(); + const host = { + ...original, + useInkRenderer: true, + inkRenderer: undefined as typeof original.inkRenderer | undefined, + initializeUI: vi.fn(async () => { host.inkRenderer = original.inkRenderer; }), + setComposerIdle: vi.fn(), + }; + host.initializeManagers.mockImplementation(async () => { + expect(host.inkRenderer).toBe(original.inkRenderer); + expect(host.setComposerIdle).toHaveBeenCalled(); + await initializing.promise; + }); + vi.spyOn(console, 'log').mockImplementation(() => {}); + const resume = resumeAgentSession(host, 'sess-123'); + try { + await vi.waitFor(() => expect(host.initializeManagers).toHaveBeenCalledOnce()); + expect(host.installExitSignalHandlers).toHaveBeenCalledOnce(); + expect(host.restoreSessionState).not.toHaveBeenCalled(); + expect(host.runInteractiveLoop).not.toHaveBeenCalled(); + } finally { + initializing.resolve(); + await resume; + } + expect(host.initializeUI).toHaveBeenCalledOnce(); + expect(host.removeExitSignalHandlers).toHaveBeenCalledOnce(); + }); + + it('releases signal handlers when manager initialization fails after mounting input', async () => { + const host = makeResumeHost(); + host.initializeManagers.mockRejectedValue(new Error('manager unavailable')); + await expect(resumeAgentSession(host, 'sess-123')).rejects.toThrow('manager unavailable'); + expect(host.installExitSignalHandlers).toHaveBeenCalledOnce(); + expect(host.removeExitSignalHandlers).toHaveBeenCalledOnce(); + expect(host.sessionManager.createSession).not.toHaveBeenCalled(); + }); + + it('does not restore or execute after the user exits during manager initialization', async () => { + const host = makeResumeHost(); + host.initializeManagers.mockImplementation(async () => { host.shouldExit = true; }); + vi.spyOn(console, 'log').mockImplementation(() => {}); + await resumeAgentSession(host, 'sess-123'); + expect(host.restoreSessionState).not.toHaveBeenCalled(); + expect(host.runInteractiveLoop).not.toHaveBeenCalled(); + expect(host.removeExitSignalHandlers).toHaveBeenCalledOnce(); + }); + + it('does not create a fallback session when restoration fails after the user exits', async () => { + const host = makeResumeHost(); + host.restoreSessionState.mockImplementation(async () => { + host.shouldExit = true; + throw new Error('restore interrupted'); + }); + vi.spyOn(console, 'error').mockImplementation(() => {}); + await resumeAgentSession(host, 'sess-123'); + expect(host.sessionManager.createSession).not.toHaveBeenCalled(); + expect(host.runInteractiveLoop).not.toHaveBeenCalled(); + expect(host.removeExitSignalHandlers).toHaveBeenCalledOnce(); + }); + + it('keeps an existing composer editable while the saved session is loading', async () => { + const restoring = Promise.withResolvers(); + const host = { ...makeResumeHost(), useInkRenderer: true, setComposerIdle: vi.fn() }; + host.restoreSessionState.mockImplementation(async () => { + await restoring.promise; + return { getMessages: () => [], metadata: { model: 'test-model', sessionId: 'sess-123' } }; + }); + vi.spyOn(console, 'log').mockImplementation(() => {}); + + const resume = resumeAgentSession(host, 'sess-123'); + try { + await vi.waitFor(() => expect(host.restoreSessionState).toHaveBeenCalledOnce()); + expect(host.setComposerIdle).toHaveBeenCalled(); + expect(host.runInteractiveLoop).not.toHaveBeenCalled(); + expect(host.sessionManager.createSession).not.toHaveBeenCalled(); + } finally { + restoring.resolve(); + await resume; + } + expect(host.runInteractiveLoop).toHaveBeenCalledOnce(); + }); + it('sets initDone and initReady so ensureInitComplete does not hang', async () => { const host = makeResumeHost(); const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); @@ -225,4 +309,4 @@ describe('resumeAgentSession freeze fix', () => { expect(host.mcpStartupCoordinator.markConnectStarted).not.toHaveBeenCalled(); expect(host.removeExitSignalHandlers).toHaveBeenCalledTimes(1); }); -}); \ No newline at end of file +}); diff --git a/tests/mcp/squadBuiltin.test.ts b/tests/mcp/squadBuiltin.test.ts new file mode 100644 index 000000000..2e30e1cb9 --- /dev/null +++ b/tests/mcp/squadBuiltin.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it, vi } from 'vitest'; +import { createRpcHandler, createTools } from '../../src/squad/vendor/runtime.mjs'; +import { ensureSquadMcpServer } from '../../src/squad/builtin.js'; +import type { LoadedConfig } from '../../src/types.js'; + +describe('built-in Squad MCP', () => { + it('advertises and calls the bundled profile, directory, channel and routine tools', async () => { + const get = vi.fn(async (path: string) => path.startsWith('/api/control-plane/') ? { id: 'kai', revision: 7 } : {}); + const patch = vi.fn(async (_path: string, body?: Record) => body); + const tools = createTools({ get, patch, post: vi.fn(), put: vi.fn() }); + const handle = createRpcHandler(tools); + const listed = await handle({ jsonrpc: '2.0', id: 1, method: 'tools/list', params: {} }); + expect(listed).toEqual(expect.objectContaining({ result: expect.objectContaining({ tools: expect.arrayContaining(['get_member_profile', 'add_member_directory', 'send_channel_message', 'set_member_skills', 'run_routine'].map(name => expect.objectContaining({ name }))) }) })); + await tools.update_member_profile({ member_id: 'kai', personality: 'Precise' }); + expect(patch).toHaveBeenCalledWith('/api/control-plane/members/kai', { personality: 'Precise', expectedRevision: 7 }); + }); + + it('adds a runtime connection using this installed Node CLI without persisting config', () => { + const config: LoadedConfig = { configPath: '/fixture/config.json' }; + expect(ensureSquadMcpServer(config, { env: {}, execPath: '/runtime/node', entryPath: '/package/dist/index.js' })).toEqual(expect.objectContaining({ name: 'autohand-squad', command: '/runtime/node', args: ['/package/dist/index.js', 'squad', 'mcp'], autoConnect: true })); + expect(config.mcp).toBeUndefined(); + }); + + it('launches a compiled binary directly, without passing its virtual entrypoint', () => { + expect(ensureSquadMcpServer({ configPath: '/fixture/config.json' }, { env: {}, execPath: '/usr/local/bin/autohand', entryPath: '/$bunfs/root/autohand', compiled: true })?.args).toEqual(['squad', 'mcp']); + }); + + it('honors disabled, bare, offline and managed-member boundaries', () => { + for (const options of [{ bare: true }, { offline: true }, { env: { AUTOHAND_DISABLE_SQUAD: '1' } }, { env: { AUTOHAND_SQUAD_TEAM_TOKEN: 'private-member-token' } }]) { + expect(ensureSquadMcpServer({ configPath: '/fixture/config.json' }, options)).toBeUndefined(); + } + expect(ensureSquadMcpServer({ configPath: '/fixture/config.json', mcp: { enabled: false } }, { env: {} })).toBeUndefined(); + expect(ensureSquadMcpServer({ configPath: '/fixture/config.json', mcp: { servers: [{ name: 'autohand_squad', transport: 'stdio', command: 'managed-proxy' }] } }, { env: {} })).toBeUndefined(); + }); +}); diff --git a/tests/mcp/stdioConnectionEnv.test.ts b/tests/mcp/stdioConnectionEnv.test.ts index 83a323423..ac7d79338 100644 --- a/tests/mcp/stdioConnectionEnv.test.ts +++ b/tests/mcp/stdioConnectionEnv.test.ts @@ -6,7 +6,7 @@ import { EventEmitter } from 'node:events'; import { PassThrough } from 'node:stream'; import type { ChildProcess } from 'node:child_process'; -import { afterEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; vi.mock('node:child_process', () => ({ spawn: vi.fn(() => { @@ -18,10 +18,16 @@ vi.mock('node:child_process', () => ({ child.stdin.destroy(); child.emit('close', 0); }; + queueMicrotask(() => child.emit('spawn')); return child; }), })); +beforeEach(async () => { + const { spawn } = await import('node:child_process'); + vi.mocked(spawn).mockReset(); +}); + describe('MCP stdio server environment', () => { afterEach(async () => { vi.restoreAllMocks(); @@ -40,6 +46,7 @@ describe('MCP stdio server environment', () => { const child = new EventEmitter() as ChildProcess; Object.assign(child, { stdin: new PassThrough(), stdout: new PassThrough(), stderr: new PassThrough(), kill: () => { child.emit('close', 125); return true; } }); + queueMicrotask(() => child.emit('spawn')); child.stdin!.on('data', () => { child.stderr!.emit('data', Buffer.from('docker: Head ghcr.io/github/github-mcp-server/manifests/latest: denied: denied')); child.emit('close', 125); @@ -82,3 +89,184 @@ describe('MCP stdio server environment', () => { } }); }); + +describe('MCP stdio startup and response framing', () => { + it('settles startup on the child spawn event without an artificial timer', async () => { + vi.useFakeTimers(); + const baseline = vi.getTimerCount(); + const { spawn } = await import('node:child_process'); + const { McpStdioConnection } = await import('../../src/mcp/McpClientManager.js'); + const connection = new McpStdioConnection({ name: 'fixture', transport: 'stdio', command: 'fixture' }, 'newline'); + let started = false; + try { + const startup = connection.start().then(() => { started = true; }); + await vi.advanceTimersByTimeAsync(0); + expect(started).toBe(true); + await startup; + expect(vi.getTimerCount()).toBe(baseline); + expect(vi.mocked(spawn).mock.results.at(-1)?.value.listenerCount('spawn')).toBe(0); + } finally { + await connection.stop(); + expect(vi.getTimerCount()).toBe(baseline); + vi.useRealTimers(); + } + }); + + it('rejects a child closing before spawn and detaches the startup listener', async () => { + const { spawn } = await import('node:child_process'); + const { McpStdioConnection } = await import('../../src/mcp/McpClientManager.js'); + const connection = new McpStdioConnection({ name: 'fixture', transport: 'stdio', command: 'fixture' }, 'newline'); + const startup = connection.start(); + const failure = expect(startup).rejects.toThrow('before spawning'); + const child = vi.mocked(spawn).mock.results.at(-1)?.value; + child.emit('close', 0); + await failure; + expect(child.listenerCount('spawn')).toBe(0); + await connection.stop(); + }); + + it('detaches the startup listener after a spawn error', async () => { + const { spawn } = await import('node:child_process'); + const { McpStdioConnection } = await import('../../src/mcp/McpClientManager.js'); + vi.mocked(spawn).mockImplementationOnce(() => { + const child = new EventEmitter() as ChildProcess; + Object.assign(child, { stdin: new PassThrough(), stdout: new PassThrough(), stderr: new PassThrough(), + kill: () => { child.emit('close', 1); return true; } }); + return child; + }); + const connection = new McpStdioConnection({ name: 'fixture', transport: 'stdio', command: 'fixture' }, 'newline'); + connection.on('error', () => {}); + const startup = connection.start(); + const failure = expect(startup).rejects.toThrow('fixture spawn failed'); + const child = vi.mocked(spawn).mock.results.at(-1)?.value; + child.emit('error', new Error('fixture spawn failed')); + await failure; + expect(child.listenerCount('spawn')).toBe(0); + await connection.stop(); + }); + + it('preserves a Unicode app title when its UTF-8 bytes span stdout chunks', async () => { + const { spawn } = await import('node:child_process'); + const { McpStdioConnection } = await import('../../src/mcp/McpClientManager.js'); + const connection = new McpStdioConnection({ name: 'fixture', transport: 'stdio', command: 'fixture' }, 'newline'); + await connection.start(); + try { + const response = connection.request('tools/call'); + const child = vi.mocked(spawn).mock.results.at(-1)?.value; + const title = '音乐 🎵 — café'; + const frame = Buffer.from(JSON.stringify({ jsonrpc: '2.0', id: 1, result: { title } }) + '\n'); + const split = frame.indexOf(Buffer.from('🎵')) + 1; + child.stdout.emit('data', frame.subarray(0, split)); + child.stdout.emit('data', frame.subarray(split)); + await expect(response).resolves.toEqual({ title }); + } finally { + await connection.stop(); + } + }); + + it.each(['unterminated', 'newline-terminated'] as const)('closes an oversized %s frame and settles pending resources', async framing => { + vi.useFakeTimers(); + const baseline = vi.getTimerCount(); + const frameLimitBytes = 64 * 1024 * 1024; + const { spawn } = await import('node:child_process'); + const { McpStdioConnection } = await import('../../src/mcp/McpClientManager.js'); + const connection = new McpStdioConnection({ name: 'fixture', transport: 'stdio', command: 'fixture' }, 'newline'); + const errorListener = vi.fn(); + connection.on('error', errorListener); + const startup = connection.start(); + await vi.advanceTimersByTimeAsync(0); + await startup; + const controller = new AbortController(); + const removeAbortListener = vi.spyOn(controller.signal, 'removeEventListener'); + const pending = [ + connection.request('tools/call', undefined, { signal: controller.signal }).catch(error => error), + connection.request('tools/list').catch(error => error), + ]; + const child = vi.mocked(spawn).mock.results.at(-1)?.value; + const kill = vi.spyOn(child, 'kill'); + const observer = vi.fn(); + child.stdout.on('data', observer); + try { + if (framing === 'unterminated') { + const chunk = Buffer.alloc(64 * 1024, 0x20); + for (let bytes = 0; bytes < frameLimitBytes; bytes += chunk.length) child.stdout.emit('data', chunk); + expect(kill).not.toHaveBeenCalled(); + child.stdout.emit('data', Buffer.from('x')); + } else { + const oversized = Buffer.alloc(frameLimitBytes + 2, 0x20); + oversized[oversized.length - 1] = 0x0a; + child.stdout.emit('data', oversized); + } + expect(kill).toHaveBeenCalledOnce(); + await connection.stop(); + for (const error of await Promise.all(pending)) { + expect(error).toBeInstanceOf(Error); + expect(error.message).toMatch(/frame.*64 MiB/i); + } + expect(errorListener).toHaveBeenCalledOnce(); + expect(removeAbortListener).toHaveBeenCalledWith('abort', expect.any(Function)); + expect(child.stdout.listenerCount('data')).toBe(1); + expect(child.stderr.listenerCount('data')).toBe(0); + expect(child.listenerCount('close')).toBe(0); + expect(child.listenerCount('error')).toBe(0); + expect(child.stdin.listenerCount('error')).toBe(0); + expect(child.stdin.listenerCount('close')).toBe(0); + expect(vi.getTimerCount()).toBe(baseline); + await expect(connection.request('tools/list')).rejects.toThrow(/frame|not connected/i); + } finally { + child.stdout.off('data', observer); + await connection.stop(); + await Promise.all(pending); + vi.useRealTimers(); + } + }); + + it('accepts a fragmented frame at the byte limit and resets the next frame budget', async () => { + const frameLimitBytes = 64 * 1024 * 1024; + const { spawn } = await import('node:child_process'); + const { McpStdioConnection } = await import('../../src/mcp/McpClientManager.js'); + const connection = new McpStdioConnection({ name: 'fixture', transport: 'stdio', command: 'fixture' }, 'newline'); + await connection.start(); + try { + const first = connection.request('tools/call'); + const second = connection.request('tools/list'); + const child = vi.mocked(spawn).mock.results.at(-1)?.value; + const prefix = Buffer.from(JSON.stringify({ jsonrpc: '2.0', id: 1, result: '音乐 🎵 — café' })); + child.stdout.emit('data', prefix.subarray(0, prefix.indexOf(Buffer.from('🎵')) + 1)); + child.stdout.emit('data', prefix.subarray(prefix.indexOf(Buffer.from('🎵')) + 1)); + const padding = Buffer.alloc(64 * 1024, 0x20); + for (let bytes = prefix.length; bytes < frameLimitBytes; bytes += padding.length) { + child.stdout.emit('data', padding.subarray(0, Math.min(padding.length, frameLimitBytes - bytes))); + } + child.stdout.emit('data', Buffer.from('\n' + JSON.stringify({ jsonrpc: '2.0', id: 2, result: 'next' }) + '\n')); + await expect(first).resolves.toBe('音乐 🎵 — café'); + await expect(second).resolves.toBe('next'); + } finally { + await connection.stop(); + } + }); + + it('handles a fragmented large image followed by coalesced replies and CRLF', async () => { + const { spawn } = await import('node:child_process'); + const { McpStdioConnection } = await import('../../src/mcp/McpClientManager.js'); + const connection = new McpStdioConnection({ name: 'fixture', transport: 'stdio', command: 'fixture' }, 'newline'); + await connection.start(); + try { + const first = connection.request('tools/call'); + const second = connection.request('tools/call'); + const third = connection.request('tools/call'); + const child = vi.mocked(spawn).mock.results.at(-1)?.value; + const result = { content: [{ type: 'image', data: 'a'.repeat(2 * 1024 * 1024), mimeType: 'image/png' }] }; + const frame = Buffer.from(JSON.stringify({ jsonrpc: '2.0', id: 1, result }) + '\r\n'); + for (let offset = 0; offset < frame.length; offset += 4096) { + child.stdout.emit('data', frame.subarray(offset, offset + 4096)); + } + child.stdout.emit('data', Buffer.from('\nserver log\n' + JSON.stringify({ id: 2, result: 'two' }) + '\n' + JSON.stringify({ id: 3, result: '三' }) + '\n')); + await expect(first).resolves.toEqual(result); + await expect(second).resolves.toBe('two'); + await expect(third).resolves.toBe('三'); + } finally { + await connection.stop(); + } + }); +}); diff --git a/tests/runtime/CliRuntimeResourceOwner.test.ts b/tests/runtime/CliRuntimeResourceOwner.test.ts index efc616785..acce0fd6b 100644 --- a/tests/runtime/CliRuntimeResourceOwner.test.ts +++ b/tests/runtime/CliRuntimeResourceOwner.test.ts @@ -48,6 +48,17 @@ function makeService() { } describe('CliRuntimeResourceOwner', () => { + it('owns an opted-out service without starting it, so /sync can enable it later', async () => { + const service = makeService(); const register = vi.fn(); + const owner = new CliRuntimeResourceOwner({ process: new TestProcess(), stopPing: vi.fn(), setSyncService: register, onSignal: vi.fn() }); + owner.startBackgroundStartup({ resolveAuthAndVersion: async () => ({ authUser: { id: 'user' }, versionResult: null }), + onVersionResult: vi.fn(), shouldCreateSync: () => true, shouldStartSync: () => false, createSyncService: async () => service }); + await vi.waitFor(() => expect(register).toHaveBeenCalledWith(service)); + expect(service.start).not.toHaveBeenCalled(); + await owner.shutdown(); + expect(service.shutdown).toHaveBeenCalled(); + }); + it('never starts background work when disabled by the launch policy', async () => { const stopPing = vi.fn(); const startPing = vi.fn(); diff --git a/tests/session/SessionManager.test.ts b/tests/session/SessionManager.test.ts index 2c7f27d78..b718a58ca 100644 --- a/tests/session/SessionManager.test.ts +++ b/tests/session/SessionManager.test.ts @@ -391,6 +391,21 @@ describe('SessionManager', () => { expect(await manager.getLastSession('/empty')).toBeNull(); }); + it('continues a conversation instead of a newer empty session, within the current project', async () => { + const manager = new SessionManager(tmpDir); + const conversation = await manager.createSession('/project', 'test-model'); + await conversation.append({ role: 'user', content: 'Remember BANANA-42', timestamp: new Date().toISOString() }); + Object.assign(conversation.metadata, { lastActiveAt: '2026-01-01' }); + await conversation.save(); + const empty = await manager.createSession('/project', 'test-model'); + Object.assign(empty.metadata, { lastActiveAt: '2026-01-03' }); + await empty.save(); + const elsewhere = await manager.createSession('/other', 'test-model'); + await elsewhere.append({ role: 'user', content: 'Other conversation', timestamp: new Date().toISOString() }); + expect((await manager.getLastSession('/project'))?.sessionId).toBe(conversation.metadata.sessionId); + expect((await manager.getLastSession('/other'))?.sessionId).toBe(elsewhere.metadata.sessionId); + }); + it('loads only the newest indexed metadata page for the resume picker', async () => { const projectPath = '/workspace/recent-sessions'; const entries = [ diff --git a/tests/slashCommandHandler.spec.ts b/tests/slashCommandHandler.spec.ts index 0fb12fe36..cfa8433eb 100644 --- a/tests/slashCommandHandler.spec.ts +++ b/tests/slashCommandHandler.spec.ts @@ -444,7 +444,7 @@ describe('SlashCommandHandler', () => { expect(result).toBe('Autohand Squad is ready.'); expect(mockSquad).toHaveBeenCalledWith( - { workspaceRoot: '/tmp/workspace', config: ctx.config }, + expect.objectContaining({ workspaceRoot: '/tmp/workspace', config: ctx.config, squadConnection: expect.anything() }), ['--port', '19999'], ); }); diff --git a/tests/startup/__snapshots__/cli-options.json b/tests/startup/__snapshots__/cli-options.json new file mode 100644 index 000000000..bd5e69258 --- /dev/null +++ b/tests/startup/__snapshots__/cli-options.json @@ -0,0 +1,2034 @@ +[ + { + "command": "autohand", + "options": [ + { + "flags": "--about", + "required": false, + "optional": false, + "variadic": false, + "negate": false, + "hasDefault": true, + "defaultValue": false + }, + { + "flags": "--acp", + "required": false, + "optional": false, + "variadic": false, + "negate": false, + "hasDefault": true, + "defaultValue": false + }, + { + "flags": "--add-dir ", + "required": true, + "optional": false, + "variadic": true, + "negate": false, + "hasDefault": false, + "defaultValue": null + }, + { + "flags": "--agent ", + "required": true, + "optional": false, + "variadic": false, + "negate": false, + "hasDefault": false, + "defaultValue": null + }, + { + "flags": "--agents ", + "required": true, + "optional": false, + "variadic": false, + "negate": false, + "hasDefault": false, + "defaultValue": null + }, + { + "flags": "--allowed-tools ", + "required": true, + "optional": false, + "variadic": false, + "negate": false, + "hasDefault": false, + "defaultValue": null + }, + { + "flags": "--answer-only", + "required": false, + "optional": false, + "variadic": false, + "negate": false, + "hasDefault": true, + "defaultValue": false + }, + { + "flags": "--append-sys-prompt ", + "required": true, + "optional": false, + "variadic": false, + "negate": false, + "hasDefault": false, + "defaultValue": null + }, + { + "flags": "--append-system-prompt ", + "required": true, + "optional": false, + "variadic": false, + "negate": false, + "hasDefault": false, + "defaultValue": null + }, + { + "flags": "--append-system-prompt-file ", + "required": true, + "optional": false, + "variadic": false, + "negate": false, + "hasDefault": false, + "defaultValue": null + }, + { + "flags": "--auto-commit", + "required": false, + "optional": false, + "variadic": false, + "negate": false, + "hasDefault": true, + "defaultValue": false + }, + { + "flags": "--auto-mode [prompt]", + "required": false, + "optional": true, + "variadic": false, + "negate": false, + "hasDefault": false, + "defaultValue": null + }, + { + "flags": "--auto-skill", + "required": false, + "optional": false, + "variadic": false, + "negate": false, + "hasDefault": true, + "defaultValue": false + }, + { + "flags": "--bare", + "required": false, + "optional": false, + "variadic": false, + "negate": false, + "hasDefault": true, + "defaultValue": false + }, + { + "flags": "--browser", + "required": false, + "optional": false, + "variadic": false, + "negate": false, + "hasDefault": false, + "defaultValue": null + }, + { + "flags": "--cc, --context-compact", + "required": false, + "optional": false, + "variadic": false, + "negate": false, + "hasDefault": false, + "defaultValue": null + }, + { + "flags": "--checkpoint-interval ", + "required": true, + "optional": false, + "variadic": false, + "negate": false, + "hasDefault": false, + "defaultValue": null + }, + { + "flags": "--chrome", + "required": false, + "optional": false, + "variadic": false, + "negate": false, + "hasDefault": false, + "defaultValue": null + }, + { + "flags": "--client-context ", + "required": true, + "optional": false, + "variadic": false, + "negate": false, + "hasDefault": false, + "defaultValue": null + }, + { + "flags": "--cloud", + "required": false, + "optional": false, + "variadic": false, + "negate": false, + "hasDefault": true, + "defaultValue": false + }, + { + "flags": "--cloud-account ", + "required": true, + "optional": false, + "variadic": false, + "negate": false, + "hasDefault": false, + "defaultValue": null + }, + { + "flags": "--cloud-branch ", + "required": true, + "optional": false, + "variadic": false, + "negate": false, + "hasDefault": false, + "defaultValue": null + }, + { + "flags": "--cloud-connector ", + "required": true, + "optional": false, + "variadic": false, + "negate": false, + "hasDefault": false, + "defaultValue": null + }, + { + "flags": "--cloud-provider ", + "required": true, + "optional": false, + "variadic": false, + "negate": false, + "hasDefault": false, + "defaultValue": null + }, + { + "flags": "--cloud-repository ", + "required": true, + "optional": false, + "variadic": false, + "negate": false, + "hasDefault": false, + "defaultValue": null + }, + { + "flags": "--completion-promise ", + "required": true, + "optional": false, + "variadic": false, + "negate": false, + "hasDefault": false, + "defaultValue": null + }, + { + "flags": "--config ", + "required": true, + "optional": false, + "variadic": false, + "negate": false, + "hasDefault": false, + "defaultValue": null + }, + { + "flags": "--dir ", + "required": true, + "optional": false, + "variadic": false, + "negate": false, + "hasDefault": false, + "defaultValue": null + }, + { + "flags": "--disallowed-tools ", + "required": true, + "optional": false, + "variadic": false, + "negate": false, + "hasDefault": false, + "defaultValue": null + }, + { + "flags": "--display-language ", + "required": true, + "optional": false, + "variadic": false, + "negate": false, + "hasDefault": false, + "defaultValue": null + }, + { + "flags": "--dry-run", + "required": false, + "optional": false, + "variadic": false, + "negate": false, + "hasDefault": true, + "defaultValue": false + }, + { + "flags": "--ephemeral", + "required": false, + "optional": false, + "variadic": false, + "negate": false, + "hasDefault": true, + "defaultValue": false + }, + { + "flags": "--feedback", + "required": false, + "optional": false, + "variadic": false, + "negate": false, + "hasDefault": true, + "defaultValue": false + }, + { + "flags": "--fork ", + "required": true, + "optional": false, + "variadic": false, + "negate": false, + "hasDefault": false, + "defaultValue": null + }, + { + "flags": "--goal [input]", + "required": false, + "optional": true, + "variadic": false, + "negate": false, + "hasDefault": false, + "defaultValue": null + }, + { + "flags": "--interactive-on-complete", + "required": false, + "optional": false, + "variadic": false, + "negate": false, + "hasDefault": true, + "defaultValue": false + }, + { + "flags": "--json [mode]", + "required": false, + "optional": true, + "variadic": false, + "negate": false, + "hasDefault": false, + "defaultValue": null + }, + { + "flags": "--lead-session ", + "required": true, + "optional": false, + "variadic": false, + "negate": false, + "hasDefault": false, + "defaultValue": null + }, + { + "flags": "--learn", + "required": false, + "optional": false, + "variadic": false, + "negate": false, + "hasDefault": true, + "defaultValue": false + }, + { + "flags": "--learn-update", + "required": false, + "optional": false, + "variadic": false, + "negate": false, + "hasDefault": true, + "defaultValue": false + }, + { + "flags": "--login", + "required": false, + "optional": false, + "variadic": false, + "negate": false, + "hasDefault": true, + "defaultValue": false + }, + { + "flags": "--logout", + "required": false, + "optional": false, + "variadic": false, + "negate": false, + "hasDefault": true, + "defaultValue": false + }, + { + "flags": "--max-cost ", + "required": true, + "optional": false, + "variadic": false, + "negate": false, + "hasDefault": false, + "defaultValue": null + }, + { + "flags": "--max-duration ", + "required": true, + "optional": false, + "variadic": false, + "negate": false, + "hasDefault": false, + "defaultValue": null + }, + { + "flags": "--max-iterations ", + "required": true, + "optional": false, + "variadic": false, + "negate": false, + "hasDefault": false, + "defaultValue": null + }, + { + "flags": "--max-requests ", + "required": true, + "optional": false, + "variadic": false, + "negate": false, + "hasDefault": false, + "defaultValue": null + }, + { + "flags": "--max-runtime ", + "required": true, + "optional": false, + "variadic": false, + "negate": false, + "hasDefault": false, + "defaultValue": null + }, + { + "flags": "--max-tokens ", + "required": true, + "optional": false, + "variadic": false, + "negate": false, + "hasDefault": false, + "defaultValue": null + }, + { + "flags": "--mcp-config ", + "required": true, + "optional": false, + "variadic": false, + "negate": false, + "hasDefault": false, + "defaultValue": null + }, + { + "flags": "--mode ", + "required": true, + "optional": false, + "variadic": false, + "negate": false, + "hasDefault": true, + "defaultValue": "interactive" + }, + { + "flags": "--model ", + "required": true, + "optional": false, + "variadic": false, + "negate": false, + "hasDefault": false, + "defaultValue": null + }, + { + "flags": "--name ", + "required": true, + "optional": false, + "variadic": false, + "negate": false, + "hasDefault": false, + "defaultValue": null + }, + { + "flags": "--no-browser", + "required": false, + "optional": false, + "variadic": false, + "negate": true, + "hasDefault": false, + "defaultValue": null + }, + { + "flags": "--no-cc, --no-context-compact", + "required": false, + "optional": false, + "variadic": false, + "negate": true, + "hasDefault": false, + "defaultValue": null + }, + { + "flags": "--no-chrome", + "required": false, + "optional": false, + "variadic": false, + "negate": true, + "hasDefault": false, + "defaultValue": null + }, + { + "flags": "--no-daemon", + "required": false, + "optional": false, + "variadic": false, + "negate": true, + "hasDefault": false, + "defaultValue": null + }, + { + "flags": "--no-idle-logout", + "required": false, + "optional": false, + "variadic": false, + "negate": true, + "hasDefault": false, + "defaultValue": null + }, + { + "flags": "--no-worktree", + "required": false, + "optional": false, + "variadic": false, + "negate": true, + "hasDefault": false, + "defaultValue": null + }, + { + "flags": "--offline", + "required": false, + "optional": false, + "variadic": false, + "negate": false, + "hasDefault": true, + "defaultValue": false + }, + { + "flags": "--output ", + "required": true, + "optional": false, + "variadic": false, + "negate": false, + "hasDefault": false, + "defaultValue": null + }, + { + "flags": "--output-format ", + "required": true, + "optional": false, + "variadic": false, + "negate": false, + "hasDefault": false, + "defaultValue": null + }, + { + "flags": "--output-schema ", + "required": true, + "optional": false, + "variadic": false, + "negate": false, + "hasDefault": false, + "defaultValue": null + }, + { + "flags": "--patch", + "required": false, + "optional": false, + "variadic": false, + "negate": false, + "hasDefault": true, + "defaultValue": false + }, + { + "flags": "--path ", + "required": true, + "optional": false, + "variadic": false, + "negate": false, + "hasDefault": false, + "defaultValue": null + }, + { + "flags": "--permissions", + "required": false, + "optional": false, + "variadic": false, + "negate": false, + "hasDefault": true, + "defaultValue": false + }, + { + "flags": "--plan", + "required": false, + "optional": false, + "variadic": false, + "negate": false, + "hasDefault": false, + "defaultValue": null + }, + { + "flags": "--plugin-dir ", + "required": true, + "optional": false, + "variadic": false, + "negate": false, + "hasDefault": false, + "defaultValue": null + }, + { + "flags": "--profile ", + "required": true, + "optional": false, + "variadic": false, + "negate": false, + "hasDefault": false, + "defaultValue": null + }, + { + "flags": "--project", + "required": false, + "optional": false, + "variadic": false, + "negate": false, + "hasDefault": true, + "defaultValue": false + }, + { + "flags": "--provider ", + "required": true, + "optional": false, + "variadic": false, + "negate": false, + "hasDefault": false, + "defaultValue": null + }, + { + "flags": "--rename ", + "required": true, + "optional": false, + "variadic": false, + "negate": false, + "hasDefault": false, + "defaultValue": null + }, + { + "flags": "--restricted", + "required": false, + "optional": false, + "variadic": false, + "negate": false, + "hasDefault": true, + "defaultValue": false + }, + { + "flags": "--sdk-resume-last", + "required": false, + "optional": false, + "variadic": false, + "negate": false, + "hasDefault": false, + "defaultValue": null + }, + { + "flags": "--sdk-session-reference ", + "required": true, + "optional": false, + "variadic": false, + "negate": false, + "hasDefault": false, + "defaultValue": null + }, + { + "flags": "--sdk-settings-version ", + "required": true, + "optional": false, + "variadic": false, + "negate": false, + "hasDefault": false, + "defaultValue": null + }, + { + "flags": "--search-engine ", + "required": true, + "optional": false, + "variadic": false, + "negate": false, + "hasDefault": false, + "defaultValue": null + }, + { + "flags": "--set ", + "required": true, + "optional": false, + "variadic": false, + "negate": false, + "hasDefault": false, + "defaultValue": null + }, + { + "flags": "--settings", + "required": false, + "optional": false, + "variadic": false, + "negate": false, + "hasDefault": true, + "defaultValue": false + }, + { + "flags": "--setup", + "required": false, + "optional": false, + "variadic": false, + "negate": false, + "hasDefault": true, + "defaultValue": false + }, + { + "flags": "--setup-only", + "required": false, + "optional": false, + "variadic": false, + "negate": false, + "hasDefault": true, + "defaultValue": false + }, + { + "flags": "--skill-install [skill-name]", + "required": false, + "optional": true, + "variadic": false, + "negate": false, + "hasDefault": false, + "defaultValue": null + }, + { + "flags": "--sync-settings [bool]", + "required": false, + "optional": true, + "variadic": false, + "negate": false, + "hasDefault": false, + "defaultValue": null + }, + { + "flags": "--sys-prompt ", + "required": true, + "optional": false, + "variadic": false, + "negate": false, + "hasDefault": false, + "defaultValue": null + }, + { + "flags": "--system-prompt ", + "required": true, + "optional": false, + "variadic": false, + "negate": false, + "hasDefault": false, + "defaultValue": null + }, + { + "flags": "--system-prompt-file ", + "required": true, + "optional": false, + "variadic": false, + "negate": false, + "hasDefault": false, + "defaultValue": null + }, + { + "flags": "--team ", + "required": true, + "optional": false, + "variadic": false, + "negate": false, + "hasDefault": false, + "defaultValue": null + }, + { + "flags": "--teammate-mode ", + "required": true, + "optional": false, + "variadic": false, + "negate": false, + "hasDefault": false, + "defaultValue": null + }, + { + "flags": "--temperature ", + "required": true, + "optional": false, + "variadic": false, + "negate": false, + "hasDefault": false, + "defaultValue": null + }, + { + "flags": "--thinking [level]", + "required": false, + "optional": true, + "variadic": false, + "negate": false, + "hasDefault": false, + "defaultValue": null + }, + { + "flags": "--timeout ", + "required": true, + "optional": false, + "variadic": false, + "negate": false, + "hasDefault": false, + "defaultValue": null + }, + { + "flags": "--tmux", + "required": false, + "optional": false, + "variadic": false, + "negate": false, + "hasDefault": false, + "defaultValue": null + }, + { + "flags": "--traces-off", + "required": false, + "optional": false, + "variadic": false, + "negate": false, + "hasDefault": false, + "defaultValue": null + }, + { + "flags": "--traces-on", + "required": false, + "optional": false, + "variadic": false, + "negate": false, + "hasDefault": false, + "defaultValue": null + }, + { + "flags": "--unrestricted", + "required": false, + "optional": false, + "variadic": false, + "negate": false, + "hasDefault": true, + "defaultValue": false + }, + { + "flags": "--worktree [name]", + "required": false, + "optional": true, + "variadic": false, + "negate": false, + "hasDefault": false, + "defaultValue": null + }, + { + "flags": "--y", + "required": false, + "optional": false, + "variadic": false, + "negate": false, + "hasDefault": true, + "defaultValue": false + }, + { + "flags": "--yolo [pattern]", + "required": false, + "optional": true, + "variadic": false, + "negate": false, + "hasDefault": false, + "defaultValue": null + }, + { + "flags": "--zit [intent]", + "required": false, + "optional": true, + "variadic": false, + "negate": false, + "hasDefault": false, + "defaultValue": null + }, + { + "flags": "-c, --continue", + "required": false, + "optional": false, + "variadic": false, + "negate": false, + "hasDefault": true, + "defaultValue": false + }, + { + "flags": "-d, --debug", + "required": false, + "optional": false, + "variadic": false, + "negate": false, + "hasDefault": true, + "defaultValue": false + }, + { + "flags": "-p, --prompt [text]", + "required": false, + "optional": true, + "variadic": false, + "negate": false, + "hasDefault": false, + "defaultValue": null + }, + { + "flags": "-r, --resume [reference]", + "required": false, + "optional": true, + "variadic": false, + "negate": false, + "hasDefault": false, + "defaultValue": null + }, + { + "flags": "-v, --version", + "required": false, + "optional": false, + "variadic": false, + "negate": false, + "hasDefault": false, + "defaultValue": null + }, + { + "flags": "-y, --yes", + "required": false, + "optional": false, + "variadic": false, + "negate": false, + "hasDefault": true, + "defaultValue": false + } + ] + }, + { + "command": "autohand account", + "options": [] + }, + { + "command": "autohand agents", + "options": [ + { + "flags": "--once", + "required": false, + "optional": false, + "variadic": false, + "negate": false, + "hasDefault": false, + "defaultValue": null + } + ] + }, + { + "command": "autohand auto-research", + "options": [] + }, + { + "command": "autohand browser", + "options": [] + }, + { + "command": "autohand browser install", + "options": [ + { + "flags": "--browser ", + "required": true, + "optional": false, + "variadic": false, + "negate": false, + "hasDefault": true, + "defaultValue": "all" + }, + { + "flags": "--cli-path ", + "required": true, + "optional": false, + "variadic": false, + "negate": false, + "hasDefault": false, + "defaultValue": null + }, + { + "flags": "--extension-id ", + "required": true, + "optional": false, + "variadic": false, + "negate": false, + "hasDefault": false, + "defaultValue": null + }, + { + "flags": "--install-url ", + "required": true, + "optional": false, + "variadic": false, + "negate": false, + "hasDefault": true, + "defaultValue": "https://autohand.ai/chrome/installed" + }, + { + "flags": "--open", + "required": false, + "optional": false, + "variadic": false, + "negate": false, + "hasDefault": true, + "defaultValue": false + } + ] + }, + { + "command": "autohand chrome", + "options": [] + }, + { + "command": "autohand chrome install", + "options": [ + { + "flags": "--browser ", + "required": true, + "optional": false, + "variadic": false, + "negate": false, + "hasDefault": true, + "defaultValue": "all" + }, + { + "flags": "--cli-path ", + "required": true, + "optional": false, + "variadic": false, + "negate": false, + "hasDefault": false, + "defaultValue": null + }, + { + "flags": "--extension-id ", + "required": true, + "optional": false, + "variadic": false, + "negate": false, + "hasDefault": false, + "defaultValue": null + }, + { + "flags": "--install-url ", + "required": true, + "optional": false, + "variadic": false, + "negate": false, + "hasDefault": true, + "defaultValue": "https://autohand.ai/chrome/installed" + }, + { + "flags": "--open", + "required": false, + "optional": false, + "variadic": false, + "negate": false, + "hasDefault": true, + "defaultValue": false + } + ] + }, + { + "command": "autohand completion", + "options": [] + }, + { + "command": "autohand computer", + "options": [ + { + "flags": "--json", + "required": false, + "optional": false, + "variadic": false, + "negate": false, + "hasDefault": true, + "defaultValue": false + } + ] + }, + { + "command": "autohand computer doctor", + "options": [ + { + "flags": "--json", + "required": false, + "optional": false, + "variadic": false, + "negate": false, + "hasDefault": true, + "defaultValue": false + } + ] + }, + { + "command": "autohand computer install", + "options": [ + { + "flags": "--bin-dir ", + "required": true, + "optional": false, + "variadic": false, + "negate": false, + "hasDefault": false, + "defaultValue": null + }, + { + "flags": "--force", + "required": false, + "optional": false, + "variadic": false, + "negate": false, + "hasDefault": true, + "defaultValue": false + }, + { + "flags": "--json", + "required": false, + "optional": false, + "variadic": false, + "negate": false, + "hasDefault": true, + "defaultValue": false + }, + { + "flags": "--non-interactive", + "required": false, + "optional": false, + "variadic": false, + "negate": false, + "hasDefault": true, + "defaultValue": false + }, + { + "flags": "--postinstall", + "required": false, + "optional": false, + "variadic": false, + "negate": false, + "hasDefault": false, + "defaultValue": null + } + ] + }, + { + "command": "autohand computer status", + "options": [ + { + "flags": "--json", + "required": false, + "optional": false, + "variadic": false, + "negate": false, + "hasDefault": true, + "defaultValue": false + } + ] + }, + { + "command": "autohand config", + "options": [] + }, + { + "command": "autohand config set", + "options": [] + }, + { + "command": "autohand discovery", + "options": [ + { + "flags": "--agent ", + "required": true, + "optional": false, + "variadic": false, + "negate": false, + "hasDefault": false, + "defaultValue": null + }, + { + "flags": "--analyze", + "required": false, + "optional": false, + "variadic": false, + "negate": false, + "hasDefault": false, + "defaultValue": null + }, + { + "flags": "--depth <0|1|2>", + "required": true, + "optional": false, + "variadic": false, + "negate": false, + "hasDefault": true, + "defaultValue": "2" + }, + { + "flags": "--dry-run", + "required": false, + "optional": false, + "variadic": false, + "negate": false, + "hasDefault": false, + "defaultValue": null + }, + { + "flags": "--github", + "required": false, + "optional": false, + "variadic": false, + "negate": false, + "hasDefault": false, + "defaultValue": null + }, + { + "flags": "--json", + "required": false, + "optional": false, + "variadic": false, + "negate": false, + "hasDefault": false, + "defaultValue": null + }, + { + "flags": "--no-behavior", + "required": false, + "optional": false, + "variadic": false, + "negate": true, + "hasDefault": false, + "defaultValue": null + }, + { + "flags": "--output ", + "required": true, + "optional": false, + "variadic": false, + "negate": false, + "hasDefault": false, + "defaultValue": null + }, + { + "flags": "--push", + "required": false, + "optional": false, + "variadic": false, + "negate": false, + "hasDefault": false, + "defaultValue": null + }, + { + "flags": "--select ", + "required": true, + "optional": false, + "variadic": false, + "negate": false, + "hasDefault": false, + "defaultValue": null + }, + { + "flags": "--since ", + "required": true, + "optional": false, + "variadic": false, + "negate": false, + "hasDefault": true, + "defaultValue": "30d" + }, + { + "flags": "--skill-query ", + "required": true, + "optional": false, + "variadic": false, + "negate": false, + "hasDefault": false, + "defaultValue": null + }, + { + "flags": "--with-report", + "required": false, + "optional": false, + "variadic": false, + "negate": false, + "hasDefault": false, + "defaultValue": null + }, + { + "flags": "--workspace ", + "required": true, + "optional": false, + "variadic": false, + "negate": false, + "hasDefault": false, + "defaultValue": null + } + ] + }, + { + "command": "autohand doctor", + "options": [ + { + "flags": "--config ", + "required": true, + "optional": false, + "variadic": false, + "negate": false, + "hasDefault": false, + "defaultValue": null + }, + { + "flags": "--json", + "required": false, + "optional": false, + "variadic": false, + "negate": false, + "hasDefault": true, + "defaultValue": false + }, + { + "flags": "--path ", + "required": true, + "optional": false, + "variadic": false, + "negate": false, + "hasDefault": false, + "defaultValue": null + }, + { + "flags": "--skip-mcp", + "required": false, + "optional": false, + "variadic": false, + "negate": false, + "hasDefault": true, + "defaultValue": false + } + ] + }, + { + "command": "autohand experiments", + "options": [] + }, + { + "command": "autohand experiments disable", + "options": [] + }, + { + "command": "autohand experiments enable", + "options": [] + }, + { + "command": "autohand experiments list", + "options": [] + }, + { + "command": "autohand experiments refresh", + "options": [] + }, + { + "command": "autohand experiments status", + "options": [] + }, + { + "command": "autohand extensions", + "options": [] + }, + { + "command": "autohand extensions disable", + "options": [ + { + "flags": "--scope ", + "required": true, + "optional": false, + "variadic": false, + "negate": false, + "hasDefault": true, + "defaultValue": "user" + } + ] + }, + { + "command": "autohand extensions doctor", + "options": [ + { + "flags": "--json", + "required": false, + "optional": false, + "variadic": false, + "negate": false, + "hasDefault": true, + "defaultValue": false + } + ] + }, + { + "command": "autohand extensions enable", + "options": [ + { + "flags": "--scope ", + "required": true, + "optional": false, + "variadic": false, + "negate": false, + "hasDefault": true, + "defaultValue": "user" + } + ] + }, + { + "command": "autohand extensions install", + "options": [ + { + "flags": "--link", + "required": false, + "optional": false, + "variadic": false, + "negate": false, + "hasDefault": true, + "defaultValue": false + }, + { + "flags": "--replace", + "required": false, + "optional": false, + "variadic": false, + "negate": false, + "hasDefault": true, + "defaultValue": false + }, + { + "flags": "--scope ", + "required": true, + "optional": false, + "variadic": false, + "negate": false, + "hasDefault": true, + "defaultValue": "user" + }, + { + "flags": "--trust", + "required": false, + "optional": false, + "variadic": false, + "negate": false, + "hasDefault": true, + "defaultValue": false + } + ] + }, + { + "command": "autohand extensions list", + "options": [ + { + "flags": "--json", + "required": false, + "optional": false, + "variadic": false, + "negate": false, + "hasDefault": true, + "defaultValue": false + }, + { + "flags": "--scope ", + "required": true, + "optional": false, + "variadic": false, + "negate": false, + "hasDefault": false, + "defaultValue": null + } + ] + }, + { + "command": "autohand extensions remove", + "options": [ + { + "flags": "--scope ", + "required": true, + "optional": false, + "variadic": false, + "negate": false, + "hasDefault": true, + "defaultValue": "user" + }, + { + "flags": "--yes", + "required": false, + "optional": false, + "variadic": false, + "negate": false, + "hasDefault": true, + "defaultValue": false + } + ] + }, + { + "command": "autohand extensions show", + "options": [ + { + "flags": "--json", + "required": false, + "optional": false, + "variadic": false, + "negate": false, + "hasDefault": true, + "defaultValue": false + }, + { + "flags": "--scope ", + "required": true, + "optional": false, + "variadic": false, + "negate": false, + "hasDefault": false, + "defaultValue": null + } + ] + }, + { + "command": "autohand extensions validate", + "options": [ + { + "flags": "--json", + "required": false, + "optional": false, + "variadic": false, + "negate": false, + "hasDefault": true, + "defaultValue": false + } + ] + }, + { + "command": "autohand import", + "options": [ + { + "flags": "--all", + "required": false, + "optional": false, + "variadic": false, + "negate": false, + "hasDefault": false, + "defaultValue": null + }, + { + "flags": "--categories ", + "required": true, + "optional": false, + "variadic": false, + "negate": false, + "hasDefault": false, + "defaultValue": null + }, + { + "flags": "--dry-run", + "required": false, + "optional": false, + "variadic": false, + "negate": false, + "hasDefault": false, + "defaultValue": null + }, + { + "flags": "--retry-failed", + "required": false, + "optional": false, + "variadic": false, + "negate": false, + "hasDefault": false, + "defaultValue": null + } + ] + }, + { + "command": "autohand init", + "options": [ + { + "flags": "--path ", + "required": true, + "optional": false, + "variadic": false, + "negate": false, + "hasDefault": false, + "defaultValue": null + } + ] + }, + { + "command": "autohand login", + "options": [] + }, + { + "command": "autohand logout", + "options": [] + }, + { + "command": "autohand mcp", + "options": [] + }, + { + "command": "autohand mcp add", + "options": [ + { + "flags": "-s, --scope ", + "required": true, + "optional": false, + "variadic": false, + "negate": false, + "hasDefault": true, + "defaultValue": "user" + }, + { + "flags": "-t, --transport ", + "required": true, + "optional": false, + "variadic": false, + "negate": false, + "hasDefault": true, + "defaultValue": "stdio" + } + ] + }, + { + "command": "autohand mcp connect", + "options": [ + { + "flags": "-s, --scope ", + "required": true, + "optional": false, + "variadic": false, + "negate": false, + "hasDefault": true, + "defaultValue": "user" + } + ] + }, + { + "command": "autohand mcp disconnect", + "options": [ + { + "flags": "-s, --scope ", + "required": true, + "optional": false, + "variadic": false, + "negate": false, + "hasDefault": true, + "defaultValue": "user" + } + ] + }, + { + "command": "autohand mcp install", + "options": [ + { + "flags": "-s, --scope ", + "required": true, + "optional": false, + "variadic": false, + "negate": false, + "hasDefault": true, + "defaultValue": "user" + } + ] + }, + { + "command": "autohand mcp list", + "options": [ + { + "flags": "-s, --scope ", + "required": true, + "optional": false, + "variadic": false, + "negate": false, + "hasDefault": true, + "defaultValue": "user" + } + ] + }, + { + "command": "autohand mcp remove", + "options": [ + { + "flags": "-s, --scope ", + "required": true, + "optional": false, + "variadic": false, + "negate": false, + "hasDefault": true, + "defaultValue": "user" + } + ] + }, + { + "command": "autohand queue", + "options": [] + }, + { + "command": "autohand resume", + "options": [ + { + "flags": "--all", + "required": false, + "optional": false, + "variadic": false, + "negate": false, + "hasDefault": false, + "defaultValue": null + }, + { + "flags": "--config ", + "required": true, + "optional": false, + "variadic": false, + "negate": false, + "hasDefault": false, + "defaultValue": null + }, + { + "flags": "--last", + "required": false, + "optional": false, + "variadic": false, + "negate": false, + "hasDefault": false, + "defaultValue": null + }, + { + "flags": "--model ", + "required": true, + "optional": false, + "variadic": false, + "negate": false, + "hasDefault": false, + "defaultValue": null + }, + { + "flags": "--offline", + "required": false, + "optional": false, + "variadic": false, + "negate": false, + "hasDefault": false, + "defaultValue": null + }, + { + "flags": "--path ", + "required": true, + "optional": false, + "variadic": false, + "negate": false, + "hasDefault": false, + "defaultValue": null + } + ] + }, + { + "command": "autohand review", + "options": [ + { + "flags": "--audience ", + "required": true, + "optional": false, + "variadic": false, + "negate": false, + "hasDefault": false, + "defaultValue": null + }, + { + "flags": "--bare", + "required": false, + "optional": false, + "variadic": false, + "negate": false, + "hasDefault": true, + "defaultValue": false + }, + { + "flags": "--base ", + "required": true, + "optional": false, + "variadic": false, + "negate": false, + "hasDefault": false, + "defaultValue": null + }, + { + "flags": "--config ", + "required": true, + "optional": false, + "variadic": false, + "negate": false, + "hasDefault": false, + "defaultValue": null + }, + { + "flags": "--focus ", + "required": true, + "optional": false, + "variadic": false, + "negate": false, + "hasDefault": false, + "defaultValue": null + }, + { + "flags": "--format ", + "required": true, + "optional": false, + "variadic": false, + "negate": false, + "hasDefault": false, + "defaultValue": null + }, + { + "flags": "--head ", + "required": true, + "optional": false, + "variadic": false, + "negate": false, + "hasDefault": false, + "defaultValue": null + }, + { + "flags": "--json [mode]", + "required": false, + "optional": true, + "variadic": false, + "negate": false, + "hasDefault": false, + "defaultValue": null + }, + { + "flags": "--model ", + "required": true, + "optional": false, + "variadic": false, + "negate": false, + "hasDefault": false, + "defaultValue": null + }, + { + "flags": "--no-open", + "required": false, + "optional": false, + "variadic": false, + "negate": true, + "hasDefault": false, + "defaultValue": null + }, + { + "flags": "--offline", + "required": false, + "optional": false, + "variadic": false, + "negate": false, + "hasDefault": true, + "defaultValue": false + }, + { + "flags": "--output-format ", + "required": true, + "optional": false, + "variadic": false, + "negate": false, + "hasDefault": false, + "defaultValue": null + }, + { + "flags": "--path ", + "required": true, + "optional": false, + "variadic": false, + "negate": false, + "hasDefault": false, + "defaultValue": null + }, + { + "flags": "--port ", + "required": true, + "optional": false, + "variadic": false, + "negate": false, + "hasDefault": true, + "defaultValue": 0 + } + ] + }, + { + "command": "autohand sessions", + "options": [ + { + "flags": "--project ", + "required": true, + "optional": false, + "variadic": false, + "negate": false, + "hasDefault": false, + "defaultValue": null + } + ] + }, + { + "command": "autohand squad", + "options": [] + }, + { + "command": "autohand traces", + "options": [ + { + "flags": "--json", + "required": false, + "optional": false, + "variadic": false, + "negate": false, + "hasDefault": false, + "defaultValue": null + } + ] + }, + { + "command": "autohand transfer", + "options": [ + { + "flags": "--accept", + "required": false, + "optional": false, + "variadic": false, + "negate": false, + "hasDefault": false, + "defaultValue": null + }, + { + "flags": "--account ", + "required": true, + "optional": false, + "variadic": false, + "negate": false, + "hasDefault": false, + "defaultValue": null + }, + { + "flags": "--config ", + "required": true, + "optional": false, + "variadic": false, + "negate": false, + "hasDefault": false, + "defaultValue": null + }, + { + "flags": "--import-only", + "required": false, + "optional": false, + "variadic": false, + "negate": false, + "hasDefault": false, + "defaultValue": null + }, + { + "flags": "--offline", + "required": false, + "optional": false, + "variadic": false, + "negate": false, + "hasDefault": true, + "defaultValue": false + }, + { + "flags": "--path ", + "required": true, + "optional": false, + "variadic": false, + "negate": false, + "hasDefault": false, + "defaultValue": null + } + ] + }, + { + "command": "autohand update", + "options": [ + { + "flags": "--check", + "required": false, + "optional": false, + "variadic": false, + "negate": false, + "hasDefault": false, + "defaultValue": null + }, + { + "flags": "--models", + "required": false, + "optional": false, + "variadic": false, + "negate": false, + "hasDefault": false, + "defaultValue": null + } + ] + }, + { + "command": "autohand upgrade", + "options": [ + { + "flags": "--check", + "required": false, + "optional": false, + "variadic": false, + "negate": false, + "hasDefault": false, + "defaultValue": null + }, + { + "flags": "--models", + "required": false, + "optional": false, + "variadic": false, + "negate": false, + "hasDefault": false, + "defaultValue": null + } + ] + } +] diff --git a/tests/startup/cliOptions.test.ts b/tests/startup/cliOptions.test.ts index 67a765530..697b7013e 100644 --- a/tests/startup/cliOptions.test.ts +++ b/tests/startup/cliOptions.test.ts @@ -10,6 +10,7 @@ import { normalizePromptAndProtocolOptions, normalizeSearchEngineOption, normalizeTmuxWorktreeOption, + validateZitOption, type RootCliOptions, } from '../../src/startup/cliOptions.js'; @@ -145,15 +146,18 @@ describe('CLI option normalization', () => { expect(Reflect.has(options, 'chrome')).toBe(false); }); - it('defaults tmux sessions to worktree isolation and rejects an explicit opt-out', () => { - const defaults: RootCliOptions = { tmux: true }; - const conflict: RootCliOptions = { tmux: true, worktree: false }; + it.each([undefined, false, true, 'chosen-worktree'])('keeps tmux independent of workspace isolation %s', worktree => { + const options: RootCliOptions = { tmux: true, worktree }; + expect(normalizeTmuxWorktreeOption(options)).toBeNull(); + expect(options.worktree).toBe(worktree); + }); - expect(normalizeTmuxWorktreeOption(defaults)).toBeNull(); - expect(defaults.worktree).toBe(true); - expect(normalizeTmuxWorktreeOption(conflict)).toBe( - '--tmux cannot be used with --no-worktree', - ); + it('supports a Zit workspace inside tmux without implicitly adding a git worktree', () => { + const options: RootCliOptions = { tmux: true, zit: true }; + expect(validateZitOption(options)).toBeNull(); + expect(normalizeTmuxWorktreeOption(options)).toBeNull(); + expect(options.worktree).toBeUndefined(); + expect(validateZitOption(options)).toBeNull(); }); it('maps context compaction and canonicalizes search providers', () => { diff --git a/tests/startup/cliOptionsSnapshot.test.ts b/tests/startup/cliOptionsSnapshot.test.ts new file mode 100644 index 000000000..70daf4f4d --- /dev/null +++ b/tests/startup/cliOptionsSnapshot.test.ts @@ -0,0 +1,196 @@ +/** + * @license + * Copyright 2026 Autohand AI LLC + * SPDX-License-Identifier: Apache-2.0 + */ +import { describe, expect, it } from 'vitest'; +import { Command, Option } from 'commander'; +import { readFile, mkdir, writeFile, mkdtemp, rm } from 'node:fs/promises'; +import { execFileSync } from 'node:child_process'; +import os from 'node:os'; +import path from 'node:path'; +import { isDeepStrictEqual } from 'node:util'; +import { fileURLToPath } from 'node:url'; + +interface OptionSurface { + flags: string; + required: boolean; + optional: boolean; + variadic: boolean; + negate: boolean; + hasDefault: boolean; + defaultValue: unknown; +} + +interface CommandSurface { + command: string; + options: OptionSurface[]; +} + +const snapshotUrl = new URL('./__snapshots__/cli-options.json', import.meta.url); +const snapshotPath = 'tests/startup/__snapshots__/cli-options.json'; + +function readMainSnapshot(directory: string): CommandSurface[] | null { + const git = (...args: string[]) => execFileSync('git', args, { cwd: directory, encoding: 'utf8', stdio: 'pipe' }); + let entry: string; + try { + entry = git('ls-tree', '--name-only', 'origin/main', '--', snapshotPath); + } catch (cause) { + throw new Error('CLI option compatibility requires origin/main. Run git fetch origin main before testing.', { cause }); + } + return entry.trim() ? JSON.parse(git('show', `origin/main:${snapshotPath}`)) : null; +} + +function captureOptions(program: Command, parents: string[] = []): CommandSurface[] { + const names = [...parents, program.name()]; + const options = program.options.map(option => ({ + flags: option.flags, + required: option.required, + optional: option.optional, + variadic: option.variadic, + negate: option.negate, + hasDefault: option.defaultValue !== undefined, + defaultValue: option.defaultValue === undefined ? null : option.defaultValue, + })).sort((left, right) => left.flags < right.flags ? -1 : left.flags > right.flags ? 1 : 0); + return [ + { command: names.join(' '), options }, + ...program.commands.flatMap(command => captureOptions(command, names)), + ].sort((left, right) => left.command < right.command ? -1 : left.command > right.command ? 1 : 0); +} + +function compatibilityErrors(previous: CommandSurface[], current: CommandSurface[]): string[] { + const errors: string[] = []; + for (const command of previous) { + const actualCommand = current.find(candidate => candidate.command === command.command); + if (!actualCommand) { + errors.push(`Removed command: ${command.command}`); + continue; + } + for (const option of command.options) { + const actualOption = actualCommand.options.find(candidate => candidate.flags === option.flags); + if (!actualOption) errors.push(`Removed option: ${command.command} ${option.flags}`); + else if (!isDeepStrictEqual(actualOption, option)) errors.push(`Changed option: ${command.command} ${option.flags}`); + } + } + return errors; +} + +function fixture(): Command { + const program = new Command('autohand') + .option('-d, --debug', 'Debug logging', false) + .option('--mode ', 'Runtime mode', 'interactive'); + program.command('sessions').command('list') + .option('--all', 'Include all sessions', false); + program.command('login'); + return program; +} + +describe('CLI option surface compatibility', () => { + it('matches the real entrypoint and only permits additive snapshot updates', async () => { + const { program } = await import('../../src/index.js'); + expect(program).toBeInstanceOf(Command); + const current = captureOptions(program); + const mainSnapshot = readMainSnapshot(fileURLToPath(new URL('../../', import.meta.url))); + expect(compatibilityErrors(mainSnapshot ?? [], current)).toEqual([]); + if (process.env.UPDATE_CLI_OPTIONS_SNAPSHOT === '1') { + if (process.env.CI) throw new Error('Update the CLI option snapshot locally, never in CI.'); + let previous: CommandSurface[] = []; + try { + previous = JSON.parse(await readFile(snapshotUrl, 'utf8')); + } catch (error) { + if (!(error instanceof Error) || !('code' in error) || error.code !== 'ENOENT') throw error; + } + const errors = compatibilityErrors(previous, current); + if (errors.length) throw new Error(errors.join('\n')); + await mkdir(new URL('./__snapshots__/', import.meta.url), { recursive: true }); + await writeFile(snapshotUrl, JSON.stringify(current, null, 2) + '\n'); + } + const expected: CommandSurface[] = JSON.parse(await readFile(snapshotUrl, 'utf8')); + expect(compatibilityErrors(expected, current)).toEqual([]); + expect(current).toEqual(expected); + }, 30_000); + + it('captures hidden flags, negation, variadic arity and nested commands with no options', () => { + const program = fixture().addOption(new Option('--internal ').hideHelp()) + .option('--no-browser').option('--add-dir '); + const snapshot = captureOptions(program); + const root = snapshot.find(command => command.command === 'autohand'); + expect(root?.options).toEqual(expect.arrayContaining([ + expect.objectContaining({ flags: '--internal ', required: true, optional: false }), + expect.objectContaining({ flags: '--no-browser', negate: true, hasDefault: false }), + expect.objectContaining({ flags: '--add-dir ', variadic: true }), + ])); + expect(snapshot).toContainEqual({ command: 'autohand login', options: [] }); + expect(snapshot.find(command => command.command === 'autohand sessions list')?.options) + .toEqual([expect.objectContaining({ flags: '--all', defaultValue: false, hasDefault: true })]); + }); + + it('permits new options and commands without weakening existing contracts', () => { + const previous = captureOptions(fixture()); + const next = fixture().option('--print'); + next.commands[0].commands[0].option('--limit '); + next.command('purge').option('--dry-run'); + expect(compatibilityErrors(previous, captureOptions(next))).toEqual([]); + expect(captureOptions(next)).not.toEqual(previous); + }); + + it('rejects removal of root flags, nested flags, and commands with no options', () => { + const previous = captureOptions(fixture()); + const next = fixture(); + next.options.shift(); + next.commands[0].commands[0].options.pop(); + next.commands.pop(); + expect(compatibilityErrors(previous, captureOptions(next))).toEqual([ + 'Removed option: autohand -d, --debug', + 'Removed command: autohand login', + 'Removed option: autohand sessions list --all', + ]); + }); + + it.each([ + { required: true }, { optional: true }, { variadic: true }, { negate: true }, + { defaultValue: true }, { defaultValue: undefined }, { defaultValue: null }, + ])('rejects changing an existing option contract: %j', change => { + const previous = captureOptions(fixture()); + const next = fixture(); + Object.assign(next.options[0], change); + expect(compatibilityErrors(previous, captureOptions(next))).toEqual(['Changed option: autohand -d, --debug']); + }); + + it('rejects changing an existing flag spelling', () => { + const previous = captureOptions(fixture()); + const next = fixture(); + next.options[0].flags = '-d, --debug [filter]'; + expect(compatibilityErrors(previous, captureOptions(next))).toEqual(['Removed option: autohand -d, --debug']); + }); + + it('checks committed main so manually editing the golden cannot approve a regression', async () => { + const directory = await mkdtemp(path.join(os.tmpdir(), 'autohand-option-snapshot-')); + const git = (...args: string[]) => execFileSync('git', args, { cwd: directory, encoding: 'utf8', stdio: 'pipe' }); + const commit = () => git('-c', 'user.name=Option snapshot test', '-c', 'user.email=test@example.com', + '-c', 'commit.gpgsign=false', 'commit', '--allow-empty', '-m', 'Option fixture'); + try { + git('init'); + commit(); + git('update-ref', 'refs/remotes/origin/main', 'HEAD'); + expect(readMainSnapshot(directory)).toBeNull(); + const original = captureOptions(fixture()); + const file = path.join(directory, 'tests/startup/__snapshots__/cli-options.json'); + await mkdir(path.dirname(file), { recursive: true }); + await writeFile(file, JSON.stringify(original)); + git('add', '.'); + commit(); + git('update-ref', 'refs/remotes/origin/main', 'HEAD'); + const changed = fixture(); + changed.options[0].optional = true; + const rewrittenGolden = captureOptions(changed); + await writeFile(file, JSON.stringify(rewrittenGolden)); + expect(compatibilityErrors(readMainSnapshot(directory) ?? [], rewrittenGolden)) + .toEqual(['Changed option: autohand -d, --debug']); + git('update-ref', '-d', 'refs/remotes/origin/main'); + expect(() => readMainSnapshot(directory)).toThrow('git fetch origin main'); + } finally { + await rm(directory, { recursive: true, force: true }); + } + }); +}); diff --git a/tests/sync/SyncService.test.ts b/tests/sync/SyncService.test.ts index d0bcd1d2f..2f2b6674e 100644 --- a/tests/sync/SyncService.test.ts +++ b/tests/sync/SyncService.test.ts @@ -208,6 +208,32 @@ describe('SyncService', () => { }); }); + it('restarts immediately after a paused in-flight operation settles when opted back in', async () => { + const pendingManifest = Promise.withResolvers(); + const service = createSyncService({ authToken: 'test-token', userId: 'test-user', + config: { enabled: true, interval: 60000 }, apiClient: mockApiClient }); + (service as unknown as { basePath: string }).basePath = tempDir; + vi.spyOn(service as unknown as { syncCodingAgentControlPlane: () => Promise }, 'syncCodingAgentControlPlane').mockResolvedValue(undefined); + vi.mocked(mockApiClient.getRemoteManifest) + .mockImplementationOnce(() => pendingManifest.promise) + .mockRejectedValueOnce(new Error('second-generation fixture boundary')); + const first = service.sync(); + try { + await vi.waitFor(() => expect(mockApiClient.getRemoteManifest).toHaveBeenCalledOnce()); + const signal = vi.mocked(mockApiClient.getRemoteManifest).mock.calls[0][1]; + service.setEnabled(false); + expect(signal?.aborted).toBe(true); + service.setEnabled(true); + pendingManifest.resolve(null); + await first; + await vi.waitFor(() => expect(mockApiClient.getRemoteManifest).toHaveBeenCalledTimes(2)); + } finally { + pendingManifest.resolve(null); + await first; + await service.shutdown({ timeoutMs: 100 }); + } + }); + it.each([ { label: 'config', diff --git a/tests/sync/syncOptIn.test.ts b/tests/sync/syncOptIn.test.ts new file mode 100644 index 000000000..9ba8e2854 --- /dev/null +++ b/tests/sync/syncOptIn.test.ts @@ -0,0 +1,72 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { SyncService } from '../../src/sync/SyncService.js'; +import { DEFAULT_SYNC_CONFIG } from '../../src/sync/types.js'; +import { scheduleBackgroundSync, setBackgroundSyncEnabled, setSyncService } from '../../src/sync/runtimeSyncService.js'; + +afterEach(() => { setSyncService(null); vi.useRealTimers(); vi.restoreAllMocks(); }); +describe('opt-in background sync', () => { + it('does not mutate the caller configuration when the running service is toggled', () => { + vi.useFakeTimers(); + const config = { ...DEFAULT_SYNC_CONFIG }; + const service = new SyncService({ authToken: 'fixture', userId: 'fixture', config }); + vi.spyOn(service, 'sync').mockResolvedValue({ success: true, uploaded: 0, downloaded: 0, conflicts: 0 }); + try { service.setEnabled(true); expect(config.enabled).toBe(false); } + finally { service.stop(); } + }); + + it('applies an explicit choice made before startup has registered the service', () => { + vi.useFakeTimers(); + const service = new SyncService({ authToken: 'fixture', userId: 'fixture', config: { ...DEFAULT_SYNC_CONFIG } }); + vi.spyOn(service, 'sync').mockResolvedValue({ success: true, uploaded: 0, downloaded: 0, conflicts: 0 }); + try { + setBackgroundSyncEnabled(true); setSyncService(service); + expect(service.isRunning).toBe(true); + setBackgroundSyncEnabled(false); + expect(service.isRunning).toBe(false); + } finally { service.stop(); } + }); + + it('makes no sync request over ten minutes with the fresh-install defaults', async () => { + vi.useFakeTimers(); + const service = new SyncService({ authToken: 'fixture', userId: 'fixture', config: { ...DEFAULT_SYNC_CONFIG } }); + const sync = vi.spyOn(service, 'sync').mockResolvedValue({ success: true, uploaded: 0, downloaded: 0, conflicts: 0 }); + try { + service.start(); + await vi.advanceTimersByTimeAsync(600_000); + expect(service.isRunning).toBe(false); + expect(sync).not.toHaveBeenCalled(); + } finally { service.stop(); } + }); + + it('clears queued background callbacks immediately when opting out or releasing the service', () => { + vi.useFakeTimers(); + const baseline = vi.getTimerCount(); + const service = new SyncService({ authToken: 'fixture', userId: 'fixture', config: { ...DEFAULT_SYNC_CONFIG, enabled: true } }); + vi.spyOn(service, 'sync').mockResolvedValue({ success: true, uploaded: 0, downloaded: 0, conflicts: 0 }); + try { + service.start(); setSyncService(service); scheduleBackgroundSync(); + setBackgroundSyncEnabled(false); + expect(vi.getTimerCount()).toBe(baseline); + setBackgroundSyncEnabled(true); scheduleBackgroundSync(); + setSyncService(null); service.stop(); + expect(vi.getTimerCount()).toBe(baseline); + } finally { service.stop(); } + }); + it('disables a running timer and queued sync immediately, and can opt back in', async () => { + vi.useFakeTimers(); + const service = new SyncService({ authToken: 'fixture', userId: 'fixture', config: { ...DEFAULT_SYNC_CONFIG, enabled: true } }); + const sync = vi.spyOn(service, 'sync').mockResolvedValue({ success: true, uploaded: 0, downloaded: 0, conflicts: 0 }); + try { + service.start(); setSyncService(service); scheduleBackgroundSync(); + service.setEnabled(false); + expect(service.isRunning).toBe(false); + await vi.advanceTimersByTimeAsync(600_000); + expect(sync).toHaveBeenCalledTimes(1); + service.setEnabled(true); + expect(service.isRunning).toBe(true); + expect(sync).toHaveBeenCalledTimes(2); + service.stop(); service.setEnabled(true); + expect(service.isRunning).toBe(false); + } finally { service.stop(); } + }); +}); diff --git a/tests/testing/nativeTerminal.test.ts b/tests/testing/nativeTerminal.test.ts new file mode 100644 index 000000000..b43668d5c --- /dev/null +++ b/tests/testing/nativeTerminal.test.ts @@ -0,0 +1,59 @@ +/** @license Apache-2.0 */ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { execFileSync } from 'node:child_process'; +import { writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { prepareNativeTerminalTest } from '../../src/testing/drivers/native-terminal-driver.js'; + +vi.mock('node:child_process', () => ({ execFileSync: vi.fn(() => '/test/bin/bun\n') })); +vi.mock('node:fs/promises', () => ({ + copyFile: vi.fn(), mkdir: vi.fn(), writeFile: vi.fn(), + readdir: vi.fn(async () => ['TerminalLab_macosx.xctestrun']), +})); + +const platform = Object.getOwnPropertyDescriptor(process, 'platform')!; + +afterEach(() => { + Object.defineProperty(process, 'platform', platform); + vi.unstubAllEnvs(); + vi.clearAllMocks(); +}); + +describe('native terminal automation boundary', () => { + it('refuses to launch or terminate applications on a developer machine', async () => { + vi.stubEnv('GITHUB_ACTIONS', 'false'); + await expect(prepareNativeTerminalTest()).rejects.toThrow('isolated macOS GitHub Actions runner'); + expect(execFileSync).not.toHaveBeenCalled(); + expect(writeFile).not.toHaveBeenCalled(); + }); + it.skipIf(process.platform !== 'darwin')('rejects an unknown terminal before generating or launching a test', async () => { + vi.stubEnv('GITHUB_ACTIONS', 'true'); + vi.stubEnv('CI', 'true'); + vi.stubEnv('AUTOHAND_NATIVE_TERMINAL', 'unknown'); + await expect(prepareNativeTerminalTest()).rejects.toThrow('Choose iterm2, ghostty, cmux, or vscode'); + }); + + it('prepares a disposable VS Code profile without Copilot onboarding while retaining terminal accessibility and scrollback', async () => { + Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true }); + vi.stubEnv('GITHUB_ACTIONS', 'true'); + vi.stubEnv('CI', 'true'); + vi.stubEnv('AUTOHAND_NATIVE_TERMINAL', 'vscode'); + + const test = await prepareNativeTerminalTest(); + + const profile = path.resolve('.artifacts/native-terminal/vscode/vscode-profile'); + const settingsWrite = vi.mocked(writeFile).mock.calls.find(([file]) => file === path.join(profile, 'User/settings.json')); + expect(settingsWrite).toBeDefined(); + const settings: unknown = JSON.parse(String(settingsWrite?.[1])); + expect(settings).toMatchObject({ + 'chat.disableAIFeatures': true, + 'editor.accessibilitySupport': 'on', + 'window.restoreWindows': 'none', + 'terminal.integrated.enablePersistentSessions': false, + 'terminal.integrated.scrollback': 5000, + }); + expect(test.args[0]).toBe('test-without-building'); + expect(test.resultBundle).toBe(path.resolve('.artifacts/native-terminal/vscode/NativeTerminal.xcresult')); + expect(execFileSync).not.toHaveBeenCalledWith('/usr/bin/xcodebuild', expect.arrayContaining(['test-without-building']), expect.anything()); + }); +}); diff --git a/tests/toolManager.spec.ts b/tests/toolManager.spec.ts index 1d63be363..babea5c29 100644 --- a/tests/toolManager.spec.ts +++ b/tests/toolManager.spec.ts @@ -13,6 +13,7 @@ import { type ToolManagerOptions, } from '../src/core/toolManager.js'; import { PermissionManager } from '../src/permissions/PermissionManager.js'; +import { getPlanModeManager } from '../src/commands/plan.js'; import type { HookExecutionResult } from '../src/core/HookManager.js'; const noopDefinitions = [ @@ -675,6 +676,106 @@ describe('ToolManager', () => { expect(executor).not.toHaveBeenCalled(); }); + it('does not change turn mode or execute an explicitly denied native action', async () => { + const permissionManager = new PermissionManager({ + mode: 'interactive', denyPatterns: [{ kind: 'mcp__autohand-computer-use__click' }], + }); + const executor = vi.fn(); + const confirmApproval = vi.fn(); + const onToolRequested = vi.fn(() => true); + const manager = new ToolManager({ + executor, confirmApproval, onToolRequested, + definitions: [{ name: 'mcp__autohand-computer-use__click', description: 'click', requiresApproval: true }], + authorization: { permissionManager }, + }); + const [result] = await manager.execute([{ tool: 'mcp__autohand-computer-use__click', args: {} }]); + expect(result.success).toBe(false); + expect(onToolRequested).not.toHaveBeenCalled(); + expect(confirmApproval).not.toHaveBeenCalled(); + expect(executor).not.toHaveBeenCalled(); + }); + + it('leaves plan mode before the first discovered native action and subsequent calls', async () => { + const plan = getPlanModeManager(); + plan.enable(); + const permissionManager = new PermissionManager({ mode: 'interactive' }); + const executor = vi.fn().mockResolvedValue({ success: true, output: 'clicked' }); + const confirmApproval = vi.fn(); + const onToolRequested = vi.fn(() => { + if (!plan.isEnabled()) return false; + plan.disable(); + permissionManager.setMode('unrestricted'); + return true; + }); + const manager = new ToolManager({ executor, confirmApproval, onToolRequested, + definitions: [{ name: 'mcp__autohand-computer-use__click', description: 'click', requiresApproval: true }], + authorization: { permissionManager } }); + try { + const results = await manager.execute([ + { tool: 'mcp__autohand-computer-use__click', args: {} }, + { tool: 'mcp__autohand-computer-use__click', args: {} }, + ]); + expect(results.map(result => result.success)).toEqual([true, true]); + expect(confirmApproval).not.toHaveBeenCalled(); + expect(executor).toHaveBeenCalledTimes(2); + } finally { plan.disable(); } + }); + + it.each(['deny', 'block'] as const)('does not enter computer auto mode after a pre-tool hook %s', async decision => { + const onToolRequested = vi.fn(() => true); + const executor = vi.fn(); + const manager = new ToolManager({ + executor, confirmApproval: vi.fn(), onToolRequested, + definitions: [{ name: 'mcp__autohand-computer-use__click', description: 'click', requiresApproval: true }], + authorization: { + permissionManager: new PermissionManager({ mode: 'interactive' }), + runPreToolHooks: vi.fn(async () => [hookResult({ response: { decision, reason: 'NATIVE_HOOK_DENIED' } })]), + }, + }); + const [result] = await manager.execute([{ tool: 'mcp__autohand-computer-use__click', args: {} }]); + expect(result.error).toContain('NATIVE_HOOK_DENIED'); + expect(onToolRequested).not.toHaveBeenCalled(); + expect(executor).not.toHaveBeenCalled(); + }); + + it('keeps an explicit hook approval request when a native action enters auto mode', async () => { + const permissionManager = new PermissionManager({ mode: 'interactive' }); + const onToolRequested = vi.fn(() => { permissionManager.setMode('unrestricted'); return true; }); + const runPermissionRequestHooks = vi.fn(async () => [hookResult({ + hook: { event: 'permission-request', command: 'true' }, response: { decision: 'deny', reason: 'NATIVE_APPROVAL_DENIED' }, + })]); + const executor = vi.fn(); + const manager = new ToolManager({ + executor, confirmApproval: vi.fn(), onToolRequested, + definitions: [{ name: 'mcp__autohand-computer-use__click', description: 'click', requiresApproval: true }], + authorization: { permissionManager, runPermissionRequestHooks, + runPreToolHooks: vi.fn(async () => [hookResult({ response: { decision: 'ask' } })]), + }, + }); + const [result] = await manager.execute([{ tool: 'mcp__autohand-computer-use__click', args: {} }]); + expect(result.error).toContain('NATIVE_APPROVAL_DENIED'); + expect(onToolRequested).toHaveBeenCalledOnce(); + expect(runPermissionRequestHooks).toHaveBeenCalledOnce(); + expect(executor).not.toHaveBeenCalled(); + }); + + it('keeps ordinary writes restricted while plan mode remains enabled', async () => { + const plan = getPlanModeManager(); + plan.enable(); + const executor = vi.fn(); + const confirmApproval = vi.fn(); + const manager = new ToolManager({ executor, confirmApproval, + definitions: [{ name: 'write_file', description: 'write', requiresApproval: true }], + authorization: { permissionManager: new PermissionManager({ mode: 'unrestricted' }) } }); + try { + const [result] = await manager.execute([{ tool: 'write_file', args: { path: 'file.txt', contents: 'hello' } }]); + expect(result.error).toContain('not available in plan mode'); + expect(executor).not.toHaveBeenCalled(); + expect(confirmApproval).not.toHaveBeenCalled(); + expect(plan.isEnabled()).toBe(true); + } finally { plan.disable(); } + }); + it('keeps safe default-policy tools prompt-free', async () => { const permissionManager = new PermissionManager({ mode: 'interactive' }); const executor = vi.fn().mockResolvedValue(successfulOutcome('contents')); diff --git a/tests/tuistory/autohand-orchestration.tuistory.test.ts b/tests/tuistory/autohand-orchestration.tuistory.test.ts index f80988054..ce195da6c 100644 --- a/tests/tuistory/autohand-orchestration.tuistory.test.ts +++ b/tests/tuistory/autohand-orchestration.tuistory.test.ts @@ -73,7 +73,7 @@ describe('default Autohand AI orchestration', () => { }); try { expect(await runOrchestrationScenario(session)).toContain('ORCHESTRATION_VERIFIED'); - // The scenario can see the provisional answer before its completion audit. + // The verified answer is published after its completion audit. await vi.waitFor(() => expect(audited).toBe(true), { timeout: 10_000 }); expect(waitingWorkers).toHaveLength(3); const workers = requests.filter(request => request.model === 'fantail'); diff --git a/tests/tuistory/completion-review.tuistory.test.ts b/tests/tuistory/completion-review.tuistory.test.ts new file mode 100644 index 000000000..cecba75d9 --- /dev/null +++ b/tests/tuistory/completion-review.tuistory.test.ts @@ -0,0 +1,74 @@ +/** + * @license + * Copyright 2026 Autohand AI LLC + * SPDX-License-Identifier: Apache-2.0 + */ +import { createServer } from 'node:http'; +import { describe, expect, it, vi } from 'vitest'; +import { createTempAutohandHome, launchBuiltAutohand, waitForExit } from './helpers/autohandTuistory.js'; + +interface WireRequest { + messages: Array<{ role: string; content: string }>; +} + +describe('reviewed command completion', () => { + it('keeps the candidate private until approval and emits exactly one confirmed result', async () => { + const approval = Promise.withResolvers(); + let reviewStarted = false; + let commands = 0; + const server = createServer((request, response) => { + void (async () => { + const chunks: Buffer[] = []; + for await (const chunk of request) chunks.push(Buffer.from(chunk)); + const body = JSON.parse(Buffer.concat(chunks).toString()) as WireRequest; + const reviewing = body.messages.some(message => message.role === 'system' && message.content.includes('independent Moa advisor')); + if (reviewing) { + reviewStarted = true; + await approval.promise; + } + const tool = !reviewing && !body.messages.some(message => message.role === 'tool'); + if (tool) commands++; + response.writeHead(200, { 'content-type': 'application/json' }); + response.end(JSON.stringify({ id: 'completion-review-fixture', choices: [{ index: 0, + finish_reason: tool ? 'tool_calls' : 'stop', message: { role: 'assistant', + content: reviewing ? '{"verdict":"approved","feedback":"Command result verified."}' : tool ? '' : '400000', + ...(tool ? { tool_calls: [{ id: 'call_bounded_command', type: 'function', function: { + name: 'run_command', arguments: JSON.stringify({ command: 'seq 1 400000' }), + } }] } : {}), + } }], usage: { prompt_tokens: 20, completion_tokens: 10, total_tokens: 30 } })); + })().catch(error => { response.writeHead(500).end(String(error)); }); + }); + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); + const address = server.address(); + if (!address || typeof address === 'string') throw new Error('Fixture server did not bind'); + const state = await createTempAutohandHome({ config: { + provider: 'autohandai', autohandai: { plan: 'cloud', authMode: 'api-key', apiKey: 'fixture', model: 'moa', orchestration: true, baseUrl: `http://127.0.0.1:${address.port}` }, + features: { automaticSpecialists: false }, agent: { autoMemory: false, maxIterations: 4, sessionRetryLimit: 0 }, + network: { maxRetries: 0, retryDelay: 0 }, ui: { promptSuggestions: false }, + } }); + const session = await launchBuiltAutohand(['--config', state.configPath, '--no-daemon', '--yes', '--allowed-tools', 'run_command', + '--output-format', 'stream-json', '-p', 'Run seq 1 400000 once and reply with exactly 400000.'], { + autohandHome: state.autohandHome, cwd: state.workspaceRoot, + }); + try { + await vi.waitFor(() => expect(reviewStarted).toBe(true), { timeout: 20000 }); + expect(session.getRawOutput()).not.toContain('"type":"result"'); + approval.resolve(); + await waitForExit(session, 20000); + const results = session.getRawOutput().split('\n').flatMap(line => { + try { const event: unknown = JSON.parse(line); return event && typeof event === 'object' && 'type' in event && event.type === 'result' ? [event] : []; } + catch { return []; } + }); + expect(results).toEqual([expect.objectContaining({ type: 'result', content: '400000' })]); + expect(commands).toBe(1); + expect(session.exitInfo?.exitCode).toBe(0); + expect(session.getRawOutput()).not.toContain('Draft —'); + } finally { + approval.resolve(); + session.close(); + server.closeAllConnections(); + await new Promise(resolve => server.close(() => resolve())); + await state.cleanup(); + } + }); +}); diff --git a/tests/tuistory/computer-control.tuistory.test.ts b/tests/tuistory/computer-control.tuistory.test.ts index b7b0c61af..227743bd1 100644 --- a/tests/tuistory/computer-control.tuistory.test.ts +++ b/tests/tuistory/computer-control.tuistory.test.ts @@ -267,7 +267,11 @@ describe('built native computer control', () => { }); }); - it('waits for native control and preserves window IDs for the next app inspection', async () => { + it.each([ + { previousMode: 'default', instruction: 'use my spotify and play Felix Rosch', guiIntent: true }, + { previousMode: 'plan', instruction: 'use my spotify and play Felix Rosch', guiIntent: true }, + { previousMode: 'plan', instruction: 'inspect the fixture', guiIntent: false }, + ])('uses temporary auto mode from $previousMode for "$instruction" and preserves window IDs', async ({ previousMode, instruction, guiIntent }) => { const provider = await createMockAutohandAINativeSequenceServer([ { content: 'Checking the native app connection.', @@ -275,9 +279,10 @@ describe('built native computer control', () => { }, { content: 'Inspecting the discovered window.', + delayMs: 1_000, toolCall: scriptToolCall('call_window_state', 'mcp__autohand-computer-use__get_window_state', { pid: 6844, window_id: 280 }), }, - { content: 'COMPUTER_CONTROL_TURN_COMPLETE' }, + { content: 'COMPUTER_CONTROL_TURN_COMPLETE', delayMs: 1_000 }, ]); servers.push(provider); const state = await createTempAutohandHome({ @@ -297,14 +302,13 @@ describe('built native computer control', () => { }, }); states.push(state); - const driver = await writeFakeCuaDriver(state, 5_000); + const driver = await writeFakeCuaDriver(state, guiIntent ? 5_000 : 0); const computerUseHost = await writeFakeComputerUseHost(state); const session = await launchBuiltAutohand([ '--path', state.workspaceRoot, '--config', state.configPath, - '--yes', ], { autohandHome: state.autohandHome, cwd: state.workspaceRoot, @@ -317,13 +321,23 @@ describe('built native computer control', () => { }); sessions.push(session); await session.text({ timeout: 20_000, waitFor: (text) => text.includes('❯') }); - await session.type('use my spotify and play Felix Rosch'); + if (previousMode === 'plan') { + await session.press(['shift', 'tab']); + await session.waitForText('● PLAN', { timeout: 5_000 }); + } + await session.type(instruction); await session.press('enter'); + await session.waitForText('● AUTO', { timeout: 15_000 }); await session.text({ timeout: 30_000, waitFor: (text) => text.includes('COMPUTER_CONTROL_TURN_COMPLETE'), }); + const restored = await session.text({ timeout: 10_000, waitFor: text => + text.includes('❯') && !text.includes('● AUTO') + && (previousMode === 'plan' ? text.includes('● PLAN') : !text.includes('● PLAN')) }); + expect(restored).not.toContain('Allow once'); + const firstRequest = provider.requests[0] as { messages?: Array<{ role?: string; content?: string }>; tools?: Array<{ function?: { name?: string } }>; @@ -332,11 +346,13 @@ describe('built native computer control', () => { .toContain('run_tool_script'); expect(firstRequest.tools?.map(tool => tool.function?.name)).not.toContain('mcp__autohand-computer-use__list_windows'); const userMessage = firstRequest.messages?.find((message) => message.role === 'user')?.content ?? ''; - expect(userMessage).toBe('use my spotify and play Felix Rosch'); + expect(userMessage).toBe(instruction); const turnContext = firstRequest.messages?.filter(message => message.role === 'user' && message.content?.includes('')).map(message => message.content).join('\n') ?? ''; - expect(turnContext).toContain('Computer control mode'); - expect(turnContext).toContain('Operate one exact local app or window'); - expect(turnContext).toContain('mcp__autohand-computer-use__'); + if (guiIntent) { + expect(turnContext).toContain('Computer control mode'); + expect(turnContext).toContain('Operate one exact local app or window'); + expect(turnContext).toContain('mcp__autohand-computer-use__'); + } const secondRequest = provider.requests[1] as { messages?: Array<{ role?: string; content?: string }>; @@ -352,6 +368,51 @@ describe('built native computer control', () => { await exitInteractive(session); }, 45_000); + it.each(['cancel', 'choose a mode'] as const)('preserves interaction mode when users %s during a computer task', async action => { + const provider = await createMockAutohandAINativeSequenceServer([ + { content: 'UNEXPECTED_COMPLETION', delayMs: 4_000 }, + ]); + servers.push(provider); + const state = await createTempAutohandHome({ config: { + provider: 'autohandai', + autohandai: { plan: 'cloud', authMode: 'api-key', apiKey: 'tuistory-key', model: 'moa', baseUrl: provider.baseUrl }, + features: { autohand_inference: true }, + agent: { autoMemory: false, sessionRetryLimit: 0 }, + network: { maxRetries: 0 }, + ui: { promptSuggestions: false, showCompletionNotification: false, terminalBell: false }, + } }); + states.push(state); + const driver = await writeFakeCuaDriver(state); + const computerUseHost = await writeFakeComputerUseHost(state); + const session = await launchBuiltAutohand(['--path', state.workspaceRoot, '--config', state.configPath], { + autohandHome: state.autohandHome, + cwd: state.workspaceRoot, + env: { AUTOHAND_DISABLE_COMPUTER_USE: '0', AUTOHAND_CUA_DRIVER_PATH: driver, AUTOHAND_COMPUTER_USE_APP_PATH: computerUseHost }, + }); + sessions.push(session); + await session.waitForText('❯', { timeout: 20_000 }); + await session.press(['shift', 'tab']); + await session.waitForText('● PLAN', { timeout: 5_000 }); + await session.type('open the Calculator app'); + await session.press('enter'); + await session.waitForText('● AUTO', { timeout: 15_000 }); + await session.text({ timeout: 10_000, waitFor: () => provider.requests.length > 0 }); + if (action === 'cancel') { + await session.press('escape'); + const restored = await session.text({ timeout: 10_000, waitFor: text => + text.includes('● PLAN') && !text.includes('● AUTO') }); + expect(restored).not.toContain('UNEXPECTED_COMPLETION'); + } else { + await session.press(['shift', 'tab']); + await session.waitForText('● YOLO', { timeout: 5_000 }); + const completed = await session.text({ timeout: 15_000, waitFor: text => + text.includes('UNEXPECTED_COMPLETION') && text.includes('❯') }); + expect(completed).toContain('● YOLO'); + expect(completed).not.toContain('● PLAN'); + } + await exitInteractive(session); + }, 45_000); + it('hands a computer-control screenshot to the model without rendering or persisting its bytes', async () => { const provider = await createMockAutohandAINativeSequenceServer([ { diff --git a/tests/tuistory/continuation-output.tuistory.test.ts b/tests/tuistory/continuation-output.tuistory.test.ts new file mode 100644 index 000000000..55767d6ca --- /dev/null +++ b/tests/tuistory/continuation-output.tuistory.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from 'vitest'; +import { createMockAuthServer, createMockOpenRouterSequenceServer, createTempAutohandHome, exitInteractive, launchBuiltAutohand, waitForExit } from './helpers/autohandTuistory.js'; + +describe('continuation and command output release regressions', () => { + it('remembers BANANA-42 across an empty interactive launch and -c', async () => { + const server = await createMockOpenRouterSequenceServer([JSON.stringify({ finalResponse: 'BANANA-42' })]); + const auth = await createMockAuthServer(); + const state = await createTempAutohandHome({ config: { api: { baseUrl: auth.baseUrl }, openrouter: { baseUrl: server.baseUrl }, + features: { automaticSpecialists: false }, agent: { autoMemory: false, maxIterations: 3, sessionRetryLimit: 0 }, ui: { promptSuggestions: false } } }); + const launchOptions = { autohandHome: state.autohandHome, cwd: state.workspaceRoot, env: { AUTOHAND_API_URL: auth.baseUrl, AUTOHAND_AUTH_URL: auth.baseUrl, AUTOHAND_AUTH_API_URL: `${auth.baseUrl}/api/auth` } }; + try { + const first = await launchBuiltAutohand(['--config', state.configPath, '--yes', '-p', 'Reply with exactly: BANANA-42'], launchOptions); + try { await waitForExit(first, 30000); expect(first.getRawOutput()).toContain('BANANA-42'); expect(first.exitInfo?.exitCode).toBe(0); } finally { first.close(); } + const empty = await launchBuiltAutohand(['--config', state.configPath, '--offline'], launchOptions); + try { await empty.waitForText('❯'); await exitInteractive(empty); } finally { empty.close(); } + const continued = await launchBuiltAutohand(['--config', state.configPath, '--yes', '-c', '-p', 'What did I ask you to reply with?'], launchOptions); + try { await waitForExit(continued, 30000); expect(continued.getRawOutput()).toContain('BANANA-42'); expect(continued.exitInfo?.exitCode).toBe(0); } finally { continued.close(); } + expect(JSON.stringify(server.requests.at(-1))).toContain('Reply with exactly: BANANA-42'); + } finally { await server.close(); await auth.close(); await state.cleanup(); } + }, 90000); + + it('shows a visible final answer after seq 1 400000 with a bounded provider observation', async () => { + const server = await createMockOpenRouterSequenceServer([ + JSON.stringify({ toolCalls: [{ tool: 'run_command', args: { command: 'seq 1 400000' } }] }), + JSON.stringify({ finalResponse: 'VISIBLE_FINAL: 400000' }), + ]); + const state = await createTempAutohandHome({ config: { openrouter: { baseUrl: server.baseUrl }, + features: { automaticSpecialists: false }, agent: { autoMemory: false, maxIterations: 3, sessionRetryLimit: 0 }, ui: { promptSuggestions: false } } }); + try { + const session = await launchBuiltAutohand(['--config', state.configPath, '--yes', '-p', 'Run seq 1 400000 once and reply with the last number.'], + { autohandHome: state.autohandHome, cwd: state.workspaceRoot, env: { AUTOHAND_STREAM_TOOL_OUTPUT: '1' } }); + try { + await waitForExit(session, 30000); + expect(session.getRawOutput()).toContain('VISIBLE_FINAL: 400000'); expect(session.exitInfo?.exitCode).toBe(0); + const request = server.requests.at(-1)!; + const messages = request.messages as Array<{ role: string; content: string }>; + const observations = messages.filter(message => message.role === 'tool'); + expect(observations).toHaveLength(1); + expect(observations[0].content.length).toBeLessThanOrEqual(30000); + expect(observations[0].content).toContain('400000'); expect(observations[0].content).toContain('Full command output saved to'); + } finally { session.close(); } + } finally { await server.close(); await state.cleanup(); } + }, 60000); +}); diff --git a/tests/tuistory/full-screen.tuistory.test.ts b/tests/tuistory/full-screen.tuistory.test.ts index 9f8c936b5..1db44cbab 100644 --- a/tests/tuistory/full-screen.tuistory.test.ts +++ b/tests/tuistory/full-screen.tuistory.test.ts @@ -2,7 +2,7 @@ import { renderAutohandLogo } from '../../src/utils/asciiArt.js'; import { readFile } from 'node:fs/promises'; import path from 'node:path'; -import { afterEach, describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import type { Session } from 'tuistory'; import { isTerminalMouseTrackingEnabled } from '../../src/testing/scenarios/mouseScrollbackScenario.js'; import { composerCaretLine } from '../../src/testing/assertions/composerCaret.js'; @@ -159,8 +159,9 @@ describe('full-screen terminal', () => { expect(screens.paged).not.toContain('live-row-1\n'); expect(screens.resized).toContain('preserved draft'); expect(session.getRawOutput().indexOf('\x1b[?7l')).toBeGreaterThan(session.getRawOutput().indexOf('\x1b[?1049h')); - const clearedAt = screens.resizeOutput.indexOf('\x1b[2J\x1b[H'); + const clearedAt = screens.resizeOutput.indexOf('\x1b[1;1H\x1b[2K'); expect(clearedAt).toBeGreaterThanOrEqual(0); + expect(screens.resizeOutput).not.toMatch(/\x1b\[(?:[023])?J/u); expect(screens.resizeOutput.indexOf('Autohand Code', clearedAt)).toBeGreaterThan(clearedAt); const resizedRows = screens.resized.split('\n'); expect(resizedRows[0]).toContain('Autohand Code'); @@ -193,7 +194,9 @@ describe('full-screen terminal', () => { await session.waitIdle(); const narrow = outputViewport(session); const narrowRows = narrow.split('\n'); - expect(session.getRawOutput().slice(resizeOffset)).toContain('\x1b[2J\x1b[H'); + const resizePaint = session.getRawOutput().slice(resizeOffset); + expect(resizePaint).toContain('\x1b[1;1H\x1b[2K'); + expect(resizePaint).not.toMatch(/\x1b\[(?:[023])?J/u); expect(narrowRows[0]).toContain('Autohand Code'); expect(menuRows(narrow).map(row => row.split(/ {2}/u)[0])).toEqual(menuRows(wide).map(row => row.split(/ {2}/u)[0])); expect(narrowRows.filter(row => row.includes('Tab to accept'))).toHaveLength(1); @@ -265,15 +268,17 @@ describe('full-screen terminal', () => { await session.type('?'); await session.waitForText('Keyboard shortcuts'); await session.waitIdle(); - expect(composerRow()).toBe(baseline); - const screen = outputViewport(session).split('\n'); - expect(screen.findIndex(line => line.includes('Keyboard shortcuts'))).toBeLessThan(baseline); - if (columns >= 110) { - expect(screen.find(line => line.includes('Compose'))).toContain('Session'); - expect(screen.find(line => line.includes('Compose'))).toContain('Transcript'); - expect(screen.find(line => line.includes('/whatityped'))).toMatch(/\/whatityped\s+Typed history/); - } - expect(composerCaretLine(session)).toMatch(/^❯ \|/); + await vi.waitFor(() => { + expect(composerRow()).toBe(baseline); + const screen = outputViewport(session!).split('\n'); + expect(screen.findIndex(line => line.includes('Keyboard shortcuts'))).toBeLessThan(baseline); + if (columns >= 110) { + expect(screen.find(line => line.includes('Compose'))).toContain('Session'); + expect(screen.find(line => line.includes('Compose'))).toContain('Transcript'); + expect(screen.find(line => line.includes('/whatityped'))).toMatch(/\/whatityped\s+Typed history/); + } + expect(composerCaretLine(session!)).toMatch(/^❯ \|/); + }, { timeout: 5_000, interval: 20 }); await session.type('?'); await session.type('still editable'); await session.waitForText('❯ still editable'); diff --git a/tests/tuistory/goal-continuity.tuistory.test.ts b/tests/tuistory/goal-continuity.tuistory.test.ts index 40e9f0ec4..20b1227e7 100644 --- a/tests/tuistory/goal-continuity.tuistory.test.ts +++ b/tests/tuistory/goal-continuity.tuistory.test.ts @@ -35,7 +35,10 @@ describe('built CLI pinned checkpoint startup', () => { autohandHome: state.autohandHome, cwd: state.workspaceRoot, env: { AUTOHAND_AUTH_API_URL: `${auth.baseUrl}/api/auth` }, }); await inspectRecoveredCheckpointAndRefuseStaleResume(terminal, async () => { - expect((await manager.getSessionSnapshot()).goal?.status).toBe('paused'); + await vi.waitFor(async () => { + expect((await manager.getSessionSnapshot()).goal?.status).toBe('paused'); + expect(server.requests).toHaveLength(0); + }, { timeout: 15_000 }); expect(server.requests).toHaveLength(0); }); const restored = await manager.getSessionSnapshot(); diff --git a/tests/tuistory/helpers/autohandTuistory.ts b/tests/tuistory/helpers/autohandTuistory.ts index 0d1d2b20b..111cf1c95 100644 --- a/tests/tuistory/helpers/autohandTuistory.ts +++ b/tests/tuistory/helpers/autohandTuistory.ts @@ -33,7 +33,7 @@ export interface LaunchBuiltAutohandOptions { runtimeCommand?: string; runtimeArgs?: string[]; inputCommand?: string[]; - entrypoint?: string; + entrypoint?: string | null; autohandHome?: string; cwd?: string; env?: Record; @@ -1275,6 +1275,7 @@ export async function launchBuiltAutohand( AUTOHAND_OFFLINE: '1', AUTOHAND_NO_BROWSER: '1', AUTOHAND_DISABLE_COMPUTER_USE: '1', + AUTOHAND_DISABLE_SQUAD: '1', // Hermetic version resolution: an ambient AUTOHAND_VERSION_SOURCE (e.g. // when the suite runs inside an Autohand session) would otherwise make // the built CLI report a git-derived version instead of the manifest one. @@ -1288,7 +1289,7 @@ export async function launchBuiltAutohand( : launchTuistorySession; return await launch({ command: options.runtimeCommand ?? process.execPath, - args: [...options.runtimeArgs ?? [], options.entrypoint ?? path.join(root, 'dist/index.js'), ...args], + args: [...options.runtimeArgs ?? [], ...(options.entrypoint === null ? [] : [options.entrypoint ?? path.join(root, 'dist/index.js')]), ...args], cwd: options.cwd ?? root, env, cols: options.cols ?? 120, diff --git a/tests/tuistory/native-terminal.tuistory.test.ts b/tests/tuistory/native-terminal.tuistory.test.ts new file mode 100644 index 000000000..51f2aeebc --- /dev/null +++ b/tests/tuistory/native-terminal.tuistory.test.ts @@ -0,0 +1,25 @@ +/** @license Apache-2.0 */ +import { existsSync } from 'node:fs'; +import { writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { prepareNativeTerminalTest } from '../../src/testing/drivers/native-terminal-driver.js'; +import { launchTuistorySession, waitForExit } from '../../src/testing/drivers/tuistory-driver.js'; + +describe.skipIf(!process.env.AUTOHAND_NATIVE_TERMINAL)('native macOS terminal compatibility', () => { + it('retains one transcript and composer through idle and streaming window resizes', async () => { + const test = await prepareNativeTerminalTest(); + const session = await launchTuistorySession({ command: '/usr/bin/xcodebuild', args: test.args, + cwd: process.cwd(), cols: 180, rows: 50, waitForDataTimeout: 120_000, env: { ...process.env, CI: 'true' } }); + try { + await waitForExit(session, 300_000); + expect(session.exitInfo?.exitCode, session.getRawOutput().slice(-10_000)).toBe(0); + expect(session.getRawOutput()).toContain('NATIVE_TERMINAL_IDLE_PASS'); + expect(session.getRawOutput()).toContain('NATIVE_TERMINAL_STREAMING_PASS'); + expect(existsSync(path.join(test.resultBundle, 'Info.plist'))).toBe(true); + } finally { + session.close(); + await writeFile(path.join(path.dirname(test.resultBundle), 'xcodebuild.log'), session.getRawOutput()); + } + }, 360_000); +}); diff --git a/tests/tuistory/resize-flicker.tuistory.test.ts b/tests/tuistory/resize-flicker.tuistory.test.ts index 261b78bab..c35fa85b4 100644 --- a/tests/tuistory/resize-flicker.tuistory.test.ts +++ b/tests/tuistory/resize-flicker.tuistory.test.ts @@ -1,18 +1,44 @@ /** @license Apache-2.0 */ import path from 'node:path'; import { stripVTControlCharacters } from 'node:util'; -import { afterEach, describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import type { Session } from 'tuistory'; import { composerCaretLine } from '../../src/testing/assertions/composerCaret.js'; import { readTerminalWriteTrace, TerminalFrameRecorder } from '../../src/testing/assertions/terminalFrames.js'; -import { launchTuistorySession, waitForExit, waitForTerminalScreen } from '../../src/testing/drivers/tuistory-driver.js'; +import { exitInteractive, launchTuistorySession, waitForTerminalScreen } from '../../src/testing/drivers/tuistory-driver.js'; import { createTempAutohandHome, type TuistoryTempState } from './helpers/autohandTuistory.js'; +import { outputViewport } from '../../src/testing/scenarios/compactOutputScenario.js'; let session: Session | undefined; let state: TuistoryTempState | undefined; afterEach(async () => { session?.close(); await state?.cleanup(); session = undefined; state = undefined; }); describe('terminal resize frames', () => { + it.each(['node', 'bun'])('keeps one copy of each full-screen row during ordinary %s redraws', async runtime => { + state = await createTempAutohandHome(); + session = await launchTuistorySession({ + command: runtime === 'node' ? process.execPath : 'bun', + args: [...(runtime === 'node' ? ['--import', 'tsx'] : []), + path.resolve('src/testing/scenarios/resizeFlickerScenario.tsx'), '--full-screen', '--streaming'], + cwd: process.cwd(), cols: 100, rows: 30, waitForDataTimeout: 15_000, + env: { ...process.env, AUTOHAND_HOME: state.autohandHome, CI: 'false', TERM: 'xterm-256color', TERM_PROGRAM: 'iTerm.app', FORCE_COLOR: '0' }, + }); + await waitForTerminalScreen(session, { timeout: 5_000, + waitFor: screen => /RESIZE_STREAM [1-9]\d+ retained streamed text/u.test(screen) }); + await session.type('/exit'); + await vi.waitFor(() => { + const screen = outputViewport(session!); + expect(screen.match(/Autohand Code/g)).toHaveLength(1); + expect(screen.match(/RESIZE_HISTORY retained text/g)).toHaveLength(1); + expect(screen.match(/RESIZE_STREAM \d+ retained streamed text/g)).toHaveLength(1); + expect(screen.match(/❯ \/exit/g)).toHaveLength(1); + }, { timeout: 5_000, interval: 20 }); + await session.press('escape'); + await session.waitForText('RESIZE_STREAM_COMPLETE'); + await exitInteractive(session); + expect(session.exitInfo?.exitCode).toBe(0); + }); + it.each(['xterm-256color', 'xterm-ghostty', 'screen-256color', 'tmux-256color'].flatMap(terminal => [ { terminal, synchronized: true, streaming: true }, { terminal, synchronized: false, streaming: true }, @@ -34,7 +60,10 @@ describe('terminal resize frames', () => { const sizes = [[100, 24], [90, 24], [80, 26], [70, 24], [60, 22], [70, 24], [80, 26], [90, 28], [100, 30]]; for (const [columns, rows] of sizes) { session.resize({ cols: columns, rows }); - await new Promise(resolve => setTimeout(resolve, 50)); + await vi.waitFor(async () => { + const paints = (await readTerminalWriteTrace(tracePath)).slice(traceOffset); + expect(paints.some(paint => paint.columns === columns && paint.rows === rows)).toBe(true); + }, { timeout: 5_000, interval: 20 }); } await session.type('X'); await session.waitForText('preserved draftX'); @@ -55,6 +84,10 @@ describe('terminal resize frames', () => { || !frame.text.includes('RESIZE_HISTORY retained text') || !frame.text.includes('preserved draft') || !frame.text.includes('▔') || !frame.text.includes('▁')); expect(incomplete[0], `${incomplete.length} terminal writes published an incomplete transcript or composer`).toBeUndefined(); + for (const frame of frames) { + expect(frame.text.match(/Autohand Code/g), frame.text).toHaveLength(1); + expect(frame.text.match(/RESIZE_HISTORY retained text/g), frame.text).toHaveLength(1); + } if (!synchronized) expect(session.getRawOutput()).not.toMatch(/\x1b\[\?2026[hl]/); } finally { recorder.close(); @@ -63,10 +96,7 @@ describe('terminal resize frames', () => { await session.press('escape'); await session.waitForText('RESIZE_STREAM_COMPLETE'); } - await session.press(['ctrl', 'c']); - await session.press(['ctrl', 'c']); - await session.press(['ctrl', 'c']); - await waitForExit(session); + await exitInteractive(session); expect(session.exitInfo?.exitCode).toBe(0); expect(session.getRawOutput()).toContain('RESIZE_PTY_EXIT'); }); @@ -112,10 +142,7 @@ describe('terminal resize frames', () => { await session.press('escape'); await session.waitForText('RESIZE_STREAM_COMPLETE'); } - await session.press(['ctrl', 'c']); - await session.press(['ctrl', 'c']); - await session.press(['ctrl', 'c']); - await waitForExit(session); + await exitInteractive(session); expect(session.exitInfo?.exitCode).toBe(0); expect(session.getRawOutput()).toContain('RESIZE_PTY_EXIT'); }); @@ -186,10 +213,7 @@ describe('terminal resize frames', () => { } await session.waitIdle(); expect(composerCaretLine(session)).toContain(multiline ? 'taXY|il' : streaming ? 'preserved draXY|ft' : 'preserved draX|ft'); - await session.press(['ctrl', 'c']); - await session.press(['ctrl', 'c']); - await session.press(['ctrl', 'c']); - await waitForExit(session); + await exitInteractive(session); expect(session.exitInfo?.exitCode).toBe(0); expect(session.getRawOutput()).toContain('RESIZE_PTY_EXIT'); }); diff --git a/tests/tuistory/resume.tuistory.test.ts b/tests/tuistory/resume.tuistory.test.ts index 0e93b0f18..3c3019881 100644 --- a/tests/tuistory/resume.tuistory.test.ts +++ b/tests/tuistory/resume.tuistory.test.ts @@ -2,7 +2,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import path from 'node:path'; import fs from 'fs-extra'; import type { Session } from 'tuistory'; -import { chooseOlderResumeSession, chooseResumeSession, runMarkdownResumeScenario, runResumedTurnScenario, seedMarkdownResumeScenario, seedResumeScenario } from '../../src/testing/scenarios/resumeScenario.js'; +import { chooseOlderResumeSession, chooseResumeSession, createResumeLoadGate, runMarkdownResumeScenario, runResumedTurnScenario, seedMarkdownResumeScenario, seedResumeScenario, typeDuringResumeLoad } from '../../src/testing/scenarios/resumeScenario.js'; import { createTempAutohandHome, createMockOpenRouterSequenceServer, exitInteractive, launchBuiltAutohand, waitForExit, type TuistoryTempState, @@ -37,6 +37,53 @@ async function launch(args: string[], seed = true, olderSessionCount = 0) { } describe('resume startup Tuistory', () => { + it.each([ + { fullScreenMode: false, submitDuringLoad: false }, + { fullScreenMode: false, submitDuringLoad: true }, + { fullScreenMode: true, submitDuringLoad: false }, + { fullScreenMode: true, submitDuringLoad: true }, + ])('keeps the composer editable during session loading, fullscreen=$fullScreenMode, submit=$submitDuringLoad', async ({ fullScreenMode, submitDuringLoad }) => { + const server = await createMockOpenRouterSequenceServer([JSON.stringify({ finalResponse: 'RESUME_DRAFT_ACCEPTED' })]); + const state = await createTempAutohandHome({ config: { + features: { fullScreenMode, automaticSpecialists: false }, + openrouter: { baseUrl: server.baseUrl }, ui: { promptSuggestions: false }, + agent: { autoMemory: false, enableRequestQueue: false, maxIterations: 3, sessionRetryLimit: 0 }, + } }); + states.push(state); + await seedResumeScenario(state.autohandHome, state.workspaceRoot); + const gate = await createResumeLoadGate(state.autohandHome, 'resume-newer'); + const session = await launchBuiltAutohand(['resume', 'resume-newer', '--config', state.configPath, '--yes'], { + autohandHome: state.autohandHome, cwd: state.workspaceRoot, + runtimeArgs: [`--import=${gate.importSpecifier}`], cols: 120, rows: 36, + }); + sessions.push(session); + try { + await vi.waitFor(async () => expect(await fs.pathExists(gate.startedPath)).toBe(true), { timeout: 15_000 }); + expect(await typeDuringResumeLoad(session)).toContain('❯ draft typed during restore'); + if (submitDuringLoad) await session.press('enter'); + expect(server.requests).toHaveLength(0); + await gate.release(); + if (!submitDuringLoad) { + await session.waitForText('Added a vitest case that resizes', { timeout: 15_000 }); + expect(await session.text({ immediate: true })).toContain('❯ draft typed during restore'); + expect(server.requests).toHaveLength(0); + await session.press('enter'); + } + await session.waitForText('RESUME_DRAFT_ACCEPTED', { timeout: 20_000 }); + expect(await session.text({ immediate: true })).toContain('draft typed during restore'); + expect(server.requests).toHaveLength(1); + expect(JSON.stringify(server.requests[0])).toContain('Investigate why the terminal caret disappears'); + expect(JSON.stringify(server.requests[0])).toContain('draft typed during restore'); + await exitInteractive(session); + expect((await fs.readJson(path.join(state.autohandHome, 'sessions', 'index.json'))).sessions).toHaveLength(3); + expect(session.exitInfo?.exitCode).toBe(0); + } finally { + await gate.release(); + session.close(); + await server.close(); + } + }, 45_000); + it.each([false, true])('accepts the first resumed turn while MCP remains silent, fullscreen=%s', async fullScreenMode => { const server = await createMockOpenRouterSequenceServer([JSON.stringify({ finalResponse: 'RESUME_TURN_ACCEPTED' })]); const state = await createTempAutohandHome({ config: { @@ -95,7 +142,7 @@ describe('resume startup Tuistory', () => { }); it.each([ - { args: ['--last'], expected: 'resume-active' }, + { args: ['--last'], expected: 'resume-newer' }, { args: ['--last', '--all'], expected: 'elsewhere-session' }, ])('resumes the latest active session for $args', async ({ args, expected }) => { const { session, state } = await launch(args); diff --git a/tests/tuistory/settings-sync.tuistory.test.ts b/tests/tuistory/settings-sync.tuistory.test.ts index 733e57182..5e3a458bd 100644 --- a/tests/tuistory/settings-sync.tuistory.test.ts +++ b/tests/tuistory/settings-sync.tuistory.test.ts @@ -6,7 +6,7 @@ import { afterEach, describe, expect, it } from 'vitest'; import type { Session } from 'tuistory'; import { setTimeout as delay } from 'node:timers/promises'; -import { readFile, access } from 'node:fs/promises'; +import { readFile, writeFile, access } from 'node:fs/promises'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { prepareMissingSettingsSyncDownloadScenario, prepareSettingsSyncFailureScenario } from '../../src/testing/scenarios/settingsSyncScenario.js'; @@ -17,6 +17,33 @@ let session: Session | undefined; let state: TuistoryTempState | undefined; afterEach(async () => { session?.close(); await state?.cleanup(); session = undefined; state = undefined; }); describe('built settings sync health', () => { + it('keeps sync opted out by default and toggles the live service without restarting the CLI', async () => { + state = await createTempAutohandHome({ config: { api: { baseUrl: 'https://sync.example.invalid' } } }); + const config = JSON.parse(await readFile(state.configPath, 'utf8')) as Record; + delete config.sync; + await writeFile(state.configPath, JSON.stringify(config)); + const preload = await prepareSettingsSyncFailureScenario(state.autohandHome); + session = await launchBuiltAutohand(['--config', state.configPath], { + autohandHome: state.autohandHome, cwd: state.workspaceRoot, + env: { NODE_OPTIONS: `--import=${preload}` }, cols: 120, rows: 40, + }); + await session.waitForText('Plan, search, build anything'); + await session.type('/sync status'); await session.press('enter'); + await session.waitForText('Service Running'); + expect(await session.readAll()).not.toContain('413 fixture'); + await session.type('/sync on'); await session.press('enter'); + await session.waitForText('Settings sync enabled.'); + await session.waitForText('413 fixture: session store unavailable'); + expect(JSON.parse(await readFile(state.configPath, 'utf8')).sync.enabled).toBe(true); + await session.type('/sync off'); await session.press('enter'); + await session.waitForText('Settings sync disabled.'); + expect(JSON.parse(await readFile(state.configPath, 'utf8')).sync.enabled).toBe(false); + await session.type('AFTER_SYNC_TOGGLE_DRAFT'); + await session.waitForText('AFTER_SYNC_TOGGLE_DRAFT'); + expect(session.exitInfo).toBeNull(); + await exitInteractive(session); + }); + it('syncs healthy settings with a visible incomplete-history warning and usable full-screen panels', async () => { state = await createTempAutohandHome({ config: { features: { fullScreenMode: true }, sync: { enabled: true, interval: 60000 }, diff --git a/tests/tuistory/squad-connection.tuistory.test.ts b/tests/tuistory/squad-connection.tuistory.test.ts new file mode 100644 index 000000000..287434716 --- /dev/null +++ b/tests/tuistory/squad-connection.tuistory.test.ts @@ -0,0 +1,66 @@ +import { createServer } from 'node:http'; +import type { Session } from 'tuistory'; +import { expect, it } from 'vitest'; +import { createTempAutohandHome, exitInteractive, launchBuiltAutohand } from './helpers/autohandTuistory.js'; +import { invokeBuiltinSquadMcp, selectSquadAgentAndMessage } from '../../src/testing/scenarios/squadConnectionScenario.js'; + +it.each([false, true])('selects a desktop agent, sends and reads messages, and calls the built-in MCP (full screen: %s)', async fullScreenMode => { + const messages: unknown[] = []; + let modelCalls = 0; + const advertisedTools = new Set(); + let squadToolReply = false; + let squadDiscovered = false; + const server = createServer(async (request, response) => { + const url = new URL(request.url || '/', 'http://localhost'); + const chunks: Buffer[] = []; + for await (const chunk of request) chunks.push(Buffer.from(chunk)); + const body = chunks.length ? JSON.parse(Buffer.concat(chunks).toString()) as Record : {}; + let data: unknown; + if (url.pathname === '/chat/completions') { + modelCalls++; + for (const tool of body.tools as Array<{ function?: { name?: string } }> || []) if (tool.function?.name) advertisedTools.add(tool.function.name); + squadToolReply ||= (body.messages as Array<{ role?: string; content?: string }> || []).some(message => message.role === 'tool' && message.content?.includes('Kai')); + squadDiscovered ||= (body.messages as Array<{ role?: string; content?: string }> || []).some(message => message.role === 'tool' && message.content?.includes('mcp__autohand-squad__get_member_profile')); + response.writeHead(200, { 'content-type': 'application/json' }); + const tool = modelCalls === 1 ? { name: 'tool_search', arguments: JSON.stringify({ query: 'mcp__autohand-squad__get_member_profile' }) } : { name: 'run_tool_script', arguments: JSON.stringify({ script: 'return await tools["mcp__autohand-squad__get_member_profile"]({member_id: "kai"});', description: 'List Squad agents' }) }; + response.end(JSON.stringify({ id: 'squad-fixture', created: 1, choices: [{ index: 0, finish_reason: modelCalls < 3 ? 'tool_calls' : 'stop', message: { role: 'assistant', content: modelCalls < 3 ? '' : 'BUILTIN_SQUAD_MCP_VERIFIED', ...(modelCalls < 3 ? { tool_calls: [{ id: `list-squad-${modelCalls}`, type: 'function', function: tool }] } : {}) } }], usage: { prompt_tokens: 12, completion_tokens: 5, total_tokens: 17 } })); + return; + } + if (url.pathname === '/api/status/snapshot') data = { members: [{ id: 'kai', name: 'Kai', role: 'Engineer' }] }; + else if (url.pathname === '/api/control-plane/members/kai') data = { id: 'kai', name: 'Kai', personality: 'Precise', revision: 4 }; + else if (url.pathname === '/api/members/presence') data = { presence: { kai: { state: 'working' } } }; + else if (url.pathname === '/api/runs' || url.pathname === '/api/goals') data = []; + else if (url.pathname === '/api/channels') data = { agentChannelAccess: { kai: { agent: { id: 'kai', name: 'Kai', role: 'Engineer', workspace: '/fixture/member-project' } } } }; + else if (url.pathname === '/api/queues/kai/items' && request.method === 'POST') { messages.push(body); data = { id: 'receipt-1', status: 'queued' }; } + else if (url.pathname === '/api/queues/kai') data = { items: [], paused: false }; + else if (url.pathname === '/api/members/kai/transcript') data = { messages: [{ role: 'agent', body: 'SQUAD_DIRECT_REPLY' }], live: false }; + else { response.writeHead(404).end(); return; } + response.writeHead(200, { 'content-type': 'application/json' }); + response.end(JSON.stringify({ success: true, data })); + }); + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); + const address = server.address(); + if (!address || typeof address === 'string') throw new Error('Fixture server unavailable'); + const origin = `http://127.0.0.1:${address.port}`; + const state = await createTempAutohandHome({ config: { openrouter: { baseUrl: origin, model: 'openai/gpt-4o-mini' }, mcp: { enabled: true, servers: [] }, agent: { autoMemory: false, maxIterations: 4 }, ui: { promptSuggestions: false }, features: { automaticSpecialists: false, fullScreenMode } } }); + let session: Session | undefined; + try { + const binary = process.env.AUTOHAND_SQUAD_TEST_BINARY; + session = await launchBuiltAutohand(['--path', state.workspaceRoot, '--config', state.configPath, '--yes'], { ...(binary ? { runtimeCommand: binary, entrypoint: null } : {}), autohandHome: state.autohandHome, cwd: state.workspaceRoot, cols: 120, rows: 30, env: { AUTOHAND_DISABLE_SQUAD: '0', AUTOHAND_SQUAD_BRIDGE_URL: origin, AUTOHAND_SQUAD_TEAM_TOKEN: undefined, AUTOHAND_SQUAD_TEAM_URL: undefined, AUTOHAND_SQUAD_MEMBER: undefined } }); + await session.waitForText('❯', { timeout: 20000 }); + const frame = await selectSquadAgentAndMessage(session); + expect(frame).toContain('SQUAD_DIRECT_REPLY'); + expect(messages).toEqual([{ text: 'Review the CLI fixture', workspace: '/fixture/member-project' }]); + await invokeBuiltinSquadMcp(session); + expect([...advertisedTools]).toContain('tool_search'); + expect(squadDiscovered).toBe(true); + expect(squadToolReply).toBe(true); + expect(modelCalls).toBe(3); + await exitInteractive(session); + } finally { + session?.close(); + server.closeAllConnections(); + await new Promise(resolve => server.close(() => resolve())); + await state.cleanup(); + } +}, 90000); diff --git a/tests/tuistory/tmux-startup.tuistory.test.ts b/tests/tuistory/tmux-startup.tuistory.test.ts new file mode 100644 index 000000000..ceeaa03b8 --- /dev/null +++ b/tests/tuistory/tmux-startup.tuistory.test.ts @@ -0,0 +1,77 @@ +import { execFileSync } from 'node:child_process'; +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; +import type { Session } from 'tuistory'; +import { TmuxTestServer } from '../../src/testing/drivers/tmux-driver.js'; +import { resolveZitBin } from '../../src/utils/sessionZit.js'; +import { launchTuistorySession, exitInteractive } from '../../src/testing/drivers/tuistory-driver.js'; +import { outputViewport } from '../../src/testing/scenarios/compactOutputScenario.js'; +import { createTempAutohandHome, type TuistoryTempState } from './helpers/autohandTuistory.js'; + +let compiledDirectory: string | undefined; +let executable: string; +let session: Session | undefined; +let state: TuistoryTempState | undefined; +let server: TmuxTestServer | undefined; + +describe.skipIf(process.platform === 'win32')('standalone tmux startup', () => { + beforeAll(async () => { + if (process.env.AUTOHAND_TUISTORY_EXECUTABLE) { + executable = path.resolve(process.env.AUTOHAND_TUISTORY_EXECUTABLE); + return; + } + compiledDirectory = await mkdtemp(path.join(os.tmpdir(), 'autohand-tmux-compiled-')); + executable = path.join(compiledDirectory, 'autohand'); + execFileSync('bun', ['build', path.resolve('src/index.ts'), '--compile', '--external', 'node-llama-cpp', '--outfile', executable], + { cwd: process.cwd(), timeout: 60_000, stdio: 'pipe' }); + }); + afterAll(async () => { if (compiledDirectory) await rm(compiledDirectory, { recursive: true, force: true }); }); + afterEach(async () => { session?.close(); server?.stop(); await state?.cleanup(); session = undefined; state = undefined; server = undefined; }); + + it.each(['current-directory', 'worktree', 'zit'])('starts %s isolation, preserves resize input, and exits cleanly', async isolation => { + state = await createTempAutohandHome({ initializeGit: isolation !== 'current-directory', config: { + features: { fullScreenMode: true }, agent: { autoMemory: false }, ui: { promptSuggestions: false }, + } }); + if (isolation !== 'current-directory') { + await writeFile(path.join(state.workspaceRoot, 'README.md'), '# Terminal fixture\n'); + execFileSync('git', ['add', '.'], { cwd: state.workspaceRoot }); + execFileSync('git', ['-c', 'user.name=Test', '-c', 'user.email=test@example.com', 'commit', '-qm', 'Initialize terminal fixture'], { cwd: state.workspaceRoot }); + } + server = new TmuxTestServer(); + const searchPath = await server.prepare(path.join(state.autohandHome, 'bin')); + const flags = isolation === 'worktree' ? ['--worktree', 'tmux-fixture'] : isolation === 'zit' ? ['--zit'] : []; + session = await launchTuistorySession({ command: '/usr/bin/env', + args: ['TERM=xterm-256color', executable, '--tmux', ...flags, '--config', state.configPath, '--offline', '--yes'], + cwd: state.workspaceRoot, cols: 120, rows: 36, waitForDataTimeout: 20_000, + env: { ...process.env, PATH: searchPath, TMUX: undefined, TMUX_PANE: undefined, + CI: 'false', TERM: 'xterm-256color', AUTOHAND_HOME: state.autohandHome, + AUTOHAND_OFFLINE: '1', AUTOHAND_DISABLE_COMPUTER_USE: '1', AUTOHAND_SKIP_UPDATE_CHECK: '1', + AUTOHAND_NO_BROWSER: '1', AUTOHAND_SKIP_PING: '1', AUTOHAND_DISABLE_AUTO_REPORT: '1', + ZIT_HOME: path.join(state.autohandHome, 'zit-home'), + ZIT_BIN: process.env.AUTOHAND_TUISTORY_ZIT_BIN ?? resolveZitBin(), + AUTOHAND_TMUX_LAUNCHED: undefined }, + }); + await session.waitForText('Plan, search, build anything', { timeout: 20_000 }); + await session.type('preserved tmux draft'); + const historySize = server.snapshot().historySize; + for (const [cols, rows] of [[100, 30], [70, 24], [120, 36]]) { + session.resize({ cols, rows }); + await session.waitForText('preserved tmux draft'); + await vi.waitFor(() => { + const pane = server!.snapshot(); + expect(pane.historySize).toBe(historySize); + expect(pane.text.match(/^>_ Autohand Code/gm), pane.text).toHaveLength(1); + expect(pane.text.match(/❯ preserved tmux draft/g), pane.text).toHaveLength(1); + const screen = outputViewport(session!); + expect(screen.match(/^>_ Autohand Code/gm), screen).toHaveLength(1); + expect(screen.match(/❯ preserved tmux draft/g), screen).toHaveLength(1); + }, { timeout: 5_000, interval: 50 }); + } + await exitInteractive(session); + expect(session.exitInfo?.exitCode).toBe(0); + expect(session.readAll()).not.toContain('Module not found'); + expect(session.readAll()).not.toContain('--zit cannot be used with --tmux'); + }); +}); diff --git a/tests/ui/ink/InkRenderer.pause-resume.test.ts b/tests/ui/ink/InkRenderer.pause-resume.test.ts index 5c289d127..af7cb8736 100644 --- a/tests/ui/ink/InkRenderer.pause-resume.test.ts +++ b/tests/ui/ink/InkRenderer.pause-resume.test.ts @@ -527,10 +527,11 @@ describe('full-screen renderer lifecycle', () => { process.stdout.write('\x1b[?2026l'); await Promise.resolve(); const frame = writes.join(''); - expect(frame).toContain('\x1b[2J\x1b[H'); + expect(frame).not.toMatch(/\x1b\[(?:[023])?J/u); + expect(frame).toContain('\x1b[1;1H\x1b[2K'); expect(writes).toHaveLength(1); - expect(writes[0]).toContain('\x1b[2J\x1b[Hresized frame'); - expect(frame.indexOf('\x1b[?2026h')).toBeLessThan(frame.indexOf('\x1b[2J\x1b[H')); + expect(writes[0]).toContain('\x1b[Hresized frame'); + expect(frame.indexOf('\x1b[?2026h')).toBeLessThan(frame.indexOf('\x1b[1;1H\x1b[2K')); expect(frame.indexOf('resized frame')).toBeLessThan(frame.lastIndexOf('\x1b[?2026l')); writes.length = 0; process.stdout.write('\x1b[?2026h'); diff --git a/tests/ui/ink/InkRenderer.resize.test.ts b/tests/ui/ink/InkRenderer.resize.test.ts index ef275fcc6..eaf4783a7 100644 --- a/tests/ui/ink/InkRenderer.resize.test.ts +++ b/tests/ui/ink/InkRenderer.resize.test.ts @@ -30,6 +30,7 @@ vi.mock('ink', () => { Transform: (() => null) as any, measureElement: vi.fn(), }; + }); // Mock safeSetRawMode to actually call setRawMode so our spy tracks it @@ -236,10 +237,10 @@ describe('terminal resize painting', () => { process.stdout.write('\x1b[?2026l'); await Promise.resolve(); const frame = writes.join(''); - expect(frame).toContain('\x1b[2J\x1b[H'); + expect(frame).toContain('\x1b[H'); expect(writes).toHaveLength(1); - expect(writes[0]).toContain('\x1b[2J\x1b[Hresized frame'); - expect(frame.indexOf('\x1b[?2026h')).toBeLessThan(frame.indexOf('\x1b[2J\x1b[H')); + expect(writes[0]).toContain('\x1b[Hresized frame'); + expect(frame.indexOf('\x1b[?2026h')).toBeLessThan(frame.indexOf('\x1b[H')); expect(frame.indexOf('resized frame')).toBeLessThan(frame.lastIndexOf('\x1b[?2026l')); writes.length = 0; process.stdout.write('\x1b[?2026h'); @@ -271,4 +272,60 @@ describe('terminal resize painting', () => { }); }); + it('does not archive the old full-screen frame through iTerm2 erase-display handling during resize', async () => { + await withInteractiveTerminal(async writes => { + const renderer = new InkRenderer({ fullScreenMode: true, onInstruction() {}, onEscape() {}, onCtrlC() {} }); + try { + renderer.start(); + await Promise.resolve(); + writes.length = 0; + resizeTo(80, 24); + process.stdout.write('\x1b[?2026h'); + process.stdout.write('replacement screen'); + process.stdout.write('\x1b[?2026l'); + await Promise.resolve(); + expect(writes.join('')).not.toMatch(/\x1b\[(?:[023])?J/u); + expect(writes.join('')).toContain('\x1b[Hreplacement screen'); + for (let row = 1; row <= 24; row++) { + expect(writes.join('')).toContain(`\x1b[${row};1H\x1b[2K`); + } + } finally { await renderer.stop(); } + }); + }); + + it.each([true, false])('handles Ink overflow clears without losing scrollback with fullScreen=%s', async fullScreenMode => { + await withInteractiveTerminal(async writes => { + const renderer = new InkRenderer({ fullScreenMode, onInstruction() {}, onEscape() {}, onCtrlC() {} }); + try { + renderer.start(); + await Promise.resolve(); + writes.length = 0; + process.stdout.write('\x1b[?2026h'); + process.stdout.write('\x1b[2J\x1b[3J\x1b[Hoverflow frame'); + process.stdout.write('\x1b[?2026l'); + await Promise.resolve(); + if (fullScreenMode) { + expect(writes.join('')).not.toMatch(/\x1b\[(?:[023])?J/u); + expect(writes.join('')).toContain('\x1b[1;1H\x1b[2K'); + } else expect(writes.join('')).toContain('\x1b[2J\x1b[3J\x1b[H'); + expect(writes.join('')).toContain('\x1b[Hoverflow frame'); + } finally { await renderer.stop(); } + }); + }); + it.each([true, false])('keeps line advancement inside the viewport with fullScreen=%s', async fullScreenMode => { + await withInteractiveTerminal(async writes => { + const renderer = new InkRenderer({ fullScreenMode, onInstruction() {}, onEscape() {}, onCtrlC() {} }); + try { + renderer.start(); + await Promise.resolve(); + writes.length = 0; + process.stdout.write('\x1b[?2026h'); + process.stdout.write('first row\nlast row\n'); + process.stdout.write('\x1b[?2026l'); + await Promise.resolve(); + expect(writes.join('')).toContain(fullScreenMode ? 'first row\x1b[1Elast row\x1b[1E' : 'first row\nlast row\n'); + } finally { await renderer.stop(); } + }); + }); + }); diff --git a/tests/utils/commandModelOutput.test.ts b/tests/utils/commandModelOutput.test.ts new file mode 100644 index 000000000..40bad34ed --- /dev/null +++ b/tests/utils/commandModelOutput.test.ts @@ -0,0 +1,76 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { mkdtemp, readFile, rm, stat } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { CommandModelOutput, resolveMaxCommandOutputChars } from '../../src/utils/commandModelOutput.js'; +const directories: string[] = []; +afterEach(async () => { vi.unstubAllEnvs(); await Promise.all(directories.splice(0).map(dir => rm(dir, { recursive: true, force: true }))); }); +describe('model command output', () => { + it.each([30000, 60000])('uses a compact overflow preview within the %s ceiling while preserving intact logs', async limit => { + const directory = await mkdtemp(path.join(tmpdir(), 'ah-model-preview-')); directories.push(directory); + const intact = 'INTACT\n' + 'x'.repeat(20000); + const short = new CommandModelOutput(limit, directory); + short.append(intact); + expect(short.finish(intact)).toBe(intact); + const raw = 'HEAD\n' + 'x'.repeat(limit * 10) + '\nTAIL'; + const long = new CommandModelOutput(limit, directory); + long.append(raw); + const preview = long.finish('$ command\n' + raw); + expect(preview.length).toBeLessThanOrEqual(limit / 2); + expect(preview).toContain('HEAD'); + expect(preview).toContain('TAIL'); + expect(preview).toContain('output truncated:'); + expect(await readFile(long.filePath!, 'utf8')).toBe(raw); + }); + + it('keeps short output intact and respects the environment override', () => { + expect(resolveMaxCommandOutputChars()).toBe(30000); + vi.stubEnv('AUTOHAND_MAX_COMMAND_OUTPUT_CHARS', '4096'); + expect(resolveMaxCommandOutputChars(8000)).toBe(4096); + const capture = new CommandModelOutput(4096); + capture.append('a short log'); + expect(capture.finish('a short log')).toBe('a short log'); + }); + it('archives every raw chunk beyond the UI limit and bounds the model head and tail together', async () => { + const directory = await mkdtemp(path.join(tmpdir(), 'ah-model-output-')); directories.push(directory); + const capture = new CommandModelOutput(30000, directory); + const chunks = ['HEAD\n', 'x'.repeat(1024 * 1024 + 100000), '\nSTDERR\n', 'z'.repeat(50000), '\n400000\n']; + chunks.forEach(chunk => capture.append(chunk)); + const model = capture.finish(chunks.join('')); + expect(model.length).toBeLessThanOrEqual(30000); + expect(model).toContain('HEAD'); expect(model).toContain('400000'); expect(model).toContain('output truncated:'); + expect(capture.filePath).toBeDefined(); + expect(await readFile(capture.filePath!, 'utf8')).toBe(chunks.join('')); + expect((await stat(capture.filePath!)).mode & 0o777).toBe(0o600); + expect(model).toContain(capture.filePath); + }); + it('reports an unavailable archive without inventing a path and still caps output', () => { + const capture = new CommandModelOutput(2000); + capture.append('x'.repeat(10000)); + const output = capture.finish('x'.repeat(10000)); + expect(output.length).toBeLessThanOrEqual(2000); + expect(output).toContain('not saved'); + expect(capture.filePath).toBeUndefined(); + }); + + it('keeps the smallest observation limit when the archive path and command header are long', async () => { + const directory = await mkdtemp(path.join(tmpdir(), 'ah-model-long-path-')); directories.push(directory); + const session = path.join(directory, ...Array.from({ length: 6 }, () => 'a'.repeat(100))); + const raw = 'HEAD\n' + 'x'.repeat(3000) + '\nTAIL'; + const capture = new CommandModelOutput(1024, session); + capture.append(raw); + const output = capture.finish('$ ' + 'c'.repeat(500) + '\n' + raw); + expect(output.length).toBeLessThanOrEqual(1024); + expect(output).toContain(path.basename(capture.filePath!)); + expect(await readFile(capture.filePath!, 'utf8')).toBe(raw); + expect(output).toContain('TAIL'); + }); + + it.each([1024, 30000])('preserves Unicode boundaries in compact head/tail output with limit %s', limit => { + const capture = new CommandModelOutput(limit); + capture.append('😀'.repeat(limit)); + const output = capture.finish('$ emoji\n' + '😀'.repeat(limit)); + expect(output.length).toBeLessThanOrEqual(limit); + expect(output).not.toMatch(/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(? { }); describe('validateZitOption', () => { - it('rejects --zit with --worktree or --tmux', () => { + it('rejects --zit with --worktree and permits tmux as a terminal transport', () => { expect(validateZitOption({ zit: true, worktree: true })).toContain('--zit cannot be used with --worktree'); expect(validateZitOption({ zit: 'x', worktree: 'feature' })).toContain('--zit cannot be used with --worktree'); - expect(validateZitOption({ zit: true, tmux: true })).toContain('--zit cannot be used with --tmux'); + expect(validateZitOption({ zit: true, tmux: true })).toBeNull(); }); it('rejects modes that do not run a CLI session', () => { diff --git a/tests/utils/tmux.spec.ts b/tests/utils/tmux.spec.ts index 3a9ab05c9..8d34d1327 100644 --- a/tests/utils/tmux.spec.ts +++ b/tests/utils/tmux.spec.ts @@ -49,6 +49,11 @@ describe('tmux utils', () => { expect(cmd).toContain("'fix user'\"'\"'s test'"); }); + it.each(['/$bunfs/root/autohand', 'B:/~BUN/root/autohand'])('relaunches a compiled executable instead of its virtual entrypoint %s', entrypoint => { + const command = buildTmuxLaunchCommand(['bun', entrypoint, '--tmux', '--zit'], '/opt/Autohand Code/autohand'); + expect(command).toBe("AUTOHAND_TMUX_LAUNCHED=1 '/opt/Autohand Code/autohand' --zit"); + }); + it('creates a stable tmux session name format', () => { const name = createTmuxSessionName(); expect(name).toMatch(/^autohand-[a-z0-9]+-[a-f0-9]{4}$/); diff --git a/tsup.config.ts b/tsup.config.ts index 813226af5..5a9e42942 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -36,6 +36,7 @@ export default defineConfig({ onSuccess: async () => { mkdirSync('dist/assets', { recursive: true }); cpSync('assets/icon.png', 'dist/assets/icon.png'); + cpSync('src/squad/vendor/LICENSE', 'dist/assets/squad-mcp-LICENSE'); cpSync('assets/review', 'dist/assets/review', { recursive: true }); mkdirSync('dist/agents/builtin', { recursive: true }); cpSync('src/agents/builtin', 'dist/agents/builtin', { recursive: true }); diff --git a/vitest.setup.ts b/vitest.setup.ts index 55242eb15..11be9aa0b 100644 --- a/vitest.setup.ts +++ b/vitest.setup.ts @@ -9,6 +9,7 @@ * - Keeps the developer's Axo state file out of tests */ import { vi } from 'vitest'; +process.env.AUTOHAND_DISABLE_SQUAD = '1'; // Mock node:sqlite globally to avoid test isolation issues // CursorImporter uses dynamic import which can conflict with per-file mocks diff --git a/vitest.tuistory.config.ts b/vitest.tuistory.config.ts index 0e810e683..aca239736 100644 --- a/vitest.tuistory.config.ts +++ b/vitest.tuistory.config.ts @@ -2,6 +2,8 @@ import { existsSync } from 'node:fs'; import path from 'node:path'; import { defineConfig } from 'vitest/config'; +process.env.AUTOHAND_DISABLE_SQUAD = '1'; + const workerCount = process.env.AUTOHAND_TUISTORY_WORKERS === '2' ? 2 : 1; const distEntry = path.resolve(import.meta.dirname, 'dist/index.js'); From c4fe545f76c8a7d588a1d21c110dd3cf76634767 Mon Sep 17 00:00:00 2001 From: Igor Costa Date: Tue, 10 Nov 2026 13:29:51 +1300 Subject: [PATCH 02/19] Preserve provider selection and bound delegated tool requests Integrate #747 while retaining the active provider when another custom entry is removed and preserving cancellation of active-provider replacement. Clear previous local transport settings when restoring the signed-in cloud account without discarding account identity or explicit cloud gateways. Apply the 128-tool OpenAI-family budget to delegated requests with Code Mode disabled, retain discovery and relevant tools, and select compatible completion parameters for Azure reasoning models. Document the separate Azure Responses and live qualification follow-ups. Co-authored-by: Autohand Evolve --- CHANGELOG.md | 2 + .../cli-pr-integration-2026-11-10.md | 7 +- docs/providers.md | 11 +++ src/core/agent/ProviderConfigManager.ts | 45 +++++++-- src/core/agents/SubAgent.ts | 6 +- src/providers/AzureClient.ts | 7 +- src/providers/AzureProvider.ts | 2 +- src/providers/OpenAIProvider.ts | 21 ++--- src/providers/OpenRouterProvider.ts | 2 +- src/providers/openAICompatibleShared.ts | 7 ++ ...iderConfigManager.providerGrouping.test.ts | 77 ++++++++++++++++ tests/core/agents/SubAgent.test.ts | 91 ++++++++++++++++++- tests/providers/AzureClient.test.ts | 22 +++++ .../providers/nativeToolCapabilities.test.ts | 15 +-- tests/providers/openaiToolLimits.test.ts | 34 +++++++ .../model-provider-groups.tuistory.test.ts | 50 ++++++++++ 16 files changed, 353 insertions(+), 46 deletions(-) create mode 100644 tests/providers/openaiToolLimits.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 51f933f16..ec14f300b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,8 @@ - Bound command output sent to the model while retaining complete oversized output in private session artifacts. Configure the limit with `tools.maxCommandOutputChars` or `AUTOHAND_MAX_COMMAND_OUTPUT_CHARS`. - Discover installed skills through a compact name index and load descriptions on demand. Keep approved completions to one confirmed reply instead of displaying the candidate before its required review. - Require notarization checks for transported macOS CLI and trace binaries, and protect the existing CLI option contract with a committed snapshot. +- Preserve the active provider and model when removing another custom provider, and clear remembered local transport settings when restoring the cloud account. +- Bound OpenAI-family tool requests to 128 definitions, including delegated agents, and use compatible completion parameters for Azure reasoning models. ### Testing diff --git a/docs/maintenance/cli-pr-integration-2026-11-10.md b/docs/maintenance/cli-pr-integration-2026-11-10.md index 8b0897c35..b45ddf82d 100644 --- a/docs/maintenance/cli-pr-integration-2026-11-10.md +++ b/docs/maintenance/cli-pr-integration-2026-11-10.md @@ -1,8 +1,8 @@ # CLI pull-request integration -This candidate combines the remaining Igor-authored work from [#745](https://github.com/autohandai/code-cli/pull/745), [#743](https://github.com/autohandai/code-cli/pull/743), [#742](https://github.com/autohandai/code-cli/pull/742), [#727](https://github.com/autohandai/code-cli/pull/727), [#690](https://github.com/autohandai/code-cli/pull/690), and [#669](https://github.com/autohandai/code-cli/pull/669), together with the saved-session input fix from `df44f764`. +This candidate combines the remaining Igor-authored work from [#745](https://github.com/autohandai/code-cli/pull/745), [#743](https://github.com/autohandai/code-cli/pull/743), [#742](https://github.com/autohandai/code-cli/pull/742), [#727](https://github.com/autohandai/code-cli/pull/727), [#690](https://github.com/autohandai/code-cli/pull/690), [#669](https://github.com/autohandai/code-cli/pull/669), and [#747](https://github.com/autohandai/code-cli/pull/747), together with the saved-session input fix from `df44f764`. -The integration starts from main at `150be8938f3bacd4264f0622e88dcffc465bfc8f`. It preserves the intervening explicit Git-initialization change, atomic configuration writes, partial cloud-history protection, extension reload coordination, and permission enforcement. Provider PR #747 is outside this batch. +The integration starts from main at `150be8938f3bacd4264f0622e88dcffc465bfc8f`. It preserves the intervening explicit Git-initialization change, atomic configuration writes, partial cloud-history protection, extension reload coordination, and permission enforcement. The first validation revision was paused when #747 was added to the requested scope; the expanded revision receives fresh combined checks. | Area | Resulting behavior | Reference | | --- | --- | --- | @@ -15,6 +15,9 @@ The integration starts from main at `150be8938f3bacd4264f0622e88dcffc465bfc8f`. | Saved sessions | Accept composer edits while restoring history and defer submitted work until restoration completes. | [Resume terminal tests](../../tests/tuistory/resume.tuistory.test.ts) | | CLI contract | Bootstrap the current 199 option definitions across 52 command paths, and compare future changes with the committed main snapshot. | [Option contract](../compatibility/claude-code/README.md) | | macOS distribution | Check transported CLI and trace binaries for notarization in addition to existing signature checks. | [Release workflow](../../.github/workflows/release.yml) | +| Providers | Keep the active provider when removing an inactive custom entry; preserve it if replacement is cancelled. Cloud restoration clears previous local transport settings. Bound OpenAI-family native tool requests, including delegated agents, and use compatible Azure reasoning parameters. | [Provider reference](../providers.md) | + +The provider integration adds regressions for child-agent tool budgets and local-to-cloud account restoration. Azure Responses transport and live Azure tool qualification remain explicitly separate follow-ups; this candidate corrects Chat Completions parameters. Issues #719 and #720 remain open until the corresponding fix is released. Independent review identified two follow-ups. Newline MCP frames now have a 64 MiB bound with pending-request and listener cleanup. F-0402 now uses the existing names-only skill discovery path by default, keeps normal-sized command observations intact, and uses a compact preview only after output exceeds its configured ceiling. Approved completions hold their candidate while review is pending, then publish one confirmed reply. Explicitly labeled rejected or interrupted drafts retain their existing behavior. diff --git a/docs/providers.md b/docs/providers.md index d27766171..059bd103b 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -10,6 +10,7 @@ Autohand supports multiple LLM providers, giving you flexibility to choose betwe - [OpenRouter](#openrouter) - [Anthropic](#anthropic) - [OpenAI](#openai) + - [Azure OpenAI](#azure-openai) - [LLM Gateway](#llm-gateway) - [DeepSeek](#deepseek) - [AWS Bedrock](#aws-bedrock) @@ -227,6 +228,8 @@ See Anthropic's [model overview](https://platform.claude.com/docs/en/about-claud Direct access to OpenAI's API for GPT-5, o3, and other OpenAI models. +The native tool envelope is limited to 128 function definitions for OpenAI, Azure, and OpenRouter models under `openai/`. The runtime retains discovery and relevant tools within that budget, including delegated agents with code mode disabled. Other OpenRouter model families keep their own capability settings. + **Setup:** 1. Choose one of these authentication methods: @@ -268,6 +271,12 @@ Or use ChatGPT auth: --- +### Azure OpenAI + +Azure currently uses the deployment-scoped Chat Completions API. Recognized GPT-5 and later models and o1/o3/o4 reasoning families use `max_completion_tokens` and omit temperature; `gpt-4o` retains its existing `max_tokens` and temperature behavior. Configure the actual model family separately from the deployment name so the correct parameters are selected. + +This parameter correction does not add an Azure Responses transport. Deployments that require Responses remain a separate compatibility follow-up; mocked Chat Completions tests do not qualify a live Azure tool turn. See Microsoft's [reasoning model guidance](https://learn.microsoft.com/en-us/azure/foundry/openai/how-to/reasoning) and [Responses API documentation](https://learn.microsoft.com/en-us/azure/foundry/openai/how-to/responses) for the deployment's supported API path. + ### LLM Gateway LLM Gateway provides a unified API for multiple LLM providers with a single integration point. It's OpenAI-compatible and supports models from OpenAI, Anthropic, Google, and more. @@ -830,6 +839,8 @@ Custom provider telemetry and session sync include the provider id, display name You can remove a custom provider from `/model` by opening that provider's settings and choosing **Remove custom provider**. +Removing an inactive custom provider preserves the current provider and model. Removing the active provider restores the signed-in Autohand account, preserving its cloud configuration or clearing a previous local model's transport settings. Without a signed-in account, select a replacement; cancelling keeps the active custom provider and its saved configuration. + --- ## Environment Variables diff --git a/src/core/agent/ProviderConfigManager.ts b/src/core/agent/ProviderConfigManager.ts index 8311d405c..19a3019c3 100644 --- a/src/core/agent/ProviderConfigManager.ts +++ b/src/core/agent/ProviderConfigManager.ts @@ -200,9 +200,11 @@ export class ProviderConfigManager { } } - private async promptProviderSelection(): Promise { + private async promptProviderSelection(excludedProvider?: ProviderName): Promise { // Use ProviderFactory to get platform-aware list (includes MLX on Apple Silicon). - const allProviders = ProviderFactory.getProviderNames(this.runtime.config); + const allProviders = ProviderFactory.getProviderNames(this.runtime.config).filter( + provider => provider !== excludedProvider, + ); const options = buildProviderSelectionOptions({ providers: allProviders, @@ -2505,21 +2507,44 @@ export class ProviderConfigManager { return; } + const wasActive = this.getActiveProvider() === provider; + const accountToken = this.runtime.config.auth?.token ?? this.runtime.config.autohandai?.accountToken; + if (wasActive && !accountToken) { + await this.promptProviderSelection(provider); + if (this.getActiveProvider() === provider) return; + } + const nextCustomProviders = { ...(this.runtime.config.customProviders ?? {}) }; delete nextCustomProviders[id]; this.runtime.config.customProviders = Object.keys(nextCustomProviders).length > 0 ? nextCustomProviders : undefined; - this.runtime.config.provider = "openrouter"; - const fallbackModel = - getProviderConfig(this.runtime.config, "openrouter")?.model ?? - getProviderDefaultModel("openrouter", "openrouter/auto"); - this.runtime.options.model = fallbackModel; + if (wasActive && accountToken) { + const settings = this.runtime.config.autohandai; + const wasLocal = settings?.plan === "local"; + const fallbackModel = (!wasLocal ? settings?.model : undefined) + ?? getProviderDefaultModel("autohandai", "fantail"); + this.runtime.config.autohandai = { + ...settings, + plan: "cloud", + authMode: "account", + accountToken, + model: fallbackModel, + ...(wasLocal ? { + baseUrl: AUTOHAND_AI_DEFAULT_BASE_URL, + contextWindow: getAutohandAICloudModelContextWindow(fallbackModel), + port: undefined, + serverCommand: undefined, + } : {}), + }; + this.runtime.config.provider = "autohandai"; + this.runtime.options.model = fallbackModel; + this.resetLlmClient("autohandai", fallbackModel); + this.resetContextPercent(); + this.emitStatus(); + } await saveConfig(this.runtime.config); - this.resetLlmClient("openrouter", fallbackModel); - this.resetContextPercent(); - this.emitStatus(); console.log( chalk.green( diff --git a/src/core/agents/SubAgent.ts b/src/core/agents/SubAgent.ts index 07732fdec..eb8bc5cc1 100644 --- a/src/core/agents/SubAgent.ts +++ b/src/core/agents/SubAgent.ts @@ -26,7 +26,7 @@ import { type ToolDefinition, type ToolManagerOptions, } from '../toolManager.js'; -import { ToolFilter } from '../toolFilter.js'; +import { ToolFilter, limitToolSelection } from '../toolFilter.js'; import { ActionExecutor } from '../actionExecutor.js'; import { AgentDelegator, @@ -453,7 +453,6 @@ export class SubAgent { // Get function definitions for LLM function calling const definitions = this.toolManager.toFunctionDefinitions(); - const tools = selectCodeModeTools(definitions, this.llm.getCapabilities?.().maxTools) ?? definitions; const providerSettings = this.options.featureConfig ? getProviderConfig(this.options.featureConfig, this.llm.getName?.() as ProviderName | undefined) : null; @@ -474,6 +473,9 @@ export class SubAgent { this.consumePendingInstructions(); this.refreshSkillsPrompt(); await peerRuntime?.safeBoundary(); + const maxTools = this.llm.getCapabilities?.().maxTools; + const tools = selectCodeModeTools(definitions, maxTools) + ?? limitToolSelection(definitions, definitions, this.conversation.history(), maxTools); const withholdToolsForRequest = withholdToolsNextRequest; withholdToolsNextRequest = false; const requestTools = this.supportsNativeToolCalling diff --git a/src/providers/AzureClient.ts b/src/providers/AzureClient.ts index b37d23261..550be85eb 100644 --- a/src/providers/AzureClient.ts +++ b/src/providers/AzureClient.ts @@ -12,6 +12,7 @@ import type { FunctionDefinition, MultimodalMessage, } from "../types.js"; +import { usesMaxCompletionTokens } from "./openAICompatibleShared.js"; import { AzureTokenManager } from "./azure/tokenManager.js"; import { normalizeLLMUsage } from "./usage.js"; import { normalizeProviderFinishReason } from "./finishReason.js"; @@ -157,10 +158,12 @@ export class AzureClient { } async complete(request: LLMRequest): Promise { + const model = request.model ?? this.defaultModel; const payload: Record = { messages: sanitizeMessages(request.messages), - temperature: request.temperature ?? 0.2, - max_tokens: request.maxTokens ?? 16000, + ...(usesMaxCompletionTokens(model) + ? { max_completion_tokens: request.maxTokens ?? 16000 } + : { temperature: request.temperature ?? 0.2, max_tokens: request.maxTokens ?? 16000 }), stream: request.stream ?? false, }; diff --git a/src/providers/AzureProvider.ts b/src/providers/AzureProvider.ts index d21a4a6be..65978aa3c 100644 --- a/src/providers/AzureProvider.ts +++ b/src/providers/AzureProvider.ts @@ -37,7 +37,7 @@ export class AzureProvider implements LLMProvider { } getCapabilities(): LLMProviderCapabilities { - return { nativeToolCalling: true }; + return { nativeToolCalling: true, maxTools: 128 }; } setModel(model: string): void { diff --git a/src/providers/OpenAIProvider.ts b/src/providers/OpenAIProvider.ts index ac7441ded..a25eb89cb 100644 --- a/src/providers/OpenAIProvider.ts +++ b/src/providers/OpenAIProvider.ts @@ -4,13 +4,14 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type { LLMProvider } from './LLMProvider.js'; +import type { LLMProvider, LLMProviderCapabilities } from './LLMProvider.js'; import type { ContentPart, MultimodalMessage, LLMRequest, LLMResponse, LLMToolCall, FunctionDefinition, ReasoningEffort, OpenAISettings, OpenAIChatGPTAuth } from '../types.js'; import { ApiError, classifyApiError, type ApiErrorCode } from './errors.js'; import { isChatGPTAuthExpired, refreshChatGPTAuth } from './openaiAuth.js'; import { normalizeLLMUsage } from './usage.js'; import { normalizeProviderFinishReason } from './finishReason.js'; import { normalizeOutboundMessages } from './messagePayload.js'; +import { usesMaxCompletionTokens } from './openAICompatibleShared.js'; import { getProviderDefaultModel, getProviderModelIds, @@ -214,9 +215,10 @@ export class OpenAIProvider implements LLMProvider { this.model = model; } - getCapabilities(): { nativeToolCalling: boolean } { + getCapabilities(): LLMProviderCapabilities { return { nativeToolCalling: true, + maxTools: 128, }; } @@ -254,13 +256,13 @@ export class OpenAIProvider implements LLMProvider { temperature: request.temperature || 0.7, // Newer OpenAI models (gpt-5.x, o-series) require max_completion_tokens // instead of max_tokens. Use the correct parameter based on model. - ...(this.usesMaxCompletionTokens(model) + ...(usesMaxCompletionTokens(model) ? { max_completion_tokens: request.maxTokens } : { max_tokens: request.maxTokens }) }; // Add reasoning effort when configured (with runtime validation) - if (this.usesMaxCompletionTokens(model) + if (usesMaxCompletionTokens(model) && this.reasoningEffort && VALID_REASONING_EFFORTS.has(this.reasoningEffort)) { body.reasoning_effort = mustDisableChatCompletionReasoningForTools(model, hasTools) ? 'none' @@ -953,17 +955,6 @@ export class OpenAIProvider implements LLMProvider { })); } - /** - * Determine if a model requires `max_completion_tokens` instead of `max_tokens`. - * OpenAI's newer models (gpt-5.x, o-series) reject `max_tokens` with a 400 error. - */ - private usesMaxCompletionTokens(model: string): boolean { - const lower = model.toLowerCase(); - const gptGeneration = /^gpt-(\d+)(?:$|[.\-_])/.exec(lower)?.[1]; - return (gptGeneration !== undefined && Number(gptGeneration) >= 5) - || /^o[134](?:$|[-_.])/.test(lower); - } - private extractResponsesContent(data: OpenAIResponsesResponse): string { if (typeof data.output_text === 'string' && data.output_text.trim()) { return data.output_text; diff --git a/src/providers/OpenRouterProvider.ts b/src/providers/OpenRouterProvider.ts index 13f7fab53..66bc922ba 100644 --- a/src/providers/OpenRouterProvider.ts +++ b/src/providers/OpenRouterProvider.ts @@ -29,7 +29,7 @@ export class OpenRouterProvider implements LLMProvider { } getCapabilities(): LLMProviderCapabilities { - return { nativeToolCalling: true }; + return { nativeToolCalling: true, ...(this.model.toLowerCase().startsWith("openai/") ? { maxTools: 128 } : {}) }; } setModel(model: string): void { diff --git a/src/providers/openAICompatibleShared.ts b/src/providers/openAICompatibleShared.ts index 37b2b734e..5167b0128 100644 --- a/src/providers/openAICompatibleShared.ts +++ b/src/providers/openAICompatibleShared.ts @@ -27,3 +27,10 @@ export function buildChatTemplateKwargs(kwargs: NvidiaChatTemplateKwargs): Recor if (kwargs.clear_thinking !== undefined) result.clear_thinking = kwargs.clear_thinking; return result; } + +export function usesMaxCompletionTokens(model: string): boolean { + const lower = model.toLowerCase(); + const generation = /^gpt-(\d+)(?:$|[.\-_])/.exec(lower)?.[1]; + return (generation !== undefined && Number(generation) >= 5) + || /^o[134](?:$|[-_.])/.test(lower); +} diff --git a/tests/core/agent/ProviderConfigManager.providerGrouping.test.ts b/tests/core/agent/ProviderConfigManager.providerGrouping.test.ts index 102034797..a5d388b04 100644 --- a/tests/core/agent/ProviderConfigManager.providerGrouping.test.ts +++ b/tests/core/agent/ProviderConfigManager.providerGrouping.test.ts @@ -6,6 +6,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { ModalOption } from "../../../src/ui/ink/components/Modal.js"; +import { AUTOHAND_AI_DEFAULT_BASE_URL, getAutohandAICloudModelContextWindow } from "../../../src/providers/AutohandAIProvider.js"; +import { getProviderDefaultModel } from "../../../src/providers/modelCatalog.js"; var mockShowModal = vi.fn(); var mockShowInput = vi.fn(); @@ -330,4 +332,79 @@ describe("ProviderConfigManager grouped provider selection", () => { expect(configureProvider).toHaveBeenCalledWith("autohandai"); }); + it("leaves the active Autohand provider and model unchanged when removing another provider", async () => { + runtime.config.provider = "autohandai"; + runtime.config.autohandai = { authMode: "account", accountToken: "fixture", model: "fantail" }; + runtime.options.model = "fantail"; + mockShowConfirm.mockResolvedValueOnce(true); + const internals = manager as unknown as { removeCustomProvider(id: "custom:zeta-gateway"): Promise; resetLlmClient(provider: string, model: string): void }; + const reset = vi.spyOn(internals, "resetLlmClient").mockImplementation(() => {}); + await internals.removeCustomProvider("custom:zeta-gateway"); + expect(runtime.config.provider).toBe("autohandai"); + expect(runtime.options.model).toBe("fantail"); + expect(runtime.config.customProviders["zeta-gateway"]).toBeUndefined(); + expect(reset).not.toHaveBeenCalled(); + expect(mockSaveConfig).toHaveBeenCalledOnce(); + }); + + it("falls back to the signed-in Autohand account when removing the active provider", async () => { + runtime.config.provider = "custom:zeta-gateway"; + runtime.config.auth = { token: "fixture-account" }; + runtime.config.autohandai = { model: "fantail" }; + runtime.options.model = "zeta-large"; + mockShowConfirm.mockResolvedValueOnce(true); + const internals = manager as unknown as { removeCustomProvider(id: "custom:zeta-gateway"): Promise; resetLlmClient(provider: string, model: string): void }; + const reset = vi.spyOn(internals, "resetLlmClient").mockImplementation(() => {}); + await internals.removeCustomProvider("custom:zeta-gateway"); + expect(runtime.config.provider).toBe("autohandai"); + expect(runtime.options.model).toBe("fantail"); + expect(runtime.config.autohandai.authMode).toBe("account"); + expect(reset).toHaveBeenCalledWith("autohandai", "fantail"); + }); + + it("keeps an active provider when a signed-out user cancels the replacement picker", async () => { + runtime.config.provider = "custom:zeta-gateway"; + runtime.options.model = "zeta-large"; + mockShowConfirm.mockResolvedValueOnce(true); + mockShowModal.mockResolvedValueOnce(null); + const internals = manager as unknown as { removeCustomProvider(id: "custom:zeta-gateway"): Promise; resetLlmClient(provider: string, model: string): void }; + vi.spyOn(internals, "resetLlmClient").mockImplementation(() => {}); + await internals.removeCustomProvider("custom:zeta-gateway"); + expect(runtime.config.provider).toBe("custom:zeta-gateway"); + expect(runtime.config.customProviders["zeta-gateway"]).toBeDefined(); + expect(lastProviderModalOptions().some(option => option.value === "custom:zeta-gateway")).toBe(false); + expect(mockSaveConfig).not.toHaveBeenCalled(); + }); + + it("restores the cloud endpoint instead of reusing a previous local model server", async () => { + runtime.config.provider = "custom:zeta-gateway"; + runtime.config.auth = { token: "fixture-account" }; + runtime.config.autohandai = { + plan: "local", model: "mlx-community/local-model", baseUrl: "http://127.0.0.1:8080/v1", + port: 8080, serverCommand: "mlx_lm.server --model local-model", contextWindow: 8192, accountId: "selected-account", + }; + mockShowConfirm.mockResolvedValueOnce(true); + const internals = manager as unknown as { removeCustomProvider(id: "custom:zeta-gateway"): Promise; resetLlmClient(provider: string, model: string): void }; + vi.spyOn(internals, "resetLlmClient").mockImplementation(() => {}); + await internals.removeCustomProvider("custom:zeta-gateway"); + const fallbackModel = getProviderDefaultModel("autohandai", "fantail"); + expect(runtime.config.autohandai).toMatchObject({ + plan: "cloud", authMode: "account", accountToken: "fixture-account", accountId: "selected-account", model: fallbackModel, + baseUrl: AUTOHAND_AI_DEFAULT_BASE_URL, contextWindow: getAutohandAICloudModelContextWindow(fallbackModel), + }); + expect(runtime.config.autohandai.port).toBeUndefined(); + expect(runtime.config.autohandai.serverCommand).toBeUndefined(); + }); + + it("keeps an explicitly configured cloud gateway when restoring the signed-in account", async () => { + runtime.config.provider = "custom:zeta-gateway"; + runtime.config.auth = { token: "fixture-account" }; + runtime.config.autohandai = { plan: "cloud", baseUrl: "https://gateway.example/v1", model: "fantail" }; + mockShowConfirm.mockResolvedValueOnce(true); + const internals = manager as unknown as { removeCustomProvider(id: "custom:zeta-gateway"): Promise; resetLlmClient(provider: string, model: string): void }; + vi.spyOn(internals, "resetLlmClient").mockImplementation(() => {}); + await internals.removeCustomProvider("custom:zeta-gateway"); + expect(runtime.config.autohandai.baseUrl).toBe("https://gateway.example/v1"); + }); + }); diff --git a/tests/core/agents/SubAgent.test.ts b/tests/core/agents/SubAgent.test.ts index ba227c052..b9d87e6f6 100644 --- a/tests/core/agents/SubAgent.test.ts +++ b/tests/core/agents/SubAgent.test.ts @@ -9,8 +9,8 @@ import { AgentRegistry, type AgentDefinition } from '../../../src/core/agents/Ag import type { LLMProvider } from '../../../src/providers/LLMProvider.js'; import type { ActionExecutor } from '../../../src/core/actionExecutor.js'; import { PermissionManager } from '../../../src/permissions/PermissionManager.js'; -import type { ToolAuthorizationOptions } from '../../../src/core/toolManager.js'; -import type { LoadedConfig } from '../../../src/types.js'; +import type { ToolAuthorizationOptions, ToolManager } from '../../../src/core/toolManager.js'; +import type { FunctionDefinition, LoadedConfig } from '../../../src/types.js'; function nativeToolCall(name: string, args: Record, id = `call-${name}`) { return { @@ -21,6 +21,93 @@ function nativeToolCall(name: string, args: Record, id = `call- } describe('SubAgent', () => { + it('caps a native child request while retaining discovery and the requested tool when code mode is disabled', async () => { + const complete = vi.fn().mockResolvedValue({ id: 'bounded', created: 0, raw: null, content: 'Reviewed.' }); + const agent = new SubAgent({ + name: 'reader', description: 'Reader', systemPrompt: 'Inspect assigned source.', tools: ['read_file'], path: '/tmp/reader.md', + }, { + getName: () => 'openai', complete, + getCapabilities: () => ({ nativeToolCalling: true, maxTools: 128 }), + listModels: async () => [], isAvailable: async () => true, setModel: () => {}, + }, {} as ActionExecutor, { + clientContext: 'cli', depth: 1, maxDepth: 1, + featureConfig: { provider: 'openai', features: { codeMode: false } } as LoadedConfig, + }); + const definitions: FunctionDefinition[] = [ + ...Array.from({ length: 140 }, (_, index) => ({ name: `fixture_${index}`, description: 'Inspect source.' })), + { name: 'tool_search', description: 'Discover available tools.' }, + { name: 'read_file', description: 'Read the assigned source.' }, + ]; + const internals = agent as unknown as { toolManager: { toFunctionDefinitions(): FunctionDefinition[] } }; + vi.spyOn(internals.toolManager, 'toFunctionDefinitions').mockReturnValue(definitions); + await expect(agent.run('Use fixture_139 and read_file to inspect the source.')).resolves.toBe('Reviewed.'); + const sent = complete.mock.calls[0][0].tools; + expect(sent).toHaveLength(128); + expect(sent).toEqual(expect.arrayContaining([ + expect.objectContaining({ name: 'tool_search' }), + expect.objectContaining({ name: 'read_file' }), + expect.objectContaining({ name: 'fixture_139' }), + ])); + }); + + it('retains a newly discovered child tool within the next native request budget', async () => { + const complete = vi.fn().mockResolvedValueOnce({ id: 'discovery', created: 0, raw: null, content: '', + toolCalls: [nativeToolCall('tool_search', { query: 'fixture_139' })], + }).mockResolvedValue({ id: 'reviewed', created: 0, raw: null, content: 'Reviewed.' }); + const agent = new SubAgent({ + name: 'reader', description: 'Reader', systemPrompt: 'Inspect assigned source.', tools: ['read_file', 'tool_search'], path: '/tmp/reader.md', + }, { + getName: () => 'openai', complete, + getCapabilities: () => ({ nativeToolCalling: true, maxTools: 128 }), + listModels: async () => [], isAvailable: async () => true, setModel: () => {}, + }, {} as ActionExecutor, { + clientContext: 'cli', depth: 1, maxDepth: 1, + featureConfig: { provider: 'openai', features: { codeMode: false } } as LoadedConfig, + }); + const definitions: FunctionDefinition[] = [ + ...Array.from({ length: 140 }, (_, index) => ({ name: `fixture_${index}`, description: 'Inspect source.' })), + { name: 'tool_search', description: 'Discover available tools.' }, + ]; + const internals = agent as unknown as { toolManager: Pick }; + vi.spyOn(internals.toolManager, 'toFunctionDefinitions').mockReturnValue(definitions); + vi.spyOn(internals.toolManager, 'execute').mockResolvedValue([ + { tool: 'tool_search', success: true, output: '{"name":"fixture_139","description":"Inspect source."}' }, + ]); + await expect(agent.run('Discover a suitable tool, then inspect the source.')).resolves.toBe('Reviewed.'); + expect(complete).toHaveBeenCalledTimes(2); + const [first, next] = complete.mock.calls.map(([request]) => request.tools); + expect(first).toHaveLength(128); + expect(first).not.toEqual(expect.arrayContaining([expect.objectContaining({ name: 'fixture_139' })])); + expect(next).toHaveLength(128); + expect(next).toEqual(expect.arrayContaining([ + expect.objectContaining({ name: 'tool_search' }), expect.objectContaining({ name: 'fixture_139' }), + ])); + }); + + it('sends the bounded command observation instead of raw display output to the child provider', async () => { + const complete = vi.fn().mockResolvedValueOnce({ id: 'command', created: 0, raw: null, content: '', + toolCalls: [nativeToolCall('run_command', { command: 'seq 1 400000' })], + }).mockResolvedValue({ id: 'result', created: 0, raw: null, content: '400000' }); + const executeForTool = vi.fn().mockResolvedValue({ + success: true, output: 'RAW_DISPLAY_ONLY '.repeat(10_000), modelOutput: 'Bounded command result: 400000', + }); + const agent = new SubAgent({ + name: 'reader', description: 'Reader', systemPrompt: 'Inspect assigned source.', tools: ['run_command'], path: '/tmp/reader.md', + }, { + getName: () => 'openai', complete, + getCapabilities: () => ({ nativeToolCalling: true, maxTools: 128 }), + listModels: async () => [], isAvailable: async () => true, setModel: () => {}, + }, { executeForTool } as unknown as ActionExecutor, { + clientContext: 'cli', depth: 1, maxDepth: 1, confirmApproval: async () => true, + featureConfig: { provider: 'openai', features: { codeMode: false } } as LoadedConfig, + }); + await expect(agent.run('Run the command and report the last number.')).resolves.toBe('400000'); + expect(executeForTool).toHaveBeenCalledOnce(); + const observation = complete.mock.calls[1][0].messages.find((message: { role: string }) => message.role === 'tool'); + expect(observation?.content).toBe('Bounded command result: 400000'); + expect(JSON.stringify(complete.mock.calls[1][0])).not.toContain('RAW_DISPLAY_ONLY'); + }); + it.each(['task', 'tool output'])('refuses an oversized %s before sending a child request beyond the configured context window', async source => { const complete = vi.fn(async (_request: Parameters[0]) => ({ id: 'fixture', created: 0, raw: null, content: '', diff --git a/tests/providers/AzureClient.test.ts b/tests/providers/AzureClient.test.ts index def8b1d68..8a613c554 100644 --- a/tests/providers/AzureClient.test.ts +++ b/tests/providers/AzureClient.test.ts @@ -381,4 +381,26 @@ describe('AzureClient', () => { expect(response.finishReason).toBe('tool_calls'); }); }); + describe("reasoning-model request parameters", () => { + it.each(["gpt-5.3-codex", "gpt-6.1-terra", "o3", "o4-mini"])("uses completion tokens without temperature for %s", async model => { + const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(new Response(JSON.stringify({ id: "reasoning", choices: [{ message: { content: "Done", tool_calls: [{ id: "call_1", type: "function", function: { name: "read_file", arguments: "{}" } }] }, finish_reason: "tool_calls" }] }))); + const client = new AzureClient({ model, resourceName: "fixture", deploymentName: "custom-deployment", apiKey: "fixture", authMethod: "api-key" }); + const response = await client.complete({ messages: [{ role: "user", content: "Read the file" }], maxTokens: 4096, temperature: 0.2, tools: [{ name: "read_file", description: "Read a file", parameters: { type: "object", properties: {} } }] }); + const payload = JSON.parse(String(fetchSpy.mock.calls[0][1]?.body)); + expect(payload.max_completion_tokens).toBe(4096); + expect(payload).not.toHaveProperty("max_tokens"); + expect(payload).not.toHaveProperty("temperature"); + expect(response.toolCalls?.[0].function.name).toBe("read_file"); + }); + it("uses a request model override and keeps gpt-4o parameters unchanged", async () => { + const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response(JSON.stringify({ id: "legacy", choices: [{ message: { content: "Done" }, finish_reason: "stop" }] }))); + const client = new AzureClient({ model: "gpt-5.3-codex", resourceName: "fixture", deploymentName: "custom-deployment", apiKey: "fixture", authMethod: "api-key" }); + await client.complete({ model: "gpt-4o", messages: [{ role: "user", content: "Hi" }], temperature: 0 }); + const payload = JSON.parse(String(fetchSpy.mock.calls[0][1]?.body)); + expect(payload.max_tokens).toBe(16000); + expect(payload.temperature).toBe(0); + expect(payload).not.toHaveProperty("max_completion_tokens"); + }); + }); + }); diff --git a/tests/providers/nativeToolCapabilities.test.ts b/tests/providers/nativeToolCapabilities.test.ts index 39c9438f3..7390e8dc7 100644 --- a/tests/providers/nativeToolCapabilities.test.ts +++ b/tests/providers/nativeToolCapabilities.test.ts @@ -110,16 +110,9 @@ describe('native tool capability declarations', () => { }, ]; - expect( - providers.map(({ name, provider }) => ({ - name, - capabilities: provider.getCapabilities?.(), - })), - ).toEqual( - providers.map(({ name }) => ({ - name, - capabilities: { nativeToolCalling: true }, - })), - ); + for (const { name, provider } of providers) { + expect(provider.getCapabilities?.(), name).toMatchObject({ nativeToolCalling: true }); + } + }); }); diff --git a/tests/providers/openaiToolLimits.test.ts b/tests/providers/openaiToolLimits.test.ts new file mode 100644 index 000000000..02da3ea5a --- /dev/null +++ b/tests/providers/openaiToolLimits.test.ts @@ -0,0 +1,34 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { OpenAIProvider } from "../../src/providers/OpenAIProvider.js"; +import { AzureProvider } from "../../src/providers/AzureProvider.js"; +import { OpenRouterProvider } from "../../src/providers/OpenRouterProvider.js"; +import { limitToolSelection } from "../../src/core/toolFilter.js"; +import type { LLMProvider } from "../../src/providers/LLMProvider.js"; + +const tools = Array.from({ length: 140 }, (_, i) => ({ name: `fixture_${i}`, description: "Fixture tool", parameters: { type: "object", properties: {} } })); +const providers: [string, () => LLMProvider][] = [ + ["openai", () => new OpenAIProvider({ model: "gpt-6.1-terra", apiKey: "fixture" })], + ["azure", () => new AzureProvider({ model: "gpt-5.3-codex", apiKey: "fixture", resourceName: "fixture", deploymentName: "fixture" })], + ["openrouter", () => new OpenRouterProvider({ model: "openai/gpt-6.1-terra", apiKey: "fixture" })], +]; +afterEach(() => vi.restoreAllMocks()); +describe("OpenAI-family tool limits", () => { + it.each(providers)("sends 128 of 140 native tools through %s", async (_name, create) => { + const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response(JSON.stringify({ id: "fixture", choices: [{ message: { content: "Done" }, finish_reason: "stop" }], usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 } }), { headers: { "Content-Type": "application/json" } })); + const provider = create(); + const messages = [{ role: "user" as const, content: "Read the project" }]; + const selected = limitToolSelection(tools, tools, messages, provider.getCapabilities?.().maxTools); + await provider.complete({ messages, tools: selected, stream: false }); + const init = fetchSpy.mock.calls[0][1]; + const payload = JSON.parse(String(init?.body)); + expect(payload.tools).toHaveLength(128); + }); + it("changes the OpenRouter limit with the model and leaves other vendors uncapped", () => { + const provider = new OpenRouterProvider({ model: "anthropic/claude-sonnet-5", apiKey: "fixture" }); + expect(provider.getCapabilities().maxTools).toBeUndefined(); + provider.setModel("openai/gpt-6.1-terra"); + expect(provider.getCapabilities().maxTools).toBe(128); + provider.setModel("anthropic/claude-sonnet-5"); + expect(provider.getCapabilities().maxTools).toBeUndefined(); + }); +}); diff --git a/tests/tuistory/model-provider-groups.tuistory.test.ts b/tests/tuistory/model-provider-groups.tuistory.test.ts index d57710079..b9b82ee60 100644 --- a/tests/tuistory/model-provider-groups.tuistory.test.ts +++ b/tests/tuistory/model-provider-groups.tuistory.test.ts @@ -517,4 +517,54 @@ describe('/model provider and Autohand plan journeys Tuistory', () => { await session.press('escape'); await closeInteractive(session); }); + it('removes an inactive custom provider without switching the active hosted model', async () => { + const { session, state } = await launchInteractive(configuredAutohandCloud); + await openProviderList(session); + await session.press('up'); + await session.press('up'); + await session.press('enter'); + await session.text({ timeout: 30_000, waitFor: text => text.includes('Remove custom provider') }); + await session.type('5'); + await session.text({ timeout: 30_000, waitFor: text => text.includes('Remove Acme Gateway from your custom providers?') }); + await session.press('up'); + await session.press('enter'); + const composer = await session.text({ timeout: 30_000, waitFor: text => text.includes('Acme Gateway') && text.includes('removed') && text.includes('❯') }); + expect(composer).toContain('Autohand AI, fantail'); + const saved = JSON.parse(await readFile(state.configPath, 'utf8')); + expect(saved.provider).toBe('autohandai'); + expect(saved.autohandai.model).toBe('fantail'); + expect(saved.customProviders?.['acme-gateway']).toBeUndefined(); + await closeInteractive(session); + }); + + it('restores the signed-in cloud account without retaining an old local endpoint when removing the active provider', async () => { + const localEndpoint = 'http://127.0.0.1:8080/v1'; + const { session, state } = await launchInteractive({ + provider: 'custom:acme-gateway', + autohandai: { + plan: 'local', model: 'mlx-community/previous-model', baseUrl: localEndpoint, + accountToken: 'fixture-account', accountId: 'selected-account', port: 8080, + serverCommand: 'mlx_lm.server --model previous-model', contextWindow: 8192, + }, + }); + await session.type('/model'); + await session.press('enter'); + await session.text({ timeout: 30000, waitFor: text => text.includes('Remove custom provider') }); + await session.type('5'); + await session.text({ timeout: 30000, waitFor: text => text.includes('Remove Acme Gateway from your custom providers?') }); + await session.press('up'); + await session.press('enter'); + const composer = await session.text({ timeout: 30000, waitFor: text => text.includes('removed') && text.includes('❯') }); + expect(composer).toContain('Autohand AI'); + const saved = JSON.parse(await readFile(state.configPath, 'utf8')); + expect(saved.provider).toBe('autohandai'); + expect(saved.customProviders?.['acme-gateway']).toBeUndefined(); + expect(saved.autohandai).toMatchObject({ plan: 'cloud', authMode: 'account', accountId: 'selected-account' }); + expect(saved.autohandai.baseUrl).not.toBe(localEndpoint); + expect(saved.autohandai.model).not.toBe('mlx-community/previous-model'); + expect(saved.autohandai.port).toBeUndefined(); + expect(saved.autohandai.serverCommand).toBeUndefined(); + await closeInteractive(session); + }); + }); From 7c350e209ae711487d0083db1a35a1e502444ee2 Mon Sep 17 00:00:00 2001 From: Igor Costa Date: Tue, 10 Nov 2026 13:54:03 +1300 Subject: [PATCH 03/19] Validate native terminal startup and updated session contracts Read native permission text through its supported accessibility attributes, open and inspect the same fresh terminal window, and give VS Code separate profiles with renderer accessibility enabled. Use native terminal menu interaction and XCTest teardown blocks so failed assertions still release applications and interruption monitors. Align continuation, completion-review, and adaptive-skill scenarios with nonempty implicit resumption, delayed confirmed delivery, and the compact default index. Hold review responses behind explicit gates and retain cancellation, draft history, instruction activation, receipts, and context-budget assertions. Clarify benchmark measurements without changing their legacy JSON keys. Co-authored-by: Autohand Evolve --- .github/workflows/terminal-compatibility.yml | 2 +- docs/terminal-compatibility.md | 4 +- scripts/benchmark-adaptive-skills.ts | 5 +- src/testing/drivers/native-terminal-driver.ts | 19 ++- .../TerminalCompatibilityUITests.swift | 132 +++++++++++++----- src/testing/scenarios/finalReplyScenario.ts | 5 +- tests/testing/nativeTerminal.test.ts | 38 ++++- .../tuistory/adaptive-skills.tuistory.test.ts | 10 +- .../tuistory/continue-flags.tuistory.test.ts | 25 +++- .../final-reply-search.tuistory.test.ts | 31 +++- .../tuistory/helpers/adaptiveSkillsFixture.ts | 2 + tests/tuistory/helpers/autohandTuistory.ts | 14 ++ 12 files changed, 232 insertions(+), 55 deletions(-) diff --git a/.github/workflows/terminal-compatibility.yml b/.github/workflows/terminal-compatibility.yml index 5dfb13629..5314aaaad 100644 --- a/.github/workflows/terminal-compatibility.yml +++ b/.github/workflows/terminal-compatibility.yml @@ -120,6 +120,6 @@ jobs: .artifacts/native-terminal/**/*.xcresult .artifacts/native-terminal/**/screenshots .artifacts/native-terminal/**/xcodebuild.log - .artifacts/native-terminal/**/vscode-profile/User/settings.json + .artifacts/native-terminal/**/vscode-profile/**/settings.json if-no-files-found: error retention-days: 7 diff --git a/docs/terminal-compatibility.md b/docs/terminal-compatibility.md index 1f35ccd74..13b24038c 100644 --- a/docs/terminal-compatibility.md +++ b/docs/terminal-compatibility.md @@ -37,7 +37,9 @@ The tmux suite launches a compiled CLI through a real tmux server on a unique te `.github/workflows/terminal-compatibility.yml` adds a separate macOS matrix for actual iTerm2, Ghostty, cmux, and VS Code applications. Tuistory runs an XCTest UI scenario against the shared renderer fixture. It resizes real windows, checks accessible terminal contents for duplicate transcript and composer markers, retains screenshots in an `.xcresult` bundle, and checks idle and streaming execution. The workflow records installed application versions alongside those results. Native GUI cases run only on disposable GitHub Actions macOS runners; the driver refuses to launch or terminate applications on a developer machine. iTerm2's Python API remains disabled. -First-run dialogs are handled before fixture input. The offline fixture denies only the macOS request to find devices on local networks through XCTest's interruption handler, then focuses the terminal before pasting the command. VS Code uses a disposable profile with [`chat.disableAIFeatures`](https://code.visualstudio.com/docs/agents/reference/ai-settings) enabled to suppress built-in Copilot onboarding. If the specific Copilot sign-in dialog still appears, the scenario closes that dialog and opens the integrated terminal. It does not sign in or grant network access. Window, workbench, and terminal-view failures preserve bounded accessibility diagnostics and screenshots; the workflow also retains the Xcode output and generated VS Code settings. +First-run dialogs are handled before fixture input. The offline fixture denies only the macOS request to find devices on local networks through XCTest's interruption handler. The handler checks accessibility values, titles, and labels: macOS permission text can have an empty label, while its visible text is in the value or title. The iTerm2 scenario opens a fresh window after handling interruptions, so a restored session cannot consume fixture input. All terminal text and resize assertions refer to the same window. Application termination and interruption-monitor removal use XCTest teardown blocks so failed assertions also release them. + +VS Code uses separate disposable profiles for idle and streaming cases, with [`chat.disableAIFeatures`](https://code.visualstudio.com/docs/agents/reference/ai-settings) enabled to suppress built-in Copilot onboarding. It also launches with [`--force-renderer-accessibility`](https://www.chromium.org/developers/design-documents/accessibility/) to expose Chromium's native accessibility tree; the editor's screen-reader setting alone does not ensure that tree is available to XCTest. The scenario opens the terminal through its native Terminal > New Terminal menu. If the specific Copilot sign-in dialog still appears, the scenario closes that dialog and opens the integrated terminal. It does not sign in or grant network access. Window, workbench, and terminal-view failures preserve bounded accessibility diagnostics and screenshots; the workflow also retains the Xcode output and generated VS Code settings. Native CI availability and per-application results must be assessed from that workflow's completed jobs. A passing PTY suite or a successful XCTest build alone does not prove a native application passed. Failures in app launch, accessibility access, or window resizing fail the native job and preserve its XCTest evidence; they are not silently converted to headless passes. diff --git a/scripts/benchmark-adaptive-skills.ts b/scripts/benchmark-adaptive-skills.ts index 88806e977..7cbce495e 100644 --- a/scripts/benchmark-adaptive-skills.ts +++ b/scripts/benchmark-adaptive-skills.ts @@ -44,7 +44,7 @@ async function measureStartupPrompt(entries: ReturnType { activeInstructionReads++; return []; }, }).build(); const section = startupSkillSection(prompt); - return { adaptiveSkills, catalogReads, activeInstructionReads, systemPromptChars: prompt.length, estimatedSystemPromptTokens: estimateTokens(prompt), + return { adaptiveSkills, skillIndexMode: adaptiveSkills ? 'discovery-contract' : 'names-only', catalogReads, activeInstructionReads, systemPromptChars: prompt.length, estimatedSystemPromptTokens: estimateTokens(prompt), leadingPolicySha256: sha256(prompt), skillSectionChars: section.length, estimatedSkillSectionTokens: estimateTokens(section), skillSectionSha256: sha256(section), systemPrompt: prompt }; } @@ -109,6 +109,7 @@ async function main(): Promise { costs: { paidInferenceRequests: 0, providerBilledTokens: null, providerBilledCost: null }, adaptiveStartupPrefixInvariant: new Set([...startupPrompts.values()].map(prompt => prompt.adaptive.leadingPolicySha256)).size === 1, limitations: ['Fixture selection cases are deterministic contracts, not a powered task-quality or learned-transfer experiment.', + 'The legacy startupPrompts.eager key now measures the default names-only index; descriptions are loaded on demand.', 'Character counts and heuristic token estimates are separate metrics; no provider usage, billing, or prompt-cache savings are measured.', 'Cold lookup includes constructing the metadata token cache; fixture JSON parsing and filesystem catalog discovery are excluded.', 'Multilingual cases demonstrate lexical Unicode metadata matching, not translation or cross-language semantic retrieval.', @@ -116,7 +117,7 @@ async function main(): Promise { results }; const rows = results.map(result => `| ${result.catalogSize} | ${result.authorizedCompatibleEntries} | ${result.cold.p50Ms} | ${result.cold.p95Ms} | ${result.warm.p50Ms} | ${result.warm.p95Ms} | ${result.fixtureCases.filter(fixture => fixture.passed).length}/${result.fixtureCases.length} |`).join('\n'); const promptRows = results.map(result => `| ${result.catalogSize} | ${result.startupPrompts.eager.skillSectionChars} | ${result.startupPrompts.eager.estimatedSkillSectionTokens} | ${result.startupPrompts.adaptive.skillSectionChars} | ${result.startupPrompts.adaptive.estimatedSkillSectionTokens} |`).join('\n'); - const markdown = `# Adaptive skills deterministic evaluation\n\nGenerated ${report.generatedAt}. Commit ${report.repositoryCommit}; working tree modified.\n\nNo inference or paid calls. No task-quality, learning, or reuse claim.\n\n| Catalog | Authorized compatible | Cold p50 ms | Cold p95 ms | Warm p50 ms | Warm p95 ms | Fixture cases |\n| --- | --- | --- | --- | --- | --- | --- |\n${rows}\n\n| Catalog | Eager skill chars | Eager estimated tokens | Adaptive skill chars | Adaptive estimated tokens |\n| --- | --- | --- | --- | --- |\n${promptRows}\n\nAdaptive full startup prefix invariant across catalog sizes: ${report.adaptiveStartupPrefixInvariant}. Constant discovery contract: ${SKILL_DISCOVERY_CONTRACT.length} characters; ${estimateTokens(SKILL_DISCOVERY_CONTRACT)} estimated tokens using the repository character heuristic. Actual request context is measured separately.\n\n${report.limitations.map(value => `- ${value}`).join('\n')}\n`; + const markdown = `# Adaptive skills deterministic evaluation\n\nGenerated ${report.generatedAt}. Commit ${report.repositoryCommit}; working tree modified.\n\nNo inference or paid calls. No task-quality, learning, or reuse claim.\n\n| Catalog | Authorized compatible | Cold p50 ms | Cold p95 ms | Warm p50 ms | Warm p95 ms | Fixture cases |\n| --- | --- | --- | --- | --- | --- | --- |\n${rows}\n\n| Catalog | Default names-only chars | Default estimated tokens | Adaptive skill chars | Adaptive estimated tokens |\n| --- | --- | --- | --- | --- |\n${promptRows}\n\nAdaptive full startup prefix invariant across catalog sizes: ${report.adaptiveStartupPrefixInvariant}. Constant discovery contract: ${SKILL_DISCOVERY_CONTRACT.length} characters; ${estimateTokens(SKILL_DISCOVERY_CONTRACT)} estimated tokens using the repository character heuristic. Actual request context is measured separately.\n\n${report.limitations.map(value => `- ${value}`).join('\n')}\n`; await mkdir(outputRoot, { recursive: true }); await writeFile(path.join(outputRoot, 'adaptive-skills-benchmark.json'), `${JSON.stringify(report, null, 2)}\n`); await writeFile(path.join(outputRoot, 'adaptive-skills-benchmark.md'), markdown); diff --git a/src/testing/drivers/native-terminal-driver.ts b/src/testing/drivers/native-terminal-driver.ts index f1998fcaa..8c030c299 100644 --- a/src/testing/drivers/native-terminal-driver.ts +++ b/src/testing/drivers/native-terminal-driver.ts @@ -22,14 +22,24 @@ export async function prepareNativeTerminalTest(): Promise<{ args: string[]; res const bun = execFileSync('/bin/sh', ['-c', 'command -v bun'], { encoding: 'utf8' }).trim(); await writeFile(fixture, `#!/bin/sh\nset -eu\ncd ${quote(root)}\nexport AUTOHAND_HOME=${quote(path.join(output, 'home'))}\nexport CI=false\nexec ${quote(bun)} src/testing/scenarios/resizeFlickerScenario.tsx --full-screen --multiline "$@"\n`, { mode: 0o755 }); const profile = path.join(output, 'vscode-profile'); - await mkdir(path.join(profile, 'User'), { recursive: true }); - await writeFile(path.join(profile, 'User', 'settings.json'), JSON.stringify({ + const settings = { 'chat.disableAIFeatures': true, 'editor.accessibilitySupport': 'on', 'workbench.startupEditor': 'none', 'window.restoreWindows': 'none', 'workbench.panel.opensMaximized': 'always', 'update.mode': 'none', 'security.workspace.trust.enabled': false, 'terminal.integrated.enablePersistentSessions': false, 'terminal.integrated.scrollback': 5000, - })); + }; + const launchArguments: Record = {}; + for (const mode of ['idle', 'streaming']) { + const isolatedProfile = path.join(profile, mode); + await mkdir(path.join(isolatedProfile, 'User'), { recursive: true }); + await writeFile(path.join(isolatedProfile, 'User', 'settings.json'), JSON.stringify(settings)); + launchArguments[mode] = terminal === 'vscode' + ? ['--user-data-dir', isolatedProfile, '--extensions-dir', path.join(isolatedProfile, 'extensions'), + '--disable-extensions', '--disable-workspace-trust', '--skip-welcome', '--skip-release-notes', + '--force-renderer-accessibility', '--new-window'] + : terminal === 'ghostty' ? ['--config-default-files=false', '--confirm-close-surface=false'] : []; + } const project = path.resolve('src/testing/drivers/native-terminal-lab'); for (const source of ['Host.swift', 'TerminalCompatibilityUITests.swift']) { await copyFile(path.join(project, source), path.join(output, source)); @@ -46,7 +56,8 @@ export async function prepareNativeTerminalTest(): Promise<{ args: string[]; res execFileSync('/usr/bin/python3', ['-c', 'import json,plistlib,sys\np=sys.argv[1]\nd=plistlib.load(open(p,"rb"))\ne=json.loads(sys.argv[2])\ntargets=[t for c in d.get("TestConfigurations",[]) for t in c["TestTargets"]] if "TestConfigurations" in d else [v for k,v in d.items() if k!="__xctestrun_metadata__" and isinstance(v,dict)]\nfor t in targets: t.setdefault("EnvironmentVariables",{}).update(e)\nplistlib.dump(d,open(p,"wb"))', testRun, JSON.stringify({ AUTOHAND_NATIVE_TERMINAL: terminal, AUTOHAND_NATIVE_FIXTURE: fixture, - AUTOHAND_NATIVE_PROFILE: profile, AUTOHAND_NATIVE_APPLICATION: application, GITHUB_ACTIONS: 'true' })], { timeout: 10_000 }); + AUTOHAND_NATIVE_LAUNCH_ARGUMENTS: JSON.stringify(launchArguments), + AUTOHAND_NATIVE_APPLICATION: application, GITHUB_ACTIONS: 'true' })], { timeout: 10_000 }); const resultBundle = path.join(output, 'NativeTerminal.xcresult'); return { resultBundle, args: ['test-without-building', '-xctestrun', testRun, '-destination', 'platform=macOS', '-parallel-testing-enabled', 'NO', '-resultBundlePath', resultBundle] }; diff --git a/src/testing/drivers/native-terminal-lab/TerminalCompatibilityUITests.swift b/src/testing/drivers/native-terminal-lab/TerminalCompatibilityUITests.swift index 941dbc35f..527ec4903 100644 --- a/src/testing/drivers/native-terminal-lab/TerminalCompatibilityUITests.swift +++ b/src/testing/drivers/native-terminal-lab/TerminalCompatibilityUITests.swift @@ -1,46 +1,90 @@ import AppKit import XCTest +private struct NativeAccessibilityText { + var label = "" + var title = "" + var value = "" + + var strings: [String] { [label, title, value] } + var content: String { [value, label, title].first { !$0.isEmpty } ?? "" } +} + +private func localNetworkDenialButton(dialog: [NativeAccessibilityText], buttons: [NativeAccessibilityText]) -> Int? { + let text = dialog.flatMap(\.strings).joined(separator: " ").split(whereSeparator: \.isWhitespace).joined(separator: " ") + guard text.contains("find devices on local networks") else { return nil } + return buttons.firstIndex { text in + text.strings.contains { $0.replacingOccurrences(of: "’", with: "'") == "Don't Allow" } + } +} + final class TerminalCompatibilityUITests: XCTestCase { private var environment: [String: String] { ProcessInfo.processInfo.environment } @MainActor func testIdleResize() throws { try resizeScenario(streaming: false) } @MainActor func testStreamingResize() throws { try resizeScenario(streaming: true) } + func testLocalNetworkPermissionUsesStaticTextValueAndButtonTitle() { + let dialog = [NativeAccessibilityText(value: "Allow “iTerm” to find devices on local networks?")] + XCTAssertEqual(localNetworkDenialButton(dialog: dialog, buttons: [ + NativeAccessibilityText(label: "action-button-1", title: "Allow"), + NativeAccessibilityText(label: "action-button-2", title: "Don’t Allow") + ]), 1) + } + + func testLocalNetworkPermissionPreservesLabelBasedAccessibility() { + XCTAssertEqual(localNetworkDenialButton(dialog: [ + NativeAccessibilityText(label: "Allow “iTerm” to find devices on local networks?") + ], buttons: [NativeAccessibilityText(label: "Don't Allow")]), 0) + } + + func testLocalNetworkPermissionRecognizesWrappedValues() { + XCTAssertEqual(localNetworkDenialButton(dialog: [ + NativeAccessibilityText(value: "Allow “iTerm” to find devices on\nlocal networks?") + ], buttons: [NativeAccessibilityText(value: "Don’t Allow")]), 0) + } + + func testLocalNetworkPermissionDoesNotDismissOtherAlertsOrGrantAccess() { + XCTAssertNil(localNetworkDenialButton(dialog: [NativeAccessibilityText(value: "Allow notifications?")], + buttons: [NativeAccessibilityText(title: "Don't Allow")])) + XCTAssertNil(localNetworkDenialButton(dialog: [NativeAccessibilityText(value: "find devices on local networks")], + buttons: [NativeAccessibilityText(title: "Allow")])) + } + + func testAccessibilityContentDoesNotDuplicateMirroredAttributes() { + XCTAssertEqual(NativeAccessibilityText(label: "RESIZE_HISTORY retained text", title: "RESIZE_HISTORY retained text", + value: "RESIZE_HISTORY retained text").content, "RESIZE_HISTORY retained text") + } + @MainActor private func resizeScenario(streaming: Bool) throws { guard environment["GITHUB_ACTIONS"] == "true", let terminal = environment["AUTOHAND_NATIVE_TERMINAL"], let application = environment["AUTOHAND_NATIVE_APPLICATION"], let fixture = environment["AUTOHAND_NATIVE_FIXTURE"], - let profile = environment["AUTOHAND_NATIVE_PROFILE"] else { + let arguments = environment["AUTOHAND_NATIVE_LAUNCH_ARGUMENTS"]?.data(using: .utf8) else { XCTFail("This scenario requires an isolated GitHub Actions macOS runner") return } continueAfterFailure = false let interruption = addUIInterruptionMonitor(withDescription: "Deny local network access for the offline terminal fixture") { alert in - let text = ([alert.label] + alert.staticTexts.allElementsBoundByIndex.map(\.label)).joined(separator: "\n") - guard text.contains("find devices on local networks") else { return false } - for label in ["Don’t Allow", "Don't Allow"] { - let deny = alert.buttons[label] - if deny.exists { - deny.click() - print("NATIVE_TERMINAL_DENIED_LOCAL_NETWORK") - return true - } - } - return false + let buttons = alert.buttons.allElementsBoundByIndex + let text = ([alert] + alert.staticTexts.allElementsBoundByIndex).map(self.accessibilityText) + guard let denied = localNetworkDenialButton(dialog: text, buttons: buttons.map(self.accessibilityText)) else { return false } + buttons[denied].click() + print("NATIVE_TERMINAL_DENIED_LOCAL_NETWORK") + return true } - defer { removeUIInterruptionMonitor(interruption) } + addTeardownBlock { self.removeUIInterruptionMonitor(interruption) } let app = XCUIApplication(url: URL(fileURLWithPath: application)) - defer { if app.state != .notRunning { app.terminate() } } + addTeardownBlock { if app.state != .notRunning { app.terminate() } } let command = "'" + fixture.replacingOccurrences(of: "'", with: "'\\''") + "'" + (streaming ? " --streaming" : "") - if terminal == "ghostty" { - app.launchArguments = ["--config-default-files=false", "--confirm-close-surface=false"] - } else if terminal == "vscode" { - app.launchArguments = ["--user-data-dir", profile, "--extensions-dir", profile + "/extensions", "--disable-extensions", - "--disable-workspace-trust", "--skip-welcome", "--skip-release-notes", "--new-window"] + let modes = try JSONDecoder().decode([String: [String]].self, from: arguments) + guard let launchArguments = modes[streaming ? "streaming" : "idle"] else { + XCTFail("Missing isolated launch arguments for the native scenario") + return } + app.launchArguments = launchArguments let launch = expectation(description: "Launch the installed terminal through macOS") let configuration = NSWorkspace.OpenConfiguration() configuration.arguments = app.launchArguments @@ -58,25 +102,37 @@ final class TerminalCompatibilityUITests: XCTestCase { add(desktop) XCTAssertNil(launchError) app.activate() - let window = app.windows.firstMatch + var window = app.windows.firstMatch guard window.waitForExistence(timeout: 30) else { attachStartupFailure(app, terminal: terminal, phase: "window") XCTFail("The native terminal did not create a window") return } + if terminal == "iterm2" { + window.click() + let previousWindowCount = app.windows.count + app.typeKey("n", modifierFlags: .command) + let newWindow = NSPredicate { _, _ in app.windows.count > previousWindowCount } + guard XCTWaiter.wait(for: [XCTNSPredicateExpectation(predicate: newWindow, object: app)], timeout: 10) == .completed else { + attachStartupFailure(app, terminal: terminal, phase: "fresh-window") + XCTFail("iTerm2 did not create a fresh terminal window") + return + } + window = app.windows.firstMatch + } if terminal == "vscode" { - guard app.webViews.firstMatch.waitForExistence(timeout: 30) else { + guard app.menuBars.menuBarItems["Terminal"].waitForExistence(timeout: 30) else { attachStartupFailure(app, terminal: terminal, phase: "workbench") XCTFail("VS Code did not display its workbench") return } dismissCopilotSignIn(app) - app.typeKey("`", modifierFlags: [.control, .shift]) + openVSCodeTerminal(app) } - let terminalView = app.textViews.firstMatch + let terminalView = window.textViews.firstMatch let terminalReady = NSPredicate { _, _ in if terminal == "vscode", self.dismissCopilotSignIn(app) { - app.typeKey("`", modifierFlags: [.control, .shift]) + self.openVSCodeTerminal(app) return false } return terminalView.exists @@ -93,7 +149,7 @@ final class TerminalCompatibilityUITests: XCTestCase { app.typeKey(.return, modifierFlags: []) var startupValue = "" let ready = NSPredicate { _, _ in - startupValue = self.terminalValue(app) + startupValue = self.terminalValue(window) return startupValue.contains("RESIZE_HISTORY retained text") } guard XCTWaiter.wait(for: [XCTNSPredicateExpectation(predicate: ready, object: app)], timeout: 30) == .completed else { @@ -115,12 +171,12 @@ final class TerminalCompatibilityUITests: XCTestCase { corner.press(forDuration: 0.1, thenDragTo: corner.withOffset(delta)) XCTAssertNotEqual(window.frame.size, frame.size, "The native window did not resize") let settled = NSPredicate { _, _ in - let value = self.terminalValue(app) + let value = self.terminalValue(window) return value.contains("RESIZE_HISTORY retained text") && value.contains("preserved draft") } expectation(for: settled, evaluatedWith: app) waitForExpectations(timeout: 10) - let content = terminalValue(app) + let content = terminalValue(window) XCTAssertEqual(content.components(separatedBy: "Autohand Code").count - 1, 1, content) XCTAssertEqual(content.components(separatedBy: "RESIZE_HISTORY retained text").count - 1, 1, content) XCTAssertEqual(content.components(separatedBy: "preserved draft").count - 1, 1, content) @@ -133,14 +189,14 @@ final class TerminalCompatibilityUITests: XCTestCase { app.typeKey(.leftArrow, modifierFlags: []) app.typeKey(.leftArrow, modifierFlags: []) app.typeKey("x", modifierFlags: .shift) - let edited = NSPredicate { _, _ in self.terminalValue(app).contains("taXil") } + let edited = NSPredicate { _, _ in self.terminalValue(window).contains("taXil") } expectation(for: edited, evaluatedWith: app) waitForExpectations(timeout: 10) app.typeKey(.escape, modifierFlags: []) app.typeKey("c", modifierFlags: .control) app.typeKey("c", modifierFlags: .control) app.typeKey("c", modifierFlags: .control) - let exit = NSPredicate { _, _ in self.terminalValue(app).contains("RESIZE_PTY_EXIT") || app.state == .notRunning } + let exit = NSPredicate { _, _ in self.terminalValue(window).contains("RESIZE_PTY_EXIT") || app.state == .notRunning } expectation(for: exit, evaluatedWith: app) waitForExpectations(timeout: 15) print(streaming ? "NATIVE_TERMINAL_STREAMING_PASS" : "NATIVE_TERMINAL_IDLE_PASS") @@ -157,6 +213,14 @@ final class TerminalCompatibilityUITests: XCTestCase { return true } + private func openVSCodeTerminal(_ app: XCUIApplication) { + app.menuBars.menuBarItems["Terminal"].click() + let command = app.menuBars.menuItems["New Terminal"] + XCTAssertTrue(command.waitForExistence(timeout: 10), "VS Code did not expose Terminal > New Terminal") + XCTAssertTrue(command.isEnabled, "VS Code disabled Terminal > New Terminal") + command.click() + } + @MainActor private func attachStartupFailure(_ app: XCUIApplication, terminal: String, phase: String) { let hierarchy = XCTAttachment(string: String(app.debugDescription.suffix(16_000))) @@ -169,13 +233,17 @@ final class TerminalCompatibilityUITests: XCTestCase { add(screen) } - private func terminalValue(_ app: XCUIApplication) -> String { - let values = app.textViews.allElementsBoundByIndex.compactMap { $0.value as? String } + private func accessibilityText(_ element: XCUIElement) -> NativeAccessibilityText { + NativeAccessibilityText(label: element.label, title: element.title, value: element.value as? String ?? "") + } + + private func terminalValue(_ window: XCUIElement) -> String { + let values = window.textViews.allElementsBoundByIndex.compactMap { $0.value as? String } if let terminal = values.filter({ $0.contains("RESIZE_HISTORY retained text") }).max(by: { $0.count < $1.count }) { return terminal } if environment["AUTOHAND_NATIVE_TERMINAL"] == "vscode" { - return app.staticTexts.allElementsBoundByIndex.map(\.label).joined(separator: "\n") + return window.staticTexts.allElementsBoundByIndex.map { accessibilityText($0).content }.joined(separator: "\n") } return values.max(by: { $0.count < $1.count }) ?? "" } diff --git a/src/testing/scenarios/finalReplyScenario.ts b/src/testing/scenarios/finalReplyScenario.ts index 714bebee8..c5650ab97 100644 --- a/src/testing/scenarios/finalReplyScenario.ts +++ b/src/testing/scenarios/finalReplyScenario.ts @@ -1,11 +1,10 @@ import type { Session } from 'tuistory'; -export async function startReviewedReply(session: Session): Promise { +export async function startReviewedReply(session: Session, waitForReview: () => Promise): Promise { await session.waitForText('❯', { timeout: 20_000 }); await session.type('Read package.json and give the verified facts.'); await session.press('enter'); - await session.waitForText('REVIEW_PENDING_ANSWER', { timeout: 20_000 }); - await session.waitForText('completion review is pending', { timeout: 10_000 }); + await waitForReview(); } export async function configureExaSearch(session: Session): Promise { diff --git a/tests/testing/nativeTerminal.test.ts b/tests/testing/nativeTerminal.test.ts index b43668d5c..1ad032da8 100644 --- a/tests/testing/nativeTerminal.test.ts +++ b/tests/testing/nativeTerminal.test.ts @@ -19,6 +19,16 @@ afterEach(() => { vi.clearAllMocks(); }); +function preparedLaunchArguments(): unknown { + const environmentWrite = vi.mocked(execFileSync).mock.calls.find(([file]) => file === '/usr/bin/python3'); + const environment: unknown = JSON.parse(String(environmentWrite?.[1]?.[3])); + if (!environment || typeof environment !== 'object' || !('AUTOHAND_NATIVE_LAUNCH_ARGUMENTS' in environment) + || typeof environment.AUTOHAND_NATIVE_LAUNCH_ARGUMENTS !== 'string') { + throw new Error('Native test launch arguments were not passed to XCTest'); + } + return JSON.parse(environment.AUTOHAND_NATIVE_LAUNCH_ARGUMENTS); +} + describe('native terminal automation boundary', () => { it('refuses to launch or terminate applications on a developer machine', async () => { vi.stubEnv('GITHUB_ACTIONS', 'false'); @@ -33,7 +43,7 @@ describe('native terminal automation boundary', () => { await expect(prepareNativeTerminalTest()).rejects.toThrow('Choose iterm2, ghostty, cmux, or vscode'); }); - it('prepares a disposable VS Code profile without Copilot onboarding while retaining terminal accessibility and scrollback', async () => { + it.each(['idle', 'streaming'])('prepares an isolated %s VS Code profile with native renderer accessibility', async mode => { Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true }); vi.stubEnv('GITHUB_ACTIONS', 'true'); vi.stubEnv('CI', 'true'); @@ -41,7 +51,7 @@ describe('native terminal automation boundary', () => { const test = await prepareNativeTerminalTest(); - const profile = path.resolve('.artifacts/native-terminal/vscode/vscode-profile'); + const profile = path.resolve('.artifacts/native-terminal/vscode/vscode-profile', mode); const settingsWrite = vi.mocked(writeFile).mock.calls.find(([file]) => file === path.join(profile, 'User/settings.json')); expect(settingsWrite).toBeDefined(); const settings: unknown = JSON.parse(String(settingsWrite?.[1])); @@ -52,8 +62,32 @@ describe('native terminal automation boundary', () => { 'terminal.integrated.enablePersistentSessions': false, 'terminal.integrated.scrollback': 5000, }); + const launchArguments = preparedLaunchArguments(); + expect(launchArguments).toMatchObject({ + [mode]: expect.arrayContaining([ + '--force-renderer-accessibility', '--user-data-dir', profile, + '--extensions-dir', path.join(profile, 'extensions'), '--disable-extensions', + ]), + }); + expect(launchArguments).toMatchObject({ + idle: expect.not.arrayContaining([path.resolve('.artifacts/native-terminal/vscode/vscode-profile/streaming')]), + streaming: expect.not.arrayContaining([path.resolve('.artifacts/native-terminal/vscode/vscode-profile/idle')]), + }); expect(test.args[0]).toBe('test-without-building'); expect(test.resultBundle).toBe(path.resolve('.artifacts/native-terminal/vscode/NativeTerminal.xcresult')); expect(execFileSync).not.toHaveBeenCalledWith('/usr/bin/xcodebuild', expect.arrayContaining(['test-without-building']), expect.anything()); }); + + it.each([ + { terminal: 'iterm2', arguments: [] }, + { terminal: 'cmux', arguments: [] }, + { terminal: 'ghostty', arguments: ['--config-default-files=false', '--confirm-close-surface=false'] }, + ])('preserves the $terminal launch arguments for both native scenarios', async fixture => { + Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true }); + vi.stubEnv('GITHUB_ACTIONS', 'true'); + vi.stubEnv('CI', 'true'); + vi.stubEnv('AUTOHAND_NATIVE_TERMINAL', fixture.terminal); + await prepareNativeTerminalTest(); + expect(preparedLaunchArguments()).toEqual({ idle: fixture.arguments, streaming: fixture.arguments }); + }); }); diff --git a/tests/tuistory/adaptive-skills.tuistory.test.ts b/tests/tuistory/adaptive-skills.tuistory.test.ts index d3b7d3912..bb684942f 100644 --- a/tests/tuistory/adaptive-skills.tuistory.test.ts +++ b/tests/tuistory/adaptive-skills.tuistory.test.ts @@ -34,7 +34,7 @@ function expectBoundedStartupRequest(request: Record): void { } describe('adaptive skills in the built CLI', () => { - it.each([14, 1_000])('captures eager and adaptive greetings with %i local skills and zero prepared packets', async localSkills => { + it.each([14, 1_000])('captures compact and adaptive greetings with %i local skills and zero prepared packets', async localSkills => { const policies: string[] = []; for (const adaptiveSkills of [false, true]) { const fixture = await launch({ adaptiveSkills, localSkills, turns: [{ content: 'ADAPTIVE_GREETING_DONE' }, { content: 'ADAPTIVE_SECOND_GREETING_DONE' }] }); @@ -46,15 +46,19 @@ describe('adaptive skills in the built CLI', () => { const first = requestText(fixture.server.requests[0]); expect(first).not.toContain('[Skill packet '); expect(first).not.toContain('ADAPTIVE_FIXTURE_BODY_'); + expect(first).not.toContain('Adaptive fixture workflow'); expect(await readAdaptiveSkillReceipts(fixture)).toEqual([]); + const fixtureNames = new Set(startupSkillContext(fixture.server.requests[0]).match(/\badaptive-fixture-\d+\b/g) ?? []); if (adaptiveSkills) { expectBoundedStartupRequest(fixture.server.requests[0]); expect(first).toContain(SKILL_DISCOVERY_CONTRACT); - expect(first).not.toContain('Adaptive fixture workflow'); expect(first).not.toContain(''); + expect(fixtureNames.size).toBe(0); expect(estimateTokens(startupSkillContext(fixture.server.requests[0]))).toBeLessThanOrEqual(512); } else { - expect(first).toContain('Adaptive fixture workflow'); + expect(fixtureNames.size).toBe(localSkills); + expect(fixtureNames.has('adaptive-fixture-0')).toBe(true); + expect(fixtureNames.has(`adaptive-fixture-${localSkills - 1}`)).toBe(true); } const prefix = fixture.server.requests.map(request => JSON.stringify(requestMessages(request)[0])); expect(new Set(prefix).size).toBe(1); diff --git a/tests/tuistory/continue-flags.tuistory.test.ts b/tests/tuistory/continue-flags.tuistory.test.ts index 6b831ec70..39b0df7fb 100644 --- a/tests/tuistory/continue-flags.tuistory.test.ts +++ b/tests/tuistory/continue-flags.tuistory.test.ts @@ -39,12 +39,33 @@ describe('continue and resume startup flags', () => { expect(output).toMatch(/\n\s+--auto-commit\s/); expect(output).not.toContain('-c, --auto-commit'); }); - it.each(['-c', '--continue', '-r resume-active', '--resume resume-active'])('%s resumes the current-folder session', async flags => { - const { session } = await launch(flags.split(' ')); + it.each(['-c', '--continue'])('%s skips the newer empty launch and restores the current-folder conversation', async flag => { + const { session, state } = await launch([flag]); + await session.waitForText('Resumed session resume-newer', { timeout: 30_000 }); + await session.waitForText('Added a vitest case that resizes the mock terminal'); + expect(session.readAll()).not.toContain('Resumed session resume-active'); + expect(session.readAll()).not.toContain('Resumed session elsewhere-session'); + expect(session.readAll()).not.toContain("Set up the elsewhere project's linting config"); + await session.waitForText('❯'); + await exitInteractive(session); + const manager = new SessionManager(path.join(state.autohandHome, 'sessions')); + const restored = await manager.loadSession('resume-newer'); + expect(restored.getMessages()).toHaveLength(4); + expect(restored.getMessages().map(message => message.content)).toContain('Investigate why the terminal caret disappears after a resize.'); + expect((await manager.loadSession('resume-active')).getMessages()).toHaveLength(0); + expect(session.exitInfo?.exitCode).toBe(0); + }); + it.each(['-r', '--resume'])('%s resume-active explicitly resumes the selected empty session', async flag => { + const { session, state } = await launch([flag, 'resume-active']); await session.waitForText('Resumed session resume-active', { timeout: 30_000 }); + expect(session.readAll()).not.toContain('Resumed session resume-newer'); expect(session.readAll()).not.toContain('Resumed session elsewhere-session'); + expect(session.readAll()).not.toContain('Added a vitest case that resizes the mock terminal'); await session.waitForText('❯'); await exitInteractive(session); + const manager = new SessionManager(path.join(state.autohandHome, 'sessions')); + expect((await manager.loadSession('resume-active')).getMessages()).toHaveLength(0); + expect(session.exitInfo?.exitCode).toBe(0); }); it('-r opens the existing picker and allows cancellation', async () => { const { session, state } = await launch(['-r']); diff --git a/tests/tuistory/final-reply-search.tuistory.test.ts b/tests/tuistory/final-reply-search.tuistory.test.ts index 4dbdac096..8be9b0bb9 100644 --- a/tests/tuistory/final-reply-search.tuistory.test.ts +++ b/tests/tuistory/final-reply-search.tuistory.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { readFile, readdir } from 'node:fs/promises'; import path from 'node:path'; import { startReviewedReply, configureExaSearch, changeSearchInSettings, configureRouterPolicy } from '../../src/testing/scenarios/finalReplyScenario.js'; @@ -6,13 +6,15 @@ import { createMockAutohandAINativeSequenceServer, createTempAutohandHome, exitI const ui = { promptSuggestions: false, showCompletionNotification: false, terminalBell: false }; describe('final reply visibility and search setup', () => { - it.each([false, true])('keeps a reply visible while review is pending; cancel=%s', async cancel => { + it.each([false, true])('holds the candidate until review and preserves rejected or interrupted drafts; cancel=%s', async cancel => { + const changesReview = Promise.withResolvers(); + const approvalReview = Promise.withResolvers(); const server = await createMockAutohandAINativeSequenceServer([ { content: 'Reading facts.', toolCall: { id: 'read', name: 'read_file', args: { path: 'package.json' } } }, { content: 'REVIEW_PENDING_ANSWER: package facts checked.' }, - { content: '{"verdict":"changes_requested","feedback":"Verify the version too."}', delayMs: 5000 }, + { content: '{"verdict":"changes_requested","feedback":"Verify the version too."}', responseGate: changesReview.promise }, { content: 'REVIEW_REVISED_ANSWER: package and version checked.' }, - { content: '{"verdict":"approved","feedback":"Verified."}' }, + { content: '{"verdict":"approved","feedback":"Verified."}', responseGate: approvalReview.promise }, ]); const state = await createTempAutohandHome({ config: { provider: 'autohandai', autohandai: { plan: 'cloud', authMode: 'api-key', apiKey: 'fixture', model: 'moa', orchestration: true, baseUrl: server.baseUrl }, @@ -23,25 +25,44 @@ describe('final reply visibility and search setup', () => { let session: Awaited> | undefined; try { session = await launchBuiltAutohand(['--path', state.workspaceRoot, '--config', state.configPath, '--yes'], { autohandHome: state.autohandHome, cwd: state.workspaceRoot, cols: 120, rows: 50 }); - await startReviewedReply(session); + await startReviewedReply(session, () => vi.waitFor(() => expect(server.requests).toHaveLength(3), { timeout: 20_000 })); + expect(JSON.stringify(server.requests[2])).toContain('independent Moa advisor'); + expect(session.getRawOutput()).not.toContain('REVIEW_PENDING_ANSWER'); + expect(session.getRawOutput()).not.toContain('REVIEW_REVISED_ANSWER'); expect(session.getRawOutput()).not.toContain('Completed in'); if (cancel) { await session.press('escape'); await session.waitForText('completion review was interrupted', { timeout: 15_000 }); expect(session.getRawOutput()).not.toContain('Completed in'); + expect(session.getRawOutput()).not.toContain('REVIEW_REVISED_ANSWER'); + expect(server.requests).toHaveLength(3); } else { + changesReview.resolve(); + await session.waitForText('completion review requested changes'); + await vi.waitFor(() => expect(server.requests).toHaveLength(5), { timeout: 20_000 }); + expect(session.getRawOutput()).toContain('Draft — completion is not confirmed'); + expect(session.getRawOutput()).toContain('REVIEW_PENDING_ANSWER'); + expect(session.getRawOutput()).not.toContain('REVIEW_REVISED_ANSWER'); + expect(session.getRawOutput()).not.toContain('Completed in'); + approvalReview.resolve(); await session.waitForText('REVIEW_REVISED_ANSWER', { timeout: 20_000 }); await session.waitForText('Completed in', { timeout: 20_000 }); expect(session.getRawOutput()).toContain('completion review requested changes'); } expect(session.getRawOutput()).toContain('REVIEW_PENDING_ANSWER'); + expect(session.getRawOutput()).toContain('Draft — completion is not confirmed'); await exitInteractive(session); + expect(session.exitInfo?.exitCode).toBe(0); const sessionsRoot = path.join(state.autohandHome, 'sessions'); const dirs = (await readdir(sessionsRoot, { withFileTypes: true })).filter(entry => entry.isDirectory()); const histories = await Promise.all(dirs.map(entry => readFile(path.join(sessionsRoot, entry.name, 'conversation.jsonl'), 'utf8'))); expect(histories.join('\n')).toContain('REVIEW_PENDING_ANSWER'); expect(histories.join('\n')).toContain(cancel ? 'completion review was interrupted' : 'completion review requested changes'); + if (cancel) expect(histories.join('\n')).not.toContain('REVIEW_REVISED_ANSWER'); + else expect(histories.join('\n')).toContain('REVIEW_REVISED_ANSWER'); } finally { + changesReview.resolve(); + approvalReview.resolve(); session?.close(); await session?.waitForExit(5000); await server.close(); await state.cleanup(); diff --git a/tests/tuistory/helpers/adaptiveSkillsFixture.ts b/tests/tuistory/helpers/adaptiveSkillsFixture.ts index 938deb86b..c0dc4dd51 100644 --- a/tests/tuistory/helpers/adaptiveSkillsFixture.ts +++ b/tests/tuistory/helpers/adaptiveSkillsFixture.ts @@ -142,6 +142,8 @@ export async function saveAdaptiveRequestCapture(fixture: AdaptiveSkillsFixture, const skillContext = startupSkillContext(request); return { index, messageChars: text.length, estimatedMessageTokens: estimateTokens(text), startupSkillContextChars: skillContext.length, estimatedStartupSkillContextTokens: estimateTokens(skillContext), + startupFixtureNameEntries: new Set(skillContext.match(/\badaptive-fixture-\d+\b/g) ?? []).size, + startupFixtureDescriptionEntries: (skillContext.match(/Adaptive fixture workflow \d+/g) ?? []).length, nativeSchemaChars: tools.length, estimatedNativeSchemaTokens: estimateTokens(tools), nativeToolNames: nativeToolNames(request), nativeSchemaSha256: hash(tools), leadingPolicySha256: hash(JSON.stringify(firstMessage)), preparedPacketCount: (text.match(/\[Skill packet skill:\/\//g) ?? []).length, diff --git a/tests/tuistory/helpers/autohandTuistory.ts b/tests/tuistory/helpers/autohandTuistory.ts index 111cf1c95..e65bb7148 100644 --- a/tests/tuistory/helpers/autohandTuistory.ts +++ b/tests/tuistory/helpers/autohandTuistory.ts @@ -66,6 +66,7 @@ export interface MockNativeToolServer extends MockOpenRouterServer { export interface MockNativeAssistantTurn { content: string; delayMs?: number; + responseGate?: Promise; toolCall?: { id: string; name: string; @@ -372,6 +373,19 @@ export async function createMockAutohandAINativeSequenceServer( const turn = turns[Math.min(requests.length - 1, turns.length - 1)] ?? { content: '' }; + const responseGate = turn.responseGate; + if (responseGate) { + await new Promise(resolve => { + const finish = () => { + response.off('close', finish); + resolve(); + }; + response.once('close', finish); + void responseGate.then(finish, () => { response.destroy(); finish(); }); + if (response.destroyed) finish(); + }); + if (response.destroyed) return; + } if (turn.delayMs) { await new Promise(resolve => { const finish = () => { From 90caded5e1c55fd661cbd616163b2eea08c5eab5 Mon Sep 17 00:00:00 2001 From: Igor Costa Date: Tue, 10 Nov 2026 14:32:10 +1300 Subject: [PATCH 04/19] Keep native terminal fixtures within viewport and socket limits Use short disposable VS Code runtime profiles so macOS can create the main IPC socket, while retaining profile settings and bounded application logs in test artifacts. Bind native window references to accessibility identity and normalize fixture geometry within measured display bounds before the existing four resize gestures. Cover the observed 57 by 19 streaming viewport through a real PTY, checking intentional clipping, history restoration on growth, retained input and cursor edits, and clean exit. Preserve every native transcript, draft, streaming, cursor, and exit assertion. Co-authored-by: Autohand Evolve --- docs/terminal-compatibility.md | 4 +- src/testing/drivers/native-terminal-driver.ts | 15 ++- .../TerminalCompatibilityUITests.swift | 94 ++++++++++++++++++- tests/testing/nativeTerminal.test.ts | 40 ++++++-- .../tuistory/resize-flicker.tuistory.test.ts | 36 +++++++ 5 files changed, 173 insertions(+), 16 deletions(-) diff --git a/docs/terminal-compatibility.md b/docs/terminal-compatibility.md index 13b24038c..c70e5f962 100644 --- a/docs/terminal-compatibility.md +++ b/docs/terminal-compatibility.md @@ -39,7 +39,9 @@ The tmux suite launches a compiled CLI through a real tmux server on a unique te First-run dialogs are handled before fixture input. The offline fixture denies only the macOS request to find devices on local networks through XCTest's interruption handler. The handler checks accessibility values, titles, and labels: macOS permission text can have an empty label, while its visible text is in the value or title. The iTerm2 scenario opens a fresh window after handling interruptions, so a restored session cannot consume fixture input. All terminal text and resize assertions refer to the same window. Application termination and interruption-monitor removal use XCTest teardown blocks so failed assertions also release them. -VS Code uses separate disposable profiles for idle and streaming cases, with [`chat.disableAIFeatures`](https://code.visualstudio.com/docs/agents/reference/ai-settings) enabled to suppress built-in Copilot onboarding. It also launches with [`--force-renderer-accessibility`](https://www.chromium.org/developers/design-documents/accessibility/) to expose Chromium's native accessibility tree; the editor's screen-reader setting alone does not ensure that tree is available to XCTest. The scenario opens the terminal through its native Terminal > New Terminal menu. If the specific Copilot sign-in dialog still appears, the scenario closes that dialog and opens the integrated terminal. It does not sign in or grant network access. Window, workbench, and terminal-view failures preserve bounded accessibility diagnostics and screenshots; the workflow also retains the Xcode output and generated VS Code settings. +VS Code uses separate disposable profiles for idle and streaming cases under a short `/private/tmp/ah-vsc-*` directory. Its [main IPC socket](https://github.com/microsoft/vscode/blob/main/src/vs/base/parts/ipc/node/ipc.net.ts) lives under the user-data directory, so putting profiles below a long checkout path can exceed macOS's Unix socket limit and make Code exit before creating a window. Settings evidence stays under the checkout's `.artifacts/native-terminal/` directory, and bounded main-log tails are attached to XCTest results. The profiles enable [`chat.disableAIFeatures`](https://code.visualstudio.com/docs/agents/reference/ai-settings) to suppress built-in Copilot onboarding. VS Code also launches with [`--force-renderer-accessibility`](https://www.chromium.org/developers/design-documents/accessibility/) to expose Chromium's native accessibility tree; the editor's screen-reader setting alone does not ensure that tree is available to XCTest. The scenario opens the terminal through its native Terminal > New Terminal menu. If the specific Copilot sign-in dialog still appears, the scenario closes that dialog and opens the integrated terminal. It does not sign in or grant network access. Window, workbench, and terminal-view failures preserve bounded accessibility diagnostics and screenshots; the workflow also retains the Xcode output and generated VS Code settings. + +Native scenarios first position and size their fixture window within the reported display's visible bounds, leaving room for all four physical resize gestures. Their strict visible-history assertions require room for both transcript exchanges and the multiline composer. At a compact 57×19 terminal, the full-screen transcript correctly follows its newest exchange and clips older rows; a separate PTY case checks that the history reappears on growth, the draft and cursor survive, and Ctrl+C exits cleanly. This distinguishes viewport clipping from lost transcript data. Native window references bind to their accessibility identities so later queries continue inspecting the resized window. Native CI availability and per-application results must be assessed from that workflow's completed jobs. A passing PTY suite or a successful XCTest build alone does not prove a native application passed. Failures in app launch, accessibility access, or window resizing fail the native job and preserve its XCTest evidence; they are not silently converted to headless passes. diff --git a/src/testing/drivers/native-terminal-driver.ts b/src/testing/drivers/native-terminal-driver.ts index 8c030c299..eb9ea03f1 100644 --- a/src/testing/drivers/native-terminal-driver.ts +++ b/src/testing/drivers/native-terminal-driver.ts @@ -1,6 +1,6 @@ /** @license Apache-2.0 */ import { execFileSync } from 'node:child_process'; -import { copyFile, mkdir, readdir, writeFile } from 'node:fs/promises'; +import { copyFile, mkdir, mkdtemp, readdir, writeFile } from 'node:fs/promises'; import path from 'node:path'; export async function prepareNativeTerminalTest(): Promise<{ args: string[]; resultBundle: string }> { @@ -22,6 +22,7 @@ export async function prepareNativeTerminalTest(): Promise<{ args: string[]; res const bun = execFileSync('/bin/sh', ['-c', 'command -v bun'], { encoding: 'utf8' }).trim(); await writeFile(fixture, `#!/bin/sh\nset -eu\ncd ${quote(root)}\nexport AUTOHAND_HOME=${quote(path.join(output, 'home'))}\nexport CI=false\nexec ${quote(bun)} src/testing/scenarios/resizeFlickerScenario.tsx --full-screen --multiline "$@"\n`, { mode: 0o755 }); const profile = path.join(output, 'vscode-profile'); + const runtimeProfile = terminal === 'vscode' ? await mkdtemp('/private/tmp/ah-vsc-') : undefined; const settings = { 'chat.disableAIFeatures': true, 'editor.accessibilitySupport': 'on', 'workbench.startupEditor': 'none', 'window.restoreWindows': 'none', @@ -31,9 +32,15 @@ export async function prepareNativeTerminalTest(): Promise<{ args: string[]; res }; const launchArguments: Record = {}; for (const mode of ['idle', 'streaming']) { - const isolatedProfile = path.join(profile, mode); - await mkdir(path.join(isolatedProfile, 'User'), { recursive: true }); - await writeFile(path.join(isolatedProfile, 'User', 'settings.json'), JSON.stringify(settings)); + const evidenceProfile = path.join(profile, mode); + await mkdir(path.join(evidenceProfile, 'User'), { recursive: true }); + const settingsFile = path.join(evidenceProfile, 'User', 'settings.json'); + await writeFile(settingsFile, JSON.stringify(settings)); + const isolatedProfile = path.join(runtimeProfile ?? profile, mode); + if (runtimeProfile) { + await mkdir(path.join(isolatedProfile, 'User'), { recursive: true }); + await copyFile(settingsFile, path.join(isolatedProfile, 'User', 'settings.json')); + } launchArguments[mode] = terminal === 'vscode' ? ['--user-data-dir', isolatedProfile, '--extensions-dir', path.join(isolatedProfile, 'extensions'), '--disable-extensions', '--disable-workspace-trust', '--skip-welcome', '--skip-release-notes', diff --git a/src/testing/drivers/native-terminal-lab/TerminalCompatibilityUITests.swift b/src/testing/drivers/native-terminal-lab/TerminalCompatibilityUITests.swift index 527ec4903..45449fea7 100644 --- a/src/testing/drivers/native-terminal-lab/TerminalCompatibilityUITests.swift +++ b/src/testing/drivers/native-terminal-lab/TerminalCompatibilityUITests.swift @@ -18,12 +18,41 @@ private func localNetworkDenialButton(dialog: [NativeAccessibilityText], buttons } } +private let nativeResizeDeltas = [CGVector(dx: -160, dy: -100), CGVector(dx: 240, dy: 160), + CGVector(dx: -200, dy: -120), CGVector(dx: 160, dy: 100)] + +private func nativeFixtureBaseline(current: CGRect, available: CGRect) -> CGRect { + var growth = CGSize.zero + var maximumGrowth = CGSize.zero + for delta in nativeResizeDeltas { + growth.width += delta.dx + growth.height += delta.dy + maximumGrowth.width = max(maximumGrowth.width, growth.width) + maximumGrowth.height = max(maximumGrowth.height, growth.height) + } + return CGRect(x: available.minX + 20, y: available.minY + 2, + width: min(current.width, available.width - maximumGrowth.width - 40), + height: available.height - maximumGrowth.height - 4) +} + final class TerminalCompatibilityUITests: XCTestCase { private var environment: [String: String] { ProcessInfo.processInfo.environment } @MainActor func testIdleResize() throws { try resizeScenario(streaming: false) } @MainActor func testStreamingResize() throws { try resizeScenario(streaming: true) } + func testNativeViewportKeepsAllFourResizesWithinTheDisplayAndLargeEnoughForHistory() { + let available = CGRect(x: 0, y: 31, width: 1024, height: 677) + var frame = nativeFixtureBaseline(current: CGRect(x: 400, y: 246, width: 587, height: 462), available: available) + for delta in nativeResizeDeltas { + frame.size.width += delta.dx + frame.size.height += delta.dy + XCTAssertTrue(available.contains(frame)) + XCTAssertGreaterThanOrEqual(frame.width, 400) + XCTAssertGreaterThanOrEqual(frame.height, 500) + } + } + func testLocalNetworkPermissionUsesStaticTextValueAndButtonTitle() { let dialog = [NativeAccessibilityText(value: "Allow “iTerm” to find devices on local networks?")] XCTAssertEqual(localNetworkDenialButton(dialog: dialog, buttons: [ @@ -85,6 +114,10 @@ final class TerminalCompatibilityUITests: XCTestCase { return } app.launchArguments = launchArguments + if let profileIndex = launchArguments.firstIndex(of: "--user-data-dir"), profileIndex + 1 < launchArguments.count { + let profile = launchArguments[profileIndex + 1] + addTeardownBlock { self.attachApplicationLogs(profile: profile) } + } let launch = expectation(description: "Launch the installed terminal through macOS") let configuration = NSWorkspace.OpenConfiguration() configuration.arguments = app.launchArguments @@ -120,6 +153,12 @@ final class TerminalCompatibilityUITests: XCTestCase { } window = app.windows.firstMatch } + guard let fixtureWindow = app.windows.allElementsBoundByAccessibilityElement.first else { + XCTFail("The native fixture window disappeared before viewport setup") + return + } + window = fixtureWindow + let displayBounds = normalizeViewport(window, terminal: terminal) if terminal == "vscode" { guard app.menuBars.menuBarItems["Terminal"].waitForExistence(timeout: 30) else { attachStartupFailure(app, terminal: terminal, phase: "workbench") @@ -164,18 +203,25 @@ final class TerminalCompatibilityUITests: XCTestCase { XCTFail("The native terminal did not display the fixture history marker") return } - for (index, delta) in [CGVector(dx: -160, dy: -100), CGVector(dx: 240, dy: 160), - CGVector(dx: -200, dy: -120), CGVector(dx: 160, dy: 100)].enumerated() { + for (index, delta) in nativeResizeDeltas.enumerated() { let frame = window.frame let corner = window.coordinate(withNormalizedOffset: CGVector(dx: 1, dy: 1)).withOffset(CGVector(dx: -2, dy: -2)) corner.press(forDuration: 0.1, thenDragTo: corner.withOffset(delta)) XCTAssertNotEqual(window.frame.size, frame.size, "The native window did not resize") + XCTAssertTrue(displayBounds.contains(window.frame), "The resized fixture window left the display bounds") let settled = NSPredicate { _, _ in let value = self.terminalValue(window) return value.contains("RESIZE_HISTORY retained text") && value.contains("preserved draft") } - expectation(for: settled, evaluatedWith: app) - waitForExpectations(timeout: 10) + guard XCTWaiter.wait(for: [XCTNSPredicateExpectation(predicate: settled, object: window)], timeout: 10) == .completed else { + let content = XCTAttachment(string: String(terminalValue(window).suffix(16_000))) + content.name = "\(terminal)-resize-\(index)-terminal" + content.lifetime = .keepAlways + add(content) + attachStartupFailure(app, terminal: terminal, phase: "resize-\(index)") + XCTFail("The resized native window did not retain its history and draft") + return + } let content = terminalValue(window) XCTAssertEqual(content.components(separatedBy: "Autohand Code").count - 1, 1, content) XCTAssertEqual(content.components(separatedBy: "RESIZE_HISTORY retained text").count - 1, 1, content) @@ -202,6 +248,46 @@ final class TerminalCompatibilityUITests: XCTestCase { print(streaming ? "NATIVE_TERMINAL_STREAMING_PASS" : "NATIVE_TERMINAL_IDLE_PASS") } + @MainActor + private func normalizeViewport(_ window: XCUIElement, terminal: String) -> CGRect { + guard let screen = NSScreen.main else { + XCTFail("The native terminal runner has no display") + return .zero + } + let desktopTop = NSScreen.screens.first?.frame.maxY ?? screen.frame.maxY + let bounds = CGRect(x: screen.frame.minX, y: desktopTop - screen.frame.maxY, width: screen.frame.width, height: screen.frame.height) + let visible = screen.visibleFrame + let available = CGRect(x: visible.minX, y: desktopTop - visible.maxY, width: visible.width, height: visible.height) + let target = nativeFixtureBaseline(current: window.frame, available: available) + let frame = window.frame + let grip = window.coordinate(withNormalizedOffset: .zero).withOffset(CGVector(dx: terminal == "vscode" ? 110 : frame.width * 0.75, dy: 12)) + grip.press(forDuration: 0.1, thenDragTo: grip.withOffset(CGVector(dx: target.minX - frame.minX, dy: target.minY - frame.minY))) + let moved = window.frame + let corner = window.coordinate(withNormalizedOffset: CGVector(dx: 1, dy: 1)).withOffset(CGVector(dx: -2, dy: -2)) + corner.press(forDuration: 0.1, thenDragTo: corner.withOffset(CGVector(dx: target.width - moved.width, dy: target.height - moved.height))) + XCTAssertGreaterThanOrEqual(window.frame.height, target.height - 20, "The native fixture needs room for both transcript exchanges") + XCTAssertTrue(bounds.contains(window.frame), "The fixture viewport must stay within the display") + print("NATIVE_TERMINAL_VIEWPORT \(terminal) \(window.frame) display=\(available)") + return bounds + } + + private func attachApplicationLogs(profile: String) { + let logs = URL(fileURLWithPath: profile).appendingPathComponent("logs") + guard let files = FileManager.default.enumerator(at: logs, includingPropertiesForKeys: nil) else { return } + var count = 0 + for case let file as URL in files where file.lastPathComponent == "main.log" && count < 4 { + guard let handle = try? FileHandle(forReadingFrom: file) else { continue } + defer { try? handle.close() } + guard let end = try? handle.seekToEnd(), (try? handle.seek(toOffset: end > 16_000 ? end - 16_000 : 0)) != nil, + let data = try? handle.read(upToCount: 16_000) else { continue } + let log = XCTAttachment(string: String(decoding: data, as: UTF8.self)) + log.name = "vscode-\(URL(fileURLWithPath: profile).lastPathComponent)-main-log-\(count)" + log.lifetime = .keepAlways + add(log) + count += 1 + } + } + @discardableResult private func dismissCopilotSignIn(_ app: XCUIApplication) -> Bool { let dialog = app.groups.matching(NSPredicate(format: "label == %@", "Info Sign in to use GitHub Copilot")).firstMatch diff --git a/tests/testing/nativeTerminal.test.ts b/tests/testing/nativeTerminal.test.ts index 1ad032da8..1ab5e57c0 100644 --- a/tests/testing/nativeTerminal.test.ts +++ b/tests/testing/nativeTerminal.test.ts @@ -1,13 +1,14 @@ /** @license Apache-2.0 */ import { afterEach, describe, expect, it, vi } from 'vitest'; import { execFileSync } from 'node:child_process'; -import { writeFile } from 'node:fs/promises'; +import { copyFile, writeFile } from 'node:fs/promises'; import path from 'node:path'; import { prepareNativeTerminalTest } from '../../src/testing/drivers/native-terminal-driver.js'; vi.mock('node:child_process', () => ({ execFileSync: vi.fn(() => '/test/bin/bun\n') })); vi.mock('node:fs/promises', () => ({ copyFile: vi.fn(), mkdir: vi.fn(), writeFile: vi.fn(), + mkdtemp: vi.fn(async () => '/private/tmp/ah-vsc-123456'), readdir: vi.fn(async () => ['TerminalLab_macosx.xctestrun']), })); @@ -16,20 +17,43 @@ const platform = Object.getOwnPropertyDescriptor(process, 'platform')!; afterEach(() => { Object.defineProperty(process, 'platform', platform); vi.unstubAllEnvs(); + vi.restoreAllMocks(); vi.clearAllMocks(); }); -function preparedLaunchArguments(): unknown { +function preparedLaunchArguments(): Record { const environmentWrite = vi.mocked(execFileSync).mock.calls.find(([file]) => file === '/usr/bin/python3'); const environment: unknown = JSON.parse(String(environmentWrite?.[1]?.[3])); if (!environment || typeof environment !== 'object' || !('AUTOHAND_NATIVE_LAUNCH_ARGUMENTS' in environment) || typeof environment.AUTOHAND_NATIVE_LAUNCH_ARGUMENTS !== 'string') { throw new Error('Native test launch arguments were not passed to XCTest'); } - return JSON.parse(environment.AUTOHAND_NATIVE_LAUNCH_ARGUMENTS); + const parsed: unknown = JSON.parse(environment.AUTOHAND_NATIVE_LAUNCH_ARGUMENTS); + if (!parsed || typeof parsed !== 'object') throw new Error('Invalid native launch argument map'); + const result: Record = {}; + for (const [mode, argumentsForMode] of Object.entries(parsed)) { + if (!Array.isArray(argumentsForMode) || !argumentsForMode.every((argument: unknown) => typeof argument === 'string')) { + throw new Error('Invalid native launch arguments'); + } + result[mode] = argumentsForMode; + } + return result; } describe('native terminal automation boundary', () => { + it.each(['idle', 'streaming'])('keeps the %s VS Code main socket below the macOS Unix socket limit in the Actions checkout', async mode => { + Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true }); + vi.stubEnv('GITHUB_ACTIONS', 'true'); + vi.stubEnv('CI', 'true'); + vi.stubEnv('AUTOHAND_NATIVE_TERMINAL', 'vscode'); + vi.spyOn(process, 'cwd').mockReturnValue('/Users/runner/work/code-cli/code-cli'); + await prepareNativeTerminalTest(); + const argumentsForMode = preparedLaunchArguments()[mode]; + const profile = argumentsForMode[argumentsForMode.indexOf('--user-data-dir') + 1]; + expect(profile).toBeDefined(); + expect(Buffer.byteLength(path.join(profile, '1.14-main.sock'))).toBeLessThan(104); + }); + it('refuses to launch or terminate applications on a developer machine', async () => { vi.stubEnv('GITHUB_ACTIONS', 'false'); await expect(prepareNativeTerminalTest()).rejects.toThrow('isolated macOS GitHub Actions runner'); @@ -52,6 +76,7 @@ describe('native terminal automation boundary', () => { const test = await prepareNativeTerminalTest(); const profile = path.resolve('.artifacts/native-terminal/vscode/vscode-profile', mode); + const runtimeProfile = path.join('/private/tmp/ah-vsc-123456', mode); const settingsWrite = vi.mocked(writeFile).mock.calls.find(([file]) => file === path.join(profile, 'User/settings.json')); expect(settingsWrite).toBeDefined(); const settings: unknown = JSON.parse(String(settingsWrite?.[1])); @@ -65,14 +90,15 @@ describe('native terminal automation boundary', () => { const launchArguments = preparedLaunchArguments(); expect(launchArguments).toMatchObject({ [mode]: expect.arrayContaining([ - '--force-renderer-accessibility', '--user-data-dir', profile, - '--extensions-dir', path.join(profile, 'extensions'), '--disable-extensions', + '--force-renderer-accessibility', '--user-data-dir', runtimeProfile, + '--extensions-dir', path.join(runtimeProfile, 'extensions'), '--disable-extensions', ]), }); expect(launchArguments).toMatchObject({ - idle: expect.not.arrayContaining([path.resolve('.artifacts/native-terminal/vscode/vscode-profile/streaming')]), - streaming: expect.not.arrayContaining([path.resolve('.artifacts/native-terminal/vscode/vscode-profile/idle')]), + idle: expect.not.arrayContaining([path.join('/private/tmp/ah-vsc-123456', 'streaming')]), + streaming: expect.not.arrayContaining([path.join('/private/tmp/ah-vsc-123456', 'idle')]), }); + expect(copyFile).toHaveBeenCalledWith(path.join(profile, 'User', 'settings.json'), path.join(runtimeProfile, 'User', 'settings.json')); expect(test.args[0]).toBe('test-without-building'); expect(test.resultBundle).toBe(path.resolve('.artifacts/native-terminal/vscode/NativeTerminal.xcresult')); expect(execFileSync).not.toHaveBeenCalledWith('/usr/bin/xcodebuild', expect.arrayContaining(['test-without-building']), expect.anything()); diff --git a/tests/tuistory/resize-flicker.tuistory.test.ts b/tests/tuistory/resize-flicker.tuistory.test.ts index c35fa85b4..caefb8c20 100644 --- a/tests/tuistory/resize-flicker.tuistory.test.ts +++ b/tests/tuistory/resize-flicker.tuistory.test.ts @@ -14,6 +14,42 @@ let state: TuistoryTempState | undefined; afterEach(async () => { session?.close(); await state?.cleanup(); session = undefined; state = undefined; }); describe('terminal resize frames', () => { + it('clips older history at the native 57x19 viewport and restores it when the streaming multiline viewport grows', async () => { + state = await createTempAutohandHome(); + session = await launchTuistorySession({ + command: 'bun', + args: [path.resolve('src/testing/scenarios/resizeFlickerScenario.tsx'), '--full-screen', '--streaming', '--multiline'], + cwd: process.cwd(), cols: 100, rows: 30, waitForDataTimeout: 15_000, + env: { ...process.env, AUTOHAND_HOME: state.autohandHome, CI: 'false', TERM: 'xterm-256color', + TERM_PROGRAM: 'iTerm.app', FORCE_COLOR: '0' }, + }); + await waitForTerminalScreen(session, { timeout: 5_000, + waitFor: screen => screen.includes('RESIZE_HISTORY retained text') && /RESIZE_STREAM \d+/u.test(screen) }); + session.resize({ cols: 57, rows: 19 }); + const compact = await waitForTerminalScreen(session, { timeout: 5_000, + waitFor: screen => screen.includes('preserved draft') && /RESIZE_STREAM \d+/u.test(screen) + && !screen.includes('RESIZE_HISTORY retained text') }); + expect(compact.match(/Autohand Code/gu)).toHaveLength(1); + expect(compact.match(/preserved draft/gu)).toHaveLength(1); + await session.press('left'); + await session.press('left'); + await session.type('X'); + await session.waitForText('taXil'); + session.resize({ cols: 100, rows: 30 }); + const expanded = await waitForTerminalScreen(session, { timeout: 5_000, + waitFor: screen => screen.includes('RESIZE_HISTORY retained text') && /RESIZE_STREAM \d+/u.test(screen) + && screen.includes('taXil') }); + expect(expanded.match(/Autohand Code/gu)).toHaveLength(1); + expect(expanded.match(/RESIZE_HISTORY retained text/gu)).toHaveLength(1); + expect(expanded.match(/preserved draft/gu)).toHaveLength(1); + expect(expanded.match(/RESIZE_STREAM \d+ retained streamed text/gu)).toHaveLength(1); + await session.press('escape'); + await session.waitForText('RESIZE_STREAM_COMPLETE'); + await exitInteractive(session); + expect(session.exitInfo?.exitCode).toBe(0); + expect(session.getRawOutput()).toContain('RESIZE_PTY_EXIT'); + }); + it.each(['node', 'bun'])('keeps one copy of each full-screen row during ordinary %s redraws', async runtime => { state = await createTempAutohandHome(); session = await launchTuistorySession({ From 41934c68ff759210c6d172a59fc364aafe690927 Mon Sep 17 00:00:00 2001 From: Igor Costa Date: Tue, 10 Nov 2026 15:13:43 +1300 Subject: [PATCH 05/19] Make native terminal evidence and lock contention tests deterministic Read fresh VS Code terminal buffers when accessibility omits output, keep resize gestures on-screen, and synchronize configuration contention tests before advancing fake timers. Co-authored-by: Autohand Evolve --- .github/workflows/terminal-compatibility.yml | 1 + docs/terminal-compatibility.md | 6 +- src/testing/drivers/native-terminal-driver.ts | 9 ++ .../TerminalCompatibilityUITests.swift | 120 +++++++++++++++--- tests/config.test.ts | 16 ++- tests/testing/nativeTerminal.test.ts | 18 +++ 6 files changed, 150 insertions(+), 20 deletions(-) diff --git a/.github/workflows/terminal-compatibility.yml b/.github/workflows/terminal-compatibility.yml index 5314aaaad..4bd234fa5 100644 --- a/.github/workflows/terminal-compatibility.yml +++ b/.github/workflows/terminal-compatibility.yml @@ -121,5 +121,6 @@ jobs: .artifacts/native-terminal/**/screenshots .artifacts/native-terminal/**/xcodebuild.log .artifacts/native-terminal/**/vscode-profile/**/settings.json + .artifacts/native-terminal/**/vscode-profile/**/keybindings.json if-no-files-found: error retention-days: 7 diff --git a/docs/terminal-compatibility.md b/docs/terminal-compatibility.md index c70e5f962..267e83f2b 100644 --- a/docs/terminal-compatibility.md +++ b/docs/terminal-compatibility.md @@ -41,7 +41,11 @@ First-run dialogs are handled before fixture input. The offline fixture denies o VS Code uses separate disposable profiles for idle and streaming cases under a short `/private/tmp/ah-vsc-*` directory. Its [main IPC socket](https://github.com/microsoft/vscode/blob/main/src/vs/base/parts/ipc/node/ipc.net.ts) lives under the user-data directory, so putting profiles below a long checkout path can exceed macOS's Unix socket limit and make Code exit before creating a window. Settings evidence stays under the checkout's `.artifacts/native-terminal/` directory, and bounded main-log tails are attached to XCTest results. The profiles enable [`chat.disableAIFeatures`](https://code.visualstudio.com/docs/agents/reference/ai-settings) to suppress built-in Copilot onboarding. VS Code also launches with [`--force-renderer-accessibility`](https://www.chromium.org/developers/design-documents/accessibility/) to expose Chromium's native accessibility tree; the editor's screen-reader setting alone does not ensure that tree is available to XCTest. The scenario opens the terminal through its native Terminal > New Terminal menu. If the specific Copilot sign-in dialog still appears, the scenario closes that dialog and opens the integrated terminal. It does not sign in or grant network access. Window, workbench, and terminal-view failures preserve bounded accessibility diagnostics and screenshots; the workflow also retains the Xcode output and generated VS Code settings. -Native scenarios first position and size their fixture window within the reported display's visible bounds, leaving room for all four physical resize gestures. Their strict visible-history assertions require room for both transcript exchanges and the multiline composer. At a compact 57×19 terminal, the full-screen transcript correctly follows its newest exchange and clips older rows; a separate PTY case checks that the history reappears on growth, the draft and cursor survive, and Ctrl+C exits cleanly. This distinguishes viewport clipping from lost transcript data. Native window references bind to their accessibility identities so later queries continue inspecting the resized window. +Native scenarios first size and position their fixture window within the reported display's visible bounds, leaving room for all four physical resize gestures. Setup shrinks oversized dimensions before moving the window, then grows it at the target position, so a full-width window's resize handle never moves off-screen. VS Code's input readiness uses the native Split Terminal command becoming enabled after New Terminal, which establishes terminal focus; it does not require xterm's input to expose the same TextView role as standalone terminals. The fixture's rendered output remains the mandatory proof that paste, Enter, and terminal rendering worked. All scenarios retain the exact transcript, streaming, draft, cursor, resize, and exit assertions. + +VS Code's readout preflight first checks accessibility for the real fixture-history marker. If accessibility lacks it, a disposable-profile shortcut uses [`runCommands`](https://code.visualstudio.com/docs/configure/keybindings#_running-multiple-commands) to select the full terminal buffer and [copy and clear its selection](https://github.com/microsoft/vscode/blob/main/src/vs/workbench/contrib/terminalContrib/clipboard/browser/terminal.clipboard.contribution.ts). The preflight uses that raw copied buffer only if it contains the actual fixture marker and a second accessibility read still lacks it. Each subsequent copy clears the pasteboard and requires a new pasteboard generation; old frames cannot satisfy later resize checks. The command clears terminal selection while preserving focus and the PTY cursor, which the existing cursor-edit assertion verifies. No terminal output is filtered or deduplicated. Readout samples, selected backend, settings, and keybindings are retained with native results. + +The strict visible-history assertions require room for both transcript exchanges and the multiline composer. At a compact 57×19 terminal, the full-screen transcript correctly follows its newest exchange and clips older rows; a separate PTY case checks that the history reappears on growth, the draft and cursor survive, and Ctrl+C exits cleanly. This distinguishes viewport clipping from lost transcript data. Native window references bind to their accessibility identities so later queries continue inspecting the resized window. Native CI availability and per-application results must be assessed from that workflow's completed jobs. A passing PTY suite or a successful XCTest build alone does not prove a native application passed. Failures in app launch, accessibility access, or window resizing fail the native job and preserve its XCTest evidence; they are not silently converted to headless passes. diff --git a/src/testing/drivers/native-terminal-driver.ts b/src/testing/drivers/native-terminal-driver.ts index eb9ea03f1..0e30519ac 100644 --- a/src/testing/drivers/native-terminal-driver.ts +++ b/src/testing/drivers/native-terminal-driver.ts @@ -36,10 +36,19 @@ export async function prepareNativeTerminalTest(): Promise<{ args: string[]; res await mkdir(path.join(evidenceProfile, 'User'), { recursive: true }); const settingsFile = path.join(evidenceProfile, 'User', 'settings.json'); await writeFile(settingsFile, JSON.stringify(settings)); + const keybindingsFile = path.join(evidenceProfile, 'User', 'keybindings.json'); + if (runtimeProfile) { + await writeFile(keybindingsFile, JSON.stringify([{ + key: 'ctrl+shift+y', command: 'runCommands', when: 'terminalFocus', args: { + commands: ['workbench.action.terminal.selectAll', 'workbench.action.terminal.copyAndClearSelection'], + }, + }])); + } const isolatedProfile = path.join(runtimeProfile ?? profile, mode); if (runtimeProfile) { await mkdir(path.join(isolatedProfile, 'User'), { recursive: true }); await copyFile(settingsFile, path.join(isolatedProfile, 'User', 'settings.json')); + await copyFile(keybindingsFile, path.join(isolatedProfile, 'User', 'keybindings.json')); } launchArguments[mode] = terminal === 'vscode' ? ['--user-data-dir', isolatedProfile, '--extensions-dir', path.join(isolatedProfile, 'extensions'), diff --git a/src/testing/drivers/native-terminal-lab/TerminalCompatibilityUITests.swift b/src/testing/drivers/native-terminal-lab/TerminalCompatibilityUITests.swift index 45449fea7..3753b3492 100644 --- a/src/testing/drivers/native-terminal-lab/TerminalCompatibilityUITests.swift +++ b/src/testing/drivers/native-terminal-lab/TerminalCompatibilityUITests.swift @@ -35,12 +35,51 @@ private func nativeFixtureBaseline(current: CGRect, available: CGRect) -> CGRect height: available.height - maximumGrowth.height - 4) } +private func nativeFixturePreparationFrames(current: CGRect, available: CGRect) -> [CGRect] { + let target = nativeFixtureBaseline(current: current, available: available) + let reduced = CGRect(origin: current.origin, size: CGSize(width: min(current.width, target.width), height: min(current.height, target.height))) + return [reduced, CGRect(origin: target.origin, size: reduced.size), target] +} + +private func nativeTerminalInputReady(terminal: String, textViewExists: Bool, splitTerminalEnabled: Bool) -> Bool { + terminal == "vscode" ? splitTerminalEnabled : textViewExists +} + +private func nativeReadoutUsesCopiedBuffer(terminal: String, accessibilityHasFixture: Bool, copiedBufferHasFixture: Bool) -> Bool { + terminal == "vscode" && !accessibilityHasFixture && copiedBufferHasFixture +} + final class TerminalCompatibilityUITests: XCTestCase { private var environment: [String: String] { ProcessInfo.processInfo.environment } @MainActor func testIdleResize() throws { try resizeScenario(streaming: false) } @MainActor func testStreamingResize() throws { try resizeScenario(streaming: true) } + func testNativeViewportShrinksBeforeMovingAFullWidthWindow() { + let display = CGRect(x: 0, y: 0, width: 1024, height: 768) + let available = CGRect(x: 0, y: 31, width: 1024, height: 674) + let frames = nativeFixturePreparationFrames(current: CGRect(x: 0, y: 31, width: 1024, height: 677), available: available) + for frame in frames { + XCTAssertTrue(display.contains(frame), "Every setup frame and resize corner must remain on-screen") + } + XCTAssertEqual(frames.last, nativeFixtureBaseline(current: CGRect(x: 0, y: 31, width: 1024, height: 677), available: available)) + } + + func testNativeInputReadinessUsesTheCodeTerminalInstanceInsteadOfATextViewRole() { + XCTAssertTrue(nativeTerminalInputReady(terminal: "vscode", textViewExists: false, splitTerminalEnabled: true)) + XCTAssertFalse(nativeTerminalInputReady(terminal: "vscode", textViewExists: false, splitTerminalEnabled: false)) + XCTAssertFalse(nativeTerminalInputReady(terminal: "vscode", textViewExists: true, splitTerminalEnabled: false)) + XCTAssertFalse(nativeTerminalInputReady(terminal: "iterm2", textViewExists: false, splitTerminalEnabled: true)) + XCTAssertTrue(nativeTerminalInputReady(terminal: "iterm2", textViewExists: true, splitTerminalEnabled: false)) + } + + func testNativeInputReadoutRequiresTheActualFixtureBeforeUsingTheCopiedBuffer() { + XCTAssertTrue(nativeReadoutUsesCopiedBuffer(terminal: "vscode", accessibilityHasFixture: false, copiedBufferHasFixture: true)) + XCTAssertFalse(nativeReadoutUsesCopiedBuffer(terminal: "vscode", accessibilityHasFixture: false, copiedBufferHasFixture: false)) + XCTAssertFalse(nativeReadoutUsesCopiedBuffer(terminal: "vscode", accessibilityHasFixture: true, copiedBufferHasFixture: true)) + XCTAssertFalse(nativeReadoutUsesCopiedBuffer(terminal: "iterm2", accessibilityHasFixture: false, copiedBufferHasFixture: true)) + } + func testNativeViewportKeepsAllFourResizesWithinTheDisplayAndLargeEnoughForHistory() { let available = CGRect(x: 0, y: 31, width: 1024, height: 677) var frame = nativeFixtureBaseline(current: CGRect(x: 400, y: 246, width: 587, height: 462), available: available) @@ -174,21 +213,35 @@ final class TerminalCompatibilityUITests: XCTestCase { self.openVSCodeTerminal(app) return false } - return terminalView.exists + let split = app.menuBars.menuItems.matching(NSPredicate(format: "title == %@", "Split Terminal")).firstMatch + return nativeTerminalInputReady(terminal: terminal, textViewExists: terminal != "vscode" && terminalView.exists, + splitTerminalEnabled: terminal == "vscode" && split.exists && split.isEnabled) } guard XCTWaiter.wait(for: [XCTNSPredicateExpectation(predicate: terminalReady, object: app)], timeout: 20) == .completed else { attachStartupFailure(app, terminal: terminal, phase: "terminal-view") - XCTFail("The native terminal did not expose its terminal text view") + XCTFail("The native terminal did not expose a ready input target") return } - terminalView.click() + if terminal != "vscode" { terminalView.click() } NSPasteboard.general.clearContents() XCTAssertTrue(NSPasteboard.general.writeObjects([command as NSString])) app.typeKey("v", modifierFlags: .command) app.typeKey(.return, modifierFlags: []) var startupValue = "" + var accessibilityStartup = "" + var copiedStartup = "" + var useCopiedBuffer = false let ready = NSPredicate { _, _ in - startupValue = self.terminalValue(window) + accessibilityStartup = self.terminalValue(window) + startupValue = accessibilityStartup + if !startupValue.contains("RESIZE_HISTORY retained text"), terminal == "vscode" { + copiedStartup = self.copiedTerminalValue(window) + accessibilityStartup = self.terminalValue(window) + useCopiedBuffer = nativeReadoutUsesCopiedBuffer(terminal: terminal, + accessibilityHasFixture: accessibilityStartup.contains("RESIZE_HISTORY retained text"), + copiedBufferHasFixture: copiedStartup.contains("RESIZE_HISTORY retained text")) + startupValue = useCopiedBuffer ? copiedStartup : accessibilityStartup + } return startupValue.contains("RESIZE_HISTORY retained text") } guard XCTWaiter.wait(for: [XCTNSPredicateExpectation(predicate: ready, object: app)], timeout: 30) == .completed else { @@ -203,6 +256,14 @@ final class TerminalCompatibilityUITests: XCTestCase { XCTFail("The native terminal did not display the fixture history marker") return } + if terminal == "vscode" { + print("NATIVE_TERMINAL_READOUT vscode \(useCopiedBuffer ? "native-copy" : "accessibility")") + let evidence = XCTAttachment(string: "Readout: \(useCopiedBuffer ? "native-copy" : "accessibility")\nAccessibility:\n" + + String(accessibilityStartup.prefix(7_900)) + "\nCopied buffer:\n" + String(copiedStartup.prefix(7_900))) + evidence.name = "vscode-\(streaming ? "streaming" : "idle")-readout-preflight" + evidence.lifetime = .keepAlways + add(evidence) + } for (index, delta) in nativeResizeDeltas.enumerated() { let frame = window.frame let corner = window.coordinate(withNormalizedOffset: CGVector(dx: 1, dy: 1)).withOffset(CGVector(dx: -2, dy: -2)) @@ -210,11 +271,11 @@ final class TerminalCompatibilityUITests: XCTestCase { XCTAssertNotEqual(window.frame.size, frame.size, "The native window did not resize") XCTAssertTrue(displayBounds.contains(window.frame), "The resized fixture window left the display bounds") let settled = NSPredicate { _, _ in - let value = self.terminalValue(window) + let value = self.terminalValue(window, useCopiedBuffer: useCopiedBuffer) return value.contains("RESIZE_HISTORY retained text") && value.contains("preserved draft") } guard XCTWaiter.wait(for: [XCTNSPredicateExpectation(predicate: settled, object: window)], timeout: 10) == .completed else { - let content = XCTAttachment(string: String(terminalValue(window).suffix(16_000))) + let content = XCTAttachment(string: String(terminalValue(window, useCopiedBuffer: useCopiedBuffer).suffix(16_000))) content.name = "\(terminal)-resize-\(index)-terminal" content.lifetime = .keepAlways add(content) @@ -222,7 +283,7 @@ final class TerminalCompatibilityUITests: XCTestCase { XCTFail("The resized native window did not retain its history and draft") return } - let content = terminalValue(window) + let content = terminalValue(window, useCopiedBuffer: useCopiedBuffer) XCTAssertEqual(content.components(separatedBy: "Autohand Code").count - 1, 1, content) XCTAssertEqual(content.components(separatedBy: "RESIZE_HISTORY retained text").count - 1, 1, content) XCTAssertEqual(content.components(separatedBy: "preserved draft").count - 1, 1, content) @@ -235,14 +296,14 @@ final class TerminalCompatibilityUITests: XCTestCase { app.typeKey(.leftArrow, modifierFlags: []) app.typeKey(.leftArrow, modifierFlags: []) app.typeKey("x", modifierFlags: .shift) - let edited = NSPredicate { _, _ in self.terminalValue(window).contains("taXil") } + let edited = NSPredicate { _, _ in self.terminalValue(window, useCopiedBuffer: useCopiedBuffer).contains("taXil") } expectation(for: edited, evaluatedWith: app) waitForExpectations(timeout: 10) app.typeKey(.escape, modifierFlags: []) app.typeKey("c", modifierFlags: .control) app.typeKey("c", modifierFlags: .control) app.typeKey("c", modifierFlags: .control) - let exit = NSPredicate { _, _ in self.terminalValue(window).contains("RESIZE_PTY_EXIT") || app.state == .notRunning } + let exit = NSPredicate { _, _ in app.state == .notRunning || self.terminalValue(window, useCopiedBuffer: useCopiedBuffer).contains("RESIZE_PTY_EXIT") } expectation(for: exit, evaluatedWith: app) waitForExpectations(timeout: 15) print(streaming ? "NATIVE_TERMINAL_STREAMING_PASS" : "NATIVE_TERMINAL_IDLE_PASS") @@ -259,12 +320,20 @@ final class TerminalCompatibilityUITests: XCTestCase { let visible = screen.visibleFrame let available = CGRect(x: visible.minX, y: desktopTop - visible.maxY, width: visible.width, height: visible.height) let target = nativeFixtureBaseline(current: window.frame, available: available) - let frame = window.frame - let grip = window.coordinate(withNormalizedOffset: .zero).withOffset(CGVector(dx: terminal == "vscode" ? 110 : frame.width * 0.75, dy: 12)) - grip.press(forDuration: 0.1, thenDragTo: grip.withOffset(CGVector(dx: target.minX - frame.minX, dy: target.minY - frame.minY))) - let moved = window.frame - let corner = window.coordinate(withNormalizedOffset: CGVector(dx: 1, dy: 1)).withOffset(CGVector(dx: -2, dy: -2)) - corner.press(forDuration: 0.1, thenDragTo: corner.withOffset(CGVector(dx: target.width - moved.width, dy: target.height - moved.height))) + for step in nativeFixturePreparationFrames(current: window.frame, available: available) { + let frame = window.frame + if abs(step.minX - frame.minX) > 0.5 || abs(step.minY - frame.minY) > 0.5 { + let grip = window.coordinate(withNormalizedOffset: .zero).withOffset(CGVector(dx: terminal == "vscode" ? 110 : frame.width * 0.75, dy: 12)) + grip.press(forDuration: 0.1, thenDragTo: grip.withOffset(CGVector(dx: step.minX - frame.minX, dy: step.minY - frame.minY))) + } + let moved = window.frame + if abs(step.width - moved.width) > 0.5 || abs(step.height - moved.height) > 0.5 { + let corner = window.coordinate(withNormalizedOffset: CGVector(dx: 1, dy: 1)).withOffset(CGVector(dx: -2, dy: -2)) + corner.press(forDuration: 0.1, thenDragTo: corner.withOffset(CGVector(dx: step.width - moved.width, dy: step.height - moved.height))) + } + print("NATIVE_TERMINAL_PREPARE \(terminal) \(window.frame) target=\(step) display=\(bounds)") + XCTAssertTrue(bounds.contains(window.frame), "Every fixture setup gesture must stay within the display") + } XCTAssertGreaterThanOrEqual(window.frame.height, target.height - 20, "The native fixture needs room for both transcript exchanges") XCTAssertTrue(bounds.contains(window.frame), "The fixture viewport must stay within the display") print("NATIVE_TERMINAL_VIEWPORT \(terminal) \(window.frame) display=\(available)") @@ -309,7 +378,9 @@ final class TerminalCompatibilityUITests: XCTestCase { @MainActor private func attachStartupFailure(_ app: XCUIApplication, terminal: String, phase: String) { - let hierarchy = XCTAttachment(string: String(app.debugDescription.suffix(16_000))) + let description = app.debugDescription + let bounded = description.count > 16_000 ? String(description.prefix(8_000)) + "\n…\n" + String(description.suffix(7_997)) : description + let hierarchy = XCTAttachment(string: bounded) hierarchy.name = "\(terminal)-startup-\(phase)-hierarchy" hierarchy.lifetime = .keepAlways add(hierarchy) @@ -323,7 +394,22 @@ final class TerminalCompatibilityUITests: XCTestCase { NativeAccessibilityText(label: element.label, title: element.title, value: element.value as? String ?? "") } - private func terminalValue(_ window: XCUIElement) -> String { + private func copiedTerminalValue(_ window: XCUIElement) -> String { + let pasteboard = NSPasteboard.general + pasteboard.clearContents() + let generation = pasteboard.changeCount + window.typeKey("y", modifierFlags: [.control, .shift]) + let written = NSPredicate { _, _ in pasteboard.changeCount > generation && pasteboard.string(forType: .string) != nil } + guard XCTWaiter.wait(for: [XCTNSPredicateExpectation(predicate: written, object: pasteboard)], timeout: 2) == .completed, + pasteboard.changeCount > generation, let value = pasteboard.string(forType: .string) else { + XCTFail("VS Code did not copy a fresh terminal buffer through its native readout binding") + return "" + } + return value + } + + private func terminalValue(_ window: XCUIElement, useCopiedBuffer: Bool = false) -> String { + if useCopiedBuffer { return copiedTerminalValue(window) } let values = window.textViews.allElementsBoundByIndex.compactMap { $0.value as? String } if let terminal = values.filter({ $0.contains("RESIZE_HISTORY retained text") }).max(by: { $0.count < $1.count }) { return terminal diff --git a/tests/config.test.ts b/tests/config.test.ts index ad3844a70..82a9e9a3a 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -23,15 +23,26 @@ describe('configuration write contention', () => { const config = await loadConfig(configPath); const lease = await atomicFiles.acquireFileLock(`${configPath}.lock`); expect(lease).not.toBeNull(); + const scheduleDeadline = globalThis.setTimeout; + const clearDeadline = globalThis.clearTimeout; vi.useFakeTimers(); const timerBaseline = vi.getTimerCount(); - const retryTimer = vi.spyOn(globalThis, 'setTimeout'); + const retryScheduled = Promise.withResolvers(); + const scheduleTimeout = globalThis.setTimeout; + const retryTimer = vi.spyOn(globalThis, 'setTimeout').mockImplementation((callback, delay, ...args) => { + const timer = scheduleTimeout(callback, delay, ...args); + if (delay === 25) retryScheduled.resolve(); + return timer; + }); + const retryDeadline = scheduleDeadline(() => retryScheduled.reject(new Error('Configuration writer did not schedule a lock retry')), 1_000); try { const result = (operation === 'configuration' ? saveConfig({ ...config, mcp: { servers: [{ name: 'fixture', transport: 'http', url: 'https://example.com/mcp' }] } }) : saveValidatedAuth({ ...config, auth: { token: 'fixture-token', user: { id: 'updated', email: 'fixture@example.com' } } }, 'fixture-token')) .then(() => undefined, (error: unknown) => error); - await vi.waitFor(() => expect(retryTimer.mock.calls.some(([, delay]) => delay === 25)).toBe(true)); + await retryScheduled.promise; + clearDeadline(retryDeadline); + expect(vi.getTimerCount()).toBe(timerBaseline + 1); await lease?.release(); await vi.runAllTimersAsync(); await expect(result).resolves.toBeUndefined(); @@ -40,6 +51,7 @@ describe('configuration write contention', () => { else expect(saved.auth.user.id).toBe('updated'); expect(vi.getTimerCount()).toBe(timerBaseline); } finally { + clearDeadline(retryDeadline); retryTimer.mockRestore(); vi.useRealTimers(); await lease?.release(); diff --git a/tests/testing/nativeTerminal.test.ts b/tests/testing/nativeTerminal.test.ts index 1ab5e57c0..19bba4d95 100644 --- a/tests/testing/nativeTerminal.test.ts +++ b/tests/testing/nativeTerminal.test.ts @@ -41,6 +41,24 @@ function preparedLaunchArguments(): Record { } describe('native terminal automation boundary', () => { + it.each(['idle', 'streaming'])('installs a single native buffer-copy binding in the isolated %s VS Code profile', async mode => { + Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true }); + vi.stubEnv('GITHUB_ACTIONS', 'true'); + vi.stubEnv('CI', 'true'); + vi.stubEnv('AUTOHAND_NATIVE_TERMINAL', 'vscode'); + await prepareNativeTerminalTest(); + const evidence = path.resolve('.artifacts/native-terminal/vscode/vscode-profile', mode, 'User/keybindings.json'); + const bindingWrite = vi.mocked(writeFile).mock.calls.find(([file]) => file === evidence); + expect(bindingWrite).toBeDefined(); + const bindings: unknown = JSON.parse(String(bindingWrite?.[1])); + expect(bindings).toEqual([{ + key: 'ctrl+shift+y', command: 'runCommands', when: 'terminalFocus', args: { + commands: ['workbench.action.terminal.selectAll', 'workbench.action.terminal.copyAndClearSelection'], + }, + }]); + expect(copyFile).toHaveBeenCalledWith(evidence, path.join('/private/tmp/ah-vsc-123456', mode, 'User/keybindings.json')); + }); + it.each(['idle', 'streaming'])('keeps the %s VS Code main socket below the macOS Unix socket limit in the Actions checkout', async mode => { Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true }); vi.stubEnv('GITHUB_ACTIONS', 'true'); From 9e15448269172150c3f638d48aed5b3ddfa1d0e0 Mon Sep 17 00:00:00 2001 From: Igor Costa Date: Tue, 10 Nov 2026 15:33:00 +1300 Subject: [PATCH 06/19] Read VS Code terminal buffers without replaying accessibility announcements Capture one bounded accessibility snapshot and accept complete TextView buffers before using fresh native clipboard readout. Preserve raw duplication, resize, cursor, streaming, and exit assertions. Co-authored-by: Autohand Evolve --- docs/terminal-compatibility.md | 2 +- .../TerminalCompatibilityUITests.swift | 79 ++++++++++++++++++- 2 files changed, 77 insertions(+), 4 deletions(-) diff --git a/docs/terminal-compatibility.md b/docs/terminal-compatibility.md index 267e83f2b..056558fe4 100644 --- a/docs/terminal-compatibility.md +++ b/docs/terminal-compatibility.md @@ -43,7 +43,7 @@ VS Code uses separate disposable profiles for idle and streaming cases under a s Native scenarios first size and position their fixture window within the reported display's visible bounds, leaving room for all four physical resize gestures. Setup shrinks oversized dimensions before moving the window, then grows it at the target position, so a full-width window's resize handle never moves off-screen. VS Code's input readiness uses the native Split Terminal command becoming enabled after New Terminal, which establishes terminal focus; it does not require xterm's input to expose the same TextView role as standalone terminals. The fixture's rendered output remains the mandatory proof that paste, Enter, and terminal rendering worked. All scenarios retain the exact transcript, streaming, draft, cursor, resize, and exit assertions. -VS Code's readout preflight first checks accessibility for the real fixture-history marker. If accessibility lacks it, a disposable-profile shortcut uses [`runCommands`](https://code.visualstudio.com/docs/configure/keybindings#_running-multiple-commands) to select the full terminal buffer and [copy and clear its selection](https://github.com/microsoft/vscode/blob/main/src/vs/workbench/contrib/terminalContrib/clipboard/browser/terminal.clipboard.contribution.ts). The preflight uses that raw copied buffer only if it contains the actual fixture marker and a second accessibility read still lacks it. Each subsequent copy clears the pasteboard and requires a new pasteboard generation; old frames cannot satisfy later resize checks. The command clears terminal selection while preserving focus and the PTY cursor, which the existing cursor-edit assertion verifies. No terminal output is filtered or deduplicated. Readout samples, selected backend, settings, and keybindings are retained with native results. +VS Code's readout preflight captures one immutable native window snapshot and searches at most 2,048 nodes for a TextView buffer containing the real fixture-history marker. It does not concatenate workbench StaticText nodes: xterm exposes both individual screen rows and a live announcement region, which can repeat hundreds of full-screen redraw announcements. Those announcements are not the terminal buffer. The native snapshot avoids separate remote accessibility calls for every node and attribute; exceeding its traversal budget fails closed to the buffer-copy preflight. If accessibility lacks it, a disposable-profile shortcut uses [`runCommands`](https://code.visualstudio.com/docs/configure/keybindings#_running-multiple-commands) to select the full terminal buffer and [copy and clear its selection](https://github.com/microsoft/vscode/blob/main/src/vs/workbench/contrib/terminalContrib/clipboard/browser/terminal.clipboard.contribution.ts). The preflight uses that raw copied buffer only if it contains the actual fixture marker and a second accessibility read still lacks it. Each subsequent copy clears the pasteboard and requires a new pasteboard generation; old frames cannot satisfy later resize checks. The command clears terminal selection while preserving focus and the PTY cursor, which the existing cursor-edit assertion verifies. No terminal output is filtered or deduplicated. Readout samples, selected backend, settings, and keybindings are retained with native results. The strict visible-history assertions require room for both transcript exchanges and the multiline composer. At a compact 57×19 terminal, the full-screen transcript correctly follows its newest exchange and clips older rows; a separate PTY case checks that the history reappears on growth, the draft and cursor survive, and Ctrl+C exits cleanly. This distinguishes viewport clipping from lost transcript data. Native window references bind to their accessibility identities so later queries continue inspecting the resized window. diff --git a/src/testing/drivers/native-terminal-lab/TerminalCompatibilityUITests.swift b/src/testing/drivers/native-terminal-lab/TerminalCompatibilityUITests.swift index 3753b3492..4f266f179 100644 --- a/src/testing/drivers/native-terminal-lab/TerminalCompatibilityUITests.swift +++ b/src/testing/drivers/native-terminal-lab/TerminalCompatibilityUITests.swift @@ -10,6 +10,31 @@ private struct NativeAccessibilityText { var content: String { [value, label, title].first { !$0.isEmpty } ?? "" } } +private struct NativeReadoutNode { + var isTextView = false + var text = NativeAccessibilityText() + var children: [NativeReadoutNode] = [] +} + +private func nativeCodeTerminalBuffer(_ capture: () throws -> Snapshot, + children: (Snapshot) -> [Snapshot], + textViewValue: (Snapshot) -> String?, + maximumNodes: Int = 2_048) rethrows -> String { + var pending = [try capture()] + var visited = 0 + var values: [String] = [] + while let node = pending.popLast() { + guard visited < maximumNodes else { return "" } + visited += 1 + if let value = textViewValue(node) { values.append(value) } + let descendants = children(node) + guard descendants.count <= maximumNodes - visited - pending.count else { return "" } + pending.append(contentsOf: descendants.reversed()) + } + return values.filter { $0.contains("RESIZE_HISTORY retained text") }.max { $0.count < $1.count } + ?? values.max { $0.count < $1.count } ?? "" +} + private func localNetworkDenialButton(dialog: [NativeAccessibilityText], buttons: [NativeAccessibilityText]) -> Int? { let text = dialog.flatMap(\.strings).joined(separator: " ").split(whereSeparator: \.isWhitespace).joined(separator: " ") guard text.contains("find devices on local networks") else { return nil } @@ -55,6 +80,53 @@ final class TerminalCompatibilityUITests: XCTestCase { @MainActor func testIdleResize() throws { try resizeScenario(streaming: false) } @MainActor func testStreamingResize() throws { try resizeScenario(streaming: true) } + func testNativeReadoutIgnoresAnnouncementsAndRowMirrorsWhenNoCompleteTextViewExists() { + let announcement = "Autohand Code RESIZE_HISTORY retained text preserved draft" + let root = NativeReadoutNode(children: [ + NativeReadoutNode(text: NativeAccessibilityText(value: announcement + announcement)), + NativeReadoutNode(text: NativeAccessibilityText(value: "RESIZE_HISTORY retained text")), + NativeReadoutNode(text: NativeAccessibilityText(value: "preserved draft")) + ]) + let accessibility = nativeCodeTerminalBuffer({ root }, children: { $0.children }, + textViewValue: { $0.isTextView ? $0.text.value : nil }) + XCTAssertEqual(accessibility, "", "Announcements and individual rows are not a native terminal buffer") + let copied = announcement + "\n" + announcement + let useCopied = nativeReadoutUsesCopiedBuffer(terminal: "vscode", + accessibilityHasFixture: accessibility.contains("RESIZE_HISTORY retained text"), + copiedBufferHasFixture: copied.contains("RESIZE_HISTORY retained text")) + XCTAssertTrue(useCopied) + let content = useCopied ? copied : accessibility + XCTAssertEqual(content, copied) + XCTAssertEqual(content.components(separatedBy: "RESIZE_HISTORY retained text").count - 1, 2, + "A duplicated copied frame must still fail the native count assertion") + } + + func testNativeReadoutCapturesOneSnapshotAndPreservesRawBufferDuplicates() { + let buffer = "Autohand Code\nRESIZE_HISTORY retained text\nRESIZE_HISTORY retained text\npreserved draft\ntaXil" + let root = NativeReadoutNode(children: [ + NativeReadoutNode(text: NativeAccessibilityText(value: buffer + buffer)), + NativeReadoutNode(isTextView: true, text: NativeAccessibilityText(label: buffer, value: buffer)), + NativeReadoutNode(text: NativeAccessibilityText(value: "RESIZE_HISTORY retained text")) + ]) + var captures = 0 + let value = nativeCodeTerminalBuffer({ captures += 1; return root }, children: { $0.children }, + textViewValue: { $0.isTextView ? $0.text.value : nil }) + XCTAssertEqual(captures, 1) + XCTAssertEqual(value, buffer) + XCTAssertEqual(value.components(separatedBy: "RESIZE_HISTORY retained text").count - 1, 2) + } + + func testNativeReadoutFailsClosedWhenTheSnapshotExceedsItsNodeBudget() { + let root = NativeReadoutNode(children: [ + NativeReadoutNode(isTextView: true, text: NativeAccessibilityText(value: "Autohand Code RESIZE_HISTORY retained text")), + NativeReadoutNode() + ]) + XCTAssertEqual(nativeCodeTerminalBuffer({ root }, children: { $0.children }, + textViewValue: { $0.isTextView ? $0.text.value : nil }, maximumNodes: 2), "") + XCTAssertEqual(nativeCodeTerminalBuffer({ root }, children: { $0.children }, + textViewValue: { $0.isTextView ? $0.text.value : nil }, maximumNodes: 3), "Autohand Code RESIZE_HISTORY retained text") + } + func testNativeViewportShrinksBeforeMovingAFullWidthWindow() { let display = CGRect(x: 0, y: 0, width: 1024, height: 768) let available = CGRect(x: 0, y: 31, width: 1024, height: 674) @@ -410,13 +482,14 @@ final class TerminalCompatibilityUITests: XCTestCase { private func terminalValue(_ window: XCUIElement, useCopiedBuffer: Bool = false) -> String { if useCopiedBuffer { return copiedTerminalValue(window) } + if environment["AUTOHAND_NATIVE_TERMINAL"] == "vscode" { + return (try? nativeCodeTerminalBuffer({ try window.snapshot() }, children: { $0.children }, + textViewValue: { $0.elementType == .textView ? $0.value as? String : nil })) ?? "" + } let values = window.textViews.allElementsBoundByIndex.compactMap { $0.value as? String } if let terminal = values.filter({ $0.contains("RESIZE_HISTORY retained text") }).max(by: { $0.count < $1.count }) { return terminal } - if environment["AUTOHAND_NATIVE_TERMINAL"] == "vscode" { - return window.staticTexts.allElementsBoundByIndex.map { accessibilityText($0).content }.joined(separator: "\n") - } return values.max(by: { $0.count < $1.count }) ?? "" } } From bbbcbe11ea2fd74f847a69339ade85b17adc435b Mon Sep 17 00:00:00 2001 From: Igor Costa Date: Tue, 10 Nov 2026 15:51:30 +1300 Subject: [PATCH 07/19] Assert interactive readiness before advancing terminal scenarios Inspect the accepted fresh native terminal frame without a second clipboard read, and wait for the mounted question menu before submitting keyboard input. Preserve exact output, lifecycle, cursor, exit, and deadline checks. Co-authored-by: Autohand Evolve --- docs/terminal-compatibility.md | 2 +- .../TerminalCompatibilityUITests.swift | 79 +++++++++++++++---- .../tuistory/program-status.tuistory.test.ts | 9 ++- 3 files changed, 72 insertions(+), 18 deletions(-) diff --git a/docs/terminal-compatibility.md b/docs/terminal-compatibility.md index 056558fe4..6b78dfdd4 100644 --- a/docs/terminal-compatibility.md +++ b/docs/terminal-compatibility.md @@ -43,7 +43,7 @@ VS Code uses separate disposable profiles for idle and streaming cases under a s Native scenarios first size and position their fixture window within the reported display's visible bounds, leaving room for all four physical resize gestures. Setup shrinks oversized dimensions before moving the window, then grows it at the target position, so a full-width window's resize handle never moves off-screen. VS Code's input readiness uses the native Split Terminal command becoming enabled after New Terminal, which establishes terminal focus; it does not require xterm's input to expose the same TextView role as standalone terminals. The fixture's rendered output remains the mandatory proof that paste, Enter, and terminal rendering worked. All scenarios retain the exact transcript, streaming, draft, cursor, resize, and exit assertions. -VS Code's readout preflight captures one immutable native window snapshot and searches at most 2,048 nodes for a TextView buffer containing the real fixture-history marker. It does not concatenate workbench StaticText nodes: xterm exposes both individual screen rows and a live announcement region, which can repeat hundreds of full-screen redraw announcements. Those announcements are not the terminal buffer. The native snapshot avoids separate remote accessibility calls for every node and attribute; exceeding its traversal budget fails closed to the buffer-copy preflight. If accessibility lacks it, a disposable-profile shortcut uses [`runCommands`](https://code.visualstudio.com/docs/configure/keybindings#_running-multiple-commands) to select the full terminal buffer and [copy and clear its selection](https://github.com/microsoft/vscode/blob/main/src/vs/workbench/contrib/terminalContrib/clipboard/browser/terminal.clipboard.contribution.ts). The preflight uses that raw copied buffer only if it contains the actual fixture marker and a second accessibility read still lacks it. Each subsequent copy clears the pasteboard and requires a new pasteboard generation; old frames cannot satisfy later resize checks. The command clears terminal selection while preserving focus and the PTY cursor, which the existing cursor-edit assertion verifies. No terminal output is filtered or deduplicated. Readout samples, selected backend, settings, and keybindings are retained with native results. +VS Code's readout preflight captures one immutable native window snapshot and searches at most 2,048 nodes for a TextView buffer containing the real fixture-history marker. It does not concatenate workbench StaticText nodes: xterm exposes both individual screen rows and a live announcement region, which can repeat hundreds of full-screen redraw announcements. Those announcements are not the terminal buffer. The native snapshot avoids separate remote accessibility calls for every node and attribute; exceeding its traversal budget fails closed to the buffer-copy preflight. If accessibility lacks it, a disposable-profile shortcut uses [`runCommands`](https://code.visualstudio.com/docs/configure/keybindings#_running-multiple-commands) to select the full terminal buffer and [copy and clear its selection](https://github.com/microsoft/vscode/blob/main/src/vs/workbench/contrib/terminalContrib/clipboard/browser/terminal.clipboard.contribution.ts). The preflight uses that raw copied buffer only if it contains the actual fixture marker and a second accessibility read still lacks it. Each subsequent copy clears the pasteboard and requires a new pasteboard generation; old frames cannot satisfy later resize checks. A temporarily unavailable copy produces no frame; the original startup, resize, cursor, or exit predicate retains its deadline. Once resize readiness accepts a fresh raw frame, all exact counts and streaming assertions inspect that same frame without issuing a second copy. Readiness checks only history and draft presence, so duplicated accepted frames still fail immediately. The command clears terminal selection while preserving focus and the PTY cursor, which the existing cursor-edit assertion verifies. No terminal output is filtered or deduplicated. Readout samples, selected backend, settings, and keybindings are retained with native results. The strict visible-history assertions require room for both transcript exchanges and the multiline composer. At a compact 57×19 terminal, the full-screen transcript correctly follows its newest exchange and clips older rows; a separate PTY case checks that the history reappears on growth, the draft and cursor survive, and Ctrl+C exits cleanly. This distinguishes viewport clipping from lost transcript data. Native window references bind to their accessibility identities so later queries continue inspecting the resized window. diff --git a/src/testing/drivers/native-terminal-lab/TerminalCompatibilityUITests.swift b/src/testing/drivers/native-terminal-lab/TerminalCompatibilityUITests.swift index 4f266f179..01bb92368 100644 --- a/src/testing/drivers/native-terminal-lab/TerminalCompatibilityUITests.swift +++ b/src/testing/drivers/native-terminal-lab/TerminalCompatibilityUITests.swift @@ -35,6 +35,17 @@ private func nativeCodeTerminalBuffer(_ capture: () throws -> Snapshot ?? values.max { $0.count < $1.count } ?? "" } +private struct NativeTerminalReadout { + let read: () -> String? + private(set) var frame: String? + + mutating func capture() -> Bool { + frame = read() + guard let value = frame else { return false } + return value.contains("RESIZE_HISTORY retained text") && value.contains("preserved draft") + } +} + private func localNetworkDenialButton(dialog: [NativeAccessibilityText], buttons: [NativeAccessibilityText]) -> Int? { let text = dialog.flatMap(\.strings).joined(separator: " ").split(whereSeparator: \.isWhitespace).joined(separator: " ") guard text.contains("find devices on local networks") else { return nil } @@ -80,6 +91,44 @@ final class TerminalCompatibilityUITests: XCTestCase { @MainActor func testIdleResize() throws { try resizeScenario(streaming: false) } @MainActor func testStreamingResize() throws { try resizeScenario(streaming: true) } + func testNativeReadoutAssertsAgainstTheAcceptedFreshFrameWithoutCopyingAgain() { + let raw = "Autohand Code\nRESIZE_HISTORY retained text\npreserved draft\nRESIZE_STREAM 308" + var reads = 0 + var readout = NativeTerminalReadout(read: { reads += 1; return reads == 1 ? raw : nil }) + XCTAssertTrue(readout.capture()) + XCTAssertEqual(readout.frame, raw) + XCTAssertEqual(reads, 1) + } + + func testNativeReadoutUnavailableSamplesDoNotSelectOrCleanTheNextFreshFrame() { + let raw = "Autohand Code\nRESIZE_HISTORY retained text\nRESIZE_HISTORY retained text\npreserved draft\nRESIZE_STREAM 309" + var reads = 0 + var readout = NativeTerminalReadout(read: { reads += 1; return reads == 1 ? nil : raw }) + XCTAssertFalse(readout.capture()) + XCTAssertTrue(readout.capture()) + XCTAssertEqual(readout.frame, raw) + XCTAssertEqual(readout.frame?.components(separatedBy: "RESIZE_HISTORY retained text").count, 3) + XCTAssertEqual(reads, 2) + } + + func testNativeReadoutCannotReuseAPreviousFrameWhenTheNextCopyIsUnavailable() { + var reads = 0 + var readout = NativeTerminalReadout(read: { + reads += 1 + return reads == 1 ? "RESIZE_HISTORY retained text\npreserved draft" : nil + }) + XCTAssertTrue(readout.capture()) + XCTAssertFalse(readout.capture()) + XCTAssertNil(readout.frame) + } + + func testNativeReadoutPermanentlyUnavailableSamplesFailTheReadinessDeadline() { + var readout = NativeTerminalReadout(read: { nil }) + let ready = NSPredicate { _, _ in readout.capture() } + XCTAssertEqual(XCTWaiter.wait(for: [XCTNSPredicateExpectation(predicate: ready, object: nil)], timeout: 0.01), .timedOut) + XCTAssertNil(readout.frame) + } + func testNativeReadoutIgnoresAnnouncementsAndRowMirrorsWhenNoCompleteTextViewExists() { let announcement = "Autohand Code RESIZE_HISTORY retained text preserved draft" let root = NativeReadoutNode(children: [ @@ -304,11 +353,11 @@ final class TerminalCompatibilityUITests: XCTestCase { var copiedStartup = "" var useCopiedBuffer = false let ready = NSPredicate { _, _ in - accessibilityStartup = self.terminalValue(window) + accessibilityStartup = self.terminalValue(window) ?? "" startupValue = accessibilityStartup if !startupValue.contains("RESIZE_HISTORY retained text"), terminal == "vscode" { - copiedStartup = self.copiedTerminalValue(window) - accessibilityStartup = self.terminalValue(window) + copiedStartup = self.copiedTerminalValue(window) ?? "" + accessibilityStartup = self.terminalValue(window) ?? "" useCopiedBuffer = nativeReadoutUsesCopiedBuffer(terminal: terminal, accessibilityHasFixture: accessibilityStartup.contains("RESIZE_HISTORY retained text"), copiedBufferHasFixture: copiedStartup.contains("RESIZE_HISTORY retained text")) @@ -342,12 +391,11 @@ final class TerminalCompatibilityUITests: XCTestCase { corner.press(forDuration: 0.1, thenDragTo: corner.withOffset(delta)) XCTAssertNotEqual(window.frame.size, frame.size, "The native window did not resize") XCTAssertTrue(displayBounds.contains(window.frame), "The resized fixture window left the display bounds") - let settled = NSPredicate { _, _ in - let value = self.terminalValue(window, useCopiedBuffer: useCopiedBuffer) - return value.contains("RESIZE_HISTORY retained text") && value.contains("preserved draft") - } - guard XCTWaiter.wait(for: [XCTNSPredicateExpectation(predicate: settled, object: window)], timeout: 10) == .completed else { - let content = XCTAttachment(string: String(terminalValue(window, useCopiedBuffer: useCopiedBuffer).suffix(16_000))) + var readout = NativeTerminalReadout(read: { self.terminalValue(window, useCopiedBuffer: useCopiedBuffer) }) + let settled = NSPredicate { _, _ in readout.capture() } + guard XCTWaiter.wait(for: [XCTNSPredicateExpectation(predicate: settled, object: window)], timeout: 10) == .completed, + let content = readout.frame else { + let content = XCTAttachment(string: String((readout.frame ?? "No fresh terminal buffer was available").suffix(16_000))) content.name = "\(terminal)-resize-\(index)-terminal" content.lifetime = .keepAlways add(content) @@ -355,7 +403,6 @@ final class TerminalCompatibilityUITests: XCTestCase { XCTFail("The resized native window did not retain its history and draft") return } - let content = terminalValue(window, useCopiedBuffer: useCopiedBuffer) XCTAssertEqual(content.components(separatedBy: "Autohand Code").count - 1, 1, content) XCTAssertEqual(content.components(separatedBy: "RESIZE_HISTORY retained text").count - 1, 1, content) XCTAssertEqual(content.components(separatedBy: "preserved draft").count - 1, 1, content) @@ -368,14 +415,14 @@ final class TerminalCompatibilityUITests: XCTestCase { app.typeKey(.leftArrow, modifierFlags: []) app.typeKey(.leftArrow, modifierFlags: []) app.typeKey("x", modifierFlags: .shift) - let edited = NSPredicate { _, _ in self.terminalValue(window, useCopiedBuffer: useCopiedBuffer).contains("taXil") } + let edited = NSPredicate { _, _ in self.terminalValue(window, useCopiedBuffer: useCopiedBuffer)?.contains("taXil") == true } expectation(for: edited, evaluatedWith: app) waitForExpectations(timeout: 10) app.typeKey(.escape, modifierFlags: []) app.typeKey("c", modifierFlags: .control) app.typeKey("c", modifierFlags: .control) app.typeKey("c", modifierFlags: .control) - let exit = NSPredicate { _, _ in app.state == .notRunning || self.terminalValue(window, useCopiedBuffer: useCopiedBuffer).contains("RESIZE_PTY_EXIT") } + let exit = NSPredicate { _, _ in app.state == .notRunning || self.terminalValue(window, useCopiedBuffer: useCopiedBuffer)?.contains("RESIZE_PTY_EXIT") == true } expectation(for: exit, evaluatedWith: app) waitForExpectations(timeout: 15) print(streaming ? "NATIVE_TERMINAL_STREAMING_PASS" : "NATIVE_TERMINAL_IDLE_PASS") @@ -466,7 +513,7 @@ final class TerminalCompatibilityUITests: XCTestCase { NativeAccessibilityText(label: element.label, title: element.title, value: element.value as? String ?? "") } - private func copiedTerminalValue(_ window: XCUIElement) -> String { + private func copiedTerminalValue(_ window: XCUIElement) -> String? { let pasteboard = NSPasteboard.general pasteboard.clearContents() let generation = pasteboard.changeCount @@ -474,13 +521,13 @@ final class TerminalCompatibilityUITests: XCTestCase { let written = NSPredicate { _, _ in pasteboard.changeCount > generation && pasteboard.string(forType: .string) != nil } guard XCTWaiter.wait(for: [XCTNSPredicateExpectation(predicate: written, object: pasteboard)], timeout: 2) == .completed, pasteboard.changeCount > generation, let value = pasteboard.string(forType: .string) else { - XCTFail("VS Code did not copy a fresh terminal buffer through its native readout binding") - return "" + print("NATIVE_TERMINAL_COPY_UNAVAILABLE baseline=\(generation) observed=\(pasteboard.changeCount)") + return nil } return value } - private func terminalValue(_ window: XCUIElement, useCopiedBuffer: Bool = false) -> String { + private func terminalValue(_ window: XCUIElement, useCopiedBuffer: Bool = false) -> String? { if useCopiedBuffer { return copiedTerminalValue(window) } if environment["AUTOHAND_NATIVE_TERMINAL"] == "vscode" { return (try? nativeCodeTerminalBuffer({ try window.snapshot() }, children: { $0.children }, diff --git a/tests/tuistory/program-status.tuistory.test.ts b/tests/tuistory/program-status.tuistory.test.ts index 217be9a6d..26824f38b 100644 --- a/tests/tuistory/program-status.tuistory.test.ts +++ b/tests/tuistory/program-status.tuistory.test.ts @@ -116,15 +116,22 @@ describe('OSC7501 built terminal lifecycle', () => { }, 60_000); it('reports idle, a question wait, resumed work, done and scoped clear with a usable composer', async () => { - const { session } = await launch([ + const { session, server } = await launch([ { content: 'I need your choice.', toolCall: { id: 'question', name: 'ask_followup_question', args: { question: 'Choose a deployment target', suggested_answers: ['Staging', 'Production'] } } }, { content: 'PROGRAM_STATUS_COMPLETE' }, ]); await submitProgramStatusPrompt(session, 'Ask me which deployment target to use'); await session.waitForText('Choose a deployment target'); await vi.waitFor(() => expect(programStatusReports(session)).toContainEqual(expect.objectContaining({ state: 'blocked', kind: 'question' }))); + await session.text({ waitFor: text => /▸\s+1\. Staging/u.test(text) }); + await session.press('down'); + await session.text({ waitFor: text => /▸\s+2\. Production/u.test(text) }); + await session.press('up'); + await session.text({ waitFor: text => /▸\s+1\. Staging/u.test(text) }); await session.press('enter'); await session.waitForText('PROGRAM_STATUS_COMPLETE'); + expect(server.requests).toHaveLength(2); + expect(JSON.stringify(server.requests[1])).toContain('Staging'); await vi.waitFor(() => expect(programStatusReports(session).at(-1)?.state).toBe('done')); expect(programStatusReports(session)[0]?.state).toBe('idle'); const id = programStatusReports(session).find(report => report.state === 'working')?.id; From 7737a86121ce99a6ede5bc97ba4eb8194cc4c02a Mon Sep 17 00:00:00 2001 From: Igor Costa Date: Tue, 10 Nov 2026 16:29:58 +1300 Subject: [PATCH 08/19] Track native VS Code windows while reading current terminal rows Read one unambiguous source-defined screen row list from a fresh application snapshot, preserve raw duplicates, and guard the process and native window identity. Refresh setup geometry around physical gestures before permitting a move. Co-authored-by: Autohand Evolve --- docs/terminal-compatibility.md | 6 +- .../TerminalCompatibilityUITests.swift | 261 +++++++++++++++++- 2 files changed, 251 insertions(+), 16 deletions(-) diff --git a/docs/terminal-compatibility.md b/docs/terminal-compatibility.md index 6b78dfdd4..dff512d22 100644 --- a/docs/terminal-compatibility.md +++ b/docs/terminal-compatibility.md @@ -41,11 +41,11 @@ First-run dialogs are handled before fixture input. The offline fixture denies o VS Code uses separate disposable profiles for idle and streaming cases under a short `/private/tmp/ah-vsc-*` directory. Its [main IPC socket](https://github.com/microsoft/vscode/blob/main/src/vs/base/parts/ipc/node/ipc.net.ts) lives under the user-data directory, so putting profiles below a long checkout path can exceed macOS's Unix socket limit and make Code exit before creating a window. Settings evidence stays under the checkout's `.artifacts/native-terminal/` directory, and bounded main-log tails are attached to XCTest results. The profiles enable [`chat.disableAIFeatures`](https://code.visualstudio.com/docs/agents/reference/ai-settings) to suppress built-in Copilot onboarding. VS Code also launches with [`--force-renderer-accessibility`](https://www.chromium.org/developers/design-documents/accessibility/) to expose Chromium's native accessibility tree; the editor's screen-reader setting alone does not ensure that tree is available to XCTest. The scenario opens the terminal through its native Terminal > New Terminal menu. If the specific Copilot sign-in dialog still appears, the scenario closes that dialog and opens the integrated terminal. It does not sign in or grant network access. Window, workbench, and terminal-view failures preserve bounded accessibility diagnostics and screenshots; the workflow also retains the Xcode output and generated VS Code settings. -Native scenarios first size and position their fixture window within the reported display's visible bounds, leaving room for all four physical resize gestures. Setup shrinks oversized dimensions before moving the window, then grows it at the target position, so a full-width window's resize handle never moves off-screen. VS Code's input readiness uses the native Split Terminal command becoming enabled after New Terminal, which establishes terminal focus; it does not require xterm's input to expose the same TextView role as standalone terminals. The fixture's rendered output remains the mandatory proof that paste, Enter, and terminal rendering worked. All scenarios retain the exact transcript, streaming, draft, cursor, resize, and exit assertions. +Native scenarios first size and position their fixture window within the reported display's visible bounds, leaving room for all four physical resize gestures. Setup shrinks oversized dimensions before moving the window, then grows it at the target position, so a full-width window's resize handle never moves off-screen. VS Code setup starts after terminal readiness and verifies the actual width and height accepted by each gesture; a partially accepted shrink cannot authorize an off-screen move. VS Code's input readiness uses the native Split Terminal command becoming enabled after New Terminal, which establishes terminal focus; it does not require xterm's input to expose the same TextView role as standalone terminals. The fixture's rendered output remains the mandatory proof that paste, Enter, and terminal rendering worked. All scenarios retain the exact transcript, streaming, draft, cursor, resize, and exit assertions. -VS Code's readout preflight captures one immutable native window snapshot and searches at most 2,048 nodes for a TextView buffer containing the real fixture-history marker. It does not concatenate workbench StaticText nodes: xterm exposes both individual screen rows and a live announcement region, which can repeat hundreds of full-screen redraw announcements. Those announcements are not the terminal buffer. The native snapshot avoids separate remote accessibility calls for every node and attribute; exceeding its traversal budget fails closed to the buffer-copy preflight. If accessibility lacks it, a disposable-profile shortcut uses [`runCommands`](https://code.visualstudio.com/docs/configure/keybindings#_running-multiple-commands) to select the full terminal buffer and [copy and clear its selection](https://github.com/microsoft/vscode/blob/main/src/vs/workbench/contrib/terminalContrib/clipboard/browser/terminal.clipboard.contribution.ts). The preflight uses that raw copied buffer only if it contains the actual fixture marker and a second accessibility read still lacks it. Each subsequent copy clears the pasteboard and requires a new pasteboard generation; old frames cannot satisfy later resize checks. A temporarily unavailable copy produces no frame; the original startup, resize, cursor, or exit predicate retains its deadline. Once resize readiness accepts a fresh raw frame, all exact counts and streaming assertions inspect that same frame without issuing a second copy. Readiness checks only history and draft presence, so duplicated accepted frames still fail immediately. The command clears terminal selection while preserving focus and the PTY cursor, which the existing cursor-edit assertion verifies. No terminal output is filtered or deduplicated. Readout samples, selected backend, settings, and keybindings are retained with native results. +VS Code's readout preflight captures one immutable app snapshot, scopes it to the fixture's current native window frame, and searches at most 2,048 nodes. Its native process ID, Core Graphics window ID, and single-window count remain fixed through all gestures and reads, while accessibility window references are refreshed after resize. [xterm's accessibility tree](https://github.com/xtermjs/xterm.js/blob/master/src/browser/AccessibilityManager.ts) exposes a screen-row list separately from its live announcement region. In the recorded macOS tree this is an Other node containing consecutive, equal-height Group rows, each with one StaticText value. The adapter requires one unambiguous, complete row list in the native window; it preserves row order, blank rows, and repeated values. It never selects rows based on fixture content or concatenates announcement siblings. Native TextView buffers remain supported. Ambiguous, oversized, malformed, or stale snapshots fail closed to the buffer-copy preflight. If accessibility lacks it, a disposable-profile shortcut uses [`runCommands`](https://code.visualstudio.com/docs/configure/keybindings#_running-multiple-commands) to select the full terminal buffer and [copy and clear its selection](https://github.com/microsoft/vscode/blob/main/src/vs/workbench/contrib/terminalContrib/clipboard/browser/terminal.clipboard.contribution.ts). The preflight uses that raw copied buffer only if it contains the actual fixture marker and a second accessibility read still lacks it. Each subsequent copy clears the pasteboard and requires a new pasteboard generation; old frames cannot satisfy later resize checks. A temporarily unavailable copy produces no frame; the original startup, resize, cursor, or exit predicate retains its deadline. Once resize readiness accepts a fresh raw frame, all exact counts and streaming assertions inspect that same frame without issuing a second copy. Readiness checks only history and draft presence, so duplicated accepted frames still fail immediately. The command clears terminal selection while preserving focus and the PTY cursor, which the existing cursor-edit assertion verifies. No terminal output is filtered or deduplicated. Readout samples, selected backend, settings, and keybindings are retained with native results. -The strict visible-history assertions require room for both transcript exchanges and the multiline composer. At a compact 57×19 terminal, the full-screen transcript correctly follows its newest exchange and clips older rows; a separate PTY case checks that the history reappears on growth, the draft and cursor survive, and Ctrl+C exits cleanly. This distinguishes viewport clipping from lost transcript data. Native window references bind to their accessibility identities so later queries continue inspecting the resized window. +The strict visible-history assertions require room for both transcript exchanges and the multiline composer. At a compact 57×19 terminal, the full-screen transcript correctly follows its newest exchange and clips older rows; a separate PTY case checks that the history reappears on growth, the draft and cursor survive, and Ctrl+C exits cleanly. This distinguishes viewport clipping from lost transcript data. Standalone terminal references bind to their accessibility identities. VS Code refreshes its accessibility window before setup steps and after every setup or scenario gesture, guarded by the same native process ID, window ID, single-window count, and current frame. Native CI availability and per-application results must be assessed from that workflow's completed jobs. A passing PTY suite or a successful XCTest build alone does not prove a native application passed. Failures in app launch, accessibility access, or window resizing fail the native job and preserve its XCTest evidence; they are not silently converted to headless passes. diff --git a/src/testing/drivers/native-terminal-lab/TerminalCompatibilityUITests.swift b/src/testing/drivers/native-terminal-lab/TerminalCompatibilityUITests.swift index 01bb92368..752db9450 100644 --- a/src/testing/drivers/native-terminal-lab/TerminalCompatibilityUITests.swift +++ b/src/testing/drivers/native-terminal-lab/TerminalCompatibilityUITests.swift @@ -12,6 +12,8 @@ private struct NativeAccessibilityText { private struct NativeReadoutNode { var isTextView = false + var elementType: XCUIElement.ElementType = .other + var frame = CGRect.zero var text = NativeAccessibilityText() var children: [NativeReadoutNode] = [] } @@ -19,22 +21,87 @@ private struct NativeReadoutNode { private func nativeCodeTerminalBuffer(_ capture: () throws -> Snapshot, children: (Snapshot) -> [Snapshot], textViewValue: (Snapshot) -> String?, + screenBuffer: (Snapshot) -> String? = { _ in nil }, maximumNodes: Int = 2_048) rethrows -> String { var pending = [try capture()] var visited = 0 var values: [String] = [] + var screens: [String] = [] while let node = pending.popLast() { guard visited < maximumNodes else { return "" } visited += 1 if let value = textViewValue(node) { values.append(value) } + if let screen = screenBuffer(node) { screens.append(screen) } + guard screens.count <= 1 else { return "" } let descendants = children(node) guard descendants.count <= maximumNodes - visited - pending.count else { return "" } pending.append(contentsOf: descendants.reversed()) } + if let screen = screens.first { return screen } return values.filter { $0.contains("RESIZE_HISTORY retained text") }.max { $0.count < $1.count } ?? values.max { $0.count < $1.count } ?? "" } +private struct NativeWindowFrame: Equatable { + let processIdentifier: Int32 + let windowIdentifier: UInt32 + let frame: CGRect +} + +private func nativeFixtureWindow(_ windows: [NativeWindowFrame], identity: NativeWindowFrame) -> NativeWindowFrame? { + let owned = windows.filter { $0.processIdentifier == identity.processIdentifier } + guard owned.count == 1, owned[0].windowIdentifier == identity.windowIdentifier else { return nil } + return owned[0] +} + +private func nativeFixtureWindowReference(_ retained: Window, refresh: (() -> Window?)?) -> Window? { + if let refresh { return refresh() } + return retained +} + +private func nativeFixtureMoveFrame(current: CGRect, target: CGRect, bounds: CGRect) -> CGRect? { + let proposed = CGRect(origin: target.origin, size: current.size) + return bounds.contains(proposed) ? proposed : nil +} + +private func nativeCodeScreenRows(_ capture: () throws -> Snapshot, windowFrame: CGRect, + children: (Snapshot) -> [Snapshot], + elementType: (Snapshot) -> XCUIElement.ElementType, + frame: (Snapshot) -> CGRect, value: (Snapshot) -> String?, + maximumNodes: Int = 2_048) rethrows -> String { + let snapshot = try capture() + let windows = children(snapshot).filter { elementType($0) == .window && frame($0) == windowFrame } + guard windows.count == 1 else { return "" } + return nativeCodeTerminalBuffer({ windows[0] }, children: children, + textViewValue: { elementType($0) == .textView ? value($0) : nil }, + screenBuffer: { node in + guard elementType(node) == .other else { return nil } + let rows = children(node) + let viewport = frame(node) + guard rows.count >= 2, rows.count <= maximumNodes / 2, + viewport.width > 0, viewport.height > 0, windowFrame.contains(viewport) else { return nil } + var previous: CGRect? + var values: [String] = [] + for row in rows { + let position = frame(row) + let contents = children(row) + guard elementType(row) == .group, contents.count == 1, + elementType(contents[0]) == .staticText, let text = value(contents[0]), + position.width > 0, position.height > 0, + abs(position.minX - viewport.minX) <= 1, + position.maxX <= viewport.maxX + 1, position.maxY <= viewport.maxY + 1 else { return nil } + if let previous { + guard abs(previous.maxY - position.minY) <= 1, + abs(previous.height - position.height) <= 1 else { return nil } + } else if abs(position.minY - viewport.minY) > 1 { return nil } + values.append(text) + previous = position + } + guard let last = previous, viewport.maxY - last.maxY <= last.height + 1 else { return nil } + return values.joined(separator: "\n") + }, maximumNodes: maximumNodes) +} + private struct NativeTerminalReadout { let read: () -> String? private(set) var frame: String? @@ -91,6 +158,85 @@ final class TerminalCompatibilityUITests: XCTestCase { @MainActor func testIdleResize() throws { try resizeScenario(streaming: false) } @MainActor func testStreamingResize() throws { try resizeScenario(streaming: true) } + private func screenRows(_ values: [String], origin: CGPoint = CGPoint(x: 73, y: 101)) -> NativeReadoutNode { + NativeReadoutNode(elementType: .other, + frame: CGRect(origin: origin, size: CGSize(width: 731, height: CGFloat(values.count) * 14 + 6)), + children: values.enumerated().map { index, value in + let row = CGRect(x: origin.x, y: origin.y + CGFloat(index) * 14, width: 731, height: 14) + return NativeReadoutNode(elementType: .group, frame: row, children: [ + NativeReadoutNode(elementType: .staticText, frame: row.offsetBy(dx: 0, dy: 2), + text: NativeAccessibilityText(value: value)) + ]) + }) + } + + private func screenReadout(_ snapshot: NativeReadoutNode, maximumNodes: Int = 2_048) -> String { + nativeCodeScreenRows({ snapshot }, windowFrame: CGRect(x: 20, y: 33, width: 784, height: 550), + children: { $0.children }, elementType: { $0.elementType }, frame: { $0.frame }, + value: { $0.text.value }, maximumNodes: maximumNodes) + } + + func testNativeScreenRowsReadTheFreshAppWindowAndExcludeAnnouncementSiblingsByStructure() { + let values = ["Autohand Code", "\u{00a0}", "RESIZE_HISTORY retained text", "preserved draft", "RESIZE_STREAM 365"] + let current = NativeReadoutNode(elementType: .window, frame: CGRect(x: 20, y: 33, width: 784, height: 550), children: [ + NativeReadoutNode(elementType: .staticText, text: NativeAccessibilityText(value: values.joined() + values.joined())), + screenRows(values) + ]) + var captures = 0 + let value = nativeCodeScreenRows({ captures += 1; return NativeReadoutNode(children: [current]) }, windowFrame: current.frame, + children: { $0.children }, elementType: { $0.elementType }, frame: { $0.frame }, value: { $0.text.value }) + XCTAssertEqual(value, values.joined(separator: "\n")) + XCTAssertEqual(captures, 1) + let sparseOldWindow = NativeReadoutNode(elementType: .window, frame: current.frame, + children: [NativeReadoutNode(elementType: .button)]) + XCTAssertEqual(screenReadout(NativeReadoutNode(children: [sparseOldWindow])), "") + } + + func testNativeScreenRowsPreserveDuplicateAndBlankRowsWithoutChoosingACleanCandidate() { + let values = ["RESIZE_HISTORY retained text", "", "RESIZE_HISTORY retained text", "preserved draft"] + var window = NativeReadoutNode(elementType: .window, frame: CGRect(x: 20, y: 33, width: 784, height: 550), children: [screenRows(values)]) + XCTAssertEqual(screenReadout(NativeReadoutNode(children: [window])), values.joined(separator: "\n")) + window.children.append(screenRows(["RESIZE_HISTORY retained text", "preserved draft"])) + XCTAssertEqual(screenReadout(NativeReadoutNode(children: [window])), "") + } + + func testNativeScreenRowsRejectStaleGeometryBudgetsAndNonConsecutiveRows() { + var window = NativeReadoutNode(elementType: .window, frame: CGRect(x: 20, y: 33, width: 784, height: 550), + children: [screenRows(["RESIZE_HISTORY retained text", "preserved draft"])]) + XCTAssertEqual(screenReadout(NativeReadoutNode(children: [window]), maximumNodes: 3), "") + window.frame.size.width += 1 + XCTAssertEqual(screenReadout(NativeReadoutNode(children: [window])), "") + window.frame.size.width -= 1 + window.children[0].children.reverse() + XCTAssertEqual(screenReadout(NativeReadoutNode(children: [window])), "") + } + + func testNativeViewportDoesNotMoveAWindowWhoseWidthIgnoredTheShrink() { + let bounds = CGRect(x: 0, y: 0, width: 1024, height: 768) + let target = CGRect(x: 20, y: 33, width: 904, height: 610) + XCTAssertNil(nativeFixtureMoveFrame(current: CGRect(x: 0, y: 31, width: 1024, height: 610), target: target, bounds: bounds)) + XCTAssertEqual(nativeFixtureMoveFrame(current: CGRect(x: 0, y: 31, width: 904, height: 610), target: target, bounds: bounds), target) + } + + func testNativeViewportRefreshesSetupGeometryAndDoesNotReuseAStaleReferenceOnFailure() { + let retained = NativeWindowFrame(processIdentifier: 6736, windowIdentifier: 101, frame: CGRect(x: 0, y: 31, width: 1024, height: 610)) + let current = NativeWindowFrame(processIdentifier: 6736, windowIdentifier: 101, frame: CGRect(x: 0, y: 31, width: 904, height: 610)) + let refreshed = nativeFixtureWindowReference(retained, refresh: { nativeFixtureWindow([current], identity: retained) }) + XCTAssertEqual(refreshed?.frame.width, 904) + let replaced = NativeWindowFrame(processIdentifier: 6736, windowIdentifier: 102, frame: current.frame) + XCTAssertNil(nativeFixtureWindowReference(retained, refresh: { nativeFixtureWindow([replaced], identity: retained) })) + XCTAssertEqual(nativeFixtureWindowReference(retained, refresh: nil), retained) + } + + func testNativeViewportKeepsTheNativeWindowIdentityWhileAccessibilityNodesChange() { + let identity = NativeWindowFrame(processIdentifier: 6736, windowIdentifier: 101, frame: CGRect(x: 0, y: 31, width: 904, height: 610)) + let resized = NativeWindowFrame(processIdentifier: 6736, windowIdentifier: 101, frame: CGRect(x: 20, y: 33, width: 784, height: 550)) + XCTAssertEqual(nativeFixtureWindow([resized], identity: identity), resized) + XCTAssertNil(nativeFixtureWindow([NativeWindowFrame(processIdentifier: 6736, windowIdentifier: 102, frame: resized.frame)], identity: identity)) + XCTAssertNil(nativeFixtureWindow([resized, identity], identity: identity)) + XCTAssertNil(nativeFixtureWindow([NativeWindowFrame(processIdentifier: 6737, windowIdentifier: 101, frame: resized.frame)], identity: identity)) + } + func testNativeReadoutAssertsAgainstTheAcceptedFreshFrameWithoutCopyingAgain() { let raw = "Autohand Code\nRESIZE_HISTORY retained text\npreserved draft\nRESIZE_STREAM 308" var reads = 0 @@ -284,7 +430,9 @@ final class TerminalCompatibilityUITests: XCTestCase { configuration.environment = app.launchEnvironment configuration.createsNewApplicationInstance = true var launchError: Error? - NSWorkspace.shared.openApplication(at: URL(fileURLWithPath: application), configuration: configuration) { _, error in + var launchedProcessIdentifier: Int32? + NSWorkspace.shared.openApplication(at: URL(fileURLWithPath: application), configuration: configuration) { running, error in + launchedProcessIdentifier = running?.processIdentifier launchError = error launch.fulfill() } @@ -318,7 +466,7 @@ final class TerminalCompatibilityUITests: XCTestCase { return } window = fixtureWindow - let displayBounds = normalizeViewport(window, terminal: terminal) + let initialBounds = terminal == "vscode" ? nil : normalizeViewport(window, terminal: terminal) if terminal == "vscode" { guard app.menuBars.menuBarItems["Terminal"].waitForExistence(timeout: 30) else { attachStartupFailure(app, terminal: terminal, phase: "workbench") @@ -343,6 +491,31 @@ final class TerminalCompatibilityUITests: XCTestCase { XCTFail("The native terminal did not expose a ready input target") return } + let nativeIdentity: NativeWindowFrame? + if terminal == "vscode" { + guard let processIdentifier = launchedProcessIdentifier else { + XCTFail("macOS did not identify the launched VS Code process") + return + } + let native = visibleNativeWindows(processIdentifier: processIdentifier) + guard native.count == 1, native[0].frame == window.frame else { + XCTFail("VS Code must have one identifiable native fixture window") + return + } + nativeIdentity = native[0] + print("NATIVE_TERMINAL_WINDOW vscode pid=\(native[0].processIdentifier) id=\(native[0].windowIdentifier)") + } else { nativeIdentity = nil } + let displayBounds = initialBounds ?? normalizeViewport(window, terminal: terminal, verifyWindow: { + guard let identity = nativeIdentity else { return false } + return nativeFixtureWindow(self.visibleNativeWindows(processIdentifier: identity.processIdentifier), identity: identity) != nil + }, refreshWindow: { + guard let identity = nativeIdentity else { return nil } + return self.currentCodeWindow(app, identity: identity) + }) + let readTerminal: (Bool) -> String? = { copied in + if let identity = nativeIdentity { return self.codeTerminalValue(app, identity: identity, useCopiedBuffer: copied) } + return self.terminalValue(window) + } if terminal != "vscode" { terminalView.click() } NSPasteboard.general.clearContents() XCTAssertTrue(NSPasteboard.general.writeObjects([command as NSString])) @@ -353,11 +526,11 @@ final class TerminalCompatibilityUITests: XCTestCase { var copiedStartup = "" var useCopiedBuffer = false let ready = NSPredicate { _, _ in - accessibilityStartup = self.terminalValue(window) ?? "" + accessibilityStartup = readTerminal(false) ?? "" startupValue = accessibilityStartup if !startupValue.contains("RESIZE_HISTORY retained text"), terminal == "vscode" { - copiedStartup = self.copiedTerminalValue(window) ?? "" - accessibilityStartup = self.terminalValue(window) ?? "" + copiedStartup = readTerminal(true) ?? "" + accessibilityStartup = readTerminal(false) ?? "" useCopiedBuffer = nativeReadoutUsesCopiedBuffer(terminal: terminal, accessibilityHasFixture: accessibilityStartup.contains("RESIZE_HISTORY retained text"), copiedBufferHasFixture: copiedStartup.contains("RESIZE_HISTORY retained text")) @@ -386,12 +559,20 @@ final class TerminalCompatibilityUITests: XCTestCase { add(evidence) } for (index, delta) in nativeResizeDeltas.enumerated() { + if let identity = nativeIdentity { + guard let current = currentCodeWindow(app, identity: identity) else { return } + window = current + } let frame = window.frame let corner = window.coordinate(withNormalizedOffset: CGVector(dx: 1, dy: 1)).withOffset(CGVector(dx: -2, dy: -2)) corner.press(forDuration: 0.1, thenDragTo: corner.withOffset(delta)) + if let identity = nativeIdentity { + guard let current = currentCodeWindow(app, identity: identity) else { return } + window = current + } XCTAssertNotEqual(window.frame.size, frame.size, "The native window did not resize") XCTAssertTrue(displayBounds.contains(window.frame), "The resized fixture window left the display bounds") - var readout = NativeTerminalReadout(read: { self.terminalValue(window, useCopiedBuffer: useCopiedBuffer) }) + var readout = NativeTerminalReadout(read: { readTerminal(useCopiedBuffer) }) let settled = NSPredicate { _, _ in readout.capture() } guard XCTWaiter.wait(for: [XCTNSPredicateExpectation(predicate: settled, object: window)], timeout: 10) == .completed, let content = readout.frame else { @@ -415,21 +596,23 @@ final class TerminalCompatibilityUITests: XCTestCase { app.typeKey(.leftArrow, modifierFlags: []) app.typeKey(.leftArrow, modifierFlags: []) app.typeKey("x", modifierFlags: .shift) - let edited = NSPredicate { _, _ in self.terminalValue(window, useCopiedBuffer: useCopiedBuffer)?.contains("taXil") == true } + let edited = NSPredicate { _, _ in readTerminal(useCopiedBuffer)?.contains("taXil") == true } expectation(for: edited, evaluatedWith: app) waitForExpectations(timeout: 10) app.typeKey(.escape, modifierFlags: []) app.typeKey("c", modifierFlags: .control) app.typeKey("c", modifierFlags: .control) app.typeKey("c", modifierFlags: .control) - let exit = NSPredicate { _, _ in app.state == .notRunning || self.terminalValue(window, useCopiedBuffer: useCopiedBuffer)?.contains("RESIZE_PTY_EXIT") == true } + let exit = NSPredicate { _, _ in app.state == .notRunning || readTerminal(useCopiedBuffer)?.contains("RESIZE_PTY_EXIT") == true } expectation(for: exit, evaluatedWith: app) waitForExpectations(timeout: 15) print(streaming ? "NATIVE_TERMINAL_STREAMING_PASS" : "NATIVE_TERMINAL_IDLE_PASS") } @MainActor - private func normalizeViewport(_ window: XCUIElement, terminal: String) -> CGRect { + private func normalizeViewport(_ retainedWindow: XCUIElement, terminal: String, verifyWindow: () -> Bool = { true }, + refreshWindow: (() -> XCUIElement?)? = nil) -> CGRect { + guard var window = nativeFixtureWindowReference(retainedWindow, refresh: refreshWindow) else { return .zero } guard let screen = NSScreen.main else { XCTFail("The native terminal runner has no display") return .zero @@ -440,17 +623,33 @@ final class TerminalCompatibilityUITests: XCTestCase { let available = CGRect(x: visible.minX, y: desktopTop - visible.maxY, width: visible.width, height: visible.height) let target = nativeFixtureBaseline(current: window.frame, available: available) for step in nativeFixturePreparationFrames(current: window.frame, available: available) { + guard let current = nativeFixtureWindowReference(window, refresh: refreshWindow) else { return bounds } + window = current + XCTAssertTrue(verifyWindow(), "The native fixture window identity changed before setup") let frame = window.frame if abs(step.minX - frame.minX) > 0.5 || abs(step.minY - frame.minY) > 0.5 { + guard nativeFixtureMoveFrame(current: frame, target: step, bounds: bounds) != nil else { + XCTFail("The native window did not shrink enough for an on-screen move") + return bounds + } let grip = window.coordinate(withNormalizedOffset: .zero).withOffset(CGVector(dx: terminal == "vscode" ? 110 : frame.width * 0.75, dy: 12)) grip.press(forDuration: 0.1, thenDragTo: grip.withOffset(CGVector(dx: step.minX - frame.minX, dy: step.minY - frame.minY))) + guard let current = nativeFixtureWindowReference(window, refresh: refreshWindow) else { return bounds } + window = current } let moved = window.frame if abs(step.width - moved.width) > 0.5 || abs(step.height - moved.height) > 0.5 { let corner = window.coordinate(withNormalizedOffset: CGVector(dx: 1, dy: 1)).withOffset(CGVector(dx: -2, dy: -2)) corner.press(forDuration: 0.1, thenDragTo: corner.withOffset(CGVector(dx: step.width - moved.width, dy: step.height - moved.height))) + guard let current = nativeFixtureWindowReference(window, refresh: refreshWindow) else { return bounds } + window = current } print("NATIVE_TERMINAL_PREPARE \(terminal) \(window.frame) target=\(step) display=\(bounds)") + XCTAssertTrue(verifyWindow(), "The native fixture window identity changed during setup") + if terminal == "vscode" { + XCTAssertEqual(window.frame.width, step.width, accuracy: 1, "The native window did not accept the requested width") + XCTAssertEqual(window.frame.height, step.height, accuracy: 1, "The native window did not accept the requested height") + } XCTAssertTrue(bounds.contains(window.frame), "Every fixture setup gesture must stay within the display") } XCTAssertGreaterThanOrEqual(window.frame.height, target.height - 20, "The native fixture needs room for both transcript exchanges") @@ -513,6 +712,46 @@ final class TerminalCompatibilityUITests: XCTestCase { NativeAccessibilityText(label: element.label, title: element.title, value: element.value as? String ?? "") } + private func visibleNativeWindows(processIdentifier: Int32) -> [NativeWindowFrame] { + guard let windows = CGWindowListCopyWindowInfo([.optionOnScreenOnly, .excludeDesktopElements], kCGNullWindowID) as? [[String: Any]] else { return [] } + return windows.compactMap { window in + guard let pid = window[kCGWindowOwnerPID as String] as? NSNumber, pid.int32Value == processIdentifier, + let layer = window[kCGWindowLayer as String] as? NSNumber, layer.intValue == 0, + let identifier = window[kCGWindowNumber as String] as? NSNumber, + let bounds = window[kCGWindowBounds as String] as? NSDictionary, + let frame = CGRect(dictionaryRepresentation: bounds) else { return nil } + return NativeWindowFrame(processIdentifier: pid.int32Value, windowIdentifier: identifier.uint32Value, frame: frame) + } + } + + private func currentCodeWindow(_ app: XCUIApplication, identity: NativeWindowFrame) -> XCUIElement? { + guard let native = nativeFixtureWindow(visibleNativeWindows(processIdentifier: identity.processIdentifier), identity: identity) else { + XCTFail("VS Code changed the fixture's native window ID, process, or window count") + return nil + } + let windows = app.windows.allElementsBoundByAccessibilityElement.filter { $0.frame == native.frame } + guard windows.count == 1 else { + XCTFail("VS Code did not expose exactly one accessibility window for the native fixture frame") + return nil + } + return windows[0] + } + + private func codeTerminalValue(_ app: XCUIApplication, identity: NativeWindowFrame, useCopiedBuffer: Bool) -> String? { + guard let native = nativeFixtureWindow(visibleNativeWindows(processIdentifier: identity.processIdentifier), identity: identity) else { + XCTFail("VS Code changed the fixture's native window ID, process, or window count") + return nil + } + let value: String? + if useCopiedBuffer { value = copiedTerminalValue(app) } + else { + value = try? nativeCodeScreenRows({ try app.snapshot() }, windowFrame: native.frame, + children: { $0.children }, elementType: { $0.elementType }, frame: { $0.frame }, value: { $0.value as? String }) + } + guard nativeFixtureWindow(visibleNativeWindows(processIdentifier: identity.processIdentifier), identity: identity) == native else { return nil } + return value + } + private func copiedTerminalValue(_ window: XCUIElement) -> String? { let pasteboard = NSPasteboard.general pasteboard.clearContents() @@ -529,10 +768,6 @@ final class TerminalCompatibilityUITests: XCTestCase { private func terminalValue(_ window: XCUIElement, useCopiedBuffer: Bool = false) -> String? { if useCopiedBuffer { return copiedTerminalValue(window) } - if environment["AUTOHAND_NATIVE_TERMINAL"] == "vscode" { - return (try? nativeCodeTerminalBuffer({ try window.snapshot() }, children: { $0.children }, - textViewValue: { $0.elementType == .textView ? $0.value as? String : nil })) ?? "" - } let values = window.textViews.allElementsBoundByIndex.compactMap { $0.value as? String } if let terminal = values.filter({ $0.contains("RESIZE_HISTORY retained text") }).max(by: { $0.count < $1.count }) { return terminal From f1106145bd555216dca5ab1ebd1ddc33e0a82f77 Mon Sep 17 00:00:00 2001 From: Igor Costa Date: Tue, 10 Nov 2026 16:42:36 +1300 Subject: [PATCH 09/19] Match native terminal row geometry to xterm text scaling Accept text-scaled accessibility rows that extend beyond their list width while retaining vertical, structural, window, and content checks. Reproduce the captured resize geometry without removing duplicate rows. Co-authored-by: Autohand Evolve --- docs/terminal-compatibility.md | 2 +- .../TerminalCompatibilityUITests.swift | 19 ++++++++++++++++++- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/docs/terminal-compatibility.md b/docs/terminal-compatibility.md index dff512d22..de282d520 100644 --- a/docs/terminal-compatibility.md +++ b/docs/terminal-compatibility.md @@ -43,7 +43,7 @@ VS Code uses separate disposable profiles for idle and streaming cases under a s Native scenarios first size and position their fixture window within the reported display's visible bounds, leaving room for all four physical resize gestures. Setup shrinks oversized dimensions before moving the window, then grows it at the target position, so a full-width window's resize handle never moves off-screen. VS Code setup starts after terminal readiness and verifies the actual width and height accepted by each gesture; a partially accepted shrink cannot authorize an off-screen move. VS Code's input readiness uses the native Split Terminal command becoming enabled after New Terminal, which establishes terminal focus; it does not require xterm's input to expose the same TextView role as standalone terminals. The fixture's rendered output remains the mandatory proof that paste, Enter, and terminal rendering worked. All scenarios retain the exact transcript, streaming, draft, cursor, resize, and exit assertions. -VS Code's readout preflight captures one immutable app snapshot, scopes it to the fixture's current native window frame, and searches at most 2,048 nodes. Its native process ID, Core Graphics window ID, and single-window count remain fixed through all gestures and reads, while accessibility window references are refreshed after resize. [xterm's accessibility tree](https://github.com/xtermjs/xterm.js/blob/master/src/browser/AccessibilityManager.ts) exposes a screen-row list separately from its live announcement region. In the recorded macOS tree this is an Other node containing consecutive, equal-height Group rows, each with one StaticText value. The adapter requires one unambiguous, complete row list in the native window; it preserves row order, blank rows, and repeated values. It never selects rows based on fixture content or concatenates announcement siblings. Native TextView buffers remain supported. Ambiguous, oversized, malformed, or stale snapshots fail closed to the buffer-copy preflight. If accessibility lacks it, a disposable-profile shortcut uses [`runCommands`](https://code.visualstudio.com/docs/configure/keybindings#_running-multiple-commands) to select the full terminal buffer and [copy and clear its selection](https://github.com/microsoft/vscode/blob/main/src/vs/workbench/contrib/terminalContrib/clipboard/browser/terminal.clipboard.contribution.ts). The preflight uses that raw copied buffer only if it contains the actual fixture marker and a second accessibility read still lacks it. Each subsequent copy clears the pasteboard and requires a new pasteboard generation; old frames cannot satisfy later resize checks. A temporarily unavailable copy produces no frame; the original startup, resize, cursor, or exit predicate retains its deadline. Once resize readiness accepts a fresh raw frame, all exact counts and streaming assertions inspect that same frame without issuing a second copy. Readiness checks only history and draft presence, so duplicated accepted frames still fail immediately. The command clears terminal selection while preserving focus and the PTY cursor, which the existing cursor-edit assertion verifies. No terminal output is filtered or deduplicated. Readout samples, selected backend, settings, and keybindings are retained with native results. +VS Code's readout preflight captures one immutable app snapshot, scopes it to the fixture's current native window frame, and searches at most 2,048 nodes. Its native process ID, Core Graphics window ID, and single-window count remain fixed through all gestures and reads, while accessibility window references are refreshed after resize. [xterm's accessibility tree](https://github.com/xtermjs/xterm.js/blob/master/src/browser/AccessibilityManager.ts) exposes a screen-row list separately from its live announcement region. In the recorded macOS tree this is an Other node containing consecutive, equal-height Group rows, each with one StaticText value. The adapter requires one unambiguous, complete row list in the native window; it preserves row order, blank rows, and repeated values. xterm scales each accessibility row to its text and cell width, so rows can be wider than the list container after resize; the adapter validates their vertical grid without requiring horizontal containment. It never selects rows based on fixture content or concatenates announcement siblings. Native TextView buffers remain supported. Ambiguous, oversized, malformed, or stale snapshots fail closed to the buffer-copy preflight. If accessibility lacks it, a disposable-profile shortcut uses [`runCommands`](https://code.visualstudio.com/docs/configure/keybindings#_running-multiple-commands) to select the full terminal buffer and [copy and clear its selection](https://github.com/microsoft/vscode/blob/main/src/vs/workbench/contrib/terminalContrib/clipboard/browser/terminal.clipboard.contribution.ts). The preflight uses that raw copied buffer only if it contains the actual fixture marker and a second accessibility read still lacks it. Each subsequent copy clears the pasteboard and requires a new pasteboard generation; old frames cannot satisfy later resize checks. A temporarily unavailable copy produces no frame; the original startup, resize, cursor, or exit predicate retains its deadline. Once resize readiness accepts a fresh raw frame, all exact counts and streaming assertions inspect that same frame without issuing a second copy. Readiness checks only history and draft presence, so duplicated accepted frames still fail immediately. The command clears terminal selection while preserving focus and the PTY cursor, which the existing cursor-edit assertion verifies. No terminal output is filtered or deduplicated. Readout samples, selected backend, settings, and keybindings are retained with native results. The strict visible-history assertions require room for both transcript exchanges and the multiline composer. At a compact 57×19 terminal, the full-screen transcript correctly follows its newest exchange and clips older rows; a separate PTY case checks that the history reappears on growth, the draft and cursor survive, and Ctrl+C exits cleanly. This distinguishes viewport clipping from lost transcript data. Standalone terminal references bind to their accessibility identities. VS Code refreshes its accessibility window before setup steps and after every setup or scenario gesture, guarded by the same native process ID, window ID, single-window count, and current frame. diff --git a/src/testing/drivers/native-terminal-lab/TerminalCompatibilityUITests.swift b/src/testing/drivers/native-terminal-lab/TerminalCompatibilityUITests.swift index 752db9450..e8bbc5a5b 100644 --- a/src/testing/drivers/native-terminal-lab/TerminalCompatibilityUITests.swift +++ b/src/testing/drivers/native-terminal-lab/TerminalCompatibilityUITests.swift @@ -89,7 +89,7 @@ private func nativeCodeScreenRows(_ capture: () throws -> Snapshot, wi elementType(contents[0]) == .staticText, let text = value(contents[0]), position.width > 0, position.height > 0, abs(position.minX - viewport.minX) <= 1, - position.maxX <= viewport.maxX + 1, position.maxY <= viewport.maxY + 1 else { return nil } + position.maxY <= viewport.maxY + 1 else { return nil } if let previous { guard abs(previous.maxY - position.minY) <= 1, abs(previous.height - position.height) <= 1 else { return nil } @@ -200,6 +200,23 @@ final class TerminalCompatibilityUITests: XCTestCase { XCTAssertEqual(screenReadout(NativeReadoutNode(children: [window])), "") } + func testNativeScreenRowsPreserveTextScaledBeyondTheCapturedListWidth() { + let values = ["Autohand Code", "RESIZE_HISTORY retained text", "", "RESIZE_HISTORY retained text", "preserved draft"] + + Array(repeating: "\u{00a0}", count: 22) + var rows = screenRows(values) + rows.frame.size = CGSize(width: 651, height: 378) + for index in rows.children.indices { + rows.children[index].frame.size.width = index == 0 ? 182 : 686 + rows.children[index].children[0].frame = rows.children[index].frame.offsetBy(dx: 0, dy: 2) + } + let nativeFrame = CGRect(x: 20, y: 33, width: 744, height: 510) + let window = NativeReadoutNode(elementType: .window, frame: nativeFrame, children: [rows]) + let content = nativeCodeScreenRows({ NativeReadoutNode(children: [window]) }, windowFrame: nativeFrame, + children: { $0.children }, elementType: { $0.elementType }, frame: { $0.frame }, value: { $0.text.value }) + XCTAssertEqual(content, values.joined(separator: "\n")) + XCTAssertEqual(content.components(separatedBy: "RESIZE_HISTORY retained text").count - 1, 2) + } + func testNativeScreenRowsRejectStaleGeometryBudgetsAndNonConsecutiveRows() { var window = NativeReadoutNode(elementType: .window, frame: CGRect(x: 20, y: 33, width: 784, height: 550), children: [screenRows(["RESIZE_HISTORY retained text", "preserved draft"])]) From 5d6f6373c0c2a8a89f73a1a879fb66e001dd6c01 Mon Sep 17 00:00:00 2001 From: Igor Costa Date: Tue, 10 Nov 2026 17:00:25 +1300 Subject: [PATCH 10/19] Wait for complete native terminal frames before asserting content counts Require header, history, draft, and streaming marker presence within the existing resize deadline. Retain exact raw-frame count assertions so duplicated content and permanent marker loss remain failures. Co-authored-by: Autohand Evolve --- docs/terminal-compatibility.md | 2 +- .../TerminalCompatibilityUITests.swift | 46 +++++++++++++++++-- 2 files changed, 44 insertions(+), 4 deletions(-) diff --git a/docs/terminal-compatibility.md b/docs/terminal-compatibility.md index de282d520..8ae1b4b16 100644 --- a/docs/terminal-compatibility.md +++ b/docs/terminal-compatibility.md @@ -43,7 +43,7 @@ VS Code uses separate disposable profiles for idle and streaming cases under a s Native scenarios first size and position their fixture window within the reported display's visible bounds, leaving room for all four physical resize gestures. Setup shrinks oversized dimensions before moving the window, then grows it at the target position, so a full-width window's resize handle never moves off-screen. VS Code setup starts after terminal readiness and verifies the actual width and height accepted by each gesture; a partially accepted shrink cannot authorize an off-screen move. VS Code's input readiness uses the native Split Terminal command becoming enabled after New Terminal, which establishes terminal focus; it does not require xterm's input to expose the same TextView role as standalone terminals. The fixture's rendered output remains the mandatory proof that paste, Enter, and terminal rendering worked. All scenarios retain the exact transcript, streaming, draft, cursor, resize, and exit assertions. -VS Code's readout preflight captures one immutable app snapshot, scopes it to the fixture's current native window frame, and searches at most 2,048 nodes. Its native process ID, Core Graphics window ID, and single-window count remain fixed through all gestures and reads, while accessibility window references are refreshed after resize. [xterm's accessibility tree](https://github.com/xtermjs/xterm.js/blob/master/src/browser/AccessibilityManager.ts) exposes a screen-row list separately from its live announcement region. In the recorded macOS tree this is an Other node containing consecutive, equal-height Group rows, each with one StaticText value. The adapter requires one unambiguous, complete row list in the native window; it preserves row order, blank rows, and repeated values. xterm scales each accessibility row to its text and cell width, so rows can be wider than the list container after resize; the adapter validates their vertical grid without requiring horizontal containment. It never selects rows based on fixture content or concatenates announcement siblings. Native TextView buffers remain supported. Ambiguous, oversized, malformed, or stale snapshots fail closed to the buffer-copy preflight. If accessibility lacks it, a disposable-profile shortcut uses [`runCommands`](https://code.visualstudio.com/docs/configure/keybindings#_running-multiple-commands) to select the full terminal buffer and [copy and clear its selection](https://github.com/microsoft/vscode/blob/main/src/vs/workbench/contrib/terminalContrib/clipboard/browser/terminal.clipboard.contribution.ts). The preflight uses that raw copied buffer only if it contains the actual fixture marker and a second accessibility read still lacks it. Each subsequent copy clears the pasteboard and requires a new pasteboard generation; old frames cannot satisfy later resize checks. A temporarily unavailable copy produces no frame; the original startup, resize, cursor, or exit predicate retains its deadline. Once resize readiness accepts a fresh raw frame, all exact counts and streaming assertions inspect that same frame without issuing a second copy. Readiness checks only history and draft presence, so duplicated accepted frames still fail immediately. The command clears terminal selection while preserving focus and the PTY cursor, which the existing cursor-edit assertion verifies. No terminal output is filtered or deduplicated. Readout samples, selected backend, settings, and keybindings are retained with native results. +VS Code's readout preflight captures one immutable app snapshot, scopes it to the fixture's current native window frame, and searches at most 2,048 nodes. Its native process ID, Core Graphics window ID, and single-window count remain fixed through all gestures and reads, while accessibility window references are refreshed after resize. [xterm's accessibility tree](https://github.com/xtermjs/xterm.js/blob/master/src/browser/AccessibilityManager.ts) exposes a screen-row list separately from its live announcement region. In the recorded macOS tree this is an Other node containing consecutive, equal-height Group rows, each with one StaticText value. The adapter requires one unambiguous, complete row list in the native window; it preserves row order, blank rows, and repeated values. xterm scales each accessibility row to its text and cell width, so rows can be wider than the list container after resize; the adapter validates their vertical grid without requiring horizontal containment. It never selects rows based on fixture content or concatenates announcement siblings. Native TextView buffers remain supported. Ambiguous, oversized, malformed, or stale snapshots fail closed to the buffer-copy preflight. If accessibility lacks it, a disposable-profile shortcut uses [`runCommands`](https://code.visualstudio.com/docs/configure/keybindings#_running-multiple-commands) to select the full terminal buffer and [copy and clear its selection](https://github.com/microsoft/vscode/blob/main/src/vs/workbench/contrib/terminalContrib/clipboard/browser/terminal.clipboard.contribution.ts). The preflight uses that raw copied buffer only if it contains the actual fixture marker and a second accessibility read still lacks it. Each subsequent copy clears the pasteboard and requires a new pasteboard generation; old frames cannot satisfy later resize checks. A temporarily unavailable copy produces no frame; the original startup, resize, cursor, or exit predicate retains its deadline. Once resize readiness accepts a fresh raw frame, all exact counts and streaming assertions inspect that same frame without issuing a second copy. Resize readiness requires the header, history, draft, and the stream marker for streaming scenarios, without examining their counts. This avoids accepting a partial accessibility update before the header returns; duplicated accepted frames still fail immediately against the exact-count assertions. Permanent marker loss fails the original settling deadline. The command clears terminal selection while preserving focus and the PTY cursor, which the existing cursor-edit assertion verifies. No terminal output is filtered or deduplicated. Readout samples, selected backend, settings, and keybindings are retained with native results. The strict visible-history assertions require room for both transcript exchanges and the multiline composer. At a compact 57×19 terminal, the full-screen transcript correctly follows its newest exchange and clips older rows; a separate PTY case checks that the history reappears on growth, the draft and cursor survive, and Ctrl+C exits cleanly. This distinguishes viewport clipping from lost transcript data. Standalone terminal references bind to their accessibility identities. VS Code refreshes its accessibility window before setup steps and after every setup or scenario gesture, guarded by the same native process ID, window ID, single-window count, and current frame. diff --git a/src/testing/drivers/native-terminal-lab/TerminalCompatibilityUITests.swift b/src/testing/drivers/native-terminal-lab/TerminalCompatibilityUITests.swift index e8bbc5a5b..5cfc0909e 100644 --- a/src/testing/drivers/native-terminal-lab/TerminalCompatibilityUITests.swift +++ b/src/testing/drivers/native-terminal-lab/TerminalCompatibilityUITests.swift @@ -104,12 +104,19 @@ private func nativeCodeScreenRows(_ capture: () throws -> Snapshot, wi private struct NativeTerminalReadout { let read: () -> String? + let requiresStreaming: Bool private(set) var frame: String? + init(read: @escaping () -> String?, requiresStreaming: Bool = false) { + self.read = read + self.requiresStreaming = requiresStreaming + } + mutating func capture() -> Bool { frame = read() guard let value = frame else { return false } - return value.contains("RESIZE_HISTORY retained text") && value.contains("preserved draft") + return value.contains("Autohand Code") && value.contains("RESIZE_HISTORY retained text") + && value.contains("preserved draft") && (!requiresStreaming || value.contains("RESIZE_STREAM ")) } } @@ -254,6 +261,39 @@ final class TerminalCompatibilityUITests: XCTestCase { XCTAssertNil(nativeFixtureWindow([NativeWindowFrame(processIdentifier: 6737, windowIdentifier: 101, frame: resized.frame)], identity: identity)) } + func testNativeReadoutWaitsForTheHeaderBeforeAcceptingACompleteFrame() { + let partial = "RESIZE_HISTORY retained text\npreserved draft\nRESIZE_STREAM 293" + let complete = "Autohand Code\n" + partial + var reads = 0 + var readout = NativeTerminalReadout(read: { reads += 1; return reads == 1 ? partial : complete }, requiresStreaming: true) + XCTAssertFalse(readout.capture()) + XCTAssertTrue(readout.capture()) + XCTAssertEqual(readout.frame, complete) + XCTAssertEqual(reads, 2) + var duplicate = NativeTerminalReadout(read: { "Autohand Code\n" + complete }, requiresStreaming: true) + XCTAssertTrue(duplicate.capture()) + XCTAssertEqual(duplicate.frame?.components(separatedBy: "Autohand Code").count, 3, + "Presence readiness must leave duplicate headers for the exact-count assertion") + } + + func testNativeReadoutPermanentlyMissingHeaderFailsTheUnchangedReadinessDeadline() { + var readout = NativeTerminalReadout(read: { "RESIZE_HISTORY retained text\npreserved draft" }) + XCTAssertFalse(readout.capture()) + let ready = NSPredicate { _, _ in readout.capture() } + XCTAssertEqual(XCTWaiter.wait(for: [XCTNSPredicateExpectation(predicate: ready, object: nil)], timeout: 0.01), .timedOut) + } + + func testNativeReadoutRequiresTheStreamMarkerOnlyForStreamingScenarios() { + var value = "Autohand Code\nRESIZE_HISTORY retained text\npreserved draft" + var idle = NativeTerminalReadout(read: { value }) + XCTAssertTrue(idle.capture()) + var streaming = NativeTerminalReadout(read: { value }, requiresStreaming: true) + XCTAssertFalse(streaming.capture()) + value += "\nRESIZE_STREAM 341" + XCTAssertTrue(streaming.capture()) + XCTAssertEqual(streaming.frame, value) + } + func testNativeReadoutAssertsAgainstTheAcceptedFreshFrameWithoutCopyingAgain() { let raw = "Autohand Code\nRESIZE_HISTORY retained text\npreserved draft\nRESIZE_STREAM 308" var reads = 0 @@ -278,7 +318,7 @@ final class TerminalCompatibilityUITests: XCTestCase { var reads = 0 var readout = NativeTerminalReadout(read: { reads += 1 - return reads == 1 ? "RESIZE_HISTORY retained text\npreserved draft" : nil + return reads == 1 ? "Autohand Code\nRESIZE_HISTORY retained text\npreserved draft" : nil }) XCTAssertTrue(readout.capture()) XCTAssertFalse(readout.capture()) @@ -589,7 +629,7 @@ final class TerminalCompatibilityUITests: XCTestCase { } XCTAssertNotEqual(window.frame.size, frame.size, "The native window did not resize") XCTAssertTrue(displayBounds.contains(window.frame), "The resized fixture window left the display bounds") - var readout = NativeTerminalReadout(read: { readTerminal(useCopiedBuffer) }) + var readout = NativeTerminalReadout(read: { readTerminal(useCopiedBuffer) }, requiresStreaming: streaming) let settled = NSPredicate { _, _ in readout.capture() } guard XCTWaiter.wait(for: [XCTNSPredicateExpectation(predicate: settled, object: window)], timeout: 10) == .completed, let content = readout.frame else { From c2c4a1c6d457deb3b7896a5789afceac3d0ed286 Mon Sep 17 00:00:00 2001 From: Igor Costa Date: Tue, 10 Nov 2026 20:56:04 +1300 Subject: [PATCH 11/19] Read VS Code terminal content through focused native clipboard copies Require a fresh pasteboard generation after focusing the existing terminal and preserve the copied text unchanged. Remove the stale accessibility content adapter while retaining bounded diagnostics and every terminal behavior assertion. Co-authored-by: Autohand Evolve --- docs/terminal-compatibility.md | 2 +- src/testing/drivers/native-terminal-driver.ts | 8 +- .../TerminalCompatibilityUITests.swift | 273 +++--------------- tests/testing/nativeTerminal.test.ts | 10 +- 4 files changed, 53 insertions(+), 240 deletions(-) diff --git a/docs/terminal-compatibility.md b/docs/terminal-compatibility.md index 8ae1b4b16..828d7ece3 100644 --- a/docs/terminal-compatibility.md +++ b/docs/terminal-compatibility.md @@ -43,7 +43,7 @@ VS Code uses separate disposable profiles for idle and streaming cases under a s Native scenarios first size and position their fixture window within the reported display's visible bounds, leaving room for all four physical resize gestures. Setup shrinks oversized dimensions before moving the window, then grows it at the target position, so a full-width window's resize handle never moves off-screen. VS Code setup starts after terminal readiness and verifies the actual width and height accepted by each gesture; a partially accepted shrink cannot authorize an off-screen move. VS Code's input readiness uses the native Split Terminal command becoming enabled after New Terminal, which establishes terminal focus; it does not require xterm's input to expose the same TextView role as standalone terminals. The fixture's rendered output remains the mandatory proof that paste, Enter, and terminal rendering worked. All scenarios retain the exact transcript, streaming, draft, cursor, resize, and exit assertions. -VS Code's readout preflight captures one immutable app snapshot, scopes it to the fixture's current native window frame, and searches at most 2,048 nodes. Its native process ID, Core Graphics window ID, and single-window count remain fixed through all gestures and reads, while accessibility window references are refreshed after resize. [xterm's accessibility tree](https://github.com/xtermjs/xterm.js/blob/master/src/browser/AccessibilityManager.ts) exposes a screen-row list separately from its live announcement region. In the recorded macOS tree this is an Other node containing consecutive, equal-height Group rows, each with one StaticText value. The adapter requires one unambiguous, complete row list in the native window; it preserves row order, blank rows, and repeated values. xterm scales each accessibility row to its text and cell width, so rows can be wider than the list container after resize; the adapter validates their vertical grid without requiring horizontal containment. It never selects rows based on fixture content or concatenates announcement siblings. Native TextView buffers remain supported. Ambiguous, oversized, malformed, or stale snapshots fail closed to the buffer-copy preflight. If accessibility lacks it, a disposable-profile shortcut uses [`runCommands`](https://code.visualstudio.com/docs/configure/keybindings#_running-multiple-commands) to select the full terminal buffer and [copy and clear its selection](https://github.com/microsoft/vscode/blob/main/src/vs/workbench/contrib/terminalContrib/clipboard/browser/terminal.clipboard.contribution.ts). The preflight uses that raw copied buffer only if it contains the actual fixture marker and a second accessibility read still lacks it. Each subsequent copy clears the pasteboard and requires a new pasteboard generation; old frames cannot satisfy later resize checks. A temporarily unavailable copy produces no frame; the original startup, resize, cursor, or exit predicate retains its deadline. Once resize readiness accepts a fresh raw frame, all exact counts and streaming assertions inspect that same frame without issuing a second copy. Resize readiness requires the header, history, draft, and the stream marker for streaming scenarios, without examining their counts. This avoids accepting a partial accessibility update before the header returns; duplicated accepted frames still fail immediately against the exact-count assertions. Permanent marker loss fails the original settling deadline. The command clears terminal selection while preserving focus and the PTY cursor, which the existing cursor-edit assertion verifies. No terminal output is filtered or deduplicated. Readout samples, selected backend, settings, and keybindings are retained with native results. +VS Code's authoritative readout uses its native terminal buffer copy. Hosted recordings showed the canvas progressing from stream 377 to 382 while correctly shaped accessibility rows remained on stream 313 with missing transcript text throughout the settling deadline. Accessibility content is therefore retained only as bounded failure diagnostics. A disposable-profile shortcut uses [`runCommands`](https://code.visualstudio.com/docs/configure/keybindings#_running-multiple-commands) to focus the existing terminal, select its full buffer, and [copy and clear its selection](https://github.com/microsoft/vscode/blob/main/src/vs/workbench/contrib/terminalContrib/clipboard/browser/terminal.clipboard.contribution.ts). The binding is scoped to `terminalIsOpen`, so it can restore terminal focus after a resize. Each observation clears the pasteboard and requires a new pasteboard generation with text; a stale value cannot pass even if all expected markers are present. The copied bytes are preserved without filtering, trimming, or deduplication. A temporarily unavailable copy produces no frame within the original readiness deadlines. Resize readiness requires the header, history, draft, and stream marker for streaming scenarios, without examining their counts; all exact assertions inspect that same accepted raw frame. Duplicate markers fail the unchanged count assertions, and permanent marker loss fails the original settling deadline. The native process ID, Core Graphics window ID, single-window count, current geometry, cursor-edit and exit checks remain mandatory. The command clears selection while preserving the PTY cursor. Backend, bounded preflight samples, pasteboard generation metadata, settings, and keybindings are retained with native results. The strict visible-history assertions require room for both transcript exchanges and the multiline composer. At a compact 57×19 terminal, the full-screen transcript correctly follows its newest exchange and clips older rows; a separate PTY case checks that the history reappears on growth, the draft and cursor survive, and Ctrl+C exits cleanly. This distinguishes viewport clipping from lost transcript data. Standalone terminal references bind to their accessibility identities. VS Code refreshes its accessibility window before setup steps and after every setup or scenario gesture, guarded by the same native process ID, window ID, single-window count, and current frame. diff --git a/src/testing/drivers/native-terminal-driver.ts b/src/testing/drivers/native-terminal-driver.ts index 0e30519ac..d83404528 100644 --- a/src/testing/drivers/native-terminal-driver.ts +++ b/src/testing/drivers/native-terminal-driver.ts @@ -39,8 +39,12 @@ export async function prepareNativeTerminalTest(): Promise<{ args: string[]; res const keybindingsFile = path.join(evidenceProfile, 'User', 'keybindings.json'); if (runtimeProfile) { await writeFile(keybindingsFile, JSON.stringify([{ - key: 'ctrl+shift+y', command: 'runCommands', when: 'terminalFocus', args: { - commands: ['workbench.action.terminal.selectAll', 'workbench.action.terminal.copyAndClearSelection'], + key: 'ctrl+shift+y', command: 'runCommands', when: 'terminalIsOpen', args: { + commands: [ + 'workbench.action.terminal.focus', + 'workbench.action.terminal.selectAll', + 'workbench.action.terminal.copyAndClearSelection', + ], }, }])); } diff --git a/src/testing/drivers/native-terminal-lab/TerminalCompatibilityUITests.swift b/src/testing/drivers/native-terminal-lab/TerminalCompatibilityUITests.swift index 5cfc0909e..7741e099b 100644 --- a/src/testing/drivers/native-terminal-lab/TerminalCompatibilityUITests.swift +++ b/src/testing/drivers/native-terminal-lab/TerminalCompatibilityUITests.swift @@ -7,39 +7,6 @@ private struct NativeAccessibilityText { var value = "" var strings: [String] { [label, title, value] } - var content: String { [value, label, title].first { !$0.isEmpty } ?? "" } -} - -private struct NativeReadoutNode { - var isTextView = false - var elementType: XCUIElement.ElementType = .other - var frame = CGRect.zero - var text = NativeAccessibilityText() - var children: [NativeReadoutNode] = [] -} - -private func nativeCodeTerminalBuffer(_ capture: () throws -> Snapshot, - children: (Snapshot) -> [Snapshot], - textViewValue: (Snapshot) -> String?, - screenBuffer: (Snapshot) -> String? = { _ in nil }, - maximumNodes: Int = 2_048) rethrows -> String { - var pending = [try capture()] - var visited = 0 - var values: [String] = [] - var screens: [String] = [] - while let node = pending.popLast() { - guard visited < maximumNodes else { return "" } - visited += 1 - if let value = textViewValue(node) { values.append(value) } - if let screen = screenBuffer(node) { screens.append(screen) } - guard screens.count <= 1 else { return "" } - let descendants = children(node) - guard descendants.count <= maximumNodes - visited - pending.count else { return "" } - pending.append(contentsOf: descendants.reversed()) - } - if let screen = screens.first { return screen } - return values.filter { $0.contains("RESIZE_HISTORY retained text") }.max { $0.count < $1.count } - ?? values.max { $0.count < $1.count } ?? "" } private struct NativeWindowFrame: Equatable { @@ -64,42 +31,9 @@ private func nativeFixtureMoveFrame(current: CGRect, target: CGRect, bounds: CGR return bounds.contains(proposed) ? proposed : nil } -private func nativeCodeScreenRows(_ capture: () throws -> Snapshot, windowFrame: CGRect, - children: (Snapshot) -> [Snapshot], - elementType: (Snapshot) -> XCUIElement.ElementType, - frame: (Snapshot) -> CGRect, value: (Snapshot) -> String?, - maximumNodes: Int = 2_048) rethrows -> String { - let snapshot = try capture() - let windows = children(snapshot).filter { elementType($0) == .window && frame($0) == windowFrame } - guard windows.count == 1 else { return "" } - return nativeCodeTerminalBuffer({ windows[0] }, children: children, - textViewValue: { elementType($0) == .textView ? value($0) : nil }, - screenBuffer: { node in - guard elementType(node) == .other else { return nil } - let rows = children(node) - let viewport = frame(node) - guard rows.count >= 2, rows.count <= maximumNodes / 2, - viewport.width > 0, viewport.height > 0, windowFrame.contains(viewport) else { return nil } - var previous: CGRect? - var values: [String] = [] - for row in rows { - let position = frame(row) - let contents = children(row) - guard elementType(row) == .group, contents.count == 1, - elementType(contents[0]) == .staticText, let text = value(contents[0]), - position.width > 0, position.height > 0, - abs(position.minX - viewport.minX) <= 1, - position.maxY <= viewport.maxY + 1 else { return nil } - if let previous { - guard abs(previous.maxY - position.minY) <= 1, - abs(previous.height - position.height) <= 1 else { return nil } - } else if abs(position.minY - viewport.minY) > 1 { return nil } - values.append(text) - previous = position - } - guard let last = previous, viewport.maxY - last.maxY <= last.height + 1 else { return nil } - return values.joined(separator: "\n") - }, maximumNodes: maximumNodes) +private func nativeCopiedTerminalBuffer(baseline: Int, observed: Int, value: String?) -> String? { + guard observed > baseline else { return nil } + return value } private struct NativeTerminalReadout { @@ -155,86 +89,12 @@ private func nativeTerminalInputReady(terminal: String, textViewExists: Bool, sp terminal == "vscode" ? splitTerminalEnabled : textViewExists } -private func nativeReadoutUsesCopiedBuffer(terminal: String, accessibilityHasFixture: Bool, copiedBufferHasFixture: Bool) -> Bool { - terminal == "vscode" && !accessibilityHasFixture && copiedBufferHasFixture -} - final class TerminalCompatibilityUITests: XCTestCase { private var environment: [String: String] { ProcessInfo.processInfo.environment } @MainActor func testIdleResize() throws { try resizeScenario(streaming: false) } @MainActor func testStreamingResize() throws { try resizeScenario(streaming: true) } - private func screenRows(_ values: [String], origin: CGPoint = CGPoint(x: 73, y: 101)) -> NativeReadoutNode { - NativeReadoutNode(elementType: .other, - frame: CGRect(origin: origin, size: CGSize(width: 731, height: CGFloat(values.count) * 14 + 6)), - children: values.enumerated().map { index, value in - let row = CGRect(x: origin.x, y: origin.y + CGFloat(index) * 14, width: 731, height: 14) - return NativeReadoutNode(elementType: .group, frame: row, children: [ - NativeReadoutNode(elementType: .staticText, frame: row.offsetBy(dx: 0, dy: 2), - text: NativeAccessibilityText(value: value)) - ]) - }) - } - - private func screenReadout(_ snapshot: NativeReadoutNode, maximumNodes: Int = 2_048) -> String { - nativeCodeScreenRows({ snapshot }, windowFrame: CGRect(x: 20, y: 33, width: 784, height: 550), - children: { $0.children }, elementType: { $0.elementType }, frame: { $0.frame }, - value: { $0.text.value }, maximumNodes: maximumNodes) - } - - func testNativeScreenRowsReadTheFreshAppWindowAndExcludeAnnouncementSiblingsByStructure() { - let values = ["Autohand Code", "\u{00a0}", "RESIZE_HISTORY retained text", "preserved draft", "RESIZE_STREAM 365"] - let current = NativeReadoutNode(elementType: .window, frame: CGRect(x: 20, y: 33, width: 784, height: 550), children: [ - NativeReadoutNode(elementType: .staticText, text: NativeAccessibilityText(value: values.joined() + values.joined())), - screenRows(values) - ]) - var captures = 0 - let value = nativeCodeScreenRows({ captures += 1; return NativeReadoutNode(children: [current]) }, windowFrame: current.frame, - children: { $0.children }, elementType: { $0.elementType }, frame: { $0.frame }, value: { $0.text.value }) - XCTAssertEqual(value, values.joined(separator: "\n")) - XCTAssertEqual(captures, 1) - let sparseOldWindow = NativeReadoutNode(elementType: .window, frame: current.frame, - children: [NativeReadoutNode(elementType: .button)]) - XCTAssertEqual(screenReadout(NativeReadoutNode(children: [sparseOldWindow])), "") - } - - func testNativeScreenRowsPreserveDuplicateAndBlankRowsWithoutChoosingACleanCandidate() { - let values = ["RESIZE_HISTORY retained text", "", "RESIZE_HISTORY retained text", "preserved draft"] - var window = NativeReadoutNode(elementType: .window, frame: CGRect(x: 20, y: 33, width: 784, height: 550), children: [screenRows(values)]) - XCTAssertEqual(screenReadout(NativeReadoutNode(children: [window])), values.joined(separator: "\n")) - window.children.append(screenRows(["RESIZE_HISTORY retained text", "preserved draft"])) - XCTAssertEqual(screenReadout(NativeReadoutNode(children: [window])), "") - } - - func testNativeScreenRowsPreserveTextScaledBeyondTheCapturedListWidth() { - let values = ["Autohand Code", "RESIZE_HISTORY retained text", "", "RESIZE_HISTORY retained text", "preserved draft"] - + Array(repeating: "\u{00a0}", count: 22) - var rows = screenRows(values) - rows.frame.size = CGSize(width: 651, height: 378) - for index in rows.children.indices { - rows.children[index].frame.size.width = index == 0 ? 182 : 686 - rows.children[index].children[0].frame = rows.children[index].frame.offsetBy(dx: 0, dy: 2) - } - let nativeFrame = CGRect(x: 20, y: 33, width: 744, height: 510) - let window = NativeReadoutNode(elementType: .window, frame: nativeFrame, children: [rows]) - let content = nativeCodeScreenRows({ NativeReadoutNode(children: [window]) }, windowFrame: nativeFrame, - children: { $0.children }, elementType: { $0.elementType }, frame: { $0.frame }, value: { $0.text.value }) - XCTAssertEqual(content, values.joined(separator: "\n")) - XCTAssertEqual(content.components(separatedBy: "RESIZE_HISTORY retained text").count - 1, 2) - } - - func testNativeScreenRowsRejectStaleGeometryBudgetsAndNonConsecutiveRows() { - var window = NativeReadoutNode(elementType: .window, frame: CGRect(x: 20, y: 33, width: 784, height: 550), - children: [screenRows(["RESIZE_HISTORY retained text", "preserved draft"])]) - XCTAssertEqual(screenReadout(NativeReadoutNode(children: [window]), maximumNodes: 3), "") - window.frame.size.width += 1 - XCTAssertEqual(screenReadout(NativeReadoutNode(children: [window])), "") - window.frame.size.width -= 1 - window.children[0].children.reverse() - XCTAssertEqual(screenReadout(NativeReadoutNode(children: [window])), "") - } - func testNativeViewportDoesNotMoveAWindowWhoseWidthIgnoredTheShrink() { let bounds = CGRect(x: 0, y: 0, width: 1024, height: 768) let target = CGRect(x: 20, y: 33, width: 904, height: 610) @@ -261,6 +121,24 @@ final class TerminalCompatibilityUITests: XCTestCase { XCTAssertNil(nativeFixtureWindow([NativeWindowFrame(processIdentifier: 6737, windowIdentifier: 101, frame: resized.frame)], identity: identity)) } + func testNativeReadoutRejectsStaleClipboardValuesEvenWhenAllMarkersArePresent() { + let stale = "Autohand Code\nRESIZE_HISTORY retained text\npreserved draft\nRESIZE_STREAM 313" + XCTAssertNil(nativeCopiedTerminalBuffer(baseline: 18, observed: 18, value: stale)) + XCTAssertNil(nativeCopiedTerminalBuffer(baseline: 18, observed: 17, value: stale)) + XCTAssertNil(nativeCopiedTerminalBuffer(baseline: 18, observed: 19, value: nil)) + } + + func testNativeReadoutPreservesEveryByteAndDuplicateInAFreshNativeCopy() { + let raw = "Autohand Code\r\nRESIZE_HISTORY retained text\n\nRESIZE_HISTORY retained text\n preserved draft \nRESIZE_STREAM 382\n" + let fresh = nativeCopiedTerminalBuffer(baseline: 18, observed: 19, value: raw) + XCTAssertEqual(fresh, raw) + XCTAssertEqual(fresh.map { Array($0.utf8) }, Array(raw.utf8)) + XCTAssertEqual(fresh?.components(separatedBy: "RESIZE_HISTORY retained text").count, 3) + var readout = NativeTerminalReadout(read: { fresh }, requiresStreaming: true) + XCTAssertTrue(readout.capture()) + XCTAssertEqual(readout.frame, raw) + } + func testNativeReadoutWaitsForTheHeaderBeforeAcceptingACompleteFrame() { let partial = "RESIZE_HISTORY retained text\npreserved draft\nRESIZE_STREAM 293" let complete = "Autohand Code\n" + partial @@ -332,53 +210,6 @@ final class TerminalCompatibilityUITests: XCTestCase { XCTAssertNil(readout.frame) } - func testNativeReadoutIgnoresAnnouncementsAndRowMirrorsWhenNoCompleteTextViewExists() { - let announcement = "Autohand Code RESIZE_HISTORY retained text preserved draft" - let root = NativeReadoutNode(children: [ - NativeReadoutNode(text: NativeAccessibilityText(value: announcement + announcement)), - NativeReadoutNode(text: NativeAccessibilityText(value: "RESIZE_HISTORY retained text")), - NativeReadoutNode(text: NativeAccessibilityText(value: "preserved draft")) - ]) - let accessibility = nativeCodeTerminalBuffer({ root }, children: { $0.children }, - textViewValue: { $0.isTextView ? $0.text.value : nil }) - XCTAssertEqual(accessibility, "", "Announcements and individual rows are not a native terminal buffer") - let copied = announcement + "\n" + announcement - let useCopied = nativeReadoutUsesCopiedBuffer(terminal: "vscode", - accessibilityHasFixture: accessibility.contains("RESIZE_HISTORY retained text"), - copiedBufferHasFixture: copied.contains("RESIZE_HISTORY retained text")) - XCTAssertTrue(useCopied) - let content = useCopied ? copied : accessibility - XCTAssertEqual(content, copied) - XCTAssertEqual(content.components(separatedBy: "RESIZE_HISTORY retained text").count - 1, 2, - "A duplicated copied frame must still fail the native count assertion") - } - - func testNativeReadoutCapturesOneSnapshotAndPreservesRawBufferDuplicates() { - let buffer = "Autohand Code\nRESIZE_HISTORY retained text\nRESIZE_HISTORY retained text\npreserved draft\ntaXil" - let root = NativeReadoutNode(children: [ - NativeReadoutNode(text: NativeAccessibilityText(value: buffer + buffer)), - NativeReadoutNode(isTextView: true, text: NativeAccessibilityText(label: buffer, value: buffer)), - NativeReadoutNode(text: NativeAccessibilityText(value: "RESIZE_HISTORY retained text")) - ]) - var captures = 0 - let value = nativeCodeTerminalBuffer({ captures += 1; return root }, children: { $0.children }, - textViewValue: { $0.isTextView ? $0.text.value : nil }) - XCTAssertEqual(captures, 1) - XCTAssertEqual(value, buffer) - XCTAssertEqual(value.components(separatedBy: "RESIZE_HISTORY retained text").count - 1, 2) - } - - func testNativeReadoutFailsClosedWhenTheSnapshotExceedsItsNodeBudget() { - let root = NativeReadoutNode(children: [ - NativeReadoutNode(isTextView: true, text: NativeAccessibilityText(value: "Autohand Code RESIZE_HISTORY retained text")), - NativeReadoutNode() - ]) - XCTAssertEqual(nativeCodeTerminalBuffer({ root }, children: { $0.children }, - textViewValue: { $0.isTextView ? $0.text.value : nil }, maximumNodes: 2), "") - XCTAssertEqual(nativeCodeTerminalBuffer({ root }, children: { $0.children }, - textViewValue: { $0.isTextView ? $0.text.value : nil }, maximumNodes: 3), "Autohand Code RESIZE_HISTORY retained text") - } - func testNativeViewportShrinksBeforeMovingAFullWidthWindow() { let display = CGRect(x: 0, y: 0, width: 1024, height: 768) let available = CGRect(x: 0, y: 31, width: 1024, height: 674) @@ -397,13 +228,6 @@ final class TerminalCompatibilityUITests: XCTestCase { XCTAssertTrue(nativeTerminalInputReady(terminal: "iterm2", textViewExists: true, splitTerminalEnabled: false)) } - func testNativeInputReadoutRequiresTheActualFixtureBeforeUsingTheCopiedBuffer() { - XCTAssertTrue(nativeReadoutUsesCopiedBuffer(terminal: "vscode", accessibilityHasFixture: false, copiedBufferHasFixture: true)) - XCTAssertFalse(nativeReadoutUsesCopiedBuffer(terminal: "vscode", accessibilityHasFixture: false, copiedBufferHasFixture: false)) - XCTAssertFalse(nativeReadoutUsesCopiedBuffer(terminal: "vscode", accessibilityHasFixture: true, copiedBufferHasFixture: true)) - XCTAssertFalse(nativeReadoutUsesCopiedBuffer(terminal: "iterm2", accessibilityHasFixture: false, copiedBufferHasFixture: true)) - } - func testNativeViewportKeepsAllFourResizesWithinTheDisplayAndLargeEnoughForHistory() { let available = CGRect(x: 0, y: 31, width: 1024, height: 677) var frame = nativeFixtureBaseline(current: CGRect(x: 400, y: 246, width: 587, height: 462), available: available) @@ -443,11 +267,6 @@ final class TerminalCompatibilityUITests: XCTestCase { buttons: [NativeAccessibilityText(title: "Allow")])) } - func testAccessibilityContentDoesNotDuplicateMirroredAttributes() { - XCTAssertEqual(NativeAccessibilityText(label: "RESIZE_HISTORY retained text", title: "RESIZE_HISTORY retained text", - value: "RESIZE_HISTORY retained text").content, "RESIZE_HISTORY retained text") - } - @MainActor private func resizeScenario(streaming: Bool) throws { guard environment["GITHUB_ACTIONS"] == "true", @@ -569,8 +388,8 @@ final class TerminalCompatibilityUITests: XCTestCase { guard let identity = nativeIdentity else { return nil } return self.currentCodeWindow(app, identity: identity) }) - let readTerminal: (Bool) -> String? = { copied in - if let identity = nativeIdentity { return self.codeTerminalValue(app, identity: identity, useCopiedBuffer: copied) } + let readTerminal: () -> String? = { + if let identity = nativeIdentity { return self.codeTerminalValue(app, identity: identity) } return self.terminalValue(window) } if terminal != "vscode" { terminalView.click() } @@ -579,20 +398,8 @@ final class TerminalCompatibilityUITests: XCTestCase { app.typeKey("v", modifierFlags: .command) app.typeKey(.return, modifierFlags: []) var startupValue = "" - var accessibilityStartup = "" - var copiedStartup = "" - var useCopiedBuffer = false let ready = NSPredicate { _, _ in - accessibilityStartup = readTerminal(false) ?? "" - startupValue = accessibilityStartup - if !startupValue.contains("RESIZE_HISTORY retained text"), terminal == "vscode" { - copiedStartup = readTerminal(true) ?? "" - accessibilityStartup = readTerminal(false) ?? "" - useCopiedBuffer = nativeReadoutUsesCopiedBuffer(terminal: terminal, - accessibilityHasFixture: accessibilityStartup.contains("RESIZE_HISTORY retained text"), - copiedBufferHasFixture: copiedStartup.contains("RESIZE_HISTORY retained text")) - startupValue = useCopiedBuffer ? copiedStartup : accessibilityStartup - } + startupValue = readTerminal() ?? "" return startupValue.contains("RESIZE_HISTORY retained text") } guard XCTWaiter.wait(for: [XCTNSPredicateExpectation(predicate: ready, object: app)], timeout: 30) == .completed else { @@ -608,9 +415,8 @@ final class TerminalCompatibilityUITests: XCTestCase { return } if terminal == "vscode" { - print("NATIVE_TERMINAL_READOUT vscode \(useCopiedBuffer ? "native-copy" : "accessibility")") - let evidence = XCTAttachment(string: "Readout: \(useCopiedBuffer ? "native-copy" : "accessibility")\nAccessibility:\n" - + String(accessibilityStartup.prefix(7_900)) + "\nCopied buffer:\n" + String(copiedStartup.prefix(7_900))) + print("NATIVE_TERMINAL_READOUT vscode native-copy") + let evidence = XCTAttachment(string: "Readout: native-copy\nCopied buffer:\n" + String(startupValue.prefix(16_000))) evidence.name = "vscode-\(streaming ? "streaming" : "idle")-readout-preflight" evidence.lifetime = .keepAlways add(evidence) @@ -629,7 +435,7 @@ final class TerminalCompatibilityUITests: XCTestCase { } XCTAssertNotEqual(window.frame.size, frame.size, "The native window did not resize") XCTAssertTrue(displayBounds.contains(window.frame), "The resized fixture window left the display bounds") - var readout = NativeTerminalReadout(read: { readTerminal(useCopiedBuffer) }, requiresStreaming: streaming) + var readout = NativeTerminalReadout(read: { readTerminal() }, requiresStreaming: streaming) let settled = NSPredicate { _, _ in readout.capture() } guard XCTWaiter.wait(for: [XCTNSPredicateExpectation(predicate: settled, object: window)], timeout: 10) == .completed, let content = readout.frame else { @@ -653,14 +459,14 @@ final class TerminalCompatibilityUITests: XCTestCase { app.typeKey(.leftArrow, modifierFlags: []) app.typeKey(.leftArrow, modifierFlags: []) app.typeKey("x", modifierFlags: .shift) - let edited = NSPredicate { _, _ in readTerminal(useCopiedBuffer)?.contains("taXil") == true } + let edited = NSPredicate { _, _ in readTerminal()?.contains("taXil") == true } expectation(for: edited, evaluatedWith: app) waitForExpectations(timeout: 10) app.typeKey(.escape, modifierFlags: []) app.typeKey("c", modifierFlags: .control) app.typeKey("c", modifierFlags: .control) app.typeKey("c", modifierFlags: .control) - let exit = NSPredicate { _, _ in app.state == .notRunning || readTerminal(useCopiedBuffer)?.contains("RESIZE_PTY_EXIT") == true } + let exit = NSPredicate { _, _ in app.state == .notRunning || readTerminal()?.contains("RESIZE_PTY_EXIT") == true } expectation(for: exit, evaluatedWith: app) waitForExpectations(timeout: 15) print(streaming ? "NATIVE_TERMINAL_STREAMING_PASS" : "NATIVE_TERMINAL_IDLE_PASS") @@ -794,17 +600,12 @@ final class TerminalCompatibilityUITests: XCTestCase { return windows[0] } - private func codeTerminalValue(_ app: XCUIApplication, identity: NativeWindowFrame, useCopiedBuffer: Bool) -> String? { + private func codeTerminalValue(_ app: XCUIApplication, identity: NativeWindowFrame) -> String? { guard let native = nativeFixtureWindow(visibleNativeWindows(processIdentifier: identity.processIdentifier), identity: identity) else { XCTFail("VS Code changed the fixture's native window ID, process, or window count") return nil } - let value: String? - if useCopiedBuffer { value = copiedTerminalValue(app) } - else { - value = try? nativeCodeScreenRows({ try app.snapshot() }, windowFrame: native.frame, - children: { $0.children }, elementType: { $0.elementType }, frame: { $0.frame }, value: { $0.value as? String }) - } + let value = copiedTerminalValue(app) guard nativeFixtureWindow(visibleNativeWindows(processIdentifier: identity.processIdentifier), identity: identity) == native else { return nil } return value } @@ -814,17 +615,21 @@ final class TerminalCompatibilityUITests: XCTestCase { pasteboard.clearContents() let generation = pasteboard.changeCount window.typeKey("y", modifierFlags: [.control, .shift]) - let written = NSPredicate { _, _ in pasteboard.changeCount > generation && pasteboard.string(forType: .string) != nil } + let written = NSPredicate { _, _ in + nativeCopiedTerminalBuffer(baseline: generation, observed: pasteboard.changeCount, + value: pasteboard.string(forType: .string)) != nil + } guard XCTWaiter.wait(for: [XCTNSPredicateExpectation(predicate: written, object: pasteboard)], timeout: 2) == .completed, - pasteboard.changeCount > generation, let value = pasteboard.string(forType: .string) else { + let value = nativeCopiedTerminalBuffer(baseline: generation, observed: pasteboard.changeCount, + value: pasteboard.string(forType: .string)) else { print("NATIVE_TERMINAL_COPY_UNAVAILABLE baseline=\(generation) observed=\(pasteboard.changeCount)") return nil } + print("NATIVE_TERMINAL_COPY_FRESH baseline=\(generation) observed=\(pasteboard.changeCount) bytes=\(value.utf8.count)") return value } - private func terminalValue(_ window: XCUIElement, useCopiedBuffer: Bool = false) -> String? { - if useCopiedBuffer { return copiedTerminalValue(window) } + private func terminalValue(_ window: XCUIElement) -> String? { let values = window.textViews.allElementsBoundByIndex.compactMap { $0.value as? String } if let terminal = values.filter({ $0.contains("RESIZE_HISTORY retained text") }).max(by: { $0.count < $1.count }) { return terminal diff --git a/tests/testing/nativeTerminal.test.ts b/tests/testing/nativeTerminal.test.ts index 19bba4d95..89cbf816a 100644 --- a/tests/testing/nativeTerminal.test.ts +++ b/tests/testing/nativeTerminal.test.ts @@ -41,7 +41,7 @@ function preparedLaunchArguments(): Record { } describe('native terminal automation boundary', () => { - it.each(['idle', 'streaming'])('installs a single native buffer-copy binding in the isolated %s VS Code profile', async mode => { + it.each(['idle', 'streaming'])('focuses the existing terminal before each native copy in the isolated %s VS Code profile', async mode => { Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true }); vi.stubEnv('GITHUB_ACTIONS', 'true'); vi.stubEnv('CI', 'true'); @@ -52,8 +52,12 @@ describe('native terminal automation boundary', () => { expect(bindingWrite).toBeDefined(); const bindings: unknown = JSON.parse(String(bindingWrite?.[1])); expect(bindings).toEqual([{ - key: 'ctrl+shift+y', command: 'runCommands', when: 'terminalFocus', args: { - commands: ['workbench.action.terminal.selectAll', 'workbench.action.terminal.copyAndClearSelection'], + key: 'ctrl+shift+y', command: 'runCommands', when: 'terminalIsOpen', args: { + commands: [ + 'workbench.action.terminal.focus', + 'workbench.action.terminal.selectAll', + 'workbench.action.terminal.copyAndClearSelection', + ], }, }]); expect(copyFile).toHaveBeenCalledWith(evidence, path.join('/private/tmp/ah-vsc-123456', mode, 'User/keybindings.json')); From c42e2161b201b17d137e7a91eda158c4c6926b2b Mon Sep 17 00:00:00 2001 From: Igor Costa Date: Tue, 10 Nov 2026 21:48:32 +1300 Subject: [PATCH 12/19] Capture bounded native terminal copy and write timing diagnostics Persist capped copy observations and asynchronous fixture metadata with faithful payload, encoding, callback, and backpressure behavior. Retain late events and overflow counts without changing native commands, predicates, cadence, or deadlines. Co-authored-by: Autohand Evolve --- .github/workflows/terminal-compatibility.yml | 5 +- docs/terminal-compatibility.md | 2 + src/testing/drivers/native-terminal-driver.ts | 2 +- .../TerminalCompatibilityUITests.swift | 117 +++++++++++++++- .../scenarios/resizeFlickerScenario.tsx | 24 +++- src/testing/scenarios/resizeWriteMetadata.ts | 98 +++++++++++++ tests/testing/nativeTerminal.test.ts | 13 ++ tests/testing/resizeWriteMetadata.test.ts | 130 ++++++++++++++++++ .../tuistory/resize-flicker.tuistory.test.ts | 43 ++++++ 9 files changed, 424 insertions(+), 10 deletions(-) create mode 100644 src/testing/scenarios/resizeWriteMetadata.ts create mode 100644 tests/testing/resizeWriteMetadata.test.ts diff --git a/.github/workflows/terminal-compatibility.yml b/.github/workflows/terminal-compatibility.yml index 4bd234fa5..86de111fb 100644 --- a/.github/workflows/terminal-compatibility.yml +++ b/.github/workflows/terminal-compatibility.yml @@ -98,7 +98,7 @@ jobs: defaults write com.googlecode.iterm2 ApplePersistenceIgnoreState -bool true - run: bun install --frozen-lockfile - name: Verify native automation boundary and disposable profiles - run: bun run test tests/testing/nativeTerminal.test.ts + run: bun run test tests/testing/nativeTerminal.test.ts tests/testing/resizeWriteMetadata.test.ts - run: bun run build - name: Run Tuistory with native XCTest window and accessibility checks run: bun run test:tuistory tests/tuistory/native-terminal.tuistory.test.ts @@ -120,6 +120,9 @@ jobs: .artifacts/native-terminal/**/*.xcresult .artifacts/native-terminal/**/screenshots .artifacts/native-terminal/**/xcodebuild.log + .artifacts/native-terminal/**/write-metadata-*.jsonl + .artifacts/native-terminal/**/write-metadata-*.jsonl.pending + .artifacts/native-terminal/**/copy-samples-*.jsonl .artifacts/native-terminal/**/vscode-profile/**/settings.json .artifacts/native-terminal/**/vscode-profile/**/keybindings.json if-no-files-found: error diff --git a/docs/terminal-compatibility.md b/docs/terminal-compatibility.md index 828d7ece3..c43ef11ac 100644 --- a/docs/terminal-compatibility.md +++ b/docs/terminal-compatibility.md @@ -45,6 +45,8 @@ Native scenarios first size and position their fixture window within the reporte VS Code's authoritative readout uses its native terminal buffer copy. Hosted recordings showed the canvas progressing from stream 377 to 382 while correctly shaped accessibility rows remained on stream 313 with missing transcript text throughout the settling deadline. Accessibility content is therefore retained only as bounded failure diagnostics. A disposable-profile shortcut uses [`runCommands`](https://code.visualstudio.com/docs/configure/keybindings#_running-multiple-commands) to focus the existing terminal, select its full buffer, and [copy and clear its selection](https://github.com/microsoft/vscode/blob/main/src/vs/workbench/contrib/terminalContrib/clipboard/browser/terminal.clipboard.contribution.ts). The binding is scoped to `terminalIsOpen`, so it can restore terminal focus after a resize. Each observation clears the pasteboard and requires a new pasteboard generation with text; a stale value cannot pass even if all expected markers are present. The copied bytes are preserved without filtering, trimming, or deduplication. A temporarily unavailable copy produces no frame within the original readiness deadlines. Resize readiness requires the header, history, draft, and stream marker for streaming scenarios, without examining their counts; all exact assertions inspect that same accepted raw frame. Duplicate markers fail the unchanged count assertions, and permanent marker loss fails the original settling deadline. The native process ID, Core Graphics window ID, single-window count, current geometry, cursor-edit and exit checks remain mandatory. The command clears selection while preserving the PTY cursor. Backend, bounded preflight samples, pasteboard generation metadata, settings, and keybindings are retained with native results. +Native-copy diagnostics retain each attempt's phase, timestamps, pasteboard generations and types, raw text, availability, and native window identity and geometry. A bounded tail of 128 records and 2 MiB is persisted after each attempt, with explicit eviction counts, so late resize failures remain inspectable even if the application exits. The fixture also writes asynchronous, bounded write metadata: sequence, viewport, stream counter and bytes before stdout delegation, then separate return/backpressure and callback-completion timestamps. Metadata-only observation forwards the original string or binary chunk and its encoding unchanged. That metadata contains no terminal output, never waits in the producer, and retains the latest records within 1 MiB. The workflow retains both the committed metadata file and any in-flight `.pending` file, alongside copy samples and XCTest evidence. Diagnostic instrumentation does not establish that an intermittent failure is repaired. + The strict visible-history assertions require room for both transcript exchanges and the multiline composer. At a compact 57×19 terminal, the full-screen transcript correctly follows its newest exchange and clips older rows; a separate PTY case checks that the history reappears on growth, the draft and cursor survive, and Ctrl+C exits cleanly. This distinguishes viewport clipping from lost transcript data. Standalone terminal references bind to their accessibility identities. VS Code refreshes its accessibility window before setup steps and after every setup or scenario gesture, guarded by the same native process ID, window ID, single-window count, and current frame. Native CI availability and per-application results must be assessed from that workflow's completed jobs. A passing PTY suite or a successful XCTest build alone does not prove a native application passed. Failures in app launch, accessibility access, or window resizing fail the native job and preserve its XCTest evidence; they are not silently converted to headless passes. diff --git a/src/testing/drivers/native-terminal-driver.ts b/src/testing/drivers/native-terminal-driver.ts index d83404528..488d47a61 100644 --- a/src/testing/drivers/native-terminal-driver.ts +++ b/src/testing/drivers/native-terminal-driver.ts @@ -20,7 +20,7 @@ export async function prepareNativeTerminalTest(): Promise<{ args: string[]; res const fixture = path.join(output, 'Autohand Terminal Fixture.command'); const quote = (value: string) => `'${value.replaceAll("'", "'\\''")}'`; const bun = execFileSync('/bin/sh', ['-c', 'command -v bun'], { encoding: 'utf8' }).trim(); - await writeFile(fixture, `#!/bin/sh\nset -eu\ncd ${quote(root)}\nexport AUTOHAND_HOME=${quote(path.join(output, 'home'))}\nexport CI=false\nexec ${quote(bun)} src/testing/scenarios/resizeFlickerScenario.tsx --full-screen --multiline "$@"\n`, { mode: 0o755 }); + await writeFile(fixture, `#!/bin/sh\nset -eu\ncd ${quote(root)}\nexport AUTOHAND_HOME=${quote(path.join(output, 'home'))}\nexport CI=false\nif [ "\${1-}" = --streaming ]; then fixture_mode=streaming; else fixture_mode=idle; fi\nexport AUTOHAND_RESIZE_WRITE_METADATA=${quote(output)}/write-metadata-\${fixture_mode}.jsonl\nexec ${quote(bun)} src/testing/scenarios/resizeFlickerScenario.tsx --full-screen --multiline "$@"\n`, { mode: 0o755 }); const profile = path.join(output, 'vscode-profile'); const runtimeProfile = terminal === 'vscode' ? await mkdtemp('/private/tmp/ah-vsc-') : undefined; const settings = { diff --git a/src/testing/drivers/native-terminal-lab/TerminalCompatibilityUITests.swift b/src/testing/drivers/native-terminal-lab/TerminalCompatibilityUITests.swift index 7741e099b..1ed400f7c 100644 --- a/src/testing/drivers/native-terminal-lab/TerminalCompatibilityUITests.swift +++ b/src/testing/drivers/native-terminal-lab/TerminalCompatibilityUITests.swift @@ -36,6 +36,44 @@ private func nativeCopiedTerminalBuffer(baseline: Int, observed: Int, value: Str return value } +private struct NativeCopyEvidence { + private(set) var records: [Data] = [] + private(set) var dropped = 0 + private var bytes = 0 + + mutating func append(_ record: Data) { + let size = record.count + 1 + guard size <= 2 * 1024 * 1024 - 64 else { + dropped += 1 + return + } + while records.count >= 128 || size > 2 * 1024 * 1024 - 64 - bytes { + bytes -= records.removeFirst().count + 1 + dropped += 1 + } + records.append(record) + bytes += size + } +} + +private struct NativeCopySample: Encodable { + let phase: String + let started: TimeInterval + let finished: TimeInterval + let baseline: Int + let previousGeneration: Int + let previousRaw: String? + let previousTypes: [String] + let keyFinished: TimeInterval + let observed: Int + let types: [String] + let raw: String? + let available: Bool + let processIdentifier: Int32 + let windowIdentifier: UInt32 + let frame: CGRect +} + private struct NativeTerminalReadout { let read: () -> String? let requiresStreaming: Bool @@ -91,6 +129,9 @@ private func nativeTerminalInputReady(terminal: String, textViewExists: Bool, sp final class TerminalCompatibilityUITests: XCTestCase { private var environment: [String: String] { ProcessInfo.processInfo.environment } + private var copyEvidence = NativeCopyEvidence() + private var copyPhase = "startup" + private var copyEvidencePath: URL? @MainActor func testIdleResize() throws { try resizeScenario(streaming: false) } @MainActor func testStreamingResize() throws { try resizeScenario(streaming: true) } @@ -128,6 +169,37 @@ final class TerminalCompatibilityUITests: XCTestCase { XCTAssertNil(nativeCopiedTerminalBuffer(baseline: 18, observed: 19, value: nil)) } + func testNativeReadoutEvidenceRetainsPartialUnavailableAndCompleteSamplesUnchanged() throws { + var evidence = NativeCopyEvidence() + let partial = "RESIZE_STREAM 400\r\n" + let complete = "Autohand Code\nRESIZE_HISTORY retained text\npreserved draft\nRESIZE_STREAM 449\nRESIZE_STREAM 449" + for raw in [partial, nil, complete] { + evidence.append(Data((raw ?? "unavailable").utf8)) + } + XCTAssertEqual(evidence.records, [Data(partial.utf8), Data("unavailable".utf8), Data(complete.utf8)]) + XCTAssertEqual(evidence.dropped, 0) + XCTAssertEqual(evidence.records.last, Data(complete.utf8), "Diagnostic capture must preserve duplicate raw rows") + } + + func testNativeReadoutEvidenceBoundsTheRecordCountAndBytesWithoutChangingSamples() { + var countBounded = NativeCopyEvidence() + for index in 0..<129 { countBounded.append(Data("raw-\(index)".utf8)) } + XCTAssertEqual(countBounded.records.count, 128) + XCTAssertEqual(countBounded.dropped, 1) + XCTAssertEqual(countBounded.records.first, Data("raw-1".utf8)) + XCTAssertEqual(countBounded.records.last, Data("raw-128".utf8)) + var bytesBounded = NativeCopyEvidence() + let oversized = Data(repeating: 65, count: 2 * 1024 * 1024 + 1) + bytesBounded.append(oversized) + XCTAssertTrue(bytesBounded.records.isEmpty) + XCTAssertEqual(bytesBounded.dropped, 1) + bytesBounded.append(Data("still raw".utf8)) + XCTAssertEqual(bytesBounded.records, [Data("still raw".utf8)]) + bytesBounded.append(Data(repeating: 66, count: 2 * 1024 * 1024 - 65)) + XCTAssertEqual(bytesBounded.records.count, 1) + XCTAssertEqual(bytesBounded.dropped, 2) + } + func testNativeReadoutPreservesEveryByteAndDuplicateInAFreshNativeCopy() { let raw = "Autohand Code\r\nRESIZE_HISTORY retained text\n\nRESIZE_HISTORY retained text\n preserved draft \nRESIZE_STREAM 382\n" let fresh = nativeCopiedTerminalBuffer(baseline: 18, observed: 19, value: raw) @@ -278,6 +350,19 @@ final class TerminalCompatibilityUITests: XCTestCase { return } continueAfterFailure = false + if terminal == "vscode" { + copyEvidence = NativeCopyEvidence() + copyPhase = "startup" + copyEvidencePath = URL(fileURLWithPath: fixture).deletingLastPathComponent() + .appendingPathComponent("copy-samples-\(streaming ? "streaming" : "idle").jsonl") + addTeardownBlock { + let records = self.copyEvidence.records.map { String(decoding: $0, as: UTF8.self) }.joined(separator: "\n") + let attachment = XCTAttachment(string: records + "\n{\"dropped\":\(self.copyEvidence.dropped)}\n") + attachment.name = "vscode-\(streaming ? "streaming" : "idle")-copy-samples" + attachment.lifetime = .keepAlways + self.add(attachment) + } + } let interruption = addUIInterruptionMonitor(withDescription: "Deny local network access for the offline terminal fixture") { alert in let buttons = alert.buttons.allElementsBoundByIndex let text = ([alert] + alert.staticTexts.allElementsBoundByIndex).map(self.accessibilityText) @@ -422,6 +507,7 @@ final class TerminalCompatibilityUITests: XCTestCase { add(evidence) } for (index, delta) in nativeResizeDeltas.enumerated() { + copyPhase = "resize-\(index)" if let identity = nativeIdentity { guard let current = currentCodeWindow(app, identity: identity) else { return } window = current @@ -459,11 +545,13 @@ final class TerminalCompatibilityUITests: XCTestCase { app.typeKey(.leftArrow, modifierFlags: []) app.typeKey(.leftArrow, modifierFlags: []) app.typeKey("x", modifierFlags: .shift) + copyPhase = "cursor" let edited = NSPredicate { _, _ in readTerminal()?.contains("taXil") == true } expectation(for: edited, evaluatedWith: app) waitForExpectations(timeout: 10) app.typeKey(.escape, modifierFlags: []) app.typeKey("c", modifierFlags: .control) + copyPhase = "exit" app.typeKey("c", modifierFlags: .control) app.typeKey("c", modifierFlags: .control) let exit = NSPredicate { _, _ in app.state == .notRunning || readTerminal()?.contains("RESIZE_PTY_EXIT") == true } @@ -605,16 +693,39 @@ final class TerminalCompatibilityUITests: XCTestCase { XCTFail("VS Code changed the fixture's native window ID, process, or window count") return nil } - let value = copiedTerminalValue(app) + let value = copiedTerminalValue(app, native: native) guard nativeFixtureWindow(visibleNativeWindows(processIdentifier: identity.processIdentifier), identity: identity) == native else { return nil } return value } - private func copiedTerminalValue(_ window: XCUIElement) -> String? { + private func copiedTerminalValue(_ window: XCUIElement, native: NativeWindowFrame) -> String? { let pasteboard = NSPasteboard.general + let started = Date().timeIntervalSince1970 + let previousGeneration = pasteboard.changeCount + let previousRaw = pasteboard.string(forType: .string) + let previousTypes = pasteboard.types?.map(\.rawValue) ?? [] pasteboard.clearContents() let generation = pasteboard.changeCount + var keyFinished = started + var available = false + var copied: String? + defer { + let sample = NativeCopySample(phase: copyPhase, started: started, finished: Date().timeIntervalSince1970, + baseline: generation, previousGeneration: previousGeneration, previousRaw: previousRaw, + previousTypes: previousTypes, keyFinished: keyFinished, observed: pasteboard.changeCount, + types: pasteboard.types?.map(\.rawValue) ?? [], raw: copied ?? pasteboard.string(forType: .string), + available: available, processIdentifier: native.processIdentifier, + windowIdentifier: native.windowIdentifier, frame: native.frame) + if let record = try? JSONEncoder().encode(sample) { + copyEvidence.append(record) + if let path = copyEvidencePath { + let records = copyEvidence.records.map { String(decoding: $0, as: UTF8.self) }.joined(separator: "\n") + try? (records + "\n{\"dropped\":\(copyEvidence.dropped)}\n").write(to: path, atomically: true, encoding: .utf8) + } + } + } window.typeKey("y", modifierFlags: [.control, .shift]) + keyFinished = Date().timeIntervalSince1970 let written = NSPredicate { _, _ in nativeCopiedTerminalBuffer(baseline: generation, observed: pasteboard.changeCount, value: pasteboard.string(forType: .string)) != nil @@ -626,6 +737,8 @@ final class TerminalCompatibilityUITests: XCTestCase { return nil } print("NATIVE_TERMINAL_COPY_FRESH baseline=\(generation) observed=\(pasteboard.changeCount) bytes=\(value.utf8.count)") + copied = value + available = true return value } diff --git a/src/testing/scenarios/resizeFlickerScenario.tsx b/src/testing/scenarios/resizeFlickerScenario.tsx index ee240a02f..608a8e902 100644 --- a/src/testing/scenarios/resizeFlickerScenario.tsx +++ b/src/testing/scenarios/resizeFlickerScenario.tsx @@ -1,24 +1,35 @@ /** @license Apache-2.0 */ import { appendFileSync } from 'node:fs'; +import { createResizeWriteMetadata, observeResizeWrite, resizeWritePayload } from './resizeWriteMetadata.js'; if (!process.env.AUTOHAND_HOME) throw new Error('The resize fixture requires an isolated AUTOHAND_HOME'); const terminal = process.env.AUTOHAND_RESIZE_TERMINAL; if (terminal) process.env.TERM = terminal; const tracePath = process.env.AUTOHAND_RESIZE_WRITE_TRACE; +const metadataPath = process.env.AUTOHAND_RESIZE_WRITE_METADATA; +const writeMetadata = metadataPath ? createResizeWriteMetadata(metadataPath) : undefined; const viewportMarkers = process.argv.includes('--viewport-markers'); -if (tracePath || viewportMarkers) { +let streamed = 0; +if (tracePath || metadataPath || viewportMarkers) { const originalWrite = process.stdout.write.bind(process.stdout); let tracedBytes = 0; + let sequence = 0; const write: typeof process.stdout.write = (chunk: string | Uint8Array, encodingOrCallback?: BufferEncoding | ((error?: Error | null) => void), callback?: (error?: Error | null) => void): boolean => { - const value = typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString(); - const output = process.argv.includes('--unsupported-sync') ? value.replaceAll(/\x1b\[\?2026[hl]/g, '') : value; const encoding = typeof encodingOrCallback === 'string' ? encodingOrCallback : undefined; const complete = typeof encodingOrCallback === 'function' ? encodingOrCallback : callback; const marker = viewportMarkers ? `\x1b]777;resize-fixture;${process.stdout.columns};${process.stdout.rows}\x07` : ''; - const result = originalWrite(marker + output, encoding, complete); + const payload = resizeWritePayload(chunk, encoding, { marker, unsupportedSync: process.argv.includes('--unsupported-sync') }); + const current = ++sequence; + const result = writeMetadata ? observeResizeWrite({ + sequence: current, bytes: payload.bytes, record: writeMetadata.record, complete, + snapshot: () => ({ timestamp: Date.now() / 1000, columns: process.stdout.columns, rows: process.stdout.rows, streamed }), + write: callback => originalWrite(payload.chunk, encoding, callback), + }) : originalWrite(payload.chunk, encoding, complete); if (tracePath) { + const value = typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString(); + const output = process.argv.includes('--unsupported-sync') ? value.replaceAll(/\x1b\[\?2026[hl]/g, '') : value; const record = JSON.stringify({ columns: process.stdout.columns, rows: process.stdout.rows, terminal: process.env.TERM, output }) + '\n'; tracedBytes += Buffer.byteLength(record); if (tracedBytes > 4 * 1024 * 1024) throw new Error('Resize fixture exceeded its bounded write trace'); @@ -32,7 +43,6 @@ if (tracePath || viewportMarkers) { const { InkRenderer } = await import('../../ui/ink/InkRenderer.js'); let streamTimer: ReturnType | undefined; -let streamed = 0; const renderer = new InkRenderer({ fullScreenMode: process.argv.includes('--full-screen'), onInstruction: () => {}, @@ -44,7 +54,9 @@ const renderer = new InkRenderer({ onCtrlC: () => { clearInterval(streamTimer); void renderer.stop().then(() => { - process.stdout.write('\nRESIZE_PTY_EXIT\n', () => process.exit(0)); + process.stdout.write('\nRESIZE_PTY_EXIT\n', () => { + void (writeMetadata?.flush() ?? Promise.resolve()).then(() => process.exit(0)); + }); }); }, }); diff --git a/src/testing/scenarios/resizeWriteMetadata.ts b/src/testing/scenarios/resizeWriteMetadata.ts new file mode 100644 index 000000000..2f805871a --- /dev/null +++ b/src/testing/scenarios/resizeWriteMetadata.ts @@ -0,0 +1,98 @@ +/** @license Apache-2.0 */ +import { rename, writeFile } from 'node:fs/promises'; + +interface ResizeMetadataOptions { + persist?: (content: string) => Promise; + maxRecords?: number; + maxBytes?: number; +} + +export function resizeWritePayload(chunk: string | Uint8Array, encoding: BufferEncoding | undefined, + options: { marker?: string; unsupportedSync?: boolean } = {}): { chunk: string | Uint8Array; bytes: number } { + let payload = chunk; + if (options.marker || options.unsupportedSync) { + const value = typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString(); + const output = options.unsupportedSync ? value.replaceAll(/\x1b\[\?2026[hl]/g, '') : value; + payload = (options.marker ?? '') + output; + } + return { chunk: payload, bytes: typeof payload === 'string' ? Buffer.byteLength(payload, encoding) : payload.byteLength }; +} + +interface ResizeWriteObservation { + sequence: number; + bytes: number; + snapshot(): { timestamp: number; columns: number | undefined; rows: number | undefined; streamed: number }; + record(value: object): void; + write(complete: (error?: Error | null) => void): boolean; + complete?: (error?: Error | null) => void; +} + +export function observeResizeWrite(observation: ResizeWriteObservation): boolean { + const { sequence, bytes, snapshot, record, write, complete } = observation; + record({ ...snapshot(), kind: 'write-start', sequence, bytes }); + const result = write(error => { + record({ ...snapshot(), kind: 'complete', sequence, error: error != null }); + complete?.(error); + }); + record({ ...snapshot(), kind: 'write-return', sequence, backpressured: !result }); + return result; +} + +export function createResizeWriteMetadata(file: string, options: ResizeMetadataOptions = {}): { + record(value: object): void; + flush(): Promise; +} { + const maxRecords = options.maxRecords ?? 4096; + const maxBytes = options.maxBytes ?? 1024 * 1024; + if (!Number.isSafeInteger(maxRecords) || maxRecords < 1 || !Number.isSafeInteger(maxBytes) || maxBytes < 128) { + throw new Error('Resize metadata bounds must allow at least one record and 128 bytes'); + } + const persist = options.persist ?? (async (content: string) => { + await writeFile(`${file}.pending`, content); + await rename(`${file}.pending`, file); + }); + const records: { line: string; bytes: number }[] = []; + let bytes = 0; + let dropped = 0; + let dirty = false; + let pending: Promise | undefined; + let failure: unknown; + let failed = false; + + const schedule = () => { + if (pending || failed) return; + pending = (async () => { + do { + dirty = false; + const overflow = dropped ? JSON.stringify({ kind: 'metadata-overflow', dropped }) + '\n' : ''; + await persist(records.map(record => record.line).join('') + overflow); + } while (dirty); + })().catch(error => { failure = error; failed = true; }).finally(() => { + pending = undefined; + if (dirty && !failed) schedule(); + }); + }; + + return { + record(value) { + const line = JSON.stringify(value) + '\n'; + const size = Buffer.byteLength(line); + if (size > maxBytes - 64) { + dropped += 1; + } else { + while (records.length >= maxRecords || bytes + size > maxBytes - 64) { + bytes -= records.shift()!.bytes; + dropped += 1; + } + records.push({ line, bytes: size }); + bytes += size; + } + dirty = true; + schedule(); + }, + async flush() { + while (pending) await pending; + if (failed) throw failure; + }, + }; +} diff --git a/tests/testing/nativeTerminal.test.ts b/tests/testing/nativeTerminal.test.ts index 89cbf816a..215eeac1b 100644 --- a/tests/testing/nativeTerminal.test.ts +++ b/tests/testing/nativeTerminal.test.ts @@ -41,6 +41,19 @@ function preparedLaunchArguments(): Record { } describe('native terminal automation boundary', () => { + it('retains separate fixture write timing for each native scenario', async () => { + Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true }); + vi.stubEnv('GITHUB_ACTIONS', 'true'); + vi.stubEnv('CI', 'true'); + vi.stubEnv('AUTOHAND_NATIVE_TERMINAL', 'vscode'); + await prepareNativeTerminalTest(); + const fixture = vi.mocked(writeFile).mock.calls.find(([file]) => String(file).endsWith('Fixture.command')); + expect(fixture?.[1]).toContain('AUTOHAND_RESIZE_WRITE_METADATA='); + expect(fixture?.[1]).toContain('write-metadata-${fixture_mode}.jsonl'); + expect(fixture?.[1]).toContain('fixture_mode=streaming'); + expect(fixture?.[1]).toContain('fixture_mode=idle'); + }); + it.each(['idle', 'streaming'])('focuses the existing terminal before each native copy in the isolated %s VS Code profile', async mode => { Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true }); vi.stubEnv('GITHUB_ACTIONS', 'true'); diff --git a/tests/testing/resizeWriteMetadata.test.ts b/tests/testing/resizeWriteMetadata.test.ts new file mode 100644 index 000000000..efcb0263a --- /dev/null +++ b/tests/testing/resizeWriteMetadata.test.ts @@ -0,0 +1,130 @@ +/** @license Apache-2.0 */ +import { describe, expect, it, vi } from 'vitest'; +import { createResizeWriteMetadata, observeResizeWrite, resizeWritePayload } from '../../src/testing/scenarios/resizeWriteMetadata.js'; + +describe('resize fixture write metadata', () => { + it('forwards split multibyte and non-UTF8 chunks unchanged when only observing metadata', () => { + const chunks = [Uint8Array.from([0xe2]), Uint8Array.from([0x82, 0xac]), Buffer.from([0xff, 0xfe])]; + const forwarded = chunks.map(chunk => resizeWritePayload(chunk, undefined)); + for (let index = 0; index < chunks.length; index += 1) { + expect(forwarded[index]?.chunk).toBe(chunks[index]); + expect(forwarded[index]?.bytes).toBe(chunks[index]?.byteLength); + } + const actual = Buffer.concat(forwarded.map(payload => typeof payload.chunk === 'string' ? Buffer.from(payload.chunk) : payload.chunk)); + expect(actual).toEqual(Buffer.from([0xe2, 0x82, 0xac, 0xff, 0xfe])); + }); + + it.each<{ chunk: string | Uint8Array; encoding?: BufferEncoding; bytes: number }>([ + { chunk: 'é', encoding: 'latin1', bytes: 1 }, + { chunk: 'abcd', encoding: 'utf16le', bytes: 8 }, + { chunk: 'w6k=', encoding: 'base64', bytes: 2 }, + { chunk: Uint8Array.from([1, 2, 3, 4]).subarray(1, 3), encoding: 'utf16le', bytes: 2 }, + ])('measures the actual payload type and encoding $encoding', ({ chunk, encoding, bytes }) => { + const payload = resizeWritePayload(chunk, encoding); + expect(payload.chunk).toBe(chunk); + expect(payload.bytes).toBe(bytes); + }); + + it('limits payload transformations to explicit viewport-marker or unsupported-sync modes', () => { + const chunk = Buffer.from('\x1b[?2026htext\x1b[?2026l'); + expect(resizeWritePayload(chunk, undefined, { unsupportedSync: true }).chunk).toBe('text'); + const marked = resizeWritePayload('text', undefined, { marker: '\x1b]777;resize-fixture;100;30\x07' }); + expect(marked.chunk).toBe('\x1b]777;resize-fixture;100;30\x07text'); + expect(marked.bytes).toBe(Buffer.byteLength(marked.chunk)); + }); + + it('flushes a record appended between the last write continuation and pending cleanup', async () => { + const persist = vi.fn(async (_content: string) => {}); + const metadata = createResizeWriteMetadata('unused', { persist }); + metadata.record({ sequence: 1 }); + await Promise.resolve(); + metadata.record({ sequence: 2 }); + await metadata.flush(); + expect(persist.mock.calls.at(-1)?.[0]).toContain('"sequence":2'); + expect(persist).toHaveBeenCalledTimes(2); + }); + + it('waits for a new write chain scheduled while flush is awaiting pending cleanup', async () => { + let release: (() => void) | undefined; + const gate = new Promise(resolve => { release = resolve; }); + const persist = vi.fn(async (_content: string) => { if (persist.mock.calls.length === 2) await gate; }); + const metadata = createResizeWriteMetadata('unused', { persist }); + metadata.record({ sequence: 1 }); + await Promise.resolve(); + metadata.record({ sequence: 2 }); + let finished = false; + const flush = metadata.flush().then(() => { finished = true; }); + await vi.waitFor(() => { expect(persist).toHaveBeenCalledTimes(2); }); + expect(finished).toBe(false); + release?.(); + await flush; + expect(finished).toBe(true); + }); + + it.each([undefined, null, new Error('write failed')])('records write-start before delegation and preserves completion %s', error => { + let timestamp = 10; + const record = vi.fn(); + const complete = vi.fn(); + const result = observeResizeWrite({ + sequence: 1, bytes: 250, snapshot: () => ({ timestamp, columns: 100, rows: 30, streamed: 40 }), record, complete, + write: callback => { + expect(record.mock.calls[0]?.[0]).toEqual({ kind: 'write-start', sequence: 1, bytes: 250, + timestamp: 10, columns: 100, rows: 30, streamed: 40 }); + timestamp = 12; + callback(error); + timestamp = 13; + return false; + }, + }); + expect(result).toBe(false); + expect(complete).toHaveBeenCalledExactlyOnceWith(error); + expect(record.mock.calls[1]?.[0]).toMatchObject({ kind: 'complete', sequence: 1, timestamp: 12, error: error != null }); + expect(record.mock.calls[2]?.[0]).toMatchObject({ kind: 'write-return', sequence: 1, timestamp: 13, backpressured: true }); + }); + + it('rejects limits that cannot retain a bounded tail', () => { + expect(() => createResizeWriteMetadata('unused', { maxRecords: 0 })).toThrow('Resize metadata bounds'); + expect(() => createResizeWriteMetadata('unused', { maxBytes: 64 })).toThrow('Resize metadata bounds'); + }); + + it('persists a bounded tail while one slow writer is in flight', async () => { + let release: (() => void) | undefined; + const gate = new Promise(resolve => { release = resolve; }); + const persist = vi.fn(async (_content: string) => { await gate; }); + const metadata = createResizeWriteMetadata('unused', { persist, maxRecords: 2, maxBytes: 512 }); + for (let sequence = 0; sequence < 100; sequence += 1) metadata.record({ sequence }); + expect(persist).toHaveBeenCalledTimes(1); + release?.(); + await metadata.flush(); + expect(persist).toHaveBeenCalledTimes(2); + const latest = persist.mock.calls.at(-1)?.[0] ?? ''; + expect(latest.trim().split('\n').map((line: string) => JSON.parse(line))).toEqual([ + { sequence: 98 }, { sequence: 99 }, { kind: 'metadata-overflow', dropped: 98 }, + ]); + expect(Buffer.byteLength(latest)).toBeLessThanOrEqual(512); + }); + + it('caps bytes, reports oversized records, and retains late events', async () => { + const persist = vi.fn(async (_content: string) => {}); + const metadata = createResizeWriteMetadata('unused', { persist, maxRecords: 100, maxBytes: 256 }); + metadata.record({ oversized: 'x'.repeat(512) }); + for (let sequence = 0; sequence < 30; sequence += 1) metadata.record({ sequence, value: 'x'.repeat(40) }); + await metadata.flush(); + const latest = persist.mock.calls.at(-1)?.[0] ?? ''; + expect(Buffer.byteLength(latest)).toBeLessThanOrEqual(256); + expect(latest).toContain('"sequence":29'); + expect(latest).not.toContain('"sequence":0,'); + expect(latest).toContain('"kind":"metadata-overflow"'); + }); + + it('surfaces persistence failure on flush without rejecting the producer', async () => { + const error = new Error('write failed'); + const persist = vi.fn(async () => { throw error; }); + const metadata = createResizeWriteMetadata('unused', { persist }); + expect(() => metadata.record({ sequence: 1 })).not.toThrow(); + await expect(metadata.flush()).rejects.toBe(error); + metadata.record({ sequence: 2 }); + await expect(metadata.flush()).rejects.toBe(error); + expect(persist).toHaveBeenCalledTimes(1); + }); +}); diff --git a/tests/tuistory/resize-flicker.tuistory.test.ts b/tests/tuistory/resize-flicker.tuistory.test.ts index caefb8c20..1fff41cca 100644 --- a/tests/tuistory/resize-flicker.tuistory.test.ts +++ b/tests/tuistory/resize-flicker.tuistory.test.ts @@ -1,5 +1,6 @@ /** @license Apache-2.0 */ import path from 'node:path'; +import { readFile } from 'node:fs/promises'; import { stripVTControlCharacters } from 'node:util'; import { afterEach, describe, expect, it, vi } from 'vitest'; import type { Session } from 'tuistory'; @@ -14,6 +15,48 @@ let state: TuistoryTempState | undefined; afterEach(async () => { session?.close(); await state?.cleanup(); session = undefined; state = undefined; }); describe('terminal resize frames', () => { + it('records bounded write timing and viewport metadata without storing terminal content', async () => { + state = await createTempAutohandHome(); + const metadata = path.join(state.autohandHome, 'write-metadata.jsonl'); + session = await launchTuistorySession({ + command: 'bun', + args: [path.resolve('src/testing/scenarios/resizeFlickerScenario.tsx'), '--full-screen', '--streaming', '--multiline'], + cwd: process.cwd(), cols: 100, rows: 30, waitForDataTimeout: 15_000, + env: { ...process.env, AUTOHAND_HOME: state.autohandHome, CI: 'false', FORCE_COLOR: '0', + AUTOHAND_RESIZE_WRITE_METADATA: metadata }, + }); + const initial = await waitForTerminalScreen(session, { timeout: 5_000, + waitFor: screen => /RESIZE_STREAM [1-9]\d*/u.test(screen) }); + const initialCounter = Number(initial.match(/RESIZE_STREAM (\d+)/u)?.[1]); + session.resize({ cols: 90, rows: 28 }); + await vi.waitFor(async () => { + const records: unknown[] = (await readFile(metadata, 'utf8')).trim().split('\n').map(line => JSON.parse(line)); + expect(records).toEqual(expect.arrayContaining([ + expect.objectContaining({ kind: 'write-start', columns: 90, rows: 28, sequence: expect.any(Number), + timestamp: expect.any(Number), streamed: expect.any(Number), bytes: expect.any(Number) }), + expect.objectContaining({ kind: 'write-return', sequence: expect.any(Number), + timestamp: expect.any(Number), backpressured: expect.any(Boolean) }), + expect.objectContaining({ kind: 'complete', sequence: expect.any(Number), timestamp: expect.any(Number) }), + ])); + expect(records.every(record => typeof record === 'object' && record !== null && !('output' in record))).toBe(true); + expect(Buffer.byteLength(await readFile(metadata, 'utf8'))).toBeLessThanOrEqual(1024 * 1024); + }, { timeout: 5_000 }); + await waitForTerminalScreen(session, { timeout: 5_000, + waitFor: screen => Number(screen.match(/RESIZE_STREAM (\d+)/u)?.[1]) > initialCounter }); + await session.press('left'); + await session.press('left'); + await session.type('X'); + const edited = await waitForTerminalScreen(session, { timeout: 5_000, waitFor: screen => screen.includes('taXil') }); + expect(edited.match(/Autohand Code/gu)).toHaveLength(1); + expect(edited.match(/RESIZE_HISTORY retained text/gu)).toHaveLength(1); + expect(edited.match(/preserved draft/gu)).toHaveLength(1); + await session.press('escape'); + await session.waitForText('RESIZE_STREAM_COMPLETE'); + await exitInteractive(session); + expect(session.exitInfo?.exitCode).toBe(0); + expect(session.getRawOutput()).toContain('RESIZE_PTY_EXIT'); + }); + it('clips older history at the native 57x19 viewport and restores it when the streaming multiline viewport grows', async () => { state = await createTempAutohandHome(); session = await launchTuistorySession({ From 69c88df546f204a9878f7f2d318ce144be924fc3 Mon Sep 17 00:00:00 2001 From: Igor Costa Date: Tue, 10 Nov 2026 22:27:37 +1300 Subject: [PATCH 13/19] Coordinate streamed resize observation within the native settling deadline Keep response chunks active through the physical resize, then gate only new fixture updates for a fresh raw terminal copy. Require post-resize progress and resumed output within the original ten-second budget while preserving transcript, draft, cursor, window, and exit assertions. Co-authored-by: Autohand Evolve --- .github/workflows/terminal-compatibility.yml | 3 +- docs/terminal-compatibility.md | 2 + src/testing/drivers/native-terminal-driver.ts | 2 +- .../TerminalCompatibilityUITests.swift | 148 +++++++++++++++++- .../scenarios/resizeFlickerScenario.tsx | 12 +- src/testing/scenarios/resizeStreamGate.ts | 82 ++++++++++ tests/testing/nativeTerminal.test.ts | 1 + tests/testing/resizeStreamGate.test.ts | 75 +++++++++ .../tuistory/resize-flicker.tuistory.test.ts | 50 +++++- 9 files changed, 365 insertions(+), 10 deletions(-) create mode 100644 src/testing/scenarios/resizeStreamGate.ts create mode 100644 tests/testing/resizeStreamGate.test.ts diff --git a/.github/workflows/terminal-compatibility.yml b/.github/workflows/terminal-compatibility.yml index 86de111fb..eaf92f5bf 100644 --- a/.github/workflows/terminal-compatibility.yml +++ b/.github/workflows/terminal-compatibility.yml @@ -98,7 +98,7 @@ jobs: defaults write com.googlecode.iterm2 ApplePersistenceIgnoreState -bool true - run: bun install --frozen-lockfile - name: Verify native automation boundary and disposable profiles - run: bun run test tests/testing/nativeTerminal.test.ts tests/testing/resizeWriteMetadata.test.ts + run: bun run test tests/testing/nativeTerminal.test.ts tests/testing/resizeWriteMetadata.test.ts tests/testing/resizeStreamGate.test.ts - run: bun run build - name: Run Tuistory with native XCTest window and accessibility checks run: bun run test:tuistory tests/tuistory/native-terminal.tuistory.test.ts @@ -123,6 +123,7 @@ jobs: .artifacts/native-terminal/**/write-metadata-*.jsonl .artifacts/native-terminal/**/write-metadata-*.jsonl.pending .artifacts/native-terminal/**/copy-samples-*.jsonl + .artifacts/native-terminal/**/stream-control-*.json .artifacts/native-terminal/**/vscode-profile/**/settings.json .artifacts/native-terminal/**/vscode-profile/**/keybindings.json if-no-files-found: error diff --git a/docs/terminal-compatibility.md b/docs/terminal-compatibility.md index c43ef11ac..9884d3820 100644 --- a/docs/terminal-compatibility.md +++ b/docs/terminal-compatibility.md @@ -47,6 +47,8 @@ VS Code's authoritative readout uses its native terminal buffer copy. Hosted rec Native-copy diagnostics retain each attempt's phase, timestamps, pasteboard generations and types, raw text, availability, and native window identity and geometry. A bounded tail of 128 records and 2 MiB is persisted after each attempt, with explicit eviction counts, so late resize failures remain inspectable even if the application exits. The fixture also writes asynchronous, bounded write metadata: sequence, viewport, stream counter and bytes before stdout delegation, then separate return/backpressure and callback-completion timestamps. Metadata-only observation forwards the original string or binary chunk and its encoding unchanged. That metadata contains no terminal output, never waits in the producer, and retains the latest records within 1 MiB. The workflow retains both the committed metadata file and any in-flight `.pending` file, alongside copy samples and XCTest evidence. Diagnostic instrumentation does not establish that an intermittent failure is repaired. +VS Code's streaming scenario keeps response chunks flowing through each physical resize. After the gesture, a disposable control file (512 bytes maximum, command IDs 1–8) requests observation. Only after a newer post-command chunk at changed/current TTY dimensions does the fixture gate new response chunks. The request and working spinner stay active; no renderer operation is triggered. A fresh native copy must contain that exact paused counter and all original markers before unchanged exact-count assertions run. Resuming and proving a newer chunk at the same viewport share the original single 10-second settling budget with acknowledgement and copying, including after the final resize. Asynchronous control reads are single-flight and closed on every path; disposal suppresses late work. Native metadata reads are capped at 16 KiB. Unused Code predicate objects are omitted to avoid implicit accessibility debug snapshots during polling; explicit failure evidence remains. + The strict visible-history assertions require room for both transcript exchanges and the multiline composer. At a compact 57×19 terminal, the full-screen transcript correctly follows its newest exchange and clips older rows; a separate PTY case checks that the history reappears on growth, the draft and cursor survive, and Ctrl+C exits cleanly. This distinguishes viewport clipping from lost transcript data. Standalone terminal references bind to their accessibility identities. VS Code refreshes its accessibility window before setup steps and after every setup or scenario gesture, guarded by the same native process ID, window ID, single-window count, and current frame. Native CI availability and per-application results must be assessed from that workflow's completed jobs. A passing PTY suite or a successful XCTest build alone does not prove a native application passed. Failures in app launch, accessibility access, or window resizing fail the native job and preserve its XCTest evidence; they are not silently converted to headless passes. diff --git a/src/testing/drivers/native-terminal-driver.ts b/src/testing/drivers/native-terminal-driver.ts index 488d47a61..2dbda2d48 100644 --- a/src/testing/drivers/native-terminal-driver.ts +++ b/src/testing/drivers/native-terminal-driver.ts @@ -20,7 +20,7 @@ export async function prepareNativeTerminalTest(): Promise<{ args: string[]; res const fixture = path.join(output, 'Autohand Terminal Fixture.command'); const quote = (value: string) => `'${value.replaceAll("'", "'\\''")}'`; const bun = execFileSync('/bin/sh', ['-c', 'command -v bun'], { encoding: 'utf8' }).trim(); - await writeFile(fixture, `#!/bin/sh\nset -eu\ncd ${quote(root)}\nexport AUTOHAND_HOME=${quote(path.join(output, 'home'))}\nexport CI=false\nif [ "\${1-}" = --streaming ]; then fixture_mode=streaming; else fixture_mode=idle; fi\nexport AUTOHAND_RESIZE_WRITE_METADATA=${quote(output)}/write-metadata-\${fixture_mode}.jsonl\nexec ${quote(bun)} src/testing/scenarios/resizeFlickerScenario.tsx --full-screen --multiline "$@"\n`, { mode: 0o755 }); + await writeFile(fixture, `#!/bin/sh\nset -eu\ncd ${quote(root)}\nexport AUTOHAND_HOME=${quote(path.join(output, 'home'))}\nexport CI=false\nif [ "\${1-}" = --streaming ]; then fixture_mode=streaming; else fixture_mode=idle; fi\nexport AUTOHAND_RESIZE_WRITE_METADATA=${quote(output)}/write-metadata-\${fixture_mode}.jsonl\n${terminal === 'vscode' ? `export AUTOHAND_RESIZE_STREAM_CONTROL=${quote(output)}/stream-control-\${fixture_mode}.json\n` : ''}exec ${quote(bun)} src/testing/scenarios/resizeFlickerScenario.tsx --full-screen --multiline "$@"\n`, { mode: 0o755 }); const profile = path.join(output, 'vscode-profile'); const runtimeProfile = terminal === 'vscode' ? await mkdtemp('/private/tmp/ah-vsc-') : undefined; const settings = { diff --git a/src/testing/drivers/native-terminal-lab/TerminalCompatibilityUITests.swift b/src/testing/drivers/native-terminal-lab/TerminalCompatibilityUITests.swift index 1ed400f7c..ca3f32ff0 100644 --- a/src/testing/drivers/native-terminal-lab/TerminalCompatibilityUITests.swift +++ b/src/testing/drivers/native-terminal-lab/TerminalCompatibilityUITests.swift @@ -74,6 +74,54 @@ private struct NativeCopySample: Encodable { let frame: CGRect } +private struct NativeStreamPosition: Codable { + let kind: String + var command: Int? = nil + var counter: Int? = nil + var columns: Int? = nil + var rows: Int? = nil + var paused: Bool? = nil +} + +private struct NativeStreamCommand: Encodable { + let id: Int + let action: String + let counter: Int + let columns: Int + let rows: Int +} + +private func nativeStreamPositions(_ data: Data) -> [NativeStreamPosition] { + String(decoding: data, as: UTF8.self).split(separator: "\n").compactMap { line in + guard let position = try? JSONDecoder().decode(NativeStreamPosition.self, from: Data(line.utf8)), + position.kind == "stream-chunk" || position.kind == "stream-gate", + let counter = position.counter, counter > 0, + let columns = position.columns, columns > 0, columns <= 10000, + let rows = position.rows, rows > 0, rows <= 10000 else { return nil } + return position + } +} + +private func nativeStreamPaused(_ positions: [NativeStreamPosition], command: Int, before: NativeStreamPosition) -> NativeStreamPosition? { + positions.last { position in + position.kind == "stream-gate" && position.command == command && position.paused == true + && (position.counter ?? 0) > (before.counter ?? 0) + && (position.columns != before.columns || position.rows != before.rows) + } +} + +private func nativeStreamResumed(_ positions: [NativeStreamPosition], command: Int, after: NativeStreamPosition) -> Bool { + positions.contains { position in + position.kind == "stream-chunk" && position.command == command && position.paused == false + && (position.counter ?? 0) > (after.counter ?? 0) + && position.columns == after.columns && position.rows == after.rows + } +} + +private func nativeSettlingTime(_ deadline: Date, now: Date = Date()) -> TimeInterval { + max(0, deadline.timeIntervalSince(now)) +} + private struct NativeTerminalReadout { let read: () -> String? let requiresStreaming: Bool @@ -84,9 +132,10 @@ private struct NativeTerminalReadout { self.requiresStreaming = requiresStreaming } - mutating func capture() -> Bool { + mutating func capture(streamCounter: Int? = nil) -> Bool { frame = read() guard let value = frame else { return false } + if let counter = streamCounter, !value.contains("RESIZE_STREAM \(counter) retained streamed text") { return false } return value.contains("Autohand Code") && value.contains("RESIZE_HISTORY retained text") && value.contains("preserved draft") && (!requiresStreaming || value.contains("RESIZE_STREAM ")) } @@ -169,6 +218,37 @@ final class TerminalCompatibilityUITests: XCTestCase { XCTAssertNil(nativeCopiedTerminalBuffer(baseline: 18, observed: 19, value: nil)) } + func testNativeReadoutGateRequiresViewportProgressAndANewerResumedChunk() { + let before = NativeStreamPosition(kind: "stream-chunk", command: 0, counter: 10, columns: 100, rows: 30, paused: false) + let unchanged = NativeStreamPosition(kind: "stream-gate", command: 1, counter: 11, columns: 100, rows: 30, paused: true) + let stale = NativeStreamPosition(kind: "stream-gate", command: 1, counter: 10, columns: 90, rows: 28, paused: true) + let paused = NativeStreamPosition(kind: "stream-gate", command: 1, counter: 12, columns: 90, rows: 28, paused: true) + XCTAssertNil(nativeStreamPaused([unchanged, stale], command: 1, before: before)) + XCTAssertNotNil(nativeStreamPaused([paused], command: 1, before: before)) + XCTAssertFalse(nativeStreamResumed([paused], command: 2, after: paused)) + let resumed = NativeStreamPosition(kind: "stream-chunk", command: 2, counter: 13, columns: 90, rows: 28, paused: false) + XCTAssertTrue(nativeStreamResumed([resumed], command: 2, after: paused)) + } + + func testNativeReadoutGateCountsTheExactPausedFrameWithoutSelectingCleanCounts() { + var value = "Autohand Code\nRESIZE_HISTORY retained text\npreserved draft\nRESIZE_STREAM 11 retained streamed text" + var readout = NativeTerminalReadout(read: { value }, requiresStreaming: true) + XCTAssertFalse(readout.capture(streamCounter: 12)) + value = "Autohand Code\n" + value.replacingOccurrences(of: "STREAM 11", with: "STREAM 12") + XCTAssertTrue(readout.capture(streamCounter: 12)) + XCTAssertEqual(readout.frame?.components(separatedBy: "Autohand Code").count, 3) + let deadline = Date(timeIntervalSince1970: 100) + XCTAssertEqual(nativeSettlingTime(deadline, now: Date(timeIntervalSince1970: 97)), 3) + XCTAssertEqual(nativeSettlingTime(deadline, now: Date(timeIntervalSince1970: 101)), 0) + } + + func testNativeReadoutGateParsesOnlyCompleteValidMetadataTailRecords() { + let tail = Data("partial row\n{\"kind\":\"write-start\",\"columns\":90,\"rows\":28}\n{\"kind\":\"stream-chunk\",\"command\":2,\"counter\":13,\"columns\":90,\"rows\":28,\"paused\":false}\n".utf8) + let positions = nativeStreamPositions(tail) + XCTAssertEqual(positions.count, 1) + XCTAssertEqual(positions.first?.counter, 13) + } + func testNativeReadoutEvidenceRetainsPartialUnavailableAndCompleteSamplesUnchanged() throws { var evidence = NativeCopyEvidence() let partial = "RESIZE_STREAM 400\r\n" @@ -375,6 +455,10 @@ final class TerminalCompatibilityUITests: XCTestCase { let app = XCUIApplication(url: URL(fileURLWithPath: application)) addTeardownBlock { if app.state != .notRunning { app.terminate() } } let command = "'" + fixture.replacingOccurrences(of: "'", with: "'\\''") + "'" + (streaming ? " --streaming" : "") + let gated = terminal == "vscode" && streaming + let fixtureDirectory = URL(fileURLWithPath: fixture).deletingLastPathComponent() + let streamMetadata = fixtureDirectory.appendingPathComponent("write-metadata-streaming.jsonl") + let streamControl = fixtureDirectory.appendingPathComponent("stream-control-streaming.json") let modes = try JSONDecoder().decode([String: [String]].self, from: arguments) guard let launchArguments = modes[streaming ? "streaming" : "idle"] else { XCTFail("Missing isolated launch arguments for the native scenario") @@ -486,8 +570,9 @@ final class TerminalCompatibilityUITests: XCTestCase { let ready = NSPredicate { _, _ in startupValue = readTerminal() ?? "" return startupValue.contains("RESIZE_HISTORY retained text") + && (!gated || self.streamPositions(streamMetadata).contains { $0.kind == "stream-chunk" }) } - guard XCTWaiter.wait(for: [XCTNSPredicateExpectation(predicate: ready, object: app)], timeout: 30) == .completed else { + guard XCTWaiter.wait(for: [XCTNSPredicateExpectation(predicate: ready, object: terminal == "vscode" ? nil : app)], timeout: 30) == .completed else { let text = XCTAttachment(string: String(startupValue.suffix(16_000))) text.name = "\(terminal)-startup-terminal" text.lifetime = .keepAlways @@ -514,6 +599,11 @@ final class TerminalCompatibilityUITests: XCTestCase { } let frame = window.frame let corner = window.coordinate(withNormalizedOffset: CGVector(dx: 1, dy: 1)).withOffset(CGVector(dx: -2, dy: -2)) + let streamBefore = gated ? streamPositions(streamMetadata).last { $0.kind == "stream-chunk" } : nil + guard !gated || streamBefore != nil else { + XCTFail("The streaming fixture did not publish its pre-gesture position") + return + } corner.press(forDuration: 0.1, thenDragTo: corner.withOffset(delta)) if let identity = nativeIdentity { guard let current = currentCodeWindow(app, identity: identity) else { return } @@ -521,9 +611,19 @@ final class TerminalCompatibilityUITests: XCTestCase { } XCTAssertNotEqual(window.frame.size, frame.size, "The native window did not resize") XCTAssertTrue(displayBounds.contains(window.frame), "The resized fixture window left the display bounds") + let settlingDeadline = Date().addingTimeInterval(10) + if let before = streamBefore, !writeStreamCommand(streamControl, id: index * 2 + 1, action: "observe", position: before) { return } + var paused: NativeStreamPosition? var readout = NativeTerminalReadout(read: { readTerminal() }, requiresStreaming: streaming) - let settled = NSPredicate { _, _ in readout.capture() } - guard XCTWaiter.wait(for: [XCTNSPredicateExpectation(predicate: settled, object: window)], timeout: 10) == .completed, + let settled = NSPredicate { _, _ in + if paused == nil, let before = streamBefore { + guard let position = nativeStreamPaused(self.streamPositions(streamMetadata), command: index * 2 + 1, before: before) else { return false } + paused = position + } + return readout.capture(streamCounter: paused?.counter) + } + guard XCTWaiter.wait(for: [XCTNSPredicateExpectation(predicate: settled, object: terminal == "vscode" ? nil : window)], + timeout: gated ? nativeSettlingTime(settlingDeadline) : 10) == .completed, let content = readout.frame else { let content = XCTAttachment(string: String((readout.frame ?? "No fresh terminal buffer was available").suffix(16_000))) content.name = "\(terminal)-resize-\(index)-terminal" @@ -541,13 +641,25 @@ final class TerminalCompatibilityUITests: XCTestCase { image.name = "\(terminal)-\(streaming ? "streaming" : "idle")-\(index)" image.lifetime = .keepAlways add(image) + if let paused { + guard writeStreamCommand(streamControl, id: index * 2 + 2, action: "resume", position: paused) else { return } + let resumed = NSPredicate { _, _ in + nativeStreamResumed(self.streamPositions(streamMetadata), command: index * 2 + 2, after: paused) + } + let remaining = nativeSettlingTime(settlingDeadline) + guard remaining > 0, + XCTWaiter.wait(for: [XCTNSPredicateExpectation(predicate: resumed, object: nil)], timeout: remaining) == .completed else { + XCTFail("The streaming fixture did not resume newer chunks within the original settling deadline") + return + } + } } app.typeKey(.leftArrow, modifierFlags: []) app.typeKey(.leftArrow, modifierFlags: []) app.typeKey("x", modifierFlags: .shift) copyPhase = "cursor" let edited = NSPredicate { _, _ in readTerminal()?.contains("taXil") == true } - expectation(for: edited, evaluatedWith: app) + expectation(for: edited, evaluatedWith: terminal == "vscode" ? nil : app) waitForExpectations(timeout: 10) app.typeKey(.escape, modifierFlags: []) app.typeKey("c", modifierFlags: .control) @@ -555,7 +667,7 @@ final class TerminalCompatibilityUITests: XCTestCase { app.typeKey("c", modifierFlags: .control) app.typeKey("c", modifierFlags: .control) let exit = NSPredicate { _, _ in app.state == .notRunning || readTerminal()?.contains("RESIZE_PTY_EXIT") == true } - expectation(for: exit, evaluatedWith: app) + expectation(for: exit, evaluatedWith: terminal == "vscode" ? nil : app) waitForExpectations(timeout: 15) print(streaming ? "NATIVE_TERMINAL_STREAMING_PASS" : "NATIVE_TERMINAL_IDLE_PASS") } @@ -698,6 +810,30 @@ final class TerminalCompatibilityUITests: XCTestCase { return value } + private func streamPositions(_ file: URL) -> [NativeStreamPosition] { + guard let handle = try? FileHandle(forReadingFrom: file) else { return [] } + defer { try? handle.close() } + guard let end = try? handle.seekToEnd(), (try? handle.seek(toOffset: end > 16_384 ? end - 16_384 : 0)) != nil, + let data = try? handle.read(upToCount: 16_384) else { return [] } + return nativeStreamPositions(data) + } + + private func writeStreamCommand(_ file: URL, id: Int, action: String, position: NativeStreamPosition) -> Bool { + guard let counter = position.counter, let columns = position.columns, let rows = position.rows else { + XCTFail("The streaming fixture position is incomplete") + return false + } + do { + let data = try JSONEncoder().encode(NativeStreamCommand(id: id, action: action, counter: counter, columns: columns, rows: rows)) + guard data.count <= 512 else { XCTFail("The stream observation command exceeded its bound"); return false } + try data.write(to: file, options: .atomic) + return true + } catch { + XCTFail("The streaming fixture control command could not be saved: \(error)") + return false + } + } + private func copiedTerminalValue(_ window: XCUIElement, native: NativeWindowFrame) -> String? { let pasteboard = NSPasteboard.general let started = Date().timeIntervalSince1970 diff --git a/src/testing/scenarios/resizeFlickerScenario.tsx b/src/testing/scenarios/resizeFlickerScenario.tsx index 608a8e902..c7b8c17f2 100644 --- a/src/testing/scenarios/resizeFlickerScenario.tsx +++ b/src/testing/scenarios/resizeFlickerScenario.tsx @@ -1,6 +1,7 @@ /** @license Apache-2.0 */ import { appendFileSync } from 'node:fs'; import { createResizeWriteMetadata, observeResizeWrite, resizeWritePayload } from './resizeWriteMetadata.js'; +import { ResizeStreamGate } from './resizeStreamGate.js'; if (!process.env.AUTOHAND_HOME) throw new Error('The resize fixture requires an isolated AUTOHAND_HOME'); @@ -9,6 +10,10 @@ if (terminal) process.env.TERM = terminal; const tracePath = process.env.AUTOHAND_RESIZE_WRITE_TRACE; const metadataPath = process.env.AUTOHAND_RESIZE_WRITE_METADATA; const writeMetadata = metadataPath ? createResizeWriteMetadata(metadataPath) : undefined; +const streamControl = process.env.AUTOHAND_RESIZE_STREAM_CONTROL; +if (streamControl && !writeMetadata) throw new Error('The stream observation gate requires bounded write metadata'); +const streamGate = streamControl && process.argv.includes('--streaming') + ? new ResizeStreamGate(streamControl, record => writeMetadata?.record({ timestamp: Date.now() / 1000, ...record })) : undefined; const viewportMarkers = process.argv.includes('--viewport-markers'); let streamed = 0; if (tracePath || metadataPath || viewportMarkers) { @@ -48,12 +53,14 @@ const renderer = new InkRenderer({ onInstruction: () => {}, onEscape: () => { clearInterval(streamTimer); + void streamGate?.dispose(); renderer.setWorking(false); renderer.setFinalResponse(`RESIZE_STREAM_COMPLETE ${streamed}`); }, onCtrlC: () => { clearInterval(streamTimer); - void renderer.stop().then(() => { + void renderer.stop().then(async () => { + await streamGate?.dispose(); process.stdout.write('\nRESIZE_PTY_EXIT\n', () => { void (writeMetadata?.flush() ?? Promise.resolve()).then(() => process.exit(0)); }); @@ -70,7 +77,10 @@ if (process.argv.includes('--streaming')) { renderer.start(); if (process.argv.includes('--streaming')) { streamTimer = setInterval(() => { + const viewport = { columns: process.stdout.columns, rows: process.stdout.rows }; + if (streamGate && !streamGate.permits(viewport)) return; streamed += 1; renderer.setStreamingResponse(`RESIZE_STREAM ${streamed} retained streamed text`); + streamGate?.emitted({ counter: streamed, ...viewport }); }, 40); } diff --git a/src/testing/scenarios/resizeStreamGate.ts b/src/testing/scenarios/resizeStreamGate.ts new file mode 100644 index 000000000..d56815450 --- /dev/null +++ b/src/testing/scenarios/resizeStreamGate.ts @@ -0,0 +1,82 @@ +/** @license Apache-2.0 */ +import { open } from 'node:fs/promises'; + +interface StreamPosition { counter: number; columns: number; rows: number } +interface StreamCommand extends StreamPosition { id: number; action: 'observe' | 'resume' } + +async function readCommand(file: string): Promise { + const handle = await open(file, 'r'); + try { + const buffer = Buffer.alloc(513); + const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0); + if (bytesRead > 512) throw new Error('Resize stream command exceeded 512 bytes'); + return JSON.parse(buffer.subarray(0, bytesRead).toString('utf8')); + } finally { await handle.close(); } +} + +function parseCommand(value: unknown): StreamCommand { + if (!value || typeof value !== 'object' || !('id' in value) || !('action' in value) || !('counter' in value) + || !('columns' in value) || !('rows' in value) || (value.action !== 'observe' && value.action !== 'resume') + || typeof value.id !== 'number' || !Number.isSafeInteger(value.id) || value.id < 1 || value.id > 8 + || typeof value.counter !== 'number' || !Number.isSafeInteger(value.counter) || value.counter < 0 + || typeof value.columns !== 'number' || !Number.isSafeInteger(value.columns) || value.columns < 1 || value.columns > 10000 + || typeof value.rows !== 'number' || !Number.isSafeInteger(value.rows) || value.rows < 1 || value.rows > 10000) { + throw new Error('Invalid resize stream command'); + } + return { id: value.id, action: value.action, counter: value.counter, columns: value.columns, rows: value.rows }; +} + +export class ResizeStreamGate { + private command: StreamCommand | undefined; + private last: StreamPosition | undefined; + private emittedCommand = 0; + private paused = false; + private disposed = false; + private pending: Promise | undefined; + private failure: unknown; + + constructor(file: string, private readonly record: (value: object) => void, + private readonly read: () => Promise = () => readCommand(file)) {} + + refresh(): Promise { + if (this.disposed || this.failure) return Promise.resolve(); + if (this.pending) return this.pending; + this.pending = this.read().then(value => { + if (this.disposed) return; + const command = parseCommand(value); + if (command.id <= (this.command?.id ?? 0)) return; + this.command = command; + this.paused = false; + }).catch(error => { + if (!this.disposed && !(error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT')) this.failure = error; + }).finally(() => { this.pending = undefined; }); + return this.pending; + } + + permits(viewport: Omit): boolean { + if (this.disposed) return false; + if (this.failure) throw this.failure; + void this.refresh(); + const command = this.command; + const last = this.last; + if (!this.paused && command?.action === 'observe' && last && this.emittedCommand === command.id && last.counter > command.counter + && (last.columns !== command.columns || last.rows !== command.rows) + && last.columns === viewport.columns && last.rows === viewport.rows) { + this.paused = true; + this.record({ kind: 'stream-gate', command: command.id, ...last, paused: true }); + } + return !this.paused; + } + + emitted(position: StreamPosition): void { + if (this.disposed) return; + this.last = position; + this.emittedCommand = this.command?.id ?? 0; + this.record({ kind: 'stream-chunk', command: this.command?.id ?? 0, ...position, paused: false }); + } + + async dispose(): Promise { + this.disposed = true; + if (this.pending) await this.pending; + } +} diff --git a/tests/testing/nativeTerminal.test.ts b/tests/testing/nativeTerminal.test.ts index 215eeac1b..546485b5f 100644 --- a/tests/testing/nativeTerminal.test.ts +++ b/tests/testing/nativeTerminal.test.ts @@ -52,6 +52,7 @@ describe('native terminal automation boundary', () => { expect(fixture?.[1]).toContain('write-metadata-${fixture_mode}.jsonl'); expect(fixture?.[1]).toContain('fixture_mode=streaming'); expect(fixture?.[1]).toContain('fixture_mode=idle'); + expect(fixture?.[1]).toContain('AUTOHAND_RESIZE_STREAM_CONTROL='); }); it.each(['idle', 'streaming'])('focuses the existing terminal before each native copy in the isolated %s VS Code profile', async mode => { diff --git a/tests/testing/resizeStreamGate.test.ts b/tests/testing/resizeStreamGate.test.ts new file mode 100644 index 000000000..906cc3974 --- /dev/null +++ b/tests/testing/resizeStreamGate.test.ts @@ -0,0 +1,75 @@ +/** @license Apache-2.0 */ +import { describe, expect, it, vi } from 'vitest'; +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { ResizeStreamGate } from '../../src/testing/scenarios/resizeStreamGate.js'; + +describe('resize stream observation gate', () => { + it('requires a post-command chunk rather than pausing on intermediate drag output', async () => { + const gate = new ResizeStreamGate('unused', vi.fn(), async () => ({ id: 1, action: 'observe', counter: 10, columns: 100, rows: 30 })); + gate.emitted({ counter: 11, columns: 90, rows: 28 }); + await gate.refresh(); + expect(gate.permits({ columns: 90, rows: 28 })).toBe(true); + gate.emitted({ counter: 12, columns: 90, rows: 28 }); + expect(gate.permits({ columns: 90, rows: 28 })).toBe(false); + await gate.dispose(); + }); + + it('bounds control reads before parsing and disposes after a read failure', async () => { + const directory = await mkdtemp(path.join(tmpdir(), 'resize-stream-gate-')); + const file = path.join(directory, 'command.json'); + const gate = new ResizeStreamGate(file, vi.fn()); + try { + await writeFile(file, 'x'.repeat(513)); + await gate.refresh(); + expect(() => gate.permits({ columns: 100, rows: 30 })).toThrow('exceeded 512 bytes'); + } finally { await gate.dispose(); await rm(directory, { recursive: true, force: true }); } + }); + + it('pauses only after a newer chunk was emitted at the resulting viewport and resumes', async () => { + const record = vi.fn(); + let command = { id: 1, action: 'observe', counter: 10, columns: 100, rows: 30 }; + const gate = new ResizeStreamGate('unused', record, async () => command); + await gate.refresh(); + gate.emitted({ counter: 11, columns: 100, rows: 30 }); + expect(gate.permits({ columns: 100, rows: 30 })).toBe(true); + expect(gate.permits({ columns: 90, rows: 28 })).toBe(true); + gate.emitted({ counter: 12, columns: 90, rows: 28 }); + expect(gate.permits({ columns: 90, rows: 28 })).toBe(false); + expect(record).toHaveBeenCalledWith(expect.objectContaining({ kind: 'stream-gate', command: 1, counter: 12, + columns: 90, rows: 28, paused: true })); + await gate.refresh(); + command = { id: 2, action: 'resume', counter: 12, columns: 90, rows: 28 }; + await gate.refresh(); + expect(gate.permits({ columns: 90, rows: 28 })).toBe(true); + gate.emitted({ counter: 13, columns: 90, rows: 28 }); + expect(record).toHaveBeenLastCalledWith(expect.objectContaining({ kind: 'stream-chunk', command: 2, counter: 13 })); + await gate.dispose(); + }); + + it('owns one pending read and suppresses late work after disposal', async () => { + let release: ((command: unknown) => void) | undefined; + const read = vi.fn(() => new Promise(resolve => { release = resolve; })); + const record = vi.fn(); + const gate = new ResizeStreamGate('unused', record, read); + const pending = gate.refresh(); + void gate.refresh(); + expect(read).toHaveBeenCalledTimes(1); + const disposed = gate.dispose(); + release?.({ id: 1, action: 'resume', counter: 0, columns: 100, rows: 30 }); + await pending; + await disposed; + expect(record).not.toHaveBeenCalled(); + expect(gate.permits({ columns: 100, rows: 30 })).toBe(false); + await gate.refresh(); + expect(read).toHaveBeenCalledTimes(1); + }); + + it.each([{}, { id: 40, action: 'resume', counter: 0, columns: 100, rows: 30 }])('fails closed on invalid bounded control %j', async command => { + const gate = new ResizeStreamGate('unused', vi.fn(), async () => command); + await gate.refresh(); + expect(() => gate.permits({ columns: 100, rows: 30 })).toThrow('Invalid resize stream command'); + await gate.dispose(); + }); +}); diff --git a/tests/tuistory/resize-flicker.tuistory.test.ts b/tests/tuistory/resize-flicker.tuistory.test.ts index 1fff41cca..1b50c1e7c 100644 --- a/tests/tuistory/resize-flicker.tuistory.test.ts +++ b/tests/tuistory/resize-flicker.tuistory.test.ts @@ -1,6 +1,6 @@ /** @license Apache-2.0 */ import path from 'node:path'; -import { readFile } from 'node:fs/promises'; +import { readFile, rename, writeFile } from 'node:fs/promises'; import { stripVTControlCharacters } from 'node:util'; import { afterEach, describe, expect, it, vi } from 'vitest'; import type { Session } from 'tuistory'; @@ -15,6 +15,54 @@ let state: TuistoryTempState | undefined; afterEach(async () => { session?.close(); await state?.cleanup(); session = undefined; state = undefined; }); describe('terminal resize frames', () => { + it('gates only new stream chunks after viewport progress and resumes without changing the active request', async () => { + state = await createTempAutohandHome(); + const metadata = path.join(state.autohandHome, 'gate-metadata.jsonl'); + const control = path.join(state.autohandHome, 'stream-control.json'); + const command = async (value: object) => { await writeFile(`${control}.pending`, JSON.stringify(value)); await rename(`${control}.pending`, control); }; + const records = async (): Promise[]> => (await readFile(metadata, 'utf8')).trim().split('\n').map(line => JSON.parse(line)); + session = await launchTuistorySession({ command: 'bun', + args: [path.resolve('src/testing/scenarios/resizeFlickerScenario.tsx'), '--full-screen', '--streaming', '--multiline'], + cwd: process.cwd(), cols: 100, rows: 30, waitForDataTimeout: 15_000, + env: { ...process.env, AUTOHAND_HOME: state.autohandHome, CI: 'false', FORCE_COLOR: '0', + AUTOHAND_RESIZE_WRITE_METADATA: metadata, AUTOHAND_RESIZE_STREAM_CONTROL: control }, + }); + await session.waitForText('RESIZE_STREAM'); + await vi.waitFor(async () => { expect((await records()).some(record => record.kind === 'stream-chunk')).toBe(true); }); + const before = (await records()).filter(record => record.kind === 'stream-chunk').at(-1)!; + await command({ id: 1, action: 'observe', counter: before.counter, columns: 100, rows: 30 }); + await vi.waitFor(async () => { + expect((await records()).some(record => record.kind === 'stream-chunk' && Number(record.counter) > Number(before.counter))).toBe(true); + expect((await records()).some(record => record.kind === 'stream-gate' && record.paused === true)).toBe(false); + }); + session.resize({ cols: 90, rows: 28 }); + let paused = 0; + await vi.waitFor(async () => { + const ack = (await records()).filter(record => record.kind === 'stream-gate' && record.command === 1 && record.paused === true).at(-1); + expect(ack).toMatchObject({ columns: 90, rows: 28 }); + paused = Number(ack?.counter); + expect(paused).toBeGreaterThan(Number(before.counter)); + }); + await session.press('left'); + await session.press('left'); + await session.type('X'); + const frame = await waitForTerminalScreen(session, { timeout: 5_000, + waitFor: screen => screen.includes('taXil') && screen.includes(`RESIZE_STREAM ${paused} retained streamed text`) }); + expect(frame).toContain('Resize streaming fixture'); + expect(frame.match(/Autohand Code/gu)).toHaveLength(1); + expect(frame.match(/RESIZE_HISTORY retained text/gu)).toHaveLength(1); + expect(frame.match(/preserved draft/gu)).toHaveLength(1); + expect((await records()).filter(record => record.kind === 'stream-chunk').at(-1)?.counter).toBe(paused); + await command({ id: 2, action: 'resume', counter: paused, columns: 90, rows: 28 }); + await vi.waitFor(async () => { + expect((await records()).some(record => record.kind === 'stream-chunk' && record.command === 2 && Number(record.counter) > paused)).toBe(true); + }); + await session.press('escape'); + await session.waitForText('RESIZE_STREAM_COMPLETE'); + await exitInteractive(session); + expect(session.exitInfo?.exitCode).toBe(0); + }); + it('records bounded write timing and viewport metadata without storing terminal content', async () => { state = await createTempAutohandHome(); const metadata = path.join(state.autohandHome, 'write-metadata.jsonl'); From 81c1812bebe800aa6e6339a8e20bb287e80c1692 Mon Sep 17 00:00:00 2001 From: Igor Costa Date: Tue, 10 Nov 2026 21:52:17 +1300 Subject: [PATCH 14/19] Preserve composer input across asynchronous command completion Consume submitted input at its existing synchronous boundary and remove obsolete late clears that erased newer drafts or prefills. Verify controlled completion in both TUI layouts and separate fully rendered command drafts from Enter in terminal tests. Co-authored-by: Autohand Evolve --- docs/extension-authoring.md | 2 + src/core/agent/AgentCommandRuntime.ts | 7 -- src/core/agent/AgentLifecycleRunner.ts | 7 +- .../AgentCommandRuntime.slashParsing.test.ts | 17 +++++ .../agent/FeedbackSurveyLifecycle.test.ts | 31 +++++++- tests/tuistory/extensions.tuistory.test.ts | 70 ++++++++++++++++++- tests/tuistory/settings-sync.tuistory.test.ts | 11 ++- 7 files changed, 129 insertions(+), 16 deletions(-) diff --git a/docs/extension-authoring.md b/docs/extension-authoring.md index d0bb80fcb..7735ce364 100644 --- a/docs/extension-authoring.md +++ b/docs/extension-authoring.md @@ -187,6 +187,8 @@ api.commands.register({ Every view receives `close(value?)`, `workspaceRoot`, and the command `args`, plus the properties supplied to `context.ui.open`. Autohand owns modal pause/resume, alternate-screen cleanup, Escape, and Ctrl+C behavior. Components may build menus, dialogs, custom renderers, or editor-like interfaces with normal Ink composition. +Closing a view restores the composer. Finishing, cancelling, or failing the earlier command preserves existing drafts, later typing, and command prefills. + ## Status and help lines Append, hide, or replace line segments through the existing line-extension contract: diff --git a/src/core/agent/AgentCommandRuntime.ts b/src/core/agent/AgentCommandRuntime.ts index 6364e6a4f..966ba91bf 100644 --- a/src/core/agent/AgentCommandRuntime.ts +++ b/src/core/agent/AgentCommandRuntime.ts @@ -114,10 +114,6 @@ const INTERACTIVE_SLASH_COMMANDS = new Set([ const FIXED_COMPOSER_RESULT_COMMANDS = new Set(['/tasks', '/team', '/teams', '/squad']); const MARKDOWN_REPORT_COMMANDS = new Set(['/pr-review', '/deslop', '/tester']); -export function shouldPreserveComposerDraftAfterCommand(command: string): boolean { - return command === '/whatityped' || command === '/peers' || command === '/feedback' || command === '/status' || command === '/sync'; -} - export function shouldRenderSlashCommandResultInComposer(command: string): boolean { return FIXED_COMPOSER_RESULT_COMMANDS.has(command); } @@ -274,9 +270,6 @@ export async function runAgentSlashCommandWithInput(host: AgentCommandRuntimeHos host.persistentInputActiveTurn = false; } cleanupConsoleBridge(); - if (isInteractive && !shouldPreserveComposerDraftAfterCommand(command) && host.inkRenderer?.isRunning()) { - host.inkRenderer.clearInput(); - } } } diff --git a/src/core/agent/AgentLifecycleRunner.ts b/src/core/agent/AgentLifecycleRunner.ts index 96e3d1c26..08d61515f 100644 --- a/src/core/agent/AgentLifecycleRunner.ts +++ b/src/core/agent/AgentLifecycleRunner.ts @@ -50,7 +50,7 @@ import { shouldForceAgentIdleLogout } from './AgentSessionAccounting.js'; import { consumeAgentInkSubmittedInstructionEcho, } from './AgentUIRuntime.js'; -import { renderAgentSlashCommandResult, shouldPreserveComposerDraftAfterCommand } from './AgentCommandRuntime.js'; +import { renderAgentSlashCommandResult } from './AgentCommandRuntime.js'; import { createQueuedAgentInstruction, resolveActiveGoalContinuation, @@ -1960,10 +1960,9 @@ export async function runAgentInteractiveLoop(host: AgentLifecycleHost): Promise host.writeDebugLine?.bind(host) ); if (host.ui || host.inkRenderer) { + // Submission already consumed its text; current input belongs + // to a later draft or a command prefill. host.setComposerIdle(); - if (!shouldPreserveComposerDraftAfterCommand(command)) { - host.clearComposerInput(); - } // Return to the top of the loop so the idle-wait path can await // the next Composer submission without falling through to // instruction.startsWith('/') which would throw on null. diff --git a/tests/core/agent/AgentCommandRuntime.slashParsing.test.ts b/tests/core/agent/AgentCommandRuntime.slashParsing.test.ts index a661b39dc..db441473b 100644 --- a/tests/core/agent/AgentCommandRuntime.slashParsing.test.ts +++ b/tests/core/agent/AgentCommandRuntime.slashParsing.test.ts @@ -138,6 +138,23 @@ describe('renderAgentSlashCommandResult', () => { }); describe('runAgentSlashCommandWithInput', () => { + it.each(['cancel', 'prefill', 'error'] as const)('preserves input owned by the next turn after a modal %s', async outcome => { + let draft = ''; + const host = { + runtime: { options: {}, config: {} }, + inkRenderer: { isRunning: () => true, clearInput: () => { draft = ''; } }, + handleSlashCommand: async () => { + draft = outcome === 'prefill' ? '/model next-provider' : 'next instruction'; + if (outcome === 'error') throw new Error('modal failed'); + return null; + }, + }; + const command = runAgentSlashCommandWithInput(host, '/model', []); + if (outcome === 'error') await expect(command).rejects.toThrow('modal failed'); + else await command; + expect(draft).toBe(outcome === 'prefill' ? '/model next-provider' : 'next instruction'); + }); + it('preserves text entered as the composer returns from status', async () => { let draft = '/status'; const host = { diff --git a/tests/core/agent/FeedbackSurveyLifecycle.test.ts b/tests/core/agent/FeedbackSurveyLifecycle.test.ts index 787d1fec5..e25f0cdc2 100644 --- a/tests/core/agent/FeedbackSurveyLifecycle.test.ts +++ b/tests/core/agent/FeedbackSurveyLifecycle.test.ts @@ -96,9 +96,9 @@ describe('feedback survey in the interactive loop', () => { expect(host.closeSession).toHaveBeenCalledOnce(); }); - it('preserves a draft typed after the feedback survey becomes visible', async () => { + it.each(['/feedback', '/deploy', '/help'])('preserves a draft typed while %s finishes', async command => { let draft = ''; - const { host } = createHost(['/feedback'], { + const { host } = createHost([command], { ui: {}, setComposerIdle: vi.fn(), clearComposerInput: vi.fn(() => { draft = ''; }), @@ -114,6 +114,33 @@ describe('feedback survey in the interactive loop', () => { expect(draft).toBe('still typing'); }); + it.each(['/deploy', '/help'])('preserves a newer same-text draft when %s finishes', async command => { + let draft = ''; + const { host } = createHost([command], { + ui: {}, + setComposerIdle: vi.fn(), + clearComposerInput: vi.fn(() => { draft = ''; }), + runSlashCommandWithInput: vi.fn(async () => { + await Promise.resolve(); + draft = command; + host.shouldExit = true; + return null; + }), + }); + await runAgentInteractiveLoop(host); + expect(draft).toBe(command); + }); + + it('preserves a pre-existing draft when a keybinding dispatches a command', async () => { + let draft = 'draft before Ctrl+K'; + const { host } = createHost(['/deploy'], { + ui: {}, setComposerIdle: vi.fn(), clearComposerInput: vi.fn(() => { draft = ''; }), + runSlashCommandWithInput: vi.fn(async () => { host.shouldExit = true; return null; }), + }); + await runAgentInteractiveLoop(host); + expect(draft).toBe('draft before Ctrl+K'); + }); + it('clears the previous survey as soon as the next prompt is submitted', async () => { const { host, events } = createHost(['first prompt', 'second prompt']); diff --git a/tests/tuistory/extensions.tuistory.test.ts b/tests/tuistory/extensions.tuistory.test.ts index 1480e4f19..c1ea0b682 100644 --- a/tests/tuistory/extensions.tuistory.test.ts +++ b/tests/tuistory/extensions.tuistory.test.ts @@ -5,7 +5,7 @@ */ import fs from 'fs-extra'; import path from 'node:path'; -import { afterEach, describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import type { Session } from 'tuistory'; import { createTempAutohandHome, @@ -24,6 +24,7 @@ import { driveExtensionBuilderAuthoring, } from '../../src/testing/scenarios/extensionBuilderAuthoringDemo.js'; import { showDocumentFactoryInstruction } from '../../src/testing/scenarios/documentFactoryScenario.js'; +import { waitForTerminalScreen } from '../../src/testing/drivers/tuistory-driver.js'; const EXAMPLE_IDS = [ 'autohand.code-health', @@ -270,6 +271,64 @@ describe('built extensions CLI Tuistory E2E', () => { await exitInteractive(session); }, 90_000); + it.each([false, true].flatMap(fullScreenMode => (['existing', 'different', 'same'] as const) + .map(draftKind => ({ fullScreenMode, draftKind }))))('preserves $draftKind composer input through controlled slash completion (full screen: $fullScreenMode)', async ({ fullScreenMode, draftKind }) => { + const state = await createTempAutohandHome({ config: { features: { fullScreenMode } } }); + tempStates.push(state); + const source = path.join(state.workspaceRoot, 'completion-gate'); + const started = path.join(state.workspaceRoot, 'command-started'); + const release = path.join(state.workspaceRoot, 'command-release'); + await fs.outputJson(path.join(source, 'autohand.extension.json'), { + schemaVersion: 1, extensionApi: 1, id: 'autohand.completion-gate', name: 'Completion gate', + version: '1.0.0', description: 'Controlled slash completion fixture.', contributes: { runtime: ['dist/extension.mjs'] }, + }); + await fs.outputFile(path.join(source, 'dist/extension.mjs'), ` +import fs from 'node:fs'; +export function activate(api) { + api.commands.register({command:'/draft-gate', description:'Controlled completion', execute:async () => { + await new Promise(resolve => { + const check=()=>{if(fs.existsSync(${JSON.stringify(release)})){watcher.close();resolve();}}; + const watcher=fs.watch(${JSON.stringify(state.workspaceRoot)},check); + fs.writeFileSync(${JSON.stringify(started)},'started');check(); + }); + return 'GATED_COMMAND_FINISHED'; + }}); + api.keybindings.register({key:'ctrl+k', command:'/draft-gate', when:'always'}); + api.ui.setStatusLine({segments:[{id:'gate-ready',text:'gate:ready'}]}); +} +`); + const installation = await runBuiltCommand(state, ['extensions', 'install', source, '--trust']); + expect(installation.exitCode, installation.output).toBe(0); + const session = await launchBuiltAutohand(['--path', state.workspaceRoot, '--config', state.configPath], { + autohandHome: state.autohandHome, cwd: state.workspaceRoot, + }); + sessions.push(session); + const showsDraft = (screen: string, draft: string) => screen.split('\n') + .map(line => line.trimStart()).filter(line => line.startsWith('❯ ')).at(-1) === `❯ ${draft}`; + try { + await session.waitForText('gate:ready', { timeout: 20_000 }); + await session.type('draft before keybinding'); + await waitForTerminalScreen(session, { timeout: 10_000, waitFor: screen => showsDraft(screen, 'draft before keybinding') }); + await session.press(['ctrl', 'k']); + await vi.waitFor(async () => expect(await fs.readFile(started, 'utf8')).toBe('started'), { timeout: 10_000 }); + await waitForTerminalScreen(session, { timeout: 10_000, waitFor: screen => showsDraft(screen, 'draft before keybinding') }); + const draft = draftKind === 'existing' ? 'draft before keybinding' : draftKind === 'same' ? '/draft-gate' : 'new draft during command'; + if (draftKind !== 'existing') { + await session.press(['ctrl', 'c']); + await session.type(draft); + await waitForTerminalScreen(session, { timeout: 10_000, waitFor: screen => showsDraft(screen, draft) }); + } + expect(session.getRawOutput()).not.toContain('GATED_COMMAND_FINISHED'); + await fs.writeFile(release, 'ready'); + await session.waitForText('GATED_COMMAND_FINISHED', { timeout: 10_000 }); + await waitForTerminalScreen(session, { timeout: 10_000, waitFor: screen => showsDraft(screen, draft) }); + await exitInteractive(session); + expect(session.exitInfo?.exitCode).toBe(0); + } finally { + await fs.writeFile(release, 'ready'); + } + }); + it.each([false, true])('runs trusted slash commands, Ink views, line segments, flags, and keybindings (full screen: %s)', async (fullScreenMode) => { const state = await createTempAutohandHome({ config: { features: { fullScreenMode } } }); tempStates.push(state); @@ -311,8 +370,15 @@ describe('built extensions CLI Tuistory E2E', () => { await session.press(['ctrl', 'k']); await session.waitForText('Target: quality-assurance', { timeout: 10_000 }); await session.press('escape'); + await waitForTerminalScreen(session, { + timeout: 10_000, + waitFor: screen => screen.includes('❯ Plan, search, build anything') + && !screen.includes('Target: quality-assurance'), + }); - await session.type('/extensions disable autohand.runtime-showcase'); + const disableCommand = '/extensions disable autohand.runtime-showcase'; + await session.type(disableCommand); + await waitForTerminalScreen(session, { timeout: 10_000, waitFor: screen => screen.includes(`❯ ${disableCommand}`) }); await session.press('enter'); await session.waitForText('Disabled autohand.runtime-showcase', { timeout: 10_000 }); await session.type('/deploy'); diff --git a/tests/tuistory/settings-sync.tuistory.test.ts b/tests/tuistory/settings-sync.tuistory.test.ts index 5e3a458bd..5c32c767c 100644 --- a/tests/tuistory/settings-sync.tuistory.test.ts +++ b/tests/tuistory/settings-sync.tuistory.test.ts @@ -96,7 +96,16 @@ describe('built settings sync health', () => { timeout: 5000, waitFor: screen => screen.includes('Completed in') && screen.includes('Plan, search, build anything'), }); - await session.type(command); await session.press('enter'); + await session.type(command); + // Keep Enter separate from command text when the CLI reader is descheduled. + await waitForTerminalScreen(session, { + timeout: 5000, + waitFor: screen => screen.split('\n') + .map(line => line.trim()) + .filter(line => line.startsWith('❯ ')) + .at(-1) === `❯ ${command}`, + }); + await session.press('enter'); await session.waitForText(command === '/status' ? 'Settings:' : 'Settings Sync:'); await session.waitForText('Download failed: 404'); await delay(3000); From 31c38ad2476ec96a885707aa82e3f73f621c5ba2 Mon Sep 17 00:00:00 2001 From: Igor Costa Date: Tue, 10 Nov 2026 22:28:41 +1300 Subject: [PATCH 15/19] Document composer ownership in the combined CLI integration Record how asynchronous command completion preserves newer composer drafts alongside the existing terminal, provider, sync, and session improvements. Co-authored-by: Autohand Evolve --- docs/maintenance/cli-pr-integration-2026-11-10.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/maintenance/cli-pr-integration-2026-11-10.md b/docs/maintenance/cli-pr-integration-2026-11-10.md index b45ddf82d..3c62ca5fb 100644 --- a/docs/maintenance/cli-pr-integration-2026-11-10.md +++ b/docs/maintenance/cli-pr-integration-2026-11-10.md @@ -2,7 +2,7 @@ This candidate combines the remaining Igor-authored work from [#745](https://github.com/autohandai/code-cli/pull/745), [#743](https://github.com/autohandai/code-cli/pull/743), [#742](https://github.com/autohandai/code-cli/pull/742), [#727](https://github.com/autohandai/code-cli/pull/727), [#690](https://github.com/autohandai/code-cli/pull/690), [#669](https://github.com/autohandai/code-cli/pull/669), and [#747](https://github.com/autohandai/code-cli/pull/747), together with the saved-session input fix from `df44f764`. -The integration starts from main at `150be8938f3bacd4264f0622e88dcffc465bfc8f`. It preserves the intervening explicit Git-initialization change, atomic configuration writes, partial cloud-history protection, extension reload coordination, and permission enforcement. The first validation revision was paused when #747 was added to the requested scope; the expanded revision receives fresh combined checks. +The integration starts from main at `150be8938f3bacd4264f0622e88dcffc465bfc8f`. It preserves the intervening explicit Git-initialization change, atomic configuration writes, partial cloud-history protection, extension reload coordination, and permission enforcement. The combined revision receives fresh tests, lint, full proof, and terminal compatibility checks before merge. | Area | Resulting behavior | Reference | | --- | --- | --- | @@ -13,6 +13,7 @@ The integration starts from main at `150be8938f3bacd4264f0622e88dcffc465bfc8f`. | Command output | Cap model observations while preserving complete oversized output in private session artifacts; streamed and nested command paths use the same cap. | [Configuration reference](../config-reference.md) | | Computer Use | Temporarily enter AUTO for the computer task and restore the preceding mode, preserving subsequent manual selections, explicit denials, hooks, and restricted mode. | [Computer control](../computer-control.md) | | Saved sessions | Accept composer edits while restoring history and defer submitted work until restoration completes. | [Resume terminal tests](../../tests/tuistory/resume.tuistory.test.ts) | +| Composer ownership | Keep an existing or newly typed draft when an older slash command or extension view completes, cancels, or fails. Input submission is consumed at dispatch, so delayed completion cannot clear a later draft. | [Extension authoring](../extension-authoring.md) | | CLI contract | Bootstrap the current 199 option definitions across 52 command paths, and compare future changes with the committed main snapshot. | [Option contract](../compatibility/claude-code/README.md) | | macOS distribution | Check transported CLI and trace binaries for notarization in addition to existing signature checks. | [Release workflow](../../.github/workflows/release.yml) | | Providers | Keep the active provider when removing an inactive custom entry; preserve it if replacement is cancelled. Cloud restoration clears previous local transport settings. Bound OpenAI-family native tool requests, including delegated agents, and use compatible Azure reasoning parameters. | [Provider reference](../providers.md) | From cc24d92bda6f523e469eb3efe07ff881cdb48380 Mon Sep 17 00:00:00 2001 From: Igor Costa Date: Tue, 10 Nov 2026 22:55:37 +1300 Subject: [PATCH 16/19] Track late terminal reflow with authoritative observation state Refresh the paused producer checkpoint when a later TTY size arrives. Verify a bounded atomic state snapshot around native copying and require an emitted post-resume chunk, keeping the original settling deadline and raw screen assertions. Co-authored-by: Autohand Evolve --- .github/workflows/terminal-compatibility.yml | 2 +- docs/terminal-compatibility.md | 2 +- .../TerminalCompatibilityUITests.swift | 122 ++++++++++++------ src/testing/scenarios/resizeStreamGate.ts | 29 ++++- tests/testing/resizeStreamGate.test.ts | 26 +++- .../tuistory/resize-flicker.tuistory.test.ts | 21 ++- 6 files changed, 152 insertions(+), 50 deletions(-) diff --git a/.github/workflows/terminal-compatibility.yml b/.github/workflows/terminal-compatibility.yml index eaf92f5bf..78fa2723d 100644 --- a/.github/workflows/terminal-compatibility.yml +++ b/.github/workflows/terminal-compatibility.yml @@ -123,7 +123,7 @@ jobs: .artifacts/native-terminal/**/write-metadata-*.jsonl .artifacts/native-terminal/**/write-metadata-*.jsonl.pending .artifacts/native-terminal/**/copy-samples-*.jsonl - .artifacts/native-terminal/**/stream-control-*.json + .artifacts/native-terminal/**/stream-control-*.json* .artifacts/native-terminal/**/vscode-profile/**/settings.json .artifacts/native-terminal/**/vscode-profile/**/keybindings.json if-no-files-found: error diff --git a/docs/terminal-compatibility.md b/docs/terminal-compatibility.md index 9884d3820..a18d1052d 100644 --- a/docs/terminal-compatibility.md +++ b/docs/terminal-compatibility.md @@ -47,7 +47,7 @@ VS Code's authoritative readout uses its native terminal buffer copy. Hosted rec Native-copy diagnostics retain each attempt's phase, timestamps, pasteboard generations and types, raw text, availability, and native window identity and geometry. A bounded tail of 128 records and 2 MiB is persisted after each attempt, with explicit eviction counts, so late resize failures remain inspectable even if the application exits. The fixture also writes asynchronous, bounded write metadata: sequence, viewport, stream counter and bytes before stdout delegation, then separate return/backpressure and callback-completion timestamps. Metadata-only observation forwards the original string or binary chunk and its encoding unchanged. That metadata contains no terminal output, never waits in the producer, and retains the latest records within 1 MiB. The workflow retains both the committed metadata file and any in-flight `.pending` file, alongside copy samples and XCTest evidence. Diagnostic instrumentation does not establish that an intermittent failure is repaired. -VS Code's streaming scenario keeps response chunks flowing through each physical resize. After the gesture, a disposable control file (512 bytes maximum, command IDs 1–8) requests observation. Only after a newer post-command chunk at changed/current TTY dimensions does the fixture gate new response chunks. The request and working spinner stay active; no renderer operation is triggered. A fresh native copy must contain that exact paused counter and all original markers before unchanged exact-count assertions run. Resuming and proving a newer chunk at the same viewport share the original single 10-second settling budget with acknowledgement and copying, including after the final resize. Asynchronous control reads are single-flight and closed on every path; disposal suppresses late work. Native metadata reads are capped at 16 KiB. Unused Code predicate objects are omitted to avoid implicit accessibility debug snapshots during polling; explicit failure evidence remains. +VS Code's streaming scenario keeps response chunks flowing through each physical resize. After the gesture, a disposable control file (512 bytes maximum, command IDs 1–8) requests observation. Only after a newer post-command chunk at changed/current TTY dimensions does the fixture gate new response chunks. A late TTY resize reopens the producer until it emits at the new dimensions and refreshes the same observation command. The request and working spinner stay active; no renderer operation is triggered. A separate atomic current-state snapshot (512 bytes maximum) records command, emitted-command, revision, counter, dimensions and pause state. Native checks that state before and after each fresh copy, independently of diagnostic-history eviction. The copy must contain the exact paused counter and all original markers before unchanged exact-count assertions run. Resuming and proving a newer emitted chunk at the same viewport share the original single 10-second settling budget with acknowledgement and copying, including after the final resize. Screenshots follow resumed-progress proof. Asynchronous control reads are single-flight and closed on every path; disposal suppresses late work and drains pending state writes. Unused Code predicate objects are omitted to avoid implicit accessibility debug snapshots during polling; explicit failure evidence remains. The strict visible-history assertions require room for both transcript exchanges and the multiline composer. At a compact 57×19 terminal, the full-screen transcript correctly follows its newest exchange and clips older rows; a separate PTY case checks that the history reappears on growth, the draft and cursor survive, and Ctrl+C exits cleanly. This distinguishes viewport clipping from lost transcript data. Standalone terminal references bind to their accessibility identities. VS Code refreshes its accessibility window before setup steps and after every setup or scenario gesture, guarded by the same native process ID, window ID, single-window count, and current frame. diff --git a/src/testing/drivers/native-terminal-lab/TerminalCompatibilityUITests.swift b/src/testing/drivers/native-terminal-lab/TerminalCompatibilityUITests.swift index ca3f32ff0..1d93859dc 100644 --- a/src/testing/drivers/native-terminal-lab/TerminalCompatibilityUITests.swift +++ b/src/testing/drivers/native-terminal-lab/TerminalCompatibilityUITests.swift @@ -74,9 +74,11 @@ private struct NativeCopySample: Encodable { let frame: CGRect } -private struct NativeStreamPosition: Codable { +private struct NativeStreamPosition: Codable, Equatable { let kind: String + var revision: Int? = nil var command: Int? = nil + var emittedCommand: Int? = nil var counter: Int? = nil var columns: Int? = nil var rows: Int? = nil @@ -92,30 +94,29 @@ private struct NativeStreamCommand: Encodable { } private func nativeStreamPositions(_ data: Data) -> [NativeStreamPosition] { - String(decoding: data, as: UTF8.self).split(separator: "\n").compactMap { line in - guard let position = try? JSONDecoder().decode(NativeStreamPosition.self, from: Data(line.utf8)), - position.kind == "stream-chunk" || position.kind == "stream-gate", - let counter = position.counter, counter > 0, - let columns = position.columns, columns > 0, columns <= 10000, - let rows = position.rows, rows > 0, rows <= 10000 else { return nil } - return position - } + guard data.count <= 512, let position = try? JSONDecoder().decode(NativeStreamPosition.self, from: data), + position.kind == "stream-state", let revision = position.revision, revision > 0, + let command = position.command, (0...8).contains(command), position.paused != nil, + let emittedCommand = position.emittedCommand, (0...8).contains(emittedCommand), + let counter = position.counter, counter > 0, + let columns = position.columns, columns > 0, columns <= 10000, + let rows = position.rows, rows > 0, rows <= 10000 else { return [] } + return [position] } private func nativeStreamPaused(_ positions: [NativeStreamPosition], command: Int, before: NativeStreamPosition) -> NativeStreamPosition? { - positions.last { position in - position.kind == "stream-gate" && position.command == command && position.paused == true - && (position.counter ?? 0) > (before.counter ?? 0) - && (position.columns != before.columns || position.rows != before.rows) - } + guard let position = positions.last, position.kind == "stream-state", position.command == command, + position.emittedCommand == command, position.paused == true, + (position.counter ?? 0) > (before.counter ?? 0), + position.columns != before.columns || position.rows != before.rows else { return nil } + return position } private func nativeStreamResumed(_ positions: [NativeStreamPosition], command: Int, after: NativeStreamPosition) -> Bool { - positions.contains { position in - position.kind == "stream-chunk" && position.command == command && position.paused == false - && (position.counter ?? 0) > (after.counter ?? 0) - && position.columns == after.columns && position.rows == after.rows - } + guard let position = positions.last else { return false } + return position.kind == "stream-state" && position.command == command && position.emittedCommand == command && position.paused == false + && (position.counter ?? 0) > (after.counter ?? 0) + && position.columns == after.columns && position.rows == after.rows } private func nativeSettlingTime(_ deadline: Date, now: Date = Date()) -> TimeInterval { @@ -132,10 +133,12 @@ private struct NativeTerminalReadout { self.requiresStreaming = requiresStreaming } - mutating func capture(streamCounter: Int? = nil) -> Bool { + mutating func capture(streamCounter: Int? = nil, checkpoint: NativeStreamPosition? = nil, + currentState: (() -> NativeStreamPosition?)? = nil) -> Bool { frame = read() + if let checkpoint, currentState?() != checkpoint { return false } guard let value = frame else { return false } - if let counter = streamCounter, !value.contains("RESIZE_STREAM \(counter) retained streamed text") { return false } + if let counter = checkpoint?.counter ?? streamCounter, !value.contains("RESIZE_STREAM \(counter) retained streamed text") { return false } return value.contains("Autohand Code") && value.contains("RESIZE_HISTORY retained text") && value.contains("preserved draft") && (!requiresStreaming || value.contains("RESIZE_STREAM ")) } @@ -219,17 +222,45 @@ final class TerminalCompatibilityUITests: XCTestCase { } func testNativeReadoutGateRequiresViewportProgressAndANewerResumedChunk() { - let before = NativeStreamPosition(kind: "stream-chunk", command: 0, counter: 10, columns: 100, rows: 30, paused: false) - let unchanged = NativeStreamPosition(kind: "stream-gate", command: 1, counter: 11, columns: 100, rows: 30, paused: true) - let stale = NativeStreamPosition(kind: "stream-gate", command: 1, counter: 10, columns: 90, rows: 28, paused: true) - let paused = NativeStreamPosition(kind: "stream-gate", command: 1, counter: 12, columns: 90, rows: 28, paused: true) + let before = NativeStreamPosition(kind: "stream-state", command: 0, emittedCommand: 0, counter: 10, columns: 100, rows: 30, paused: false) + let unchanged = NativeStreamPosition(kind: "stream-state", command: 1, emittedCommand: 1, counter: 11, columns: 100, rows: 30, paused: true) + let stale = NativeStreamPosition(kind: "stream-state", command: 1, emittedCommand: 1, counter: 10, columns: 90, rows: 28, paused: true) + let paused = NativeStreamPosition(kind: "stream-state", command: 1, emittedCommand: 1, counter: 12, columns: 90, rows: 28, paused: true) XCTAssertNil(nativeStreamPaused([unchanged, stale], command: 1, before: before)) XCTAssertNotNil(nativeStreamPaused([paused], command: 1, before: before)) XCTAssertFalse(nativeStreamResumed([paused], command: 2, after: paused)) - let resumed = NativeStreamPosition(kind: "stream-chunk", command: 2, counter: 13, columns: 90, rows: 28, paused: false) + let resumed = NativeStreamPosition(kind: "stream-state", command: 2, emittedCommand: 2, counter: 13, columns: 90, rows: 28, paused: false) XCTAssertTrue(nativeStreamResumed([resumed], command: 2, after: paused)) } + func testNativeReadoutGateNeverFallsBackToAnOlderAckAfterANewerInvalidViewport() { + let before = NativeStreamPosition(kind: "stream-state", command: 2, emittedCommand: 2, counter: 166, columns: 93, rows: 27, paused: false) + let old = NativeStreamPosition(kind: "stream-state", command: 3, emittedCommand: 3, counter: 180, columns: 97, rows: 28, paused: true) + let newer = NativeStreamPosition(kind: "stream-state", command: 3, emittedCommand: 3, counter: 181, columns: 93, rows: 27, paused: true) + XCTAssertNil(nativeStreamPaused([old, newer], command: 3, before: before)) + XCTAssertNil(nativeStreamPaused([], command: 3, before: before)) + } + + func testNativeReadoutGateRejectsStateChangesDuringFreshCopyAndRequiresPostResumeEmission() { + let old = NativeStreamPosition(kind: "stream-state", revision: 5, command: 3, emittedCommand: 3, + counter: 180, columns: 97, rows: 28, paused: true) + let updated = NativeStreamPosition(kind: "stream-state", revision: 6, command: 3, emittedCommand: 3, + counter: 181, columns: 128, rows: 39, paused: true) + var state: NativeStreamPosition? = old + var readout = NativeTerminalReadout(read: { + state = updated + return "Autohand Code\nRESIZE_HISTORY retained text\npreserved draft\nRESIZE_STREAM 180 retained streamed text" + }, requiresStreaming: true) + XCTAssertFalse(readout.capture(checkpoint: old, currentState: { state })) + XCTAssertFalse(readout.capture(checkpoint: old, currentState: { nil })) + let admitted = NativeStreamPosition(kind: "stream-state", revision: 7, command: 4, emittedCommand: 3, + counter: 182, columns: 128, rows: 39, paused: false) + XCTAssertFalse(nativeStreamResumed([admitted], command: 4, after: updated)) + let emitted = NativeStreamPosition(kind: "stream-state", revision: 8, command: 4, emittedCommand: 4, + counter: 183, columns: 128, rows: 39, paused: false) + XCTAssertTrue(nativeStreamResumed([emitted], command: 4, after: updated)) + } + func testNativeReadoutGateCountsTheExactPausedFrameWithoutSelectingCleanCounts() { var value = "Autohand Code\nRESIZE_HISTORY retained text\npreserved draft\nRESIZE_STREAM 11 retained streamed text" var readout = NativeTerminalReadout(read: { value }, requiresStreaming: true) @@ -242,11 +273,13 @@ final class TerminalCompatibilityUITests: XCTestCase { XCTAssertEqual(nativeSettlingTime(deadline, now: Date(timeIntervalSince1970: 101)), 0) } - func testNativeReadoutGateParsesOnlyCompleteValidMetadataTailRecords() { - let tail = Data("partial row\n{\"kind\":\"write-start\",\"columns\":90,\"rows\":28}\n{\"kind\":\"stream-chunk\",\"command\":2,\"counter\":13,\"columns\":90,\"rows\":28,\"paused\":false}\n".utf8) + func testNativeReadoutGateParsesOnlyCompleteBoundedAuthoritativeSnapshots() { + let tail = Data("{\"kind\":\"stream-state\",\"revision\":5,\"command\":2,\"emittedCommand\":2,\"counter\":13,\"columns\":90,\"rows\":28,\"paused\":false}".utf8) let positions = nativeStreamPositions(tail) XCTAssertEqual(positions.count, 1) XCTAssertEqual(positions.first?.counter, 13) + XCTAssertTrue(nativeStreamPositions(Data(repeating: 65, count: 513)).isEmpty) + XCTAssertTrue(nativeStreamPositions(Data("partial row".utf8)).isEmpty) } func testNativeReadoutEvidenceRetainsPartialUnavailableAndCompleteSamplesUnchanged() throws { @@ -457,8 +490,8 @@ final class TerminalCompatibilityUITests: XCTestCase { let command = "'" + fixture.replacingOccurrences(of: "'", with: "'\\''") + "'" + (streaming ? " --streaming" : "") let gated = terminal == "vscode" && streaming let fixtureDirectory = URL(fileURLWithPath: fixture).deletingLastPathComponent() - let streamMetadata = fixtureDirectory.appendingPathComponent("write-metadata-streaming.jsonl") let streamControl = fixtureDirectory.appendingPathComponent("stream-control-streaming.json") + let streamState = streamControl.appendingPathExtension("state.json") let modes = try JSONDecoder().decode([String: [String]].self, from: arguments) guard let launchArguments = modes[streaming ? "streaming" : "idle"] else { XCTFail("Missing isolated launch arguments for the native scenario") @@ -570,7 +603,7 @@ final class TerminalCompatibilityUITests: XCTestCase { let ready = NSPredicate { _, _ in startupValue = readTerminal() ?? "" return startupValue.contains("RESIZE_HISTORY retained text") - && (!gated || self.streamPositions(streamMetadata).contains { $0.kind == "stream-chunk" }) + && (!gated || !self.streamPositions(streamState).isEmpty) } guard XCTWaiter.wait(for: [XCTNSPredicateExpectation(predicate: ready, object: terminal == "vscode" ? nil : app)], timeout: 30) == .completed else { let text = XCTAttachment(string: String(startupValue.suffix(16_000))) @@ -599,7 +632,7 @@ final class TerminalCompatibilityUITests: XCTestCase { } let frame = window.frame let corner = window.coordinate(withNormalizedOffset: CGVector(dx: 1, dy: 1)).withOffset(CGVector(dx: -2, dy: -2)) - let streamBefore = gated ? streamPositions(streamMetadata).last { $0.kind == "stream-chunk" } : nil + let streamBefore = gated ? streamPositions(streamState).last : nil guard !gated || streamBefore != nil else { XCTFail("The streaming fixture did not publish its pre-gesture position") return @@ -616,11 +649,15 @@ final class TerminalCompatibilityUITests: XCTestCase { var paused: NativeStreamPosition? var readout = NativeTerminalReadout(read: { readTerminal() }, requiresStreaming: streaming) let settled = NSPredicate { _, _ in - if paused == nil, let before = streamBefore { - guard let position = nativeStreamPaused(self.streamPositions(streamMetadata), command: index * 2 + 1, before: before) else { return false } + if let before = streamBefore { + guard let position = nativeStreamPaused(self.streamPositions(streamState), command: index * 2 + 1, before: before) else { + paused = nil + return false + } paused = position + return readout.capture(checkpoint: position, currentState: { self.streamPositions(streamState).last }) } - return readout.capture(streamCounter: paused?.counter) + return readout.capture() } guard XCTWaiter.wait(for: [XCTNSPredicateExpectation(predicate: settled, object: terminal == "vscode" ? nil : window)], timeout: gated ? nativeSettlingTime(settlingDeadline) : 10) == .completed, @@ -637,14 +674,10 @@ final class TerminalCompatibilityUITests: XCTestCase { XCTAssertEqual(content.components(separatedBy: "RESIZE_HISTORY retained text").count - 1, 1, content) XCTAssertEqual(content.components(separatedBy: "preserved draft").count - 1, 1, content) if streaming { XCTAssertTrue(content.contains("RESIZE_STREAM "), content) } - let image = XCTAttachment(screenshot: window.screenshot()) - image.name = "\(terminal)-\(streaming ? "streaming" : "idle")-\(index)" - image.lifetime = .keepAlways - add(image) if let paused { guard writeStreamCommand(streamControl, id: index * 2 + 2, action: "resume", position: paused) else { return } let resumed = NSPredicate { _, _ in - nativeStreamResumed(self.streamPositions(streamMetadata), command: index * 2 + 2, after: paused) + nativeStreamResumed(self.streamPositions(streamState), command: index * 2 + 2, after: paused) } let remaining = nativeSettlingTime(settlingDeadline) guard remaining > 0, @@ -653,6 +686,14 @@ final class TerminalCompatibilityUITests: XCTestCase { return } } + if let identity = nativeIdentity { + guard let current = currentCodeWindow(app, identity: identity) else { return } + window = current + } + let image = XCTAttachment(screenshot: window.screenshot()) + image.name = "\(terminal)-\(streaming ? "streaming" : "idle")-\(index)" + image.lifetime = .keepAlways + add(image) } app.typeKey(.leftArrow, modifierFlags: []) app.typeKey(.leftArrow, modifierFlags: []) @@ -813,8 +854,7 @@ final class TerminalCompatibilityUITests: XCTestCase { private func streamPositions(_ file: URL) -> [NativeStreamPosition] { guard let handle = try? FileHandle(forReadingFrom: file) else { return [] } defer { try? handle.close() } - guard let end = try? handle.seekToEnd(), (try? handle.seek(toOffset: end > 16_384 ? end - 16_384 : 0)) != nil, - let data = try? handle.read(upToCount: 16_384) else { return [] } + guard let data = try? handle.read(upToCount: 513), data.count <= 512 else { return [] } return nativeStreamPositions(data) } diff --git a/src/testing/scenarios/resizeStreamGate.ts b/src/testing/scenarios/resizeStreamGate.ts index d56815450..426ae8ab1 100644 --- a/src/testing/scenarios/resizeStreamGate.ts +++ b/src/testing/scenarios/resizeStreamGate.ts @@ -1,5 +1,6 @@ /** @license Apache-2.0 */ -import { open } from 'node:fs/promises'; +import { open, rename, writeFile } from 'node:fs/promises'; +import { createResizeWriteMetadata } from './resizeWriteMetadata.js'; interface StreamPosition { counter: number; columns: number; rows: number } interface StreamCommand extends StreamPosition { id: number; action: 'observe' | 'resume' } @@ -34,9 +35,25 @@ export class ResizeStreamGate { private disposed = false; private pending: Promise | undefined; private failure: unknown; + private revision = 0; + private readonly state: ReturnType; constructor(file: string, private readonly record: (value: object) => void, - private readonly read: () => Promise = () => readCommand(file)) {} + private readonly read: () => Promise = () => readCommand(file), persist?: (content: string) => Promise) { + const stateFile = `${file}.state.json`; + this.state = createResizeWriteMetadata(stateFile, { maxRecords: 1, maxBytes: 512, + persist: persist ?? (async content => { + await writeFile(`${stateFile}.pending`, content.split('\n', 1)[0] ?? ''); + await rename(`${stateFile}.pending`, stateFile); + }), + }); + } + + private publishState(): void { + if (!this.last) return; + this.state.record({ kind: 'stream-state', command: this.command?.id ?? 0, revision: ++this.revision, + emittedCommand: this.emittedCommand, ...this.last, paused: this.paused }); + } refresh(): Promise { if (this.disposed || this.failure) return Promise.resolve(); @@ -47,6 +64,7 @@ export class ResizeStreamGate { if (command.id <= (this.command?.id ?? 0)) return; this.command = command; this.paused = false; + this.publishState(); }).catch(error => { if (!this.disposed && !(error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT')) this.failure = error; }).finally(() => { this.pending = undefined; }); @@ -59,11 +77,16 @@ export class ResizeStreamGate { void this.refresh(); const command = this.command; const last = this.last; + if (this.paused && last && (last.columns !== viewport.columns || last.rows !== viewport.rows)) { + this.paused = false; + this.publishState(); + } if (!this.paused && command?.action === 'observe' && last && this.emittedCommand === command.id && last.counter > command.counter && (last.columns !== command.columns || last.rows !== command.rows) && last.columns === viewport.columns && last.rows === viewport.rows) { this.paused = true; this.record({ kind: 'stream-gate', command: command.id, ...last, paused: true }); + this.publishState(); } return !this.paused; } @@ -73,10 +96,12 @@ export class ResizeStreamGate { this.last = position; this.emittedCommand = this.command?.id ?? 0; this.record({ kind: 'stream-chunk', command: this.command?.id ?? 0, ...position, paused: false }); + this.publishState(); } async dispose(): Promise { this.disposed = true; if (this.pending) await this.pending; + await this.state.flush(); } } diff --git a/tests/testing/resizeStreamGate.test.ts b/tests/testing/resizeStreamGate.test.ts index 906cc3974..7be4c1e7b 100644 --- a/tests/testing/resizeStreamGate.test.ts +++ b/tests/testing/resizeStreamGate.test.ts @@ -6,8 +6,26 @@ import path from 'node:path'; import { ResizeStreamGate } from '../../src/testing/scenarios/resizeStreamGate.js'; describe('resize stream observation gate', () => { + it('reopens on a late viewport change and refreshes the same observation acknowledgement', async () => { + const record = vi.fn(); + const persist = vi.fn(async (_content: string) => {}); + const gate = new ResizeStreamGate('unused', record, async () => ({ id: 3, action: 'observe', counter: 166, columns: 93, rows: 27 }), persist); + await gate.refresh(); + gate.emitted({ counter: 180, columns: 97, rows: 28 }); + expect(gate.permits({ columns: 97, rows: 28 })).toBe(false); + expect(gate.permits({ columns: 128, rows: 39 })).toBe(true); + gate.emitted({ counter: 181, columns: 128, rows: 39 }); + expect(gate.permits({ columns: 128, rows: 39 })).toBe(false); + expect(record).toHaveBeenLastCalledWith(expect.objectContaining({ kind: 'stream-gate', command: 3, counter: 181, + columns: 128, rows: 39, paused: true })); + await gate.dispose(); + const snapshot: unknown = JSON.parse(persist.mock.calls.at(-1)?.[0].split('\n')[0] ?? '{}'); + expect(snapshot).toMatchObject({ kind: 'stream-state', command: 3, counter: 181, columns: 128, rows: 39, + paused: true, revision: expect.any(Number) }); + }); + it('requires a post-command chunk rather than pausing on intermediate drag output', async () => { - const gate = new ResizeStreamGate('unused', vi.fn(), async () => ({ id: 1, action: 'observe', counter: 10, columns: 100, rows: 30 })); + const gate = new ResizeStreamGate('unused', vi.fn(), async () => ({ id: 1, action: 'observe', counter: 10, columns: 100, rows: 30 }), async () => {}); gate.emitted({ counter: 11, columns: 90, rows: 28 }); await gate.refresh(); expect(gate.permits({ columns: 90, rows: 28 })).toBe(true); @@ -30,7 +48,7 @@ describe('resize stream observation gate', () => { it('pauses only after a newer chunk was emitted at the resulting viewport and resumes', async () => { const record = vi.fn(); let command = { id: 1, action: 'observe', counter: 10, columns: 100, rows: 30 }; - const gate = new ResizeStreamGate('unused', record, async () => command); + const gate = new ResizeStreamGate('unused', record, async () => command, async () => {}); await gate.refresh(); gate.emitted({ counter: 11, columns: 100, rows: 30 }); expect(gate.permits({ columns: 100, rows: 30 })).toBe(true); @@ -52,7 +70,7 @@ describe('resize stream observation gate', () => { let release: ((command: unknown) => void) | undefined; const read = vi.fn(() => new Promise(resolve => { release = resolve; })); const record = vi.fn(); - const gate = new ResizeStreamGate('unused', record, read); + const gate = new ResizeStreamGate('unused', record, read, async () => {}); const pending = gate.refresh(); void gate.refresh(); expect(read).toHaveBeenCalledTimes(1); @@ -67,7 +85,7 @@ describe('resize stream observation gate', () => { }); it.each([{}, { id: 40, action: 'resume', counter: 0, columns: 100, rows: 30 }])('fails closed on invalid bounded control %j', async command => { - const gate = new ResizeStreamGate('unused', vi.fn(), async () => command); + const gate = new ResizeStreamGate('unused', vi.fn(), async () => command, async () => {}); await gate.refresh(); expect(() => gate.permits({ columns: 100, rows: 30 })).toThrow('Invalid resize stream command'); await gate.dispose(); diff --git a/tests/tuistory/resize-flicker.tuistory.test.ts b/tests/tuistory/resize-flicker.tuistory.test.ts index 1b50c1e7c..abb0a07a5 100644 --- a/tests/tuistory/resize-flicker.tuistory.test.ts +++ b/tests/tuistory/resize-flicker.tuistory.test.ts @@ -53,9 +53,28 @@ describe('terminal resize frames', () => { expect(frame.match(/RESIZE_HISTORY retained text/gu)).toHaveLength(1); expect(frame.match(/preserved draft/gu)).toHaveLength(1); expect((await records()).filter(record => record.kind === 'stream-chunk').at(-1)?.counter).toBe(paused); - await command({ id: 2, action: 'resume', counter: paused, columns: 90, rows: 28 }); + session.resize({ cols: 110, rows: 32 }); + const intermediate = paused; + await vi.waitFor(async () => { + const ack = (await records()).filter(record => record.kind === 'stream-gate' && record.command === 1 && record.paused === true).at(-1); + expect(ack).toMatchObject({ columns: 110, rows: 32 }); + paused = Number(ack?.counter); + expect(paused).toBeGreaterThan(intermediate); + }); + await waitForTerminalScreen(session, { timeout: 5_000, + waitFor: screen => screen.includes('taXil') && screen.includes(`RESIZE_STREAM ${paused} retained streamed text`) }); + await vi.waitFor(async () => { + const snapshot = await readFile(`${control}.state.json`, 'utf8'); + expect(Buffer.byteLength(snapshot)).toBeLessThanOrEqual(512); + expect(JSON.parse(snapshot)).toMatchObject({ kind: 'stream-state', command: 1, emittedCommand: 1, + counter: paused, columns: 110, rows: 32, paused: true }); + }); + await command({ id: 2, action: 'resume', counter: paused, columns: 110, rows: 32 }); await vi.waitFor(async () => { expect((await records()).some(record => record.kind === 'stream-chunk' && record.command === 2 && Number(record.counter) > paused)).toBe(true); + const snapshot: Record = JSON.parse(await readFile(`${control}.state.json`, 'utf8')); + expect(snapshot).toMatchObject({ command: 2, emittedCommand: 2, columns: 110, rows: 32, paused: false }); + expect(Number(snapshot.counter)).toBeGreaterThan(paused); }); await session.press('escape'); await session.waitForText('RESIZE_STREAM_COMPLETE'); From e7010af52236f743e196c32384d67bb14cba862a Mon Sep 17 00:00:00 2001 From: Igor Costa Date: Tue, 10 Nov 2026 23:12:37 +1300 Subject: [PATCH 17/19] Prepare native Code windows with independent edge resize targets Avoid an ambiguous bottom corner hit on full-width baseline windows. Verify each accepted width and height before moving the same native window, preserving all four scenario resize gestures and existing assertions. Co-authored-by: Autohand Evolve --- docs/terminal-compatibility.md | 2 + .../TerminalCompatibilityUITests.swift | 80 ++++++++++++++++++- 2 files changed, 78 insertions(+), 4 deletions(-) diff --git a/docs/terminal-compatibility.md b/docs/terminal-compatibility.md index a18d1052d..d5134dbfb 100644 --- a/docs/terminal-compatibility.md +++ b/docs/terminal-compatibility.md @@ -49,6 +49,8 @@ Native-copy diagnostics retain each attempt's phase, timestamps, pasteboard gene VS Code's streaming scenario keeps response chunks flowing through each physical resize. After the gesture, a disposable control file (512 bytes maximum, command IDs 1–8) requests observation. Only after a newer post-command chunk at changed/current TTY dimensions does the fixture gate new response chunks. A late TTY resize reopens the producer until it emits at the new dimensions and refreshes the same observation command. The request and working spinner stay active; no renderer operation is triggered. A separate atomic current-state snapshot (512 bytes maximum) records command, emitted-command, revision, counter, dimensions and pause state. Native checks that state before and after each fresh copy, independently of diagnostic-history eviction. The copy must contain the exact paused counter and all original markers before unchanged exact-count assertions run. Resuming and proving a newer emitted chunk at the same viewport share the original single 10-second settling budget with acknowledgement and copying, including after the final resize. Screenshots follow resumed-progress proof. Asynchronous control reads are single-flight and closed on every path; disposal suppresses late work and drains pending state writes. Unused Code predicate objects are omitted to avoid implicit accessibility debug snapshots during polling; explicit failure evidence remains. +Code baseline preparation resizes width from the right-edge midpoint, then height from the bottom-edge midpoint. Each accepted size, native identity and display bounds is verified before moving. This avoids the rounded-corner hit target selecting only the bottom edge on an initially screen-width window. The four scenario corner drags remain physical and retain their original deltas and assertions. + The strict visible-history assertions require room for both transcript exchanges and the multiline composer. At a compact 57×19 terminal, the full-screen transcript correctly follows its newest exchange and clips older rows; a separate PTY case checks that the history reappears on growth, the draft and cursor survive, and Ctrl+C exits cleanly. This distinguishes viewport clipping from lost transcript data. Standalone terminal references bind to their accessibility identities. VS Code refreshes its accessibility window before setup steps and after every setup or scenario gesture, guarded by the same native process ID, window ID, single-window count, and current frame. Native CI availability and per-application results must be assessed from that workflow's completed jobs. A passing PTY suite or a successful XCTest build alone does not prove a native application passed. Failures in app launch, accessibility access, or window resizing fail the native job and preserve its XCTest evidence; they are not silently converted to headless passes. diff --git a/src/testing/drivers/native-terminal-lab/TerminalCompatibilityUITests.swift b/src/testing/drivers/native-terminal-lab/TerminalCompatibilityUITests.swift index 1d93859dc..3fa473a7a 100644 --- a/src/testing/drivers/native-terminal-lab/TerminalCompatibilityUITests.swift +++ b/src/testing/drivers/native-terminal-lab/TerminalCompatibilityUITests.swift @@ -175,6 +175,35 @@ private func nativeFixturePreparationFrames(current: CGRect, available: CGRect) return [reduced, CGRect(origin: target.origin, size: reduced.size), target] } +private struct NativeFixtureSetupDrag { + let anchor: CGVector + let offset: CGVector + let delta: CGVector + let size: CGSize +} + +private func nativeFixtureSetupDrags(current: CGRect, target: CGRect) -> [NativeFixtureSetupDrag] { + var size = current.size + var drags: [NativeFixtureSetupDrag] = [] + if abs(target.width - size.width) > 0.5 { + let delta = target.width - size.width + size.width = target.width + drags.append(NativeFixtureSetupDrag(anchor: CGVector(dx: 1, dy: 0.5), offset: CGVector(dx: -2, dy: 0), + delta: CGVector(dx: delta, dy: 0), size: size)) + } + if abs(target.height - size.height) > 0.5 { + let delta = target.height - size.height + size.height = target.height + drags.append(NativeFixtureSetupDrag(anchor: CGVector(dx: 0.5, dy: 1), offset: CGVector(dx: 0, dy: -2), + delta: CGVector(dx: 0, dy: delta), size: size)) + } + return drags +} + +private func nativeFixtureSetupSizeAccepted(_ size: CGSize, expected: CGSize) -> Bool { + abs(size.width - expected.width) <= 1 && abs(size.height - expected.height) <= 1 +} + private func nativeTerminalInputReady(terminal: String, textViewExists: Bool, splitTerminalEnabled: Bool) -> Bool { terminal == "vscode" ? splitTerminalEnabled : textViewExists } @@ -188,6 +217,31 @@ final class TerminalCompatibilityUITests: XCTestCase { @MainActor func testIdleResize() throws { try resizeScenario(streaming: false) } @MainActor func testStreamingResize() throws { try resizeScenario(streaming: true) } + func testNativeViewportPreparesCodeWithSeparateAxisEdgesBeforeMoving() { + let current = CGRect(x: 0, y: 31, width: 1024, height: 677) + let target = CGRect(x: 20, y: 33, width: 904, height: 610) + let drags = nativeFixtureSetupDrags(current: current, target: target) + XCTAssertEqual(drags.count, 2) + guard drags.count == 2 else { return } + XCTAssertEqual(drags[0].anchor.dx, 1) + XCTAssertEqual(drags[0].anchor.dy, 0.5) + XCTAssertEqual(drags[0].offset.dx, -2) + XCTAssertEqual(drags[0].offset.dy, 0) + XCTAssertEqual(drags[0].delta.dx, -120) + XCTAssertEqual(drags[0].delta.dy, 0) + XCTAssertEqual(drags[0].size, CGSize(width: 904, height: 677)) + XCTAssertEqual(drags[1].anchor.dx, 0.5) + XCTAssertEqual(drags[1].anchor.dy, 1) + XCTAssertEqual(drags[1].offset.dx, 0) + XCTAssertEqual(drags[1].offset.dy, -2) + XCTAssertEqual(drags[1].delta.dx, 0) + XCTAssertEqual(drags[1].delta.dy, -67) + XCTAssertEqual(drags[1].size, target.size) + XCTAssertFalse(nativeFixtureSetupSizeAccepted(CGSize(width: 1024, height: 610), expected: drags[0].size)) + XCTAssertTrue(nativeFixtureSetupSizeAccepted(drags[0].size, expected: drags[0].size)) + XCTAssertTrue(nativeFixtureSetupDrags(current: target, target: target).isEmpty) + } + func testNativeViewportDoesNotMoveAWindowWhoseWidthIgnoredTheShrink() { let bounds = CGRect(x: 0, y: 0, width: 1024, height: 768) let target = CGRect(x: 20, y: 33, width: 904, height: 610) @@ -743,10 +797,28 @@ final class TerminalCompatibilityUITests: XCTestCase { } let moved = window.frame if abs(step.width - moved.width) > 0.5 || abs(step.height - moved.height) > 0.5 { - let corner = window.coordinate(withNormalizedOffset: CGVector(dx: 1, dy: 1)).withOffset(CGVector(dx: -2, dy: -2)) - corner.press(forDuration: 0.1, thenDragTo: corner.withOffset(CGVector(dx: step.width - moved.width, dy: step.height - moved.height))) - guard let current = nativeFixtureWindowReference(window, refresh: refreshWindow) else { return bounds } - window = current + if terminal == "vscode" { + for drag in nativeFixtureSetupDrags(current: moved, target: step) { + guard verifyWindow(), bounds.contains(window.frame) else { + XCTFail("The Code baseline window identity or bounds changed before an axis drag") + return bounds + } + let edge = window.coordinate(withNormalizedOffset: drag.anchor).withOffset(drag.offset) + edge.press(forDuration: 0.1, thenDragTo: edge.withOffset(drag.delta)) + guard let current = nativeFixtureWindowReference(window, refresh: refreshWindow) else { return bounds } + window = current + print("NATIVE_TERMINAL_PREPARE_AXIS vscode \(window.frame) expected=\(drag.size)") + guard verifyWindow(), bounds.contains(window.frame), nativeFixtureSetupSizeAccepted(window.frame.size, expected: drag.size) else { + XCTFail("The Code baseline axis drag did not accept its size on the same bounded native window") + return bounds + } + } + } else { + let corner = window.coordinate(withNormalizedOffset: CGVector(dx: 1, dy: 1)).withOffset(CGVector(dx: -2, dy: -2)) + corner.press(forDuration: 0.1, thenDragTo: corner.withOffset(CGVector(dx: step.width - moved.width, dy: step.height - moved.height))) + guard let current = nativeFixtureWindowReference(window, refresh: refreshWindow) else { return bounds } + window = current + } } print("NATIVE_TERMINAL_PREPARE \(terminal) \(window.frame) target=\(step) display=\(bounds)") XCTAssertTrue(verifyWindow(), "The native fixture window identity changed during setup") From 1e4b7136a51dabd740157503e9e2d59cc3082f32 Mon Sep 17 00:00:00 2001 From: Igor Costa Date: Tue, 10 Nov 2026 23:26:19 +1300 Subject: [PATCH 18/19] Read fresh native terminal copies before entering the fallback waiter Validate clipboard generation and raw text immediately after the copy command. Preserve the existing two-second fallback and final freshness check for stale or missing data, removing avoidable observation latency without extending the settling deadline. Co-authored-by: Autohand Evolve --- docs/terminal-compatibility.md | 2 +- .../TerminalCompatibilityUITests.swift | 67 +++++++++++++++++-- 2 files changed, 61 insertions(+), 8 deletions(-) diff --git a/docs/terminal-compatibility.md b/docs/terminal-compatibility.md index d5134dbfb..1d29b7c95 100644 --- a/docs/terminal-compatibility.md +++ b/docs/terminal-compatibility.md @@ -43,7 +43,7 @@ VS Code uses separate disposable profiles for idle and streaming cases under a s Native scenarios first size and position their fixture window within the reported display's visible bounds, leaving room for all four physical resize gestures. Setup shrinks oversized dimensions before moving the window, then grows it at the target position, so a full-width window's resize handle never moves off-screen. VS Code setup starts after terminal readiness and verifies the actual width and height accepted by each gesture; a partially accepted shrink cannot authorize an off-screen move. VS Code's input readiness uses the native Split Terminal command becoming enabled after New Terminal, which establishes terminal focus; it does not require xterm's input to expose the same TextView role as standalone terminals. The fixture's rendered output remains the mandatory proof that paste, Enter, and terminal rendering worked. All scenarios retain the exact transcript, streaming, draft, cursor, resize, and exit assertions. -VS Code's authoritative readout uses its native terminal buffer copy. Hosted recordings showed the canvas progressing from stream 377 to 382 while correctly shaped accessibility rows remained on stream 313 with missing transcript text throughout the settling deadline. Accessibility content is therefore retained only as bounded failure diagnostics. A disposable-profile shortcut uses [`runCommands`](https://code.visualstudio.com/docs/configure/keybindings#_running-multiple-commands) to focus the existing terminal, select its full buffer, and [copy and clear its selection](https://github.com/microsoft/vscode/blob/main/src/vs/workbench/contrib/terminalContrib/clipboard/browser/terminal.clipboard.contribution.ts). The binding is scoped to `terminalIsOpen`, so it can restore terminal focus after a resize. Each observation clears the pasteboard and requires a new pasteboard generation with text; a stale value cannot pass even if all expected markers are present. The copied bytes are preserved without filtering, trimming, or deduplication. A temporarily unavailable copy produces no frame within the original readiness deadlines. Resize readiness requires the header, history, draft, and stream marker for streaming scenarios, without examining their counts; all exact assertions inspect that same accepted raw frame. Duplicate markers fail the unchanged count assertions, and permanent marker loss fails the original settling deadline. The native process ID, Core Graphics window ID, single-window count, current geometry, cursor-edit and exit checks remain mandatory. The command clears selection while preserving the PTY cursor. Backend, bounded preflight samples, pasteboard generation metadata, settings, and keybindings are retained with native results. +VS Code's authoritative readout uses its native terminal buffer copy. Hosted recordings showed the canvas progressing from stream 377 to 382 while correctly shaped accessibility rows remained on stream 313 with missing transcript text throughout the settling deadline. Accessibility content is therefore retained only as bounded failure diagnostics. A disposable-profile shortcut uses [`runCommands`](https://code.visualstudio.com/docs/configure/keybindings#_running-multiple-commands) to focus the existing terminal, select its full buffer, and [copy and clear its selection](https://github.com/microsoft/vscode/blob/main/src/vs/workbench/contrib/terminalContrib/clipboard/browser/terminal.clipboard.contribution.ts). The binding is scoped to `terminalIsOpen`, so it can restore terminal focus after a resize. Each observation clears the pasteboard and requires a new pasteboard generation with text; a stale value cannot pass even if all expected markers are present. A fresh generation and string already available after the key event return immediately; otherwise the existing waiter allows up to two seconds and revalidates freshness. The copied bytes are preserved without filtering, trimming, or deduplication. A temporarily unavailable copy produces no frame within the original readiness deadlines. Resize readiness requires the header, history, draft, and stream marker for streaming scenarios, without examining their counts; all exact assertions inspect that same accepted raw frame. Duplicate markers fail the unchanged count assertions, and permanent marker loss fails the original settling deadline. The native process ID, Core Graphics window ID, single-window count, current geometry, cursor-edit and exit checks remain mandatory. The command clears selection while preserving the PTY cursor. Backend, bounded preflight samples, pasteboard generation metadata, settings, and keybindings are retained with native results. Native-copy diagnostics retain each attempt's phase, timestamps, pasteboard generations and types, raw text, availability, and native window identity and geometry. A bounded tail of 128 records and 2 MiB is persisted after each attempt, with explicit eviction counts, so late resize failures remain inspectable even if the application exits. The fixture also writes asynchronous, bounded write metadata: sequence, viewport, stream counter and bytes before stdout delegation, then separate return/backpressure and callback-completion timestamps. Metadata-only observation forwards the original string or binary chunk and its encoding unchanged. That metadata contains no terminal output, never waits in the producer, and retains the latest records within 1 MiB. The workflow retains both the committed metadata file and any in-flight `.pending` file, alongside copy samples and XCTest evidence. Diagnostic instrumentation does not establish that an intermittent failure is repaired. diff --git a/src/testing/drivers/native-terminal-lab/TerminalCompatibilityUITests.swift b/src/testing/drivers/native-terminal-lab/TerminalCompatibilityUITests.swift index 3fa473a7a..2a32bde48 100644 --- a/src/testing/drivers/native-terminal-lab/TerminalCompatibilityUITests.swift +++ b/src/testing/drivers/native-terminal-lab/TerminalCompatibilityUITests.swift @@ -36,6 +36,15 @@ private func nativeCopiedTerminalBuffer(baseline: Int, observed: Int, value: Str return value } +private func nativeCopiedTerminalObservation(baseline: Int, read: () -> (generation: Int, value: String?), + wait: () -> Bool) -> String? { + let immediate = read() + if let value = nativeCopiedTerminalBuffer(baseline: baseline, observed: immediate.generation, value: immediate.value) { return value } + guard wait() else { return nil } + let sample = read() + return nativeCopiedTerminalBuffer(baseline: baseline, observed: sample.generation, value: sample.value) +} + private struct NativeCopyEvidence { private(set) var records: [Data] = [] private(set) var dropped = 0 @@ -275,6 +284,47 @@ final class TerminalCompatibilityUITests: XCTestCase { XCTAssertNil(nativeCopiedTerminalBuffer(baseline: 18, observed: 19, value: nil)) } + func testNativeReadoutAlreadyFreshCopyReturnsRawDataWithoutEnteringTheWaiter() { + var waits = 0 + var reads = 0 + let raw = "Autohand Code\r\nRESIZE_HISTORY retained text\nRESIZE_HISTORY retained text\n preserved draft " + let value = nativeCopiedTerminalObservation(baseline: 18, read: { + reads += 1 + return (19, raw) + }, wait: { waits += 1; return true }) + XCTAssertEqual(value, raw) + XCTAssertEqual(reads, 1) + XCTAssertEqual(waits, 0) + } + + func testNativeReadoutStaleAndMissingCopiesUseTheExistingWaiter() { + for initial in ["stale", nil] { + var generation = 18 + var value = initial + var waits = 0 + var reads = 0 + let result = nativeCopiedTerminalObservation(baseline: 18, read: { + reads += 1 + return (generation, value) + }, wait: { + waits += 1 + generation = 19 + value = "fresh\n\nraw" + return true + }) + XCTAssertEqual(result, "fresh\n\nraw") + XCTAssertEqual(waits, 1) + XCTAssertEqual(reads, 2) + } + } + + func testNativeReadoutMissingOrStillStaleCopiesFailClosed() { + var waits = 0 + XCTAssertNil(nativeCopiedTerminalObservation(baseline: 18, read: { (18, "stale") }, wait: { waits += 1; return true })) + XCTAssertEqual(waits, 1) + XCTAssertNil(nativeCopiedTerminalObservation(baseline: 18, read: { (19, nil) }, wait: { false })) + } + func testNativeReadoutGateRequiresViewportProgressAndANewerResumedChunk() { let before = NativeStreamPosition(kind: "stream-state", command: 0, emittedCommand: 0, counter: 10, columns: 100, rows: 30, paused: false) let unchanged = NativeStreamPosition(kind: "stream-state", command: 1, emittedCommand: 1, counter: 11, columns: 100, rows: 30, paused: true) @@ -974,13 +1024,16 @@ final class TerminalCompatibilityUITests: XCTestCase { } window.typeKey("y", modifierFlags: [.control, .shift]) keyFinished = Date().timeIntervalSince1970 - let written = NSPredicate { _, _ in - nativeCopiedTerminalBuffer(baseline: generation, observed: pasteboard.changeCount, - value: pasteboard.string(forType: .string)) != nil - } - guard XCTWaiter.wait(for: [XCTNSPredicateExpectation(predicate: written, object: pasteboard)], timeout: 2) == .completed, - let value = nativeCopiedTerminalBuffer(baseline: generation, observed: pasteboard.changeCount, - value: pasteboard.string(forType: .string)) else { + let fresh = nativeCopiedTerminalObservation(baseline: generation, read: { + (pasteboard.changeCount, pasteboard.string(forType: .string)) + }, wait: { + let written = NSPredicate { _, _ in + nativeCopiedTerminalBuffer(baseline: generation, observed: pasteboard.changeCount, + value: pasteboard.string(forType: .string)) != nil + } + return XCTWaiter.wait(for: [XCTNSPredicateExpectation(predicate: written, object: pasteboard)], timeout: 2) == .completed + }) + guard let value = fresh else { print("NATIVE_TERMINAL_COPY_UNAVAILABLE baseline=\(generation) observed=\(pasteboard.changeCount)") return nil } From 0afefffd7d3dce5133ae258b2e7a47be307065ae Mon Sep 17 00:00:00 2001 From: Igor Costa Date: Tue, 10 Nov 2026 23:40:06 +1300 Subject: [PATCH 19/19] Use a graphical Code profile for native buffer-copy qualification Remove renderer accessibility forcing left over from the replaced AX content reader. Keep the screen-reader row mirror off only in disposable graphical fixtures, retaining native identity, raw-copy assertions and deadlines while documenting the accessibility coverage boundary. Co-authored-by: Autohand Evolve --- docs/terminal-compatibility.md | 2 +- src/testing/drivers/native-terminal-driver.ts | 4 ++-- tests/testing/nativeTerminal.test.ts | 9 +++++---- 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/docs/terminal-compatibility.md b/docs/terminal-compatibility.md index 1d29b7c95..d16e30c63 100644 --- a/docs/terminal-compatibility.md +++ b/docs/terminal-compatibility.md @@ -39,7 +39,7 @@ The tmux suite launches a compiled CLI through a real tmux server on a unique te First-run dialogs are handled before fixture input. The offline fixture denies only the macOS request to find devices on local networks through XCTest's interruption handler. The handler checks accessibility values, titles, and labels: macOS permission text can have an empty label, while its visible text is in the value or title. The iTerm2 scenario opens a fresh window after handling interruptions, so a restored session cannot consume fixture input. All terminal text and resize assertions refer to the same window. Application termination and interruption-monitor removal use XCTest teardown blocks so failed assertions also release them. -VS Code uses separate disposable profiles for idle and streaming cases under a short `/private/tmp/ah-vsc-*` directory. Its [main IPC socket](https://github.com/microsoft/vscode/blob/main/src/vs/base/parts/ipc/node/ipc.net.ts) lives under the user-data directory, so putting profiles below a long checkout path can exceed macOS's Unix socket limit and make Code exit before creating a window. Settings evidence stays under the checkout's `.artifacts/native-terminal/` directory, and bounded main-log tails are attached to XCTest results. The profiles enable [`chat.disableAIFeatures`](https://code.visualstudio.com/docs/agents/reference/ai-settings) to suppress built-in Copilot onboarding. VS Code also launches with [`--force-renderer-accessibility`](https://www.chromium.org/developers/design-documents/accessibility/) to expose Chromium's native accessibility tree; the editor's screen-reader setting alone does not ensure that tree is available to XCTest. The scenario opens the terminal through its native Terminal > New Terminal menu. If the specific Copilot sign-in dialog still appears, the scenario closes that dialog and opens the integrated terminal. It does not sign in or grant network access. Window, workbench, and terminal-view failures preserve bounded accessibility diagnostics and screenshots; the workflow also retains the Xcode output and generated VS Code settings. +VS Code uses separate disposable profiles for idle and streaming cases under a short `/private/tmp/ah-vsc-*` directory. Its [main IPC socket](https://github.com/microsoft/vscode/blob/main/src/vs/base/parts/ipc/node/ipc.net.ts) lives under the user-data directory, so putting profiles below a long checkout path can exceed macOS's Unix socket limit and make Code exit before creating a window. Settings evidence stays under the checkout's `.artifacts/native-terminal/` directory, and bounded main-log tails are attached to XCTest results. The profiles enable [`chat.disableAIFeatures`](https://code.visualstudio.com/docs/agents/reference/ai-settings) to suppress built-in Copilot onboarding. The disposable profiles set `editor.accessibilitySupport: "off"` and omit `--force-renderer-accessibility` for ordinary graphical terminal coverage. [VS Code maps this setting to xterm screen-reader mode](https://github.com/microsoft/vscode/blob/1.144.0/src/vs/workbench/contrib/terminal/browser/terminalInstance.ts#L2047); [automatic detection can enable that mode when native accessibility is active](https://github.com/microsoft/vscode/blob/1.144.0/src/vs/platform/accessibility/browser/accessibilityService.ts#L145). Explicit `off` prevents the test observer from enabling the parallel screen-reader row mirror. Native AppKit window/menu checks and the authoritative raw buffer copy remain required. This graphical scenario does not cover screen-reader mode, and it changes only disposable fixture profiles. The scenario opens the terminal through its native Terminal > New Terminal menu. If the specific Copilot sign-in dialog still appears, the scenario closes that dialog and opens the integrated terminal. It does not sign in or grant network access. Window, workbench, and terminal-view failures preserve bounded accessibility diagnostics and screenshots; the workflow also retains the Xcode output and generated VS Code settings. Native scenarios first size and position their fixture window within the reported display's visible bounds, leaving room for all four physical resize gestures. Setup shrinks oversized dimensions before moving the window, then grows it at the target position, so a full-width window's resize handle never moves off-screen. VS Code setup starts after terminal readiness and verifies the actual width and height accepted by each gesture; a partially accepted shrink cannot authorize an off-screen move. VS Code's input readiness uses the native Split Terminal command becoming enabled after New Terminal, which establishes terminal focus; it does not require xterm's input to expose the same TextView role as standalone terminals. The fixture's rendered output remains the mandatory proof that paste, Enter, and terminal rendering worked. All scenarios retain the exact transcript, streaming, draft, cursor, resize, and exit assertions. diff --git a/src/testing/drivers/native-terminal-driver.ts b/src/testing/drivers/native-terminal-driver.ts index 2dbda2d48..5f4ce257f 100644 --- a/src/testing/drivers/native-terminal-driver.ts +++ b/src/testing/drivers/native-terminal-driver.ts @@ -25,7 +25,7 @@ export async function prepareNativeTerminalTest(): Promise<{ args: string[]; res const runtimeProfile = terminal === 'vscode' ? await mkdtemp('/private/tmp/ah-vsc-') : undefined; const settings = { 'chat.disableAIFeatures': true, - 'editor.accessibilitySupport': 'on', 'workbench.startupEditor': 'none', 'window.restoreWindows': 'none', + 'editor.accessibilitySupport': 'off', 'workbench.startupEditor': 'none', 'window.restoreWindows': 'none', 'workbench.panel.opensMaximized': 'always', 'update.mode': 'none', 'security.workspace.trust.enabled': false, 'terminal.integrated.enablePersistentSessions': false, 'terminal.integrated.scrollback': 5000, @@ -57,7 +57,7 @@ export async function prepareNativeTerminalTest(): Promise<{ args: string[]; res launchArguments[mode] = terminal === 'vscode' ? ['--user-data-dir', isolatedProfile, '--extensions-dir', path.join(isolatedProfile, 'extensions'), '--disable-extensions', '--disable-workspace-trust', '--skip-welcome', '--skip-release-notes', - '--force-renderer-accessibility', '--new-window'] + '--new-window'] : terminal === 'ghostty' ? ['--config-default-files=false', '--confirm-close-surface=false'] : []; } const project = path.resolve('src/testing/drivers/native-terminal-lab'); diff --git a/tests/testing/nativeTerminal.test.ts b/tests/testing/nativeTerminal.test.ts index 546485b5f..efd0ff1fb 100644 --- a/tests/testing/nativeTerminal.test.ts +++ b/tests/testing/nativeTerminal.test.ts @@ -103,7 +103,7 @@ describe('native terminal automation boundary', () => { await expect(prepareNativeTerminalTest()).rejects.toThrow('Choose iterm2, ghostty, cmux, or vscode'); }); - it.each(['idle', 'streaming'])('prepares an isolated %s VS Code profile with native renderer accessibility', async mode => { + it.each(['idle', 'streaming'])('prepares an isolated %s VS Code profile for graphical terminal use', async mode => { Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true }); vi.stubEnv('GITHUB_ACTIONS', 'true'); vi.stubEnv('CI', 'true'); @@ -116,9 +116,9 @@ describe('native terminal automation boundary', () => { const settingsWrite = vi.mocked(writeFile).mock.calls.find(([file]) => file === path.join(profile, 'User/settings.json')); expect(settingsWrite).toBeDefined(); const settings: unknown = JSON.parse(String(settingsWrite?.[1])); - expect(settings).toMatchObject({ + expect.soft(settings).toMatchObject({ 'chat.disableAIFeatures': true, - 'editor.accessibilitySupport': 'on', + 'editor.accessibilitySupport': 'off', 'window.restoreWindows': 'none', 'terminal.integrated.enablePersistentSessions': false, 'terminal.integrated.scrollback': 5000, @@ -126,10 +126,11 @@ describe('native terminal automation boundary', () => { const launchArguments = preparedLaunchArguments(); expect(launchArguments).toMatchObject({ [mode]: expect.arrayContaining([ - '--force-renderer-accessibility', '--user-data-dir', runtimeProfile, + '--user-data-dir', runtimeProfile, '--extensions-dir', path.join(runtimeProfile, 'extensions'), '--disable-extensions', ]), }); + expect.soft(launchArguments[mode]?.some(argument => argument.startsWith('--force-renderer-accessibility'))).toBe(false); expect(launchArguments).toMatchObject({ idle: expect.not.arrayContaining([path.join('/private/tmp/ah-vsc-123456', 'streaming')]), streaming: expect.not.arrayContaining([path.join('/private/tmp/ah-vsc-123456', 'idle')]),