diff --git a/CHANGELOG.md b/CHANGELOG.md index 814634642..3013521cf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,21 +2,30 @@ ## Unreleased -### Added - -- Choose Keenable in `/search`, onboarding or settings with your own API key. Search results use the selected provider, and keys remain masked in the terminal. - -### Fixed - -- OpenAI-compatible fine-tuned and proxy models can recover once when their API requires a different completion-token parameter. +## 0.9.11 - 2026-11-12 ### Added +- Configure delegated-agent request limits with `agent.subagentMaxIterations`; exhausted work returns partial findings and an explicit completion status. +- Answer ACP follow-up questions through capability-gated form elicitation, with cancellation and session-retirement cleanup. +- Choose Keenable in `/search`, onboarding or settings with your own API key. Search results use the selected provider, and keys remain masked in the terminal. - 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 +- Retain answer-only runtime commit identities when repository refs are packed, including worktree common-directory refs. +- Continue required completion review with disclosed session evidence when a large workspace diff exceeds its capture time budget. +- Apply supported anchored and numbered single-file patches, reject ambiguous or no-op changes, and authorize rewritten targets before editing. +- Preserve per-agent model, reasoning and explicit research-only scope; forward Code Mode image evidence once while retaining compact script-visible hints. +- Use compatible OpenAI endpoints, token limits, reasoning and sampling parameters; preserve configured reasoning on custom endpoints and bound token-parameter recovery to one resend. +- Keep malformed historical tool calls as readable context and enforce OpenRouter deadlines through complete response body reads. +- Use authoritative running llama.cpp context capacity without blocking composer startup or overriding explicit user limits. +- Send the Autohand identity and opaque per-conversation routing on official OpenCode Go endpoints, and avoid whole-turn replay after Managed Identity authentication failure. +- Stop automatic replay after storage-capacity failures, report unsaved-state risk, and recover valid project-history rows without rewriting original logs. +- Resolve built-in skills from encoded filesystem paths and preserve complete project associations for new Cursor imports containing spaces, percent signs or Unicode. +- Enforce bare all-tool denials for advertised and fabricated tools and automatic specialist preparation; ACP reports whether deny-all scope was activated. +- OpenAI-compatible fine-tuned and proxy models can recover once when their API requires a different completion-token parameter. - 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. @@ -32,6 +41,8 @@ - Exercise resize behavior through real tmux and native iTerm2, Ghostty, cmux, and VS Code windows in GitHub Actions, with the iTerm2 Python API disabled. +See [the 0.9.11 release notes](docs/changelog/whats-new-0.9.11.md) for details. + ## 0.9.10 - 2026-11-04 ### Added diff --git a/docs/autohand-orchestration.md b/docs/autohand-orchestration.md index 3418f69d7..f84fe1812 100644 --- a/docs/autohand-orchestration.md +++ b/docs/autohand-orchestration.md @@ -7,10 +7,20 @@ keep their existing behavior. The lead plans, edits code, runs tests, and integrates results. It can delegate independent file discovery, symbol and AST summaries, and documentation lookup -to Fantail research workers. The default session limit is four threads: the -lead and three workers. Workers receive read-only inspection tools and return +to an explicit read-only research pool using `research_only: true`. The default +Cloud orchestration limit is four threads: the lead and three workers. Research +workers receive read-only inspection tools and return compact findings with file references, contracts, documentation sources, and uncertainties. Their private tool transcripts stay out of the lead's context. +Ordinary reviewer and implementer delegations retain their agent definitions, +tools, and reasoning settings; orchestration does not turn them into research +workers. Explicit research-only restrictions also apply outside Cloud mode. + +Delegated agents have an independent default limit of 30 model requests. Use +`agent.subagentMaxIterations`, agent-definition `maxIterations`, or a lower +per-call `maxIterations` to control it. The final request asks for a summary +without tools. Available work returns as an explicit partial result when the +limit is reached, including through Code Mode; it is not reported as completed. Moa also provides independent, tool-free advice at three checkpoints: @@ -24,15 +34,19 @@ Each review receives the current session conversation, including any earlier context compaction, and the checkpoint evidence. Commit and completion reviews also receive the workspace diff against HEAD, including staged and untracked files, without changing the real Git index. Oversized or failed diff captures -are reported as unavailable, not approved. Non-Git workspaces are identified +are reported as unavailable, not approved. A completion-only capture that +exceeds its time budget can instead be reviewed using session evidence, with +the missing diff explicitly disclosed. Approval still requires the advisor's +verdict; commit capture failures remain blocking. Non-Git workspaces are identified explicitly in the review evidence. Arbitrary scripts that stage or commit internally cannot be detected as Git checkpoints; the final review still runs. The lead addresses requested changes before proceeding. An unavailable review or three rejected checkpoints ends the turn as incomplete with the reason -visible. When a final answer is ready, it remains visible as an unreviewed or -unapproved draft alongside the review feedback. The turn retains a failed -footer and a prepared goal stays active with its successor queued. Goal +visible. An approved answer is delivered after review. A rejected or unavailable +candidate may be shown as an explicitly unreviewed or unapproved draft alongside +the feedback. In that case the turn retains a failed footer and a prepared goal +stays active with its successor queued. Goal completion is persisted only after approval and final answer delivery succeed. Resolve the review feedback and ask to continue; a visible draft is not proof of approval. Simple chat and routine shell steps do not call the advisor. Advisor @@ -63,7 +77,8 @@ replayed by the transport. A late or malformed answer never counts as approval. The orchestration setting is saved as `autohandai.orchestration`. Explicit `features.multi_agent_v2.max_concurrent_threads_per_session` values override the -four-thread default. Worker model overrides remain supported; model suggestions -in agent definitions do not override Fantail while orchestration is enabled. +four-thread Cloud default. Worker model overrides remain supported. Agent +definitions and their reasoning requirements are respected subject to account +entitlements, rather than being replaced by a blanket Fantail assignment. Moa reasoning effort is high for advisor requests. Fantail requests do not send a reasoning-effort parameter. diff --git a/docs/changelog/whats-new-0.9.11.md b/docs/changelog/whats-new-0.9.11.md new file mode 100644 index 000000000..f14210e60 --- /dev/null +++ b/docs/changelog/whats-new-0.9.11.md @@ -0,0 +1,35 @@ +# Autohand Code 0.9.11 + +### More reliable long-running work + +- Delegated agents have a configurable 30-request default through `agent.subagentMaxIterations`. Per-agent limits and lower per-call limits remain available. Exhausted agents return their partial findings with an explicit status instead of losing completed work. +- Code Mode preserves image evidence for the model while keeping script-visible image hints compact. Agent model and reasoning choices are respected, and research-only restrictions apply when explicitly requested. +- Completion review can continue with disclosed session evidence when capturing a large workspace diff exceeds its time budget. Required review and commit guards remain in place. +- ACP clients that support form elicitation can answer follow-up questions. Cancellations and retired sessions release the pending question safely. + +### A steadier terminal and recoverable sessions + +- Reduce duplicated resize frames and scrollback growth in iTerm2, keep drafts and cursor edits intact, and launch compiled executables correctly inside tmux. Plain `--tmux` uses the current folder; `--worktree` and `--zit` request isolation explicitly. +- Keep the composer editable while restoring a saved conversation, preserve early submissions, and wait for restored history before executing queued work. +- Recover usable project-history entries around malformed log rows. Storage-capacity errors stop automatic replay and explain that recent data may be unsaved. +- Start ordinary folder work without creating a Git repository. Initialize Git only when you choose to use it. + +### Provider compatibility and local models + +- OpenAI requests use compatible endpoints, completion limits, sampling and reasoning settings, preserve configured reasoning on custom endpoints, and recover once from an explicitly rejected token parameter. +- OpenRouter deadlines cover complete response bodies. Malformed historical tool calls remain available as readable context instead of being sent back as invalid native arguments. +- Use the context capacity reported by the running llama.cpp server while retaining explicit user limits and responsive startup. +- Avoid replaying an entire turn after a pre-request Azure Managed Identity failure. Official OpenCode Go endpoints receive the Autohand identity and an opaque per-conversation routing key. + +### Safer tools and project paths + +- Answer-only clients retain the development runtime commit identity in ordinary and worktree checkouts after Git packs its refs. +- Apply supported single-file anchored and numbered patches with clearer rejection diagnostics. Rewritten targets are checked again before authorization; ambiguous and no-op changes fail explicitly. +- A bare `--disallowed-tools '*'` blocks every tool, including automatic specialist dispatch. ACP reports whether the process activated this denial so constrained teaching clients can verify the boundary. +- Find built-in skills reliably from paths containing spaces or encoded characters. New Cursor imports retain complete encoded project paths and project-index associations; existing imported history remains untouched. + +### Squad, search and skills + +- Built-in Squad MCP supports direct agent messages, history and delivery receipts, agent profiles and abilities, project folders, skills, group chats, channel membership and scheduled routines. +- Choose the opt-in Keenable search provider in `/search`, onboarding or settings with your own API key. Keys stay masked in terminal settings. +- Discover installed skills through a compact name index and load descriptions on demand. Keep background sync off until explicitly enabled, and cancel queued and active sync work immediately when it is disabled. diff --git a/docs/config-reference.md b/docs/config-reference.md index 3a1c63ce2..fcf3a2fcd 100644 --- a/docs/config-reference.md +++ b/docs/config-reference.md @@ -1173,6 +1173,7 @@ Control agent behavior and iteration limits. { "agent": { "maxIterations": 100, + "subagentMaxIterations": 30, "enableRequestQueue": true, "toolSelectionCache": true, "autoMemory": true, @@ -1187,6 +1188,7 @@ Control agent behavior and iteration limits. | Field | Type | Default | Description | | -------------------- | ------- | ------- | ------------------------------------------------------------------------------ | | `maxIterations` | number | `100` | Maximum tool iterations per user request before stopping | +| `subagentMaxIterations` | number | `30` | Default delegated-agent model-request limit; integer from `1` to `1000`, independent of the main agent limit. Agent-definition `maxIterations` overrides the default; a delegation call may lower its effective limit. The final request is reserved for a summary without tools. | | `enableRequestQueue` | boolean | `true` | Allow users to type and queue requests while agent is working | | `toolSelectionCache` | boolean | `true` | Cache local per-turn tool schema selection for equivalent tool-selection input | | `autoMemory` | boolean | `true` | Extract and save durable user/project memories after completed interactive turns, including evidence-backed lessons from failures and cancellations | @@ -1198,6 +1200,24 @@ Control agent behavior and iteration limits. | `sessionRetryDelay` | number | unset | Milliseconds before the first re-run, growing 1.5× per attempt. When unset, provider outages (5xx, network, timeout) wait 5 s, 15 s, 45 s and other errors 1 s, 1.5 s, 2.25 s; a provider retry-after always wins | | `debug` | boolean | `false` | Enable verbose debug output (logs agent internal state to stderr) | +`/settings` exposes the delegated-agent limit. Agent-definition frontmatter can override the default: + +```yaml +--- +name: reviewer +maxIterations: 50 +--- +``` + +`delegate_task` and `delegate_parallel` accept an optional `maxIterations`; individual parallel entries may lower it further. Reaching the limit returns available work with `status: "partial"` and `reason: "iteration_limit"`, rather than discarding the findings. The final summary request counts toward the limit. If that summary fails, recorded observations and the failure remain available; cancellation, approval stops, and shared run budgets still take precedence. Partial status is available to Code Mode scripts as well as ordinary tool callers and does not mark the delegated task complete. + +Disk-capacity errors (`ENOSPC` and `EDQUOT`) stop automatic session replay. The +error explains that recent data may not have been saved; free space before +retrying. A persistence failure can occur after a transcript or tool change has +already been written, so automatically rerunning the turn would risk repeating +work. Existing session files are retained and the error path does not delete +caches or conversations. + ## Concurrent Session Awareness ```json diff --git a/docs/maintenance/issue-sweep-2026-11-12.md b/docs/maintenance/issue-sweep-2026-11-12.md new file mode 100644 index 000000000..cb98e8f81 --- /dev/null +++ b/docs/maintenance/issue-sweep-2026-11-12.md @@ -0,0 +1,98 @@ +# November 12 CLI issue sweep + +The initial GitHub inventory contained 43 open issues, from 635 through 755. The scope is frozen in `.artifacts/issue-sweep-20261112/scope.json`; exact issue bodies and focused evidence are retained locally. The user subsequently set aside further investigation of 17 historical Cloud safety-verdict reports. There are 26 active issues in this sweep. + +## Candidate changes + +The candidate covers ACP questions, completion review, delegated-agent limits and partial results, patch safety, provider compatibility/deadlines, runtime local-model context, project-history recovery and storage-capacity handling. Each implemented defect started with a failing regression. Focused unit, actual protocol, and applicable built-CLI Tuistory evidence are recorded per issue. + +The recovered work is inventoried separately in [Recovered CLI work](recovered-work-2026-11-12.md). Its 41 original working files and six local commits were preserved and reconciled. The five remaining unique feature branches are preservation findings, not implied additions to this issue sweep. + +## Per-issue disposition + +`Candidate fix` means implemented in the working tree, with integrated proof and publication still pending. It does not mean the historical upstream incident was independently reproduced. Existing-main fixes remain distinct from newly implemented changes. + +| Issue | Disposition | Evidence or remaining action | +| --- | --- | --- | +| [#755](https://github.com/autohandai/code-cli/issues/755) | Candidate fix | Review completion after a capture-budget timeout using disclosed session evidence; retain approval and commit guards. | +| [#754](https://github.com/autohandai/code-cli/issues/754) | Candidate fix | Route ACP follow-up answers through capability-gated elicitation, with cancellation and session retirement. | +| [#751](https://github.com/autohandai/code-cli/issues/751) | Candidate fix | Use authoritative llama.cpp runtime context with bounded readiness and model-switch guards. | +| [#749](https://github.com/autohandai/code-cli/issues/749) | Candidate fix | Prevent disk-full turn replay and warn that recent data may be unsaved; historical write origin remains unverified. | +| [#746](https://github.com/autohandai/code-cli/issues/746) | Candidate fix | Use supported OpenAI sampling and reasoning parameters. | +| [#724](https://github.com/autohandai/code-cli/issues/724) | External configuration / evidence needed | Need sanitized HTTP status/body, endpoint/model revision and upstream request trace for the generic custom-provider failure. | +| [#722](https://github.com/autohandai/code-cli/issues/722) | Candidate fix | Retain malformed historical tool calls as text instead of invalid native arguments; original incident remains unverified. | +| [#721](https://github.com/autohandai/code-cli/issues/721) | Candidate fix | Retain malformed historical tool calls as text instead of invalid native arguments; original incident remains unverified. | +| [#720](https://github.com/autohandai/code-cli/issues/720) | Existing coverage / operational condition | Native function lists are already capped at 128; release verification remains separate. | +| [#719](https://github.com/autohandai/code-cli/issues/719) | Existing coverage / operational condition | Native function lists are already capped at 128; release verification remains separate. | +| [#718](https://github.com/autohandai/code-cli/issues/718) | External configuration / evidence needed | Select or configure the requested router model; client now identifies model_not_found without substituting a model. | +| [#715](https://github.com/autohandai/code-cli/issues/715) | Candidate fix | Stop replay after session metadata capacity failure; preserve existing data and report unsaved-state risk. | +| [#713](https://github.com/autohandai/code-cli/issues/713) | External configuration / evidence needed | Select or configure the requested router model; current historical server state is unverified. | +| [#712](https://github.com/autohandai/code-cli/issues/712) | External configuration / evidence needed | Duplicate report identity of 713; the same external router configuration remains unverified. | +| [#707](https://github.com/autohandai/code-cli/issues/707) | Existing coverage / operational condition | Current Cloud model policy already rejects Weka as an unsupported CLI lead model. | +| [#706](https://github.com/autohandai/code-cli/issues/706) | Candidate fix | Configurable delegated-agent limits, partial findings, explicit research scope, and native/Code Mode/protocol status. | +| [#705](https://github.com/autohandai/code-cli/issues/705) | External configuration / evidence needed | Need sanitized request shape, upstream request ID and prompt-template revision to diagnose the provider template failure. | +| [#704](https://github.com/autohandai/code-cli/issues/704) | Set aside by user | Historical safety-verdict investigation set aside at user request; no server false-positive fix claimed. | +| [#701](https://github.com/autohandai/code-cli/issues/701) | Set aside by user | Historical safety-verdict investigation set aside at user request; no server false-positive fix claimed. | +| [#700](https://github.com/autohandai/code-cli/issues/700) | Candidate fix | Apply supported single-file anchored/enveloped patches safely; reject ambiguity/no-ops and authorize rewritten targets. | +| [#692](https://github.com/autohandai/code-cli/issues/692) | External configuration / evidence needed | Need a WSL/device-login reproduction with redirect/account/API evidence; reporter follow-up remains absent. | +| [#691](https://github.com/autohandai/code-cli/issues/691) | Candidate fix | Enforce OpenRouter deadlines through complete body reads and forward configured network settings. | +| [#685](https://github.com/autohandai/code-cli/issues/685) | Existing coverage / operational condition | Existing client classifier handles the oMLX memory guard; actual server RAM/cache capacity remains operational. | +| [#679](https://github.com/autohandai/code-cli/issues/679) | Candidate fix | Use legitimate Autohand identity and opaque per-conversation routing on official Go endpoints. | +| [#670](https://github.com/autohandai/code-cli/issues/670) | Set aside by user | Historical safety-verdict investigation set aside at user request; no server false-positive fix claimed. | +| [#666](https://github.com/autohandai/code-cli/issues/666) | Set aside by user | Historical safety-verdict investigation set aside at user request; no server false-positive fix claimed. | +| [#663](https://github.com/autohandai/code-cli/issues/663) | Set aside by user | Historical safety-verdict investigation set aside at user request; no server false-positive fix claimed. | +| [#662](https://github.com/autohandai/code-cli/issues/662) | Candidate fix | Recover from malformed project-history rows without rewriting historical data. | +| [#661](https://github.com/autohandai/code-cli/issues/661) | Set aside by user | Historical safety-verdict investigation set aside at user request; no server false-positive fix claimed. | +| [#650](https://github.com/autohandai/code-cli/issues/650) | Set aside by user | Historical safety-verdict investigation set aside at user request; no server false-positive fix claimed. | +| [#649](https://github.com/autohandai/code-cli/issues/649) | Set aside by user | Historical safety-verdict investigation set aside at user request; no server false-positive fix claimed. | +| [#648](https://github.com/autohandai/code-cli/issues/648) | Set aside by user | Historical safety-verdict investigation set aside at user request; no server false-positive fix claimed. | +| [#647](https://github.com/autohandai/code-cli/issues/647) | Set aside by user | Historical safety-verdict investigation set aside at user request; no server false-positive fix claimed. | +| [#646](https://github.com/autohandai/code-cli/issues/646) | Set aside by user | Historical safety-verdict investigation set aside at user request; no server false-positive fix claimed. | +| [#645](https://github.com/autohandai/code-cli/issues/645) | Set aside by user | Historical safety-verdict investigation set aside at user request; no server false-positive fix claimed. | +| [#644](https://github.com/autohandai/code-cli/issues/644) | Candidate fix | Stop whole-turn replay after pre-request Managed Identity failure; retain explicit retry, credentials and auth mode. Historical host cause remains unverified. | +| [#643](https://github.com/autohandai/code-cli/issues/643) | Set aside by user | Historical safety-verdict investigation set aside at user request; no server false-positive fix claimed. | +| [#641](https://github.com/autohandai/code-cli/issues/641) | Candidate fix | Use compatible OpenAI endpoint, cap, reasoning and tool-continuation behavior. | +| [#640](https://github.com/autohandai/code-cli/issues/640) | Candidate fix | Same OpenAI compatibility family as 641. | +| [#639](https://github.com/autohandai/code-cli/issues/639) | Set aside by user | Historical safety-verdict investigation set aside at user request; no server false-positive fix claimed. | +| [#638](https://github.com/autohandai/code-cli/issues/638) | Set aside by user | Historical safety-verdict investigation set aside at user request; no server false-positive fix claimed. | +| [#637](https://github.com/autohandai/code-cli/issues/637) | Set aside by user | Historical safety-verdict investigation set aside at user request; no server false-positive fix claimed. | +| [#635](https://github.com/autohandai/code-cli/issues/635) | Set aside by user | Historical safety-verdict investigation set aside at user request; no server false-positive fix claimed. | + +## Behavior and protocol boundaries + +ACP clients advertising form elicitation receive SDK 1.7 `elicitation/create` requests. The answer schema is a plain string with suggestions in its description, so strict clients can accept arbitrary text. This supports free text; clients may need their own presentation support for quick-choice buttons. Declines, unavailable channels and transport failures remain distinct, and retired sessions cannot reuse an old question callback. Native Desktop rendering was not used as proof. + +Patch support is intentionally single-file: numbered unified hunks, bare `@@` hunks, and an optional `*** Update File:` envelope. An envelope can supply an omitted target. Ambiguous matches, no-op patches, target conflicts, unsupported labeled unnumbered headers and multi-file directives fail with diagnostics. Logical and declared targets remain subject to permissions after input rewrites. The separately observed generic raw-symlink policy hazard and physical write atomicity are outside the patch-parser claim. + +Storage capacity failures stop automatic replay because a transcript or tool write may already have succeeded before metadata persistence fails. The warning does not promise that the latest data was saved. No user caches, conversations, or historical log rows were deleted to resolve these failures. + +Provider qualification uses deterministic request/response fixtures, actual loopback deadline tests and CLI scenarios. It does not establish paid live-provider acceptance, historical server configuration, Azure host availability, or resolution of an upstream safety verdict. The user-directed deferred reports retain this boundary without further evidence requests. + +## Validation and delivery + +This section records the investigation and development evidence before release qualification. Current publication status comes from the tagged release and its workflow, not the historical run summaries below. + +The latest completed full proof used a checksum-verified project-local Bun 1.4.2, the version declared by this repository, and the supported two-worker terminal profile. Lint, typecheck, build and all 14,276 unit tests passed (986 files; 81 existing/platform skips). Its complete terminal selection reported 575 passing tests, two failures and three skips across 118 files. This is not a passing aggregate proof. + +Both remaining failures were exact fixture mismatches. The crop fixture configured `high` reasoning but expected a legacy forced `none` value for GPT-5.4. The orchestration mock omitted `research_only: true` while expecting the restricted research envelope; production guidance and schema already specify that flag. The two minimal fixture corrections retain all crop pairing, model continuation, three-reader concurrency, Moa audit, tool-envelope, exit, timeout and skip checks. + +The subsequent focused rerun could not qualify the corrections: macOS sleep logs confirm repeated maintenance sleep/dark-wake cycles during the run, with a nominal 60-second timeout taking more than 1,000 seconds. Its three readiness/timeout failures remain separate from the two reproduced assertion failures. Post-correction typecheck, targeted lint and diff checks pass. Targeted runtime confirmation and a fresh complete proof remain pending an awake, powered Mac. No test timeout, skip, or production behavior was changed to conceal these failures. + +Earlier validation exposed and corrected these additional issues: + +- Built-in skill discovery used an encoded URL pathname as a native filesystem path. Five portable registry-level cases now cover spaces and reserved/Unicode characters using `fileURLToPath`. +- Cursor's binary import fallback truncated encoded project URIs. Actual importer fixtures verify complete metadata/index association, decode-once behavior and message preservation. The correction applies to future imports; existing conversations and project aliases were not rewritten. Native Windows execution and the separate IDE Windows/UNC follow-up remain outside this claim. +- The Markdown renderer already emitted valid file URLs; its test now checks native-path roundtrips. The peer-resource test now observes an exact-child queued notice and durable cursor before waiting for the grant, retaining routing and bounded-cleanup assertions. +- Installed Bun 1.4.0 produced invalidly signed standalone executables in a dependency-free control. The declared Bun 1.4.2 passes signature verification and all four compiled scenarios. No global runtime, signing policy or dependency declaration changed. +- A tool-cap fixture returned Chat JSON to a Responses streaming route. Its contract is corrected with the 128-tool assertion retained. +- One repository preload and three temporary preloads used space-sensitive native paths in `NODE_OPTIONS`. Four actual preload failures were captured before the file-URL corrections. Both affected unit files pass 12 tests (two existing skips), and both affected terminal files pass five scenarios in the normal integration context. Worker-only socket denials remain separately recorded; no listener policy was changed. + +Independent reviews verified patch authorization/parsing, ACP cancellation/session retirement, partial-result propagation, OpenAI serialization/cancellation and Cursor URI handling. The separately authorized QuickJS correction preserves the boolean image hint inside the VM while forwarding the actual image once to the model; base64 stays out of script observations. Focused results are not substitutes for the pending final aggregate qualification. + +Remote main advanced during validation to `43614dbec51a3c56766cda3bb8f39ddcd1c459d7`, adding Keenable search and bounded OpenAI token recovery. Those changes are reconciled into the working files. Four overlaps merged cleanly; the OpenAI provider preserves the stricter recovery implementation, and both remote regressions remain alongside all local compatibility tests. A fresh build and six focused provider/search/setup/startup suites passed 196 tests. + +Local Git metadata still points to `e59017ba7ca72066a051a3f24d5f473c9f9b4ed6`: this execution environment protects `.git` and rejected branch creation. The working-file reconciliation is not a commit or merge. A base-specific recovery patch and source hashes are retained under `.artifacts/issue-sweep-20261112/`. No issue has been closed or represented as released by this sweep. Publication requires complete release qualification and authorized Git integration. + +The 0.9.11 candidate also incorporates the reviewed deny-all guard from PR #756. Five new regressions failed before the guard, and all 209 ACP, permission-advertising and specialist-runtime tests pass afterward. The guard blocks fabricated calls and implicit specialist preparation even in unrestricted mode; ACP advertises whether the run activated deny-all enforcement. The previously corrected crop and orchestration fixtures now pass all five targeted scenarios while the Mac is awake. Full release proof is recorded separately against the final versioned candidate. + +Release-version validation also exposed a missing packed-ref fallback in the answer-only runtime identity reader. Synthetic ordinary/worktree packed-ref cases failed before reusing the existing repository reader; loose-ref precedence remains covered. All 50 identity, repository-reader and Homebrew tests pass, with lint, typecheck and build green. The legacy bundled Homebrew formula now matches 0.9.11; the production tap still receives verified archive checksums from the release pipeline. diff --git a/docs/maintenance/recovered-work-2026-11-12.md b/docs/maintenance/recovered-work-2026-11-12.md new file mode 100644 index 000000000..6edc0255f --- /dev/null +++ b/docs/maintenance/recovered-work-2026-11-12.md @@ -0,0 +1,53 @@ +# Recovered CLI work + +This inventory records the work recovered before reconciling the original checkout with main at `e59017ba7ca72066a051a3f24d5f473c9f9b4ed6`. It distinguishes an exact historical snapshot from work that still needs integration. The snapshot is not a second implementation to merge wholesale. + +## Preserved snapshot + +The local branch `repair/preserved-local-20261111` points to `68e042b327ee00f1a1dd16834bd2180b48abc0d1`. Its tree preserves all 41 original working files and its parent retains the original local commits. The reconciliation receipt and file hashes are in `.artifacts/reconciliation-20261111/receipt.json`. + +The 41 working files principally covered: + +- Ink resize and pause/resume behavior, including duplicated frames and scrollback in iTerm2 and tmux. +- Plain `--tmux` startup, `--tmux --zit` argument handling, and launching the correct executable in compiled installations. +- Native terminal drivers and the XCTest terminal lab, tmux drivers, Tuistory resize/startup scenarios, and terminal compatibility CI. +- Startup option documentation and translated configuration references. + +During reconciliation, 17 of those files already matched the integrated main version exactly; the other 24 were older or superseded versions. The exact originals remain in the snapshot. They are not 41 outstanding features. + +Six original local commits were also preserved and their behavior incorporated into main: + +| Original commit | Work | +| --- | --- | +| `df44f764` | Keep composer input available while restoring a saved session. | +| `659ddbaf` | Record Squad management validation and its canonical source revision. | +| `3478d59a` | Align bundled Squad guidance and the source revision. | +| `77264a81` | Bundle Squad member management, group chats, and routines. | +| `ca4f0c54` | Isolate terminal fixtures from the installed Squad bridge. | +| `84411d86` | Integrate Squad agent messaging and built-in MCP. | + +See [the integration record](cli-pr-integration-2026-11-10.md) for the combined behavior and the boundaries between focused, aggregate, native-terminal, and live-provider validation. + +## Work retained for later integration + +The branch cleanup identified five branches containing changes not established as present in main. They were retained rather than discarded as old branch names. + +| Branch | Remaining work | +| --- | --- | +| `worktree-voice-dictation` | Voice capture, `/voice`, native helpers, waveform UI, and tests. | +| `fix/capped-plan-label` | Capped-plan parsing, labels, summaries, and upgrade hints. | +| `hosted-dev-entry` | A standalone hosted web entry point with account and browser-opening options. | +| `mcp-dependency-refresh` | MCP SDK migration, protocol negotiation, complete schema preservation, and tool-list refresh. | +| `merge/ci-tuistory-linux-polling` | Immediate and exit-aware terminal test polling that differs from the current settled-frame helper. | + +These are preservation findings, not a claim that the branches are ready to merge or pass current aggregate proof. Their independent designs, current requirements, and tests must be reconciled before integration. + +Other retained worktrees contain unfinished release, installation, Computer Use trust, or Windows/shell changes, or could not be fully checked on mounted Codync storage. Their HEADs and reasons for retention are recorded in `.artifacts/branch-cleanup-20261111/receipt.json`. + +## Recovery evidence + +Before removing obsolete worktrees, recovery patches, diagnostic scripts, notes, native test evidence, and private provider captures were preserved under `.artifacts/branch-cleanup-20261111/preserved-worktree-artifacts/`. Copied recovery files were hash-verified. Four artifact directories were moved intact by same-filesystem rename, preserving their directory identities and relative symlinks. + +Private captures remain ignored local evidence. They must not be included in commits or public issue reports. The original WIP recovery branch remains local and was not pushed during reconciliation. + +The November 12 issue sweep uses a separate per-issue inventory. Resolving an issue does not imply that every retained feature branch has been integrated, and preserving a branch does not establish that its feature is complete. diff --git a/docs/providers.md b/docs/providers.md index 059bd103b..5313616c0 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -188,6 +188,11 @@ results, and tool calls or results orphaned by context compaction. When an upstream provider does reject a request, the provider's own error text is now included rather than only OpenRouter's generic "Provider returned error". +The configured network timeout covers response headers and the complete response +body, including a stalled success or error body. Historical malformed tool calls +are retained as explanatory text and observations instead of being replayed as +invalid native function arguments; Autohand does not invent replacement arguments. + --- ### Anthropic @@ -222,6 +227,14 @@ Current catalog choices include `claude-sonnet-5`, `claude-opus-5`, `claude-fabl See Anthropic's [model overview](https://platform.claude.com/docs/en/about-claude/models/overview) and [TypeScript Messages API](https://platform.claude.com/docs/en/api/typescript/messages/create) for the upstream contracts. +Official OpenCode Go Anthropic endpoints receive Autohand's own User-Agent and +an opaque `x-opencode-session` conversation identifier, following +[Go's routing requirements](https://opencode.ai/docs/console/go/#where-can-i-use-it). +The conversation identity remains stable when prompt caching is disabled and +changes for a new session. These routing headers are restricted to supported +official HTTPS Go paths; unrelated Anthropic and custom endpoints do not receive +them. Raw session IDs are not added to provider request bodies. + --- ### OpenAI @@ -230,6 +243,20 @@ 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. +For supported modern OpenAI reasoning models, API-key tool requests use the +Responses API with the configured output cap, tool choice, and supported reasoning +effort. Encrypted reasoning items are preserved for tool continuation. ChatGPT +subscription authentication retains its separate Codex backend parameter contract. +Requests omit unsupported sampling parameters. An explicitly unsupported reasoning +setting receives actionable guidance rather than silently enabling more reasoning. +See OpenAI's [API and model compatibility guidance](https://developers.openai.com/api/docs/guides/latest-model#gpt-6-astra-update-api-and-model-parameters). + +Custom base URLs retain their Chat Completions transport unless an explicit +capability rejection requires Responses. Compatibility retries stay on the same +configured endpoint, keep tools and reasoning intact, and are bounded. Unknown +model aliases that explicitly reject `max_tokens` can be resent once with +`max_completion_tokens`. + **Setup:** 1. Choose one of these authentication methods: @@ -618,6 +645,16 @@ llama.cpp provides high-performance local inference with GGUF models. } ``` +Autohand reads the active llama.cpp slot context from `/props` when available, +without asking a router to load a different model. This runtime limit, rather +than a model's training-context metadata, bounds prompt and output budgeting. +Discovery runs asynchronously while the composer remains usable; the first +request waits only for its bounded readiness deadline. Model changes invalidate +stale discovery results. A configured smaller context limit remains respected. +If the server does not expose a runtime limit, the existing configured/model +fallback remains in use. Router errors for an unconfigured model identify a +model configuration problem; Autohand does not silently substitute another model. + **Finding GGUF Models:** - [Hugging Face GGUF Models](https://huggingface.co/models?search=gguf) diff --git a/docs/releasing.md b/docs/releasing.md index 89c93a30a..3ac03baca 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -10,7 +10,7 @@ The release pipeline is `.github/workflows/release.yml`. Use Node.js 24 and the 4. Dispatch the stable release from the approved commit on main: ```sh -gh workflow run release.yml --ref main -f version=0.9.10 -f channel=release +gh workflow run release.yml --ref main -f version=0.9.11 -f channel=release ``` If the workflow has been disabled by a maintainer, it must be enabled for an authorized release before dispatch. Enabling it also enables its main-push trigger. A push to main produces an **alpha**, not a stable release; a stable release requires manual dispatch with `channel=release`. diff --git a/homebrew/autohand.rb b/homebrew/autohand.rb index a4ffb6f64..561369635 100644 --- a/homebrew/autohand.rb +++ b/homebrew/autohand.rb @@ -3,7 +3,7 @@ class Autohand < Formula desc "Autonomous LLM-powered coding agent CLI" homepage "https://autohand.ai" - url "https://registry.npmjs.org/autohand-cli/-/autohand-cli-0.9.10.tgz" + url "https://registry.npmjs.org/autohand-cli/-/autohand-cli-0.9.11.tgz" sha256 "PLACEHOLDER" license "Apache-2.0" diff --git a/package.json b/package.json index e769eb196..185be69d2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "autohand-cli", - "version": "0.9.10", + "version": "0.9.11", "packageManager": "bun@1.4.2", "license": "Apache-2.0", "description": "Autohand Code CLI is a fast, terminal-native AI coding agent for planning, editing, testing, and automating software work.", diff --git a/src/actions/filesystem.ts b/src/actions/filesystem.ts index 1a70ea3fc..90387ccc2 100644 --- a/src/actions/filesystem.ts +++ b/src/actions/filesystem.ts @@ -10,10 +10,9 @@ import { createHash } from 'node:crypto'; import os from 'node:os'; import path from 'node:path'; import { spawnSync } from 'node:child_process'; -import { applyPatch as applyUnifiedPatch } from 'diff'; import { GitIgnoreParser } from '../utils/gitIgnore.js'; import { resolveRipgrepCommand } from '../utils/ripgrep.js'; -import { validateAndFixPatch } from '../utils/patchValidator.js'; +import { applyValidatedPatch, extractPatchTarget } from '../utils/patchValidator.js'; import { readTextFileWindow, type ReadTextWindowOptions, @@ -908,16 +907,16 @@ export class FileActionManager { async applyPatch(target: string, patch: string, description?: string): Promise { const turn = this.turnProvider(); const filePath = this.resolvePath(target); + const envelopeTarget = extractPatchTarget(patch); + if (envelopeTarget !== undefined + && this.resolveRealPathOrAncestor(this.resolvePath(envelopeTarget)) !== this.resolveRealPathOrAncestor(filePath)) { + throw new Error(`apply_patch: envelope target ${envelopeTarget} does not match ${target}.`); + } const existed = await fs.pathExists(filePath); const previousBytes = existed ? await fs.readFile(filePath) : null; const previousMode = existed ? (await fs.stat(filePath)).mode : undefined; const current = await this.readFileSafe(target); - // Validate and fix the patch to correct any mismatched line counts in hunk headers - const fixedPatch = validateAndFixPatch(patch); - const updated = applyUnifiedPatch(current, fixedPatch); - if (updated === false) { - throw new Error(`Failed to apply patch to ${target}`); - } + const updated = applyValidatedPatch(current, patch); // In preview mode, batch the change instead of writing if (this.previewMode) { diff --git a/src/commands/settings.ts b/src/commands/settings.ts index 349a70888..12d86a0b6 100644 --- a/src/commands/settings.ts +++ b/src/commands/settings.ts @@ -20,6 +20,7 @@ import { saveConfig } from '../config.js'; import { BUILT_IN_PROVIDER_NAMES, type BuiltInProviderName, type LoadedConfig } from '../types.js'; import { DEFAULT_MAX_CONCURRENT_THREADS_PER_SESSION, MAX_CONCURRENT_THREADS_PER_SESSION, isValidSessionThreadLimit } from '../core/agents/SessionThreadBudget.js'; import { TRACE_CONSENT_VERSION } from '../integrations/ahtraces/consent.js'; +import { DEFAULT_SUBAGENT_MAX_ITERATIONS, MAX_SUBAGENT_ITERATIONS, isValidSubagentIterationLimit } from '../core/agents/subagentIterations.js'; // ── Types ────────────────────────────────────────────────────────────── @@ -202,6 +203,13 @@ export const SETTINGS_REGISTRY: SettingDef[] = [ || 'Decision timeout must be an integer between 1 and 10000 milliseconds.', }, + { + key: 'agent.subagentMaxIterations', labelKey: 'commands.settings.agent.subagentMaxIterations', + descriptionKey: 'commands.settings.agent.subagentMaxIterationsDesc', category: 'agent', type: 'number', + defaultValue: DEFAULT_SUBAGENT_MAX_ITERATIONS, + validate: value => isValidSubagentIterationLimit(Number(value)) || `Sub-agent iteration limit must be an integer between 1 and ${MAX_SUBAGENT_ITERATIONS}.`, + }, + // Concurrent Sessions { key: 'sessions.awareness', labelKey: 'commands.settings.sessions.awareness', descriptionKey: 'commands.settings.sessions.awarenessDesc', category: 'sessions', type: 'enum', enumValues: ['passive', 'warn', 'coordinate'], defaultValue: 'warn' }, diff --git a/src/config.ts b/src/config.ts index fb6f67ec5..3d2765783 100644 --- a/src/config.ts +++ b/src/config.ts @@ -53,6 +53,7 @@ import { isAwsBedrockProviderEnabled } from "./features/featureRegistry.js"; import { getCustomProviderConfig, isCustomProviderName } from "./providers/customProviders.js"; import { getProviderDefaultModel, getProviderModelOptions, getProviderRuntimeDefaultModel, normalizeOpenRouterModelId } from "./providers/modelCatalog.js"; import { MAX_CONCURRENT_THREADS_PER_SESSION, isValidSessionThreadLimit } from "./core/agents/SessionThreadBudget.js"; +import { isValidSubagentIterationLimit, MAX_SUBAGENT_ITERATIONS } from './core/agents/subagentIterations.js'; import { atomicWriteFile, withFileLock, type AtomicCommitOptions } from './utils/atomicFile.js'; const DEFAULT_CONFIG_PATH = AUTOHAND_FILES.configJson; @@ -1728,6 +1729,9 @@ function validateConfig(config: AutohandConfig, configPath: string): void { // Validate agent config if (config.agent) { + if (config.agent.subagentMaxIterations !== undefined && !isValidSubagentIterationLimit(config.agent.subagentMaxIterations)) { + throw new Error(`agent.subagentMaxIterations must be an integer between 1 and ${MAX_SUBAGENT_ITERATIONS} in ${configPath}`); + } if ( config.agent.toolSelectionCache !== undefined && typeof config.agent.toolSelectionCache !== "boolean" diff --git a/src/core/HookManager.ts b/src/core/HookManager.ts index b74609742..5fba49f37 100644 --- a/src/core/HookManager.ts +++ b/src/core/HookManager.ts @@ -97,6 +97,7 @@ export interface HookContext { subagentParentId?: string; subagentSource?: string; subagentStatus?: string; + subagentReason?: 'iteration_limit'; subagentWorkspace?: string; subagentTask?: string; subagentActivity?: string; diff --git a/src/core/actionExecutor.ts b/src/core/actionExecutor.ts index 69875771c..23bc9537c 100644 --- a/src/core/actionExecutor.ts +++ b/src/core/actionExecutor.ts @@ -74,6 +74,7 @@ import { webRepo, formatRepoInfo, formatRepoDir } from '../actions/webRepo.js'; import { projectTracker } from '../actions/projectTracker.js'; import { installSubAgentFromCatalog, searchSubAgentsCatalog } from '../actions/subAgentsCatalog.js'; import { PermissionManager } from '../permissions/PermissionManager.js'; +import { extractPatchTarget } from '../utils/patchValidator.js'; import { getPermissionPolicyDisposition, type PermissionContext, @@ -908,7 +909,16 @@ export class ActionExecutor { } } + private normalizePatchAction(action: AgentAction): AgentAction { + if (!action || action.type !== 'apply_patch') return action; + const patch = this.pickText(action.patch, action.diff); + if (!patch) return action; + const target = extractPatchTarget(patch); + return !action.path && target !== undefined ? { ...action, path: target } : action; + } + async execute(action: AgentAction, context?: ToolExecutionContext): Promise { + action = this.normalizePatchAction(action); return this.withToolActivity(action, () => this.executeLegacy(action, context)); } @@ -920,6 +930,13 @@ export class ActionExecutor { return this.createAbortedOutcome(); } + try { + action = this.normalizePatchAction(action); + } catch (error) { + const message = this.normalizeToolError(error); + return { success: false, kind: 'validation', error: message, output: `Error: ${message}` }; + } + const validationFailure = this.validateToolAction(action); if (validationFailure) { return validationFailure; diff --git a/src/core/agent.ts b/src/core/agent.ts index 04aa27128..dfb6f0382 100644 --- a/src/core/agent.ts +++ b/src/core/agent.ts @@ -44,7 +44,7 @@ import { maybeOfferAutohandAISwitch } from '../commands/login.js'; import { getFeatureState, isAwsBedrockProviderEnabled } from '../features/featureRegistry.js'; import type { RemoteFeatureFlagManager } from '../features/RemoteFeatureFlagManager.js'; import type { LLMProvider } from '../providers/LLMProvider.js'; -import { ApiError, waitForProviderRetry } from '../providers/errors.js'; +import { ApiError, readStorageCapacityError, waitForProviderRetry } from '../providers/errors.js'; import { safeEmitKeypressEvents } from '../ui/inputPrompt.js'; import { safeSetRawMode } from '../ui/rawMode.js'; @@ -402,7 +402,7 @@ export class AutohandAgent { private followupQuestionCallback?: ( question: string, suggestedAnswers?: string[], - ) => Promise; + ) => Promise; private mobileRelayController?: MobileRelayController; private mobileTurnFailureMessage: string | null = null; private conversation!: ConversationManager; @@ -3047,6 +3047,12 @@ export class AutohandAgent { this.emitOutput(event); } + setFollowupQuestionCallback( + callback: (question: string, suggestedAnswers?: string[]) => Promise + ): void { + this.followupQuestionCallback = callback; + } + /** * Set a callback for confirmation prompts (used by RPC mode) * When set, this callback is used instead of the default Modal prompt @@ -3058,6 +3064,8 @@ export class AutohandAgent { } private getDisplayErrorMessage(error: unknown): string { + const storage = readStorageCapacityError(error); + if (storage) return storage.message; if (error instanceof Error && error.message.trim()) { return error.message; } diff --git a/src/core/agent/AgentCommandRuntime.ts b/src/core/agent/AgentCommandRuntime.ts index 966ba91bf..7feb69c10 100644 --- a/src/core/agent/AgentCommandRuntime.ts +++ b/src/core/agent/AgentCommandRuntime.ts @@ -448,17 +448,26 @@ export async function requestAgentDirectoryAccess(host: AgentCommandRuntimeHost, return undefined; } +function cancelFollowupQuestion(host: AgentCommandRuntimeHost): string { + host.consecutiveCancellations++; + console.log(chalk.yellow('\n (Question cancelled)\n')); + return 'User cancelled this question. Do NOT call ask_followup_question again. Continue with your best judgment or provide a final response.'; +} + export async function executeAgentAskFollowupQuestion(host: AgentCommandRuntimeHost, question: string, suggestedAnswers?: string[]): Promise { if (host.followupQuestionCallback) { try { - const mobileAnswer = await withProgramStatusWait('question', question, () => host.followupQuestionCallback(question, suggestedAnswers)); - if (typeof mobileAnswer === 'string' && mobileAnswer.trim()) { + const answer = await withProgramStatusWait('question', question, () => host.followupQuestionCallback(question, suggestedAnswers)); + if (answer === null) { + return cancelFollowupQuestion(host); + } + if (typeof answer === 'string' && answer.trim()) { host.consecutiveCancellations = 0; - console.log(chalk.green(`\n✓ Answer: ${mobileAnswer}\n`)); - return `${mobileAnswer}`; + console.log(chalk.green(`\n✓ Answer: ${answer}\n`)); + return `${answer}`; } } catch { - // A mobile transport failure falls through to the existing local prompt. + // An unavailable remote channel can still fall back to a local interactive prompt. } } @@ -488,9 +497,7 @@ export async function executeAgentAskFollowupQuestion(host: AgentCommandRuntimeH }); if (answer === null) { - host.consecutiveCancellations++; - console.log(chalk.yellow('\n (Question cancelled)\n')); - return 'User cancelled this question. Do NOT call ask_followup_question again. Continue with your best judgment or provide a final response.'; + return cancelFollowupQuestion(host); } host.consecutiveCancellations = 0; diff --git a/src/core/agent/AgentDependencyComposer.ts b/src/core/agent/AgentDependencyComposer.ts index 9e260d21d..39ee8298e 100644 --- a/src/core/agent/AgentDependencyComposer.ts +++ b/src/core/agent/AgentDependencyComposer.ts @@ -17,6 +17,7 @@ import { join } from 'node:path'; import { FileActionManager, type FileMutationTurn } from '../../actions/filesystem.js'; import { saveConfig, getProviderConfig } from '../../config.js'; import type { LLMProvider } from '../../providers/LLMProvider.js'; +import { resolveRuntimeContextWindow } from '../../providers/runtimeContextWindow.js'; import { getOpenRouterModelContextWindow } from '../../providers/modelCapabilities.js'; import { getHelpOrderedSlashCommands, promptInterrupt, promptNotify } from '../../ui/inputPrompt.js'; import { isShellCommand, parseShellCommand } from '../../ui/shellCommand.js'; @@ -490,6 +491,20 @@ export function initializeAgentDependencies( const providerSettings = getProviderConfig(runtime.config, initialProvider); const model = runtime.options.model ?? providerSettings?.model ?? 'unconfigured'; host.contextWindow = getContextWindow(model, providerSettings?.contextWindow); + if (!runtime.options.bare && runtime.options.daemon !== false && llm.getContextWindow) { + void (async () => { + const reported = await resolveRuntimeContextWindow(async signal => llm.getContextWindow?.(signal)); + const currentSettings = getProviderConfig(runtime.config, initialProvider); + const currentModel = runtime.options.model ?? currentSettings?.model ?? 'unconfigured'; + if (host.llm !== llm || (runtime.config.provider ?? 'openrouter') !== initialProvider || currentModel !== model) return; + if (typeof reported !== 'number' || !Number.isSafeInteger(reported) || reported <= 0) return; + const contextWindow = Math.min(reported, getContextWindow(model, currentSettings?.contextWindow ?? reported)); + if (contextWindow === host.contextWindow) return; + host.contextWindow = contextWindow; + host.contextOrchestrator?.setContextWindow?.(contextWindow); + if (host.conversation) host.updateContextUsage?.(host.conversation.history()); + })().catch(() => {}); + } if (!runtime.options.bare && runtime.options.daemon !== false && initialProvider === 'openrouter' && !providerSettings?.contextWindow && model !== 'unconfigured') { void getOpenRouterModelContextWindow(model) .then((contextWindow) => { @@ -1067,13 +1082,14 @@ export function initializeAgentDependencies( host.agentRunStore.finish(context.subagentId, { status: context.status ?? (context.success ? 'completed' : 'failed'), result: context.result, error: context.error, usage: context.usage, + reason: context.reason, }); host.inkRenderer?.upsertActivityItem?.({ id: context.subagentId, kind: 'subagent', label: formatSubAgentActivityLabel(context.subagentName, context.task), - status: context.success ? 'completed' : 'failed', - detail: context.success + status: context.status === 'partial' ? 'partial' : context.success ? 'completed' : 'failed', + detail: context.status === 'partial' ? 'Partial result · iteration limit reached' : context.success ? `${Math.max(0, Math.round(context.duration / 1000))}s` : context.error ?? 'failed', }); @@ -1524,7 +1540,7 @@ export function initializeAgentDependencies( } else if (action.type === 'delegate_task') { const cached = host.specialistOrchestrator.turnResultForAgent(action.agent_name); outcome = cached === undefined - ? await host.delegator.delegateTaskForTool(action.agent_name, action.task, { signal: context?.signal }) + ? await host.delegator.delegateTaskForTool(action.agent_name, action.task, { signal: context?.signal, maxIterations: action.maxIterations, researchOnly: action.research_only }) : cached; } else if (action.type === 'delegate_parallel') { const cached: ToolActionOutcome[] = []; @@ -1535,12 +1551,13 @@ export function initializeAgentDependencies( return false; }); const freshOutcome: ToolActionOutcome = pending.length > 0 - ? await host.delegator.delegateParallelForTool(pending, { signal: context?.signal }) + ? await host.delegator.delegateParallelForTool(pending, { signal: context?.signal, maxIterations: action.maxIterations, researchOnly: action.research_only }) : { success: true, output: '' }; if (cached.length > 0) { const failure = [...cached, freshOutcome].find(result => !result.success); const output = [...new Set([...cached.map(result => result.output), freshOutcome.output])].filter(Boolean).join('\n'); - outcome = { ...(failure ?? freshOutcome), output }; + const partial = [...cached, freshOutcome].some(result => result.success && result.status === 'partial'); + outcome = { ...(failure ?? freshOutcome), output, ...(!failure && partial ? { status: 'partial', reason: 'iteration_limit' } : {}) }; } else { outcome = freshOutcome; } diff --git a/src/core/agent/AutohandOrchestration.ts b/src/core/agent/AutohandOrchestration.ts index 6f848879c..b0a6a8936 100644 --- a/src/core/agent/AutohandOrchestration.ts +++ b/src/core/agent/AutohandOrchestration.ts @@ -47,7 +47,8 @@ export const RESEARCH_WORKER_INSTRUCTIONS = 'You are a read-only research worker export const ORCHESTRATION_INSTRUCTIONS = 'Autohand AI orchestration is enabled. You own planning, code edits, ' + 'tests and integration. Delegate independent file discovery, symbol/AST summaries and documentation lookup ' - + 'to the three-worker read-only research pool using delegate_parallel. Workers default to Fantail; respect ' + + 'to the three-worker read-only research pool using delegate_parallel with research_only: true. Ordinary ' + + 'reviewer and implementer delegations retain their definitions, tools and reasoning. Workers default to Fantail unless their definition requests reasoning; respect ' + 'the user\'s model overrides. Give each worker a bounded scope and request structured findings with evidence. ' + 'Use plan/exit_plan_mode when planning mode is active. Moa provides independent checkpoint advice before ' + 'finalizing plans, after a repeated test/compiler failure, and before staging, committing or completing tool work. ' diff --git a/src/core/agent/CodeModeRunner.ts b/src/core/agent/CodeModeRunner.ts index 777baa746..5f34a1dd1 100644 --- a/src/core/agent/CodeModeRunner.ts +++ b/src/core/agent/CodeModeRunner.ts @@ -95,9 +95,14 @@ function isArgumentObject(value: unknown): value is NonNullable { + const reported = await resolveRuntimeContextWindow(async signal => provider.getContextWindow?.(signal)); + if (this.getLlm() !== provider || this.getActiveProvider() !== providerName || this.runtime.options.model !== model) return; + if (typeof reported !== 'number' || !Number.isSafeInteger(reported) || reported <= 0) return; + const settings = getProviderConfig(this.runtime.config, providerName); + this.updateContextWindow(Math.min(reported, getContextWindow(model, settings?.contextWindow ?? reported))); + this.resetContextPercent(); + this.emitStatus(); + })().catch(() => {}); + } + /** * Set provider and model in runtime config */ @@ -4187,7 +4209,9 @@ export class ProviderConfigManager { }; const builtInProvider = provider as BuiltInProviderName; cfgMap[builtInProvider].model = model; - cfgMap[builtInProvider].contextWindow = contextWindow; + if (builtInProvider !== 'llamacpp' || cfgMap[builtInProvider].contextWindow !== undefined) { + cfgMap[builtInProvider].contextWindow = contextWindow; + } this.setActiveProvider(provider); } diff --git a/src/core/agent/ReactLoopRunner.ts b/src/core/agent/ReactLoopRunner.ts index 337100014..9d200c664 100644 --- a/src/core/agent/ReactLoopRunner.ts +++ b/src/core/agent/ReactLoopRunner.ts @@ -14,6 +14,7 @@ import type { SkillsRegistry } from '../../skills/SkillsRegistry.js'; import { prepareSkillRequest } from './SkillRequestAttachment.js'; import { normalizePromptRoles, toTextOnlyContent } from '../../providers/messagePayload.js'; import { ApiError } from '../../providers/errors.js'; +import { resolveRuntimeContextWindow } from '../../providers/runtimeContextWindow.js'; import { DEFAULT_TOOL_OUTPUT_MODE, formatToolOutputForDisplay, type ToolOutputDisplay, type ToolOutputMode } from '../../ui/toolOutput.js'; import { COMPUTER_USE_TOOL_PREFIX } from '../../computer/computerUseOutput.js'; import { getPlanModeManager } from '../../commands/plan.js'; @@ -46,7 +47,7 @@ import type { SteeringQueue } from './SteeringQueue.js'; import type { ContextOrchestrator } from '../context/orchestrator.js'; import type { ToolManager } from '../toolManager.js'; import type { ToolsRegistry } from '../toolsRegistry.js'; -import { calculateContextUsage } from '../context/tokenizer.js'; +import { calculateContextUsage, getContextWindow } from '../context/tokenizer.js'; import { filterToolsByRelevance, limitToolSelection } from '../toolFilter.js'; import { selectCodeModeTools } from './CodeModeRunner.js'; import type { PeerCommunicationRuntime } from './PeerCommunicationRuntime.js'; @@ -651,19 +652,34 @@ export async function runAgentReactLoop( host.setSpinnerStatus(`Moa reviewing ${checkpoint.replaceAll('_', ' ')}... (esc to interrupt)`); let context = evidence; if (checkpoint === 'commit' || checkpoint === 'completion') { - try { - if (host.captureAdvisorDiff) context += `\n${await host.captureAdvisorDiff()}`; - else { - const changes = await WorkspaceChangeCapture.pending(host.runtime.workspaceRoot); - if (changes?.omittedFiles || changes?.files.some(file => file.patch.includes('[diff truncated]'))) { - return { verdict: 'unavailable', feedback: 'The workspace diff exceeds the review limit. Review a smaller change before completing.' }; + let unavailableDiffReason: string | undefined; + if (checkpoint === 'completion' && (host.workspaceChangeCaptureDisabled || workspaceChangeCapture?.hasExceededBudget())) { + host.workspaceChangeCaptureDisabled = true; + unavailableDiffReason = 'Workspace change capture exceeded its time budget earlier in this session'; + } else { + try { + if (host.captureAdvisorDiff) context += `\n${await host.captureAdvisorDiff()}`; + else { + const changes = await WorkspaceChangeCapture.pending(host.runtime.workspaceRoot); + if (changes?.omittedFiles || changes?.files.some(file => file.patch.includes('[diff truncated]'))) { + return { verdict: 'unavailable', feedback: 'The workspace diff exceeds the review limit. Review a smaller change before completing.' }; + } + context += `\nWorkspace diff against HEAD (includes staged and untracked files): ${JSON.stringify(changes ?? { unavailable: 'This workspace is not a Git repository; use the session evidence and disclose this limit.' })}`; } - context += `\nWorkspace diff against HEAD (includes staged and untracked files): ${JSON.stringify(changes ?? { unavailable: 'This workspace is not a Git repository; use the session evidence and disclose this limit.' })}`; + } catch (error) { + abortController.signal.throwIfAborted(); + if (checkpoint !== 'completion' || !(error instanceof WorkspaceChangeCaptureBudgetError)) { + return { verdict: 'unavailable', feedback: `Cannot capture the workspace diff: ${error instanceof Error ? error.message : String(error)}` }; + } + host.workspaceChangeCaptureDisabled = true; + unavailableDiffReason = error.message; } - } catch (error) { - return { verdict: 'unavailable', feedback: `Cannot capture the workspace diff: ${error instanceof Error ? error.message : String(error)}` }; + } + if (unavailableDiffReason) { + context += `\nWorkspace diff unavailable: ${unavailableDiffReason}. Review the available session evidence and disclose this limitation. Do not assume the workspace has no changes.`; } } + abortController.signal.throwIfAborted(); const reviewContext = { history: host.conversation.history(), context, signal: abortController.signal }; let review = await advisor!.review(checkpoint, reviewContext); if (review.verdict === 'unavailable') { @@ -863,7 +879,29 @@ export async function runAgentReactLoop( } // Use ContextOrchestrator for smart auto-compaction - const model = host.runtime.options.model ?? getProviderConfig(host.runtime.config, host.activeProvider)?.model ?? 'unconfigured'; + let model = host.runtime.options.model ?? getProviderConfig(host.runtime.config, host.activeProvider)?.model ?? 'unconfigured'; + let contextProvider = host.llm; + if (!host.runtime.options.bare && host.runtime.options.daemon !== false && contextProvider.getContextWindow) { + const contextSignal = AbortSignal.any([abortController.signal, AbortSignal.timeout(2000)]); + while (!contextSignal.aborted && contextProvider.getContextWindow) { + const providerName = host.activeProvider; + const reported = await resolveRuntimeContextWindow( + async signal => contextProvider.getContextWindow?.(signal), + contextSignal, + ); + if (abortController.signal.aborted) break; + const settings = getProviderConfig(host.runtime.config, host.activeProvider); + const currentModel = host.runtime.options.model ?? settings?.model ?? 'unconfigured'; + if (host.llm !== contextProvider || host.activeProvider !== providerName || currentModel !== model) { + contextProvider = host.llm; + model = currentModel; + continue; + } + if (reported) host.contextWindow = Math.min(reported, getContextWindow(model, settings?.contextWindow ?? reported)); + break; + } + if (abortController.signal.aborted) break; + } host.contextOrchestrator.setModel(model); host.contextOrchestrator.setContextWindow?.(host.contextWindow); @@ -1016,6 +1054,7 @@ export async function runAgentReactLoop( maxTokens, thinkingLevel, promptCache: getSessionPromptCacheDirective(host), + sessionId: host.sessionManager.getCurrentSession()?.metadata.sessionId, onRetry, ...(supportsStreaming ? { stream: true, onDelta: combineDeltaHandlers(preview?.onDelta, relay?.onDelta, delta => { if (delta.text) acknowledgeSkills(); }) } : {}), }); diff --git a/src/core/agent/SimpleChatHandler.ts b/src/core/agent/SimpleChatHandler.ts index e7e88c1c6..f36c4c3a0 100644 --- a/src/core/agent/SimpleChatHandler.ts +++ b/src/core/agent/SimpleChatHandler.ts @@ -80,6 +80,7 @@ export class SimpleChatHandler { tools: [], maxTokens: 1000, temperature: 0.7, + ...(sessionId ? { sessionId } : {}), ...(promptCache ? { promptCache } : {}), ...(supportsStreaming ? { stream: true, onDelta: preview?.onDelta } : {}), }); diff --git a/src/core/agents/AgentDelegator.ts b/src/core/agents/AgentDelegator.ts index 5ca224de9..f36d30e38 100644 --- a/src/core/agents/AgentDelegator.ts +++ b/src/core/agents/AgentDelegator.ts @@ -18,6 +18,7 @@ import { UnattendedApprovalError } from '../../permissions/unattended.js'; import type { TeamModelAssignment } from '../teams/TeamModelPolicy.js'; import { getSessionThreadBudget, SessionThreadLimitError, type ThreadBudget, type ThreadLease } from './SessionThreadBudget.js'; import type { PeerRunRuntime, PeerRunRuntimeFactory } from '../agent/PeerCommunicationRuntime.js'; +import { isValidSubagentIterationLimit, MAX_SUBAGENT_ITERATIONS } from './subagentIterations.js'; /** Default maximum delegation depth to prevent infinite loops */ const DEFAULT_MAX_DEPTH = 3; @@ -27,10 +28,19 @@ export type SubagentProviderFactory = (assignment: TeamModelAssignment) => LLMPr export interface DelegationExecutionOptions { signal?: AbortSignal; + maxIterations?: number; + researchOnly?: boolean; +} + +export interface ParallelDelegationTask { + agent_name: string; + task: string; + maxIterations?: number; + research_only?: boolean; } type ParallelDelegationResult = - | { success: true; text: string } + | { success: true; text: string; partial: boolean } | { success: false; kind: 'validation' | 'operational' | 'aborted' | 'authorization'; @@ -73,7 +83,8 @@ export interface SubagentStopContext extends SubagentStartContext { error?: string; /** Duration in milliseconds */ duration: number; - status?: 'completed' | 'failed' | 'cancelled'; + status?: 'completed' | 'partial' | 'failed' | 'cancelled'; + reason?: 'iteration_limit'; } export interface SubagentProgressContext extends SubagentStartContext, SubAgentProgress {} @@ -164,6 +175,9 @@ export class AgentDelegator { public async delegateTaskForTool( agentName: string, task: string, options: DelegationExecutionOptions = {}, ): Promise { + if (options.maxIterations !== undefined && !isValidSubagentIterationLimit(options.maxIterations)) { + return { success: false, kind: 'validation', error: `maxIterations must be an integer between 1 and ${MAX_SUBAGENT_ITERATIONS}.` }; + } // Check depth limit to prevent infinite delegation loops if (this.currentDepth >= this.maxDepth) { const error = `Maximum delegation depth (${this.maxDepth}) reached. Cannot delegate to '${agentName}'.`; @@ -188,6 +202,7 @@ export class AgentDelegator { const workspaceRoot = this.options.getWorkspaceRoot?.() ?? this.options.workspaceRoot; const userRequest = this.options.getUserRequest?.(); const subAgentOptions: SubAgentOptions = { + researchOnly: options.researchOnly, workspaceRoot, projectMemoryEnabled: this.options.projectMemoryEnabled, userRequest, @@ -268,22 +283,23 @@ export class AgentDelegator { onProgress: progress => this.notifyObserver(this.options.onSubagentProgress, { ...startContext, ...progress }), }, ); - const result = await agent.run(task, { signal }); + const result = await agent.run(task, { signal, maxIterations: options.maxIterations }); + const completion = agent.getCompletionState() ?? { status: 'completed' as const }; acceptingMessages = false; // Fire subagent-stop hook on success if (started && this.onSubagentStop) { await this.notifyObserver(this.onSubagentStop, { ...startContext, - success: true, - status: 'completed', + success: completion.status === 'completed', + ...completion, result, usage: agent.getUsage(), duration: Date.now() - startTime }); } - return { success: true, output: result }; + return { success: true, output: result, ...completion }; } catch (error) { acceptingMessages = false; const errorMessage = error instanceof Error ? error.message : String(error); @@ -316,14 +332,17 @@ export class AgentDelegator { } } - public async delegateParallel(tasks: Array<{ agent_name: string; task: string }>): Promise { + public async delegateParallel(tasks: ParallelDelegationTask[]): Promise { return this.toLegacyOutput(await this.delegateParallelForTool(tasks)); } public async delegateParallelForTool( - tasks: Array<{ agent_name: string; task: string }>, + tasks: ParallelDelegationTask[], options: DelegationExecutionOptions = {}, ): Promise { + if ([options.maxIterations, ...tasks.map(task => task.maxIterations)].some(limit => limit !== undefined && !isValidSubagentIterationLimit(limit))) { + return { success: false, kind: 'validation', error: `maxIterations must be an integer between 1 and ${MAX_SUBAGENT_ITERATIONS}.` }; + } // Check depth limit if (this.currentDepth >= this.maxDepth) { const error = `Maximum delegation depth (${this.maxDepth}) reached. Cannot delegate parallel tasks.`; @@ -332,7 +351,7 @@ export class AgentDelegator { await this.registry.loadAgents(); - const promises = tasks.map(async ({ agent_name, task }): Promise => { + const promises = tasks.map(async ({ agent_name, task, maxIterations, research_only }): Promise => { const agentConfig = this.registry.getAgent(agent_name); if (!agentConfig) { const error = `Agent '${agent_name}' not found.`; @@ -344,9 +363,14 @@ export class AgentDelegator { }; } - const result = await this.runRegisteredTask(agentConfig, agent_name, task, options); + const result = await this.runRegisteredTask(agentConfig, agent_name, task, { + ...options, + maxIterations: options.maxIterations === undefined ? maxIterations + : Math.min(options.maxIterations, maxIterations ?? options.maxIterations), + researchOnly: options.researchOnly === true || research_only === true, + }); return result.success - ? { success: true, text: `[${agent_name}] Result:\n${result.output ?? ''}` } + ? { success: true, partial: result.status === 'partial', text: `[${agent_name}] ${result.status === 'partial' ? 'Partial (iteration_limit)' : 'Done'}:\n${result.output ?? ''}` } : { success: false, kind: result.kind === 'aborted' || result.kind === 'validation' || result.kind === 'authorization' ? result.kind : 'operational', @@ -369,7 +393,8 @@ export class AgentDelegator { output, }; } - return { success: true, output }; + const partial = results.some(result => result.success && result.partial); + return { success: true, output, ...(partial ? { status: 'partial' as const, reason: 'iteration_limit' as const } : { status: 'completed' as const }) }; } private toLegacyOutput(outcome: ToolActionOutcome): string { diff --git a/src/core/agents/AgentRegistry.ts b/src/core/agents/AgentRegistry.ts index db5df6686..c404bfbe8 100644 --- a/src/core/agents/AgentRegistry.ts +++ b/src/core/agents/AgentRegistry.ts @@ -14,6 +14,7 @@ import { resolveEmbeddedBuiltinAssetDirectory } from '../../skills/embeddedBuilt import type { ExternalAgentsConfig, InlineAgentDefinition } from '../../types.js'; import type { ExtensionAgentContribution, ExtensionScope } from '../../extensions/types.js'; import { BUILTIN_AGENT_HANDOFF } from './builtinAgentContract.js'; +import { isValidSubagentIterationLimit, MAX_SUBAGENT_ITERATIONS } from './subagentIterations.js'; import { isCatalogManagedContent, readCatalogProvenance, @@ -46,6 +47,7 @@ export const AgentConfigSchema = z.object({ systemPrompt: z.string(), tools: z.array(z.string()), model: z.string().optional(), + maxIterations: z.number().int().min(1).max(MAX_SUBAGENT_ITERATIONS).optional(), /** Reasoning depth the agent wants; providers with a reasoning tier route it there. */ reasoning: z.enum(AGENT_REASONING_LEVELS).optional(), /** Skills whose instructions are active for this agent from the first request. */ @@ -71,6 +73,7 @@ export const InlineAgentInputSchema = z.object({ prompt: z.string().min(1, 'agent "prompt" is required'), tools: z.union([z.array(z.string()), z.string()]).optional(), model: z.string().optional(), + maxIterations: z.number().int().min(1).max(MAX_SUBAGENT_ITERATIONS).optional(), reasoning: z.enum(AGENT_REASONING_LEVELS).optional(), skills: z.union([z.array(z.string()), z.string()]).optional(), }); @@ -125,6 +128,7 @@ export function parseInlineAgents(input: string | Record): Inli systemPrompt: def.prompt, tools: normalizeInlineTools(def.tools), model: def.model, + ...(def.maxIterations === undefined ? {} : { maxIterations: def.maxIterations }), ...(def.reasoning ? { reasoning: def.reasoning } : {}), ...(normalizeInlineTools(def.skills).length ? { skills: normalizeInlineTools(def.skills) } : {}), })); @@ -161,6 +165,7 @@ function parseMarkdownAgent(content: string): { systemPrompt: string; tools: string[]; model?: string; + maxIterations?: number; reasoning?: AgentConfig['reasoning']; skills?: string[]; } { @@ -182,11 +187,17 @@ function parseMarkdownAgent(content: string): { } } + const maxIterations = meta.maxIterations === undefined ? undefined : Number(meta.maxIterations); + if (maxIterations !== undefined && !isValidSubagentIterationLimit(maxIterations)) { + throw new Error(`maxIterations must be an integer between 1 and ${MAX_SUBAGENT_ITERATIONS}.`); + } + return { description: meta.description || extractMarkdownTitle(body), systemPrompt: body.trim(), tools: meta.tools ? meta.tools.split(',').map((t) => t.trim()).filter(Boolean) : [], model: meta.model, + ...(maxIterations === undefined ? {} : { maxIterations }), reasoning: parseAgentReasoning(meta.reasoning), ...(meta.skills ? { skills: meta.skills.split(',').map((name) => name.trim()).filter(Boolean) } : {}), }; @@ -362,6 +373,7 @@ export class AgentRegistry { systemPrompt: def.systemPrompt, tools: def.tools.length > 0 ? def.tools : ['*'], model: def.model, + ...(def.maxIterations === undefined ? {} : { maxIterations: def.maxIterations }), ...(def.reasoning ? { reasoning: def.reasoning } : {}), ...(def.skills?.length ? { skills: def.skills } : {}), }); @@ -429,6 +441,7 @@ export class AgentRegistry { : parsed.systemPrompt, tools: parsed.tools.length > 0 ? parsed.tools : ['*'], model: parsed.model, + ...(parsed.maxIterations === undefined ? {} : { maxIterations: parsed.maxIterations }), ...(parsed.reasoning ? { reasoning: parsed.reasoning } : {}), ...(parsed.skills?.length ? { skills: parsed.skills } : {}), }; diff --git a/src/core/agents/AgentRunLifecycle.ts b/src/core/agents/AgentRunLifecycle.ts index 121f61f7e..e37553c41 100644 --- a/src/core/agents/AgentRunLifecycle.ts +++ b/src/core/agents/AgentRunLifecycle.ts @@ -34,6 +34,7 @@ function hookContext({ run, message }: AgentRunLifecycleEvent): Omit): boolean { return run.status === 'pending' || run.status === 'running'; } +export function normalizeAgentRunTerminalStatus(value: unknown): AgentRunTerminalStatus | undefined { + return value === 'completed' || value === 'partial' || value === 'failed' || value === 'cancelled' ? value : undefined; +} + export class AgentRunStore { private readonly runs = new Map(); private readonly listeners = new Set<(snapshot: AgentRunsSnapshot) => void>(); @@ -126,6 +133,7 @@ export class AgentRunStore { ...run, status: completion.status, finishedAt: now, updatedAt: now, cancellable: false, messageable: false, cancelRequested: false, activity: undefined, ...(completion.result !== undefined ? { output: this.bounded(completion.result) } : {}), ...(completion.error !== undefined ? { error: this.bounded(completion.error) } : {}), + ...(completion.status === 'partial' && completion.reason === 'iteration_limit' ? { reason: completion.reason } : {}), ...(completion.usage ? { usage: { ...completion.usage } } : {}), }); this.emitLifecycle('stop', id); diff --git a/src/core/agents/SpecialistOrchestrator.ts b/src/core/agents/SpecialistOrchestrator.ts index 539abb2bb..315895f4c 100644 --- a/src/core/agents/SpecialistOrchestrator.ts +++ b/src/core/agents/SpecialistOrchestrator.ts @@ -231,6 +231,7 @@ function roleLabel(role: string): string { function interviewIsComplete(batches: SpecialistExecutionBatch[]): boolean { return batches.some((batch) => { + if (!batch.outcome.success || batch.outcome.status === 'partial') return false; const output = batch.outcome.output ?? ''; return /^\s*complete\s*:\s*true\s*$/im.test(output) || /"complete"\s*:\s*true\b/i.test(output); @@ -261,6 +262,7 @@ export function formatSpecialistResults(result: SpecialistExecutionResult): stri batches: result.batches.map((batch) => ({ agents: batch.agents, success: batch.outcome.success, + ...(batch.outcome.success ? { status: batch.outcome.status ?? 'completed', reason: batch.outcome.reason } : {}), output: batch.outcome.output, ...(!batch.outcome.success ? { error: batch.outcome.error } : {}), })), @@ -638,7 +640,7 @@ export class SpecialistOrchestrator { return { plan, batches, - completed: batches.length > 0 && batches.every((batch) => batch.outcome.success), + completed: batches.length > 0 && batches.every((batch) => batch.outcome.success && batch.outcome.status !== 'partial'), }; } } diff --git a/src/core/agents/SubAgent.ts b/src/core/agents/SubAgent.ts index eb8bc5cc1..8777633b6 100644 --- a/src/core/agents/SubAgent.ts +++ b/src/core/agents/SubAgent.ts @@ -35,7 +35,7 @@ import { type DelegatorOptions, } from './AgentDelegator.js'; import type { ThreadBudget } from './SessionThreadBudget.js'; -import type { ClientContext, LLMMessage, LLMUsage, LoadedConfig, PromptCacheDirective, ProviderName, ToolCallRequest } from '../../types.js'; +import type { ClientContext, LLMMessage, LLMResponse, LLMUsage, LoadedConfig, PromptCacheDirective, ProviderName, ToolCallRequest } from '../../types.js'; import { isGoalFeatureEnabled } from '../../goals/feature.js'; import { ReactionParser } from '../agent/ReactionParser.js'; import type { PeerClient, PeerEvent } from '../../session/peers/PeerMessaging.js'; @@ -55,12 +55,14 @@ import { CODE_MODE_TOOL_NAME, codeModeToolDefinitions, runCodeModeScript, select import { normalizePromptRoles, toTextOnlyContent } from '../../providers/messagePayload.js'; import { ApiError } from '../../providers/errors.js'; import { calculateContextUsage, getContextWindow } from '../context/tokenizer.js'; -import { isAutohandOrchestrationEnabled, RESEARCH_WORKER_INSTRUCTIONS, RESEARCH_WORKER_TOOLS } from '../agent/AutohandOrchestration.js'; +import { RESEARCH_WORKER_INSTRUCTIONS, RESEARCH_WORKER_TOOLS } from '../agent/AutohandOrchestration.js'; +import { resolveSubagentIterationLimit } from './subagentIterations.js'; /** * Options for creating a SubAgent with context inheritance */ export interface SubAgentOptions { + researchOnly?: boolean; bindPeerRun?: DelegatorOptions['bindPeerRun']; peerAutomatic?: boolean; peerMessaging?: PeerClient; @@ -117,8 +119,11 @@ export interface SubAgentProgress { export interface SubAgentRunOptions { signal?: AbortSignal; + maxIterations?: number; } +export type SubAgentCompletionState = { status: 'completed' } | { status: 'partial'; reason: 'iteration_limit' }; + export class SubAgentExecutionError extends Error { constructor(message: string) { super(message); @@ -159,8 +164,10 @@ export class SubAgent { private readonly reactionParser = new ReactionParser(); private readonly usage: LLMUsage = { promptTokens: 0, completionTokens: 0, totalTokens: 0 }; private readonly skills: SubAgentSkills | null; + private readonly sessionId = randomUUID(); private readonly promptCache: PromptCacheDirective | undefined; private lastSkillsPrompt?: string; + private completionState?: SubAgentCompletionState; constructor( private readonly config: AgentDefinition, @@ -213,7 +220,7 @@ export class SubAgent { if (options.allowedToolNames) { definitions = definitions.filter(definition => options.allowedToolNames?.has(definition.name)); } - if (options.depth > 0 && options.featureConfig && isAutohandOrchestrationEnabled(options.featureConfig)) { + if (options.researchOnly) { definitions = definitions.filter(definition => RESEARCH_WORKER_TOOLS.has(definition.name)); } @@ -283,11 +290,11 @@ export class SubAgent { return this.delegator.delegateTaskForTool( action.agent_name, action.task, - { signal: context?.signal }, + { signal: context?.signal, maxIterations: action.maxIterations, researchOnly: action.research_only }, ); } if (action.type === 'delegate_parallel' && this.delegator) { - return this.delegator.delegateParallelForTool(action.tasks, { signal: context?.signal }); + return this.delegator.delegateParallelForTool(action.tasks, { signal: context?.signal, maxIterations: action.maxIterations, researchOnly: action.research_only }); } if (action.type === 'skill' && this.skills) { return this.skills.handle(action); @@ -304,7 +311,7 @@ export class SubAgent { // Build enhanced system prompt with tool signatures const enhancedSystemPrompt = [ this.buildSystemPrompt(config.systemPrompt, definitions), - options.depth > 0 && options.featureConfig && isAutohandOrchestrationEnabled(options.featureConfig) ? RESEARCH_WORKER_INSTRUCTIONS : '', + options.researchOnly ? RESEARCH_WORKER_INSTRUCTIONS : '', options.workspaceRoot ? `## Execution workspace\nYour tools execute in: ${JSON.stringify(options.workspaceRoot)}\nUse this selected repository, not the terminal launch directory. Read applicable repository instructions before making changes. If the request refers to another repository or the scope is ambiguous, report the mismatch to the lead instead of guessing or switching repositories.` : '', 'The delegated task is a bounded part of the original user request. Preserve the user\'s constraints; a review or diagnosis does not authorize edits. Report conflicts between the delegated task and the original request to the lead.', formatAgentRoster(AgentRegistry.getInstance().getAllAgents(), new Set(definitions.map(definition => definition.name))), @@ -324,7 +331,7 @@ export class SubAgent { if (!config) return undefined; const enabled = this.options.isPromptCachingEnabled?.(provider) ?? isPromptCachingEnabled(config, undefined, provider); - return enabled ? getSessionPromptCacheDirective(`subagent:${randomUUID()}`) : undefined; + return enabled ? getSessionPromptCacheDirective(`subagent:${this.sessionId}`) : undefined; } /** Keeps the skills section in step with this agent's activation state. */ @@ -433,6 +440,7 @@ export class SubAgent { } private async runTask(task: string, options: SubAgentRunOptions, peerRuntime?: PeerCommunicationRuntime): Promise { + this.completionState = undefined; this.throwIfUnattendedApprovalDenied(); options.signal?.throwIfAborted(); console.log(chalk.cyan(`\nSub-agent '${this.name}' starting task... (depth ${this.options.depth}/${this.options.maxDepth})`)); @@ -464,7 +472,7 @@ export class SubAgent { let withholdToolsNextRequest = false; let consecutiveRepairCount = 0; const truncationRecovery = new TruncationRecoveryTracker(); - const maxIterations = 10; + const maxIterations = resolveSubagentIterationLimit(this.config.maxIterations, this.options.featureConfig?.agent?.subagentMaxIterations, options.maxIterations); for (let i = 0; i < maxIterations; i++) { options.signal?.throwIfAborted(); this.consumePendingInstructions(); @@ -473,6 +481,10 @@ export class SubAgent { this.consumePendingInstructions(); this.refreshSkillsPrompt(); await peerRuntime?.safeBoundary(); + const finalIteration = i === maxIterations - 1; + if (finalIteration) { + this.conversation.addSystemNote('[Iteration limit] This is the final allowed model request. Tools are unavailable. Return a concise final summary of findings, changes, verification, and work still remaining. Do not claim unfinished work is complete.'); + } const maxTools = this.llm.getCapabilities?.().maxTools; const tools = selectCodeModeTools(definitions, maxTools) ?? limitToolSelection(definitions, definitions, this.conversation.history(), maxTools); @@ -481,6 +493,7 @@ export class SubAgent { const requestTools = this.supportsNativeToolCalling && !loopGuard.isForcingFinalResponse() && !withholdToolsForRequest + && !finalIteration && tools.length > 0 ? tools : undefined; @@ -496,17 +509,25 @@ export class SubAgent { this.options.runBudget?.assertRequestAllowed(); this.options.runBudget?.recordRequest(); this.throwIfUnattendedApprovalDenied(); - const completion = await this.llm.complete({ - messages: requestMessages, - // A definition's model is a suggestion resolved by the team policy; never send it raw. - model: this.options.model, - maxTokens, - temperature: 0.2, - signal: options.signal, - tools: requestTools, - toolChoice: requestTools ? 'auto' : undefined, - ...(this.promptCache ? { promptCache: this.promptCache } : {}), - }); + let completion: LLMResponse; + try { + completion = await this.llm.complete({ + sessionId: this.sessionId, + messages: requestMessages, + // A definition's model is a suggestion resolved by the team policy; never send it raw. + model: this.options.model, + maxTokens, + temperature: 0.2, + signal: options.signal, + tools: requestTools, + toolChoice: requestTools ? 'auto' : undefined, + ...(this.promptCache ? { promptCache: this.promptCache } : {}), + }); + } catch (error) { + if (!finalIteration || options.signal?.aborted || error instanceof UnattendedApprovalError + || (error instanceof Error && error.name === 'AbortError')) throw error; + return this.finishPartial(undefined, maxIterations, (error instanceof Error ? error.message : String(error)).slice(0, 1000)); + } this.options.runBudget?.recordUsage(completion.usage); if (completion.usage) { this.usage.promptTokens += completion.usage.promptTokens; @@ -530,6 +551,10 @@ export class SubAgent { responseCompletionHooks: DEFAULT_RESPONSE_COMPLETION_HOOKS, }); + if (finalIteration) { + return this.finishPartial(turnOutcome.type === 'finish' ? turnOutcome.response : undefined, maxIterations); + } + if (turnOutcome.type === 'repair') { if (turnOutcome.reason === 'truncated_response') { consecutiveRepairCount = 0; @@ -700,10 +725,23 @@ export class SubAgent { // No tools, return final response console.log(chalk.cyan(`[${this.name}] Finished.`)); + this.completionState = { status: 'completed' }; return turnOutcome.response; } - throw new SubAgentExecutionError(`[${this.name}] Failed to complete task within ${maxIterations} iterations.`); + return this.finishPartial(undefined, maxIterations); + } + + private finishPartial(summary: string | undefined, maxIterations: number, summaryError?: string): string { + this.completionState = { status: 'partial', reason: 'iteration_limit' }; + const observations = this.conversation.history().filter(message => message.role === 'tool') + .slice(-4).map(message => toTextOnlyContent(message.content).slice(0, 1500)).join('\n\n'); + const report = summary?.trim() || `No final summary was returned. Work remains unverified.${summaryError ? `\nFinal summary request failed: ${summaryError}` : ''}${observations ? `\nRecent observations:\n${observations}` : ''}`; + return `[Partial result: iteration limit reached (${maxIterations} model requests).]\n${report}`; + } + + public getCompletionState(): SubAgentCompletionState | undefined { + return this.completionState ? { ...this.completionState } : undefined; } public getUsage(): LLMUsage { diff --git a/src/core/agents/subagentIterations.ts b/src/core/agents/subagentIterations.ts new file mode 100644 index 000000000..6d639b22e --- /dev/null +++ b/src/core/agents/subagentIterations.ts @@ -0,0 +1,14 @@ +export const DEFAULT_SUBAGENT_MAX_ITERATIONS = 30; +export const MAX_SUBAGENT_ITERATIONS = 1000; + +export function isValidSubagentIterationLimit(value: unknown): value is number { + return typeof value === 'number' && Number.isSafeInteger(value) && value >= 1 && value <= MAX_SUBAGENT_ITERATIONS; +} + +export function resolveSubagentIterationLimit(definition: number | undefined, configured: number | undefined, requested?: number): number { + const limit = definition ?? configured ?? DEFAULT_SUBAGENT_MAX_ITERATIONS; + if (!isValidSubagentIterationLimit(limit) || (requested !== undefined && !isValidSubagentIterationLimit(requested))) { + throw new RangeError(`Sub-agent iteration limit must be an integer between 1 and ${MAX_SUBAGENT_ITERATIONS}.`); + } + return Math.min(limit, requested ?? limit); +} diff --git a/src/core/codemode/QuickJsSandbox.ts b/src/core/codemode/QuickJsSandbox.ts index 774bc5b1f..eea3a10fb 100644 --- a/src/core/codemode/QuickJsSandbox.ts +++ b/src/core/codemode/QuickJsSandbox.ts @@ -18,7 +18,7 @@ export interface SandboxToolCall { } export type SandboxToolOutcome = - | { ok: true; output: string; images?: boolean } + | { ok: true; output: string; images?: boolean; status?: 'completed' | 'partial'; reason?: 'iteration_limit' } | { ok: false; error: string; kind?: string; output?: string }; export interface SandboxLimits { @@ -273,7 +273,12 @@ function toGuestOutcome(outcome: SandboxToolOutcome | undefined, maxChars: numbe } const cut = (value: unknown): string => truncate(typeof value === 'string' ? value : String(value ?? ''), maxChars, 'output'); if (outcome.ok) { - return { ok: true, output: cut(outcome.output) }; + return { + ok: true, output: cut(outcome.output), + ...(outcome.images === true ? { images: true } : {}), + ...(outcome.status === 'partial' || outcome.status === 'completed' ? { status: outcome.status } : {}), + ...(outcome.reason === 'iteration_limit' ? { reason: outcome.reason } : {}), + }; } return { ok: false, diff --git a/src/core/teams/TeamModelPolicy.ts b/src/core/teams/TeamModelPolicy.ts index 6ac77299b..b336b027b 100644 --- a/src/core/teams/TeamModelPolicy.ts +++ b/src/core/teams/TeamModelPolicy.ts @@ -11,7 +11,6 @@ import { isCustomProviderName } from '../../providers/customProviders.js'; import { usesAutohandAICloud } from '../../providers/AutohandAIProvider.js'; import type { AutohandConfig, BuiltInProviderName, ProviderName, ReasoningEffort } from '../../types.js'; import type { LLMProvider } from '../../providers/LLMProvider.js'; -import { isAutohandOrchestrationEnabled } from '../agent/AutohandOrchestration.js'; export type TeamModelAssignmentSource = | 'member-override' @@ -83,10 +82,13 @@ function resolveCandidate( fallback: Pick, ): TeamModelAssignment { const provider = candidate.provider ?? fallback.provider; + const model = candidate.model ?? modelForProvider(input.config, provider, input.active); return { provider, - model: candidate.model ?? modelForProvider(input.config, provider, input.active), + model, source: candidate.source, + ...(provider === 'autohandai' && model === 'moa' && input.agentReasoning && input.agentReasoning !== 'none' + ? { reasoningEffort: input.agentReasoning } : {}), }; } @@ -177,7 +179,6 @@ function resolveAssignment(input: TeamModelAssignmentInput): TeamModelAssignment ]; for (const candidate of candidates) { - if (candidate.source === 'agent-definition' && isAutohandOrchestrationEnabled(input.config, active.provider)) continue; if (!candidate.provider && !candidate.model) continue; const provider = candidate.provider ?? active.provider; if (isSuggestedSource(candidate.source) && candidate.model && !isKnownModelForProvider(input.config, provider, candidate.model)) { @@ -187,9 +188,6 @@ function resolveAssignment(input: TeamModelAssignmentInput): TeamModelAssignment } if (usesAutohandAICloud(input.config, active.provider)) { - if (isAutohandOrchestrationEnabled(input.config, active.provider)) { - return { provider: 'autohandai', model: 'fantail', source: 'agent-nature' }; - } return resolveAutohandAINature(input); } diff --git a/src/core/toolManager.ts b/src/core/toolManager.ts index 710f56474..1fa9c77e1 100644 --- a/src/core/toolManager.ts +++ b/src/core/toolManager.ts @@ -36,6 +36,7 @@ import { HOOK_TOOL_NAMES } from './hookTools.js'; import { CODE_MODE_TOOL_NAME } from './codemode/feature.js'; import { PEER_TOOL_DEFINITIONS } from './peerTools.js'; import { GOAL_STATUSES } from '../goals/types.js'; +import { extractPatchTarget } from '../utils/patchValidator.js'; const TOOL_IMAGE_REF = /^tool-image:[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; @@ -282,6 +283,15 @@ export function buildToolPermissionContexts(action: AgentAction): PermissionCont if (typeof values.description === 'string') { context.description = values.description; } + if (action.type === 'apply_patch') { + const patch = typeof values.patch === 'string' && values.patch.length > 0 + ? values.patch + : typeof values.diff === 'string' ? values.diff : undefined; + const envelopeTarget = patch === undefined ? undefined : extractPatchTarget(patch); + if (envelopeTarget !== undefined && envelopeTarget !== context.path) { + return [context, { ...context, path: envelopeTarget }]; + } + } return [context]; } @@ -585,12 +595,12 @@ export const DEFAULT_TOOL_DEFINITIONS: ToolDefinition[] = [ }, { name: 'apply_patch', - description: 'Apply a unified diff to a file', + description: 'Apply a single-file patch using numbered unified hunks or context-anchored @@ hunks. Example: @@\\n-old line\\n+new line. A single *** Update File: path envelope is also supported. Include enough unchanged context to identify one location; no-op or ambiguous patches fail.', parameters: { type: 'object', properties: { - path: { type: 'string', description: 'Relative path to the file' }, - patch: { type: 'string', description: 'Unified diff patch content' } + path: { type: 'string', description: 'Path to the file; a single Update File envelope can supply it when omitted' }, + patch: { type: 'string', description: 'Unified diff or context-anchored patch content for one file' } }, required: ['path', 'patch'] } @@ -1337,7 +1347,9 @@ export const DEFAULT_TOOL_DEFINITIONS: ToolDefinition[] = [ type: 'object', properties: { agent_name: { type: 'string', description: 'Registered agent name to delegate to' }, - task: { type: 'string', description: 'Concrete task for the delegated agent' } + task: { type: 'string', description: 'Concrete task for the delegated agent' }, + maxIterations: { type: 'integer', description: 'Optional lower model-request cap, bounded by the agent definition or agent.subagentMaxIterations (default: 30); includes the final partial-work summary.' }, + research_only: { type: 'boolean', description: 'Explicit read-only research pool task; withholds write, command and delegation tools.' } }, required: ['agent_name', 'task'] } @@ -1355,11 +1367,15 @@ export const DEFAULT_TOOL_DEFINITIONS: ToolDefinition[] = [ type: 'object', properties: { agent_name: { type: 'string', description: 'Registered agent name to delegate to' }, - task: { type: 'string', description: 'Concrete task for that agent' } + task: { type: 'string', description: 'Concrete task for that agent' }, + maxIterations: { type: 'integer', description: 'Optional lower model-request cap for this worker.' }, + research_only: { type: 'boolean', description: 'Restrict this worker to read-only research.' } }, required: ['agent_name', 'task'] } - } + }, + maxIterations: { type: 'integer', description: 'Optional lower model-request cap for all workers; entries may lower it further. Includes the final summary.' }, + research_only: { type: 'boolean', description: 'Restrict all workers to read-only research.' } }, required: ['tasks'] } @@ -2800,7 +2816,7 @@ export class ToolManager { try { - call = this.repairReadFileInput(call); + call = this.repairPatchInput(this.repairReadFileInput(call)); } catch (error) { reject(error instanceof Error ? error.message : String(error), 'validation'); continue; @@ -2908,8 +2924,7 @@ export class ToolManager { ...this.getCallArgs(call), ...response.updatedInput, }; - this.assertValidUpdatedInput(definition, response.updatedInput, mergedArgs); - call = this.cloneToolCall(call, mergedArgs); + call = this.normalizeUpdatedToolInput(call, definition, response.updatedInput, mergedArgs); action = this.toAction(call); permissionContexts = this.resolvePermissionContexts(action); policyEvaluation = this.evaluatePermissionContexts(permissionContexts); @@ -2999,8 +3014,7 @@ export class ToolManager { ...this.getCallArgs(call), ...response.updatedInput, }; - this.assertValidUpdatedInput(definition, response.updatedInput, mergedArgs); - call = this.cloneToolCall(call, mergedArgs); + call = this.normalizeUpdatedToolInput(call, definition, response.updatedInput, mergedArgs); action = this.toAction(call); permissionContexts = this.resolvePermissionContexts(action); policyEvaluation = this.evaluatePermissionContexts(permissionContexts); @@ -3054,8 +3068,7 @@ export class ToolManager { if (decision.decision === 'alternative') { const alternativeArgs = this.applyAlternative(call, decision.alternative!); - this.assertValidUpdatedInput(definition, alternativeArgs.changed, alternativeArgs.args); - call = this.cloneToolCall(call, alternativeArgs.args); + call = this.normalizeUpdatedToolInput(call, definition, alternativeArgs.changed, alternativeArgs.args); action = this.toAction(call); permissionContexts = this.resolvePermissionContexts(action); policyEvaluation = this.evaluatePermissionContexts(permissionContexts); @@ -3163,6 +3176,29 @@ export class ToolManager { return this.cloneToolCall(call, args); } + private repairPatchInput(call: ToolCallRequest): ToolCallRequest { + if (call.tool !== 'apply_patch') return call; + const args = { ...this.getCallArgs(call) }; + const patch = typeof args.patch === 'string' ? args.patch : undefined; + if (patch === undefined) return call; + const target = extractPatchTarget(patch); + if (target !== undefined && (args.path === undefined || args.path === null || args.path === '')) { + args.path = target; + } + return this.cloneToolCall(call, args); + } + + private normalizeUpdatedToolInput( + call: ToolCallRequest, + definition: ToolDefinition, + changed: Record, + args: Record, + ): ToolCallRequest { + const updated = this.repairPatchInput(this.cloneToolCall(call, args)); + this.assertValidUpdatedInput(definition, changed, this.getCallArgs(updated)); + return updated; + } + private getCallArgs(call: ToolCallRequest): Record { return (call.args ?? {}) as Record; } @@ -3627,6 +3663,9 @@ export class ToolManager { return { success: true, ...(outcome.output === undefined ? {} : { output: outcome.output }), + ...(outcome.status === 'completed' ? { status: 'completed' as const } : {}), + ...(outcome.status === 'partial' && outcome.reason === 'iteration_limit' + ? { status: 'partial' as const, reason: 'iteration_limit' as const } : {}), ...images, }; } diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 967fa54b7..4d6858a87 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -249,6 +249,8 @@ "agent": { "maxIterations": "Max iterations", "maxIterationsDesc": "Maximum tool iterations per request", + "subagentMaxIterations": "Sub-agent request limit", + "subagentMaxIterationsDesc": "Default model requests per delegated task, including its final partial-work summary (1–1000)", "enableRequestQueue": "Request queue", "enableRequestQueueDesc": "Allow typing while agent works", "idleLogoutEnabled": "Idle session timeout", diff --git a/src/import/importers/CursorImporter.ts b/src/import/importers/CursorImporter.ts index 0e0e97b98..055f70cd5 100644 --- a/src/import/importers/CursorImporter.ts +++ b/src/import/importers/CursorImporter.ts @@ -4,6 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ import path from 'node:path'; +import { fileURLToPath } from 'node:url'; import fse from 'fs-extra'; import type { ImportSource, @@ -18,6 +19,26 @@ import type { SessionMessage } from '../../session/types.js'; import { AUTOHAND_PATHS } from '../../constants.js'; import { BaseImporter } from './BaseImporter.js'; +const PROJECT_URI_SCAN_BYTES = 100000; + +function projectPathFromBlob(buffer: Buffer): string | undefined { + const text = buffer.toString('utf8', 0, Math.min(buffer.length, PROJECT_URI_SCAN_BYTES)); + for (const match of text.matchAll(/file:\/\/[^\p{Cc}"]+/giu)) { + const uri = match[0]; + if (/[\s\\\uFFFD?#]/u.test(uri)) continue; + if (match.index + uri.length === text.length && buffer.length > PROJECT_URI_SCAN_BYTES) continue; + try { + const url = new URL(uri); + if (url.protocol !== 'file:') continue; + const projectPath = fileURLToPath(url); + if (!/\p{Cc}/u.test(projectPath)) return projectPath; + } catch { + continue; + } + } + return undefined; +} + /** * Importer for Cursor editor data (~/.cursor). * @@ -466,12 +487,7 @@ export class CursorImporter extends BaseImporter { for (const row of blobRows) { const buf = Buffer.isBuffer(row.data) ? row.data : Buffer.from(row.data as unknown as ArrayBuffer); - // Scan for file:// URIs to extract project path - const bufStr = buf.toString('utf-8', 0, Math.min(buf.length, 100000)); - const fileMatch = bufStr.match(/file:\/\/\/([\w/.\-]+)/); - if (fileMatch) { - projectPath = '/' + fileMatch[1]; - } + projectPath = projectPathFromBlob(buf) ?? projectPath; // Scan for JSON objects with role field this.extractJsonMessages(buf, rawMessages, seenContent); diff --git a/src/modes/acp/adapter.ts b/src/modes/acp/adapter.ts index 205e4e2ab..742e374a8 100644 --- a/src/modes/acp/adapter.ts +++ b/src/modes/acp/adapter.ts @@ -36,14 +36,16 @@ import type { SetSessionConfigOptionRequest, SetSessionConfigOptionResponse, ToolCallStatus, + CreateElicitationResponse, } from '@agentclientprotocol/sdk'; -import { PROTOCOL_VERSION, RequestError } from '@agentclientprotocol/sdk'; +import { methods, PROTOCOL_VERSION, RequestError } from '@agentclientprotocol/sdk'; import { AutohandAgent } from '../../core/agent.js'; import { isLikelyFilePathSlashInput } from '../../core/slashInputDetection.js'; import { ConversationManager } from '../../core/conversationManager.js'; import type { ImageMimeType } from '../../core/ImageManager.js'; import { FileActionManager } from '../../actions/filesystem.js'; +import { normalizeAgentRunTerminalStatus, type AgentRunTerminalStatus } from '../../core/agents/AgentRunStore.js'; import { ProviderFactory } from '../../providers/ProviderFactory.js'; import { loadConfig } from '../../config.js'; import { prepareBareModeConfig } from '../../runtime/bareMode.js'; @@ -462,6 +464,7 @@ export class AutohandAcpAdapter implements Agent { promptCount: 0, }; + this.sessions.get(managedSessionId)?.abortController.abort(); const previousAgent = this.agents.get(managedSessionId); if (previousAgent && previousAgent !== agent) { void this.shutdownAgent(previousAgent); @@ -485,6 +488,61 @@ export class AutohandAcpAdapter implements Agent { agent.setConfirmationCallback(async (message, context) => { return permBridge.confirmAction(message, context); }); + if (this.clientCapabilities?.elicitation?.form) { + agent.setFollowupQuestionCallback(async (question, suggestedAnswers) => { + const session = state; + const signal = session.abortController.signal; + if (this.sessions.get(managedSessionId) !== session + || this.agents.get(managedSessionId) !== agent || signal.aborted) { + return null; + } + const request = this.connection.request(methods.client.elicitation.create, { + mode: 'form', + sessionId: managedSessionId, + message: question, + requestedSchema: { + type: 'object', + properties: { + answer: { + type: 'string', + title: 'Answer', + description: suggestedAnswers?.length + ? `${question}\nSuggested answers:\n${suggestedAnswers.map(answer => `- ${answer}`).join('\n')}` + : question, + minLength: 1, + }, + }, + required: ['answer'], + }, + }, { cancellationSignal: signal }); + let cancelElicitation: (() => void) | undefined; + const cancelled = new Promise(resolve => { + cancelElicitation = () => resolve({ action: 'cancel' }); + signal.addEventListener('abort', cancelElicitation, { once: true }); + if (signal.aborted) cancelElicitation(); + }); + let response: CreateElicitationResponse; + try { + response = await Promise.race([request, cancelled]); + } finally { + if (cancelElicitation) signal.removeEventListener('abort', cancelElicitation); + } + if (signal.aborted || this.sessions.get(managedSessionId) !== session) { + return null; + } + if (response.action === 'decline' || response.action === 'cancel') { + return null; + } + if (response.action !== 'accept') { + return undefined; + } + const content = response.content; + const answer = content && typeof content === 'object' && 'answer' in content + ? content.answer + : undefined; + return typeof answer === 'string' && answer.trim() ? answer : undefined; + }); + } this.permissionBridges.set(managedSessionId, permBridge); await this.publishSessionCommands(managedSessionId, config); @@ -997,6 +1055,7 @@ export class AutohandAcpAdapter implements Agent { */ async dispose(): Promise { const agents = [...this.agents.values()]; + for (const session of this.sessions.values()) session.abortController.abort(); this.sessions.clear(); this.agents.clear(); this.sessionConfigs.clear(); @@ -1311,6 +1370,8 @@ export class AutohandAcpAdapter implements Agent { context.subagentSuccess ?? false, context.subagentDuration ?? 0, context.subagentError, + normalizeAgentRunTerminalStatus(context.subagentStatus), + context.subagentReason, ); break; case 'post-response': @@ -1327,6 +1388,7 @@ export class AutohandAcpAdapter implements Agent { } private forgetSession(sessionId: string): void { + this.sessions.get(sessionId)?.abortController.abort(); this.agents.get(sessionId)?.programStatus?.dispose(); this.hookForwarders.get(sessionId)?.(); this.hookForwarders.delete(sessionId); @@ -1343,6 +1405,7 @@ export class AutohandAcpAdapter implements Agent { */ async shutdown(reason: 'quit' | 'exit' | 'error' = 'exit'): Promise { const sessions = [...this.sessions.entries()]; + for (const [, state] of sessions) state.abortController.abort(); const agents: AutohandAgent[] = []; for (const [sessionId, state] of sessions) { const duration = Math.max(0, Date.now() - state.createdAt); @@ -1460,10 +1523,13 @@ export class AutohandAcpAdapter implements Agent { emitHookSubagentStop( sessionId: string, subagentId: string, subagentName: string, - subagentType: string, success: boolean, duration: number, error?: string + subagentType: string, success: boolean, duration: number, error?: string, + status?: AgentRunTerminalStatus, reason?: 'iteration_limit' ): void { void this.emitHookSafe(ACP_HOOK_NOTIFICATIONS.HOOK_SUBAGENT_STOP, { sessionId, subagentId, subagentName, subagentType, success, duration, error, timestamp: new Date().toISOString(), + ...(status === undefined ? {} : { status }), + ...(status === 'partial' && reason === 'iteration_limit' ? { reason } : {}), }); } diff --git a/src/modes/rpc/adapter.ts b/src/modes/rpc/adapter.ts index f16e2e823..77aba1117 100644 --- a/src/modes/rpc/adapter.ts +++ b/src/modes/rpc/adapter.ts @@ -8,6 +8,7 @@ import crypto from 'node:crypto'; import type { AutohandAgent } from '../../core/agent.js'; import { AgentRegistry } from '../../core/agents/AgentRegistry.js'; +import { normalizeAgentRunTerminalStatus } from '../../core/agents/AgentRunStore.js'; import type { HookContext } from '../../core/HookManager.js'; import { resolveReviewCommand } from '../../commands/review.js'; import { @@ -225,6 +226,7 @@ function checksScriptFromParams(params: AutoresearchStartParams): string | undef // ApiErrorCode → RPC-specific error shape mapping // --------------------------------------------------------------------------- const RPC_ERROR_TYPE_MAP: Record = { + storage_full: 'unknown', content_blocked: 'content_blocked', context_overflow: 'context', model_not_found: 'model', @@ -241,6 +243,7 @@ const RPC_ERROR_TYPE_MAP: Record = { }; const RPC_ERROR_CODE_MAP: Record = { + storage_full: -32000, content_blocked: 400, context_overflow: 400, model_not_found: 404, @@ -257,6 +260,7 @@ const RPC_ERROR_CODE_MAP: Record = { }; const RPC_ERROR_ICON_MAP: Record = { + storage_full: '\u26A0\uFE0F', content_blocked: '\u26A0\uFE0F', context_overflow: '\uD83D\uDCE6', // 📦 model_not_found: '\uD83E\uDD16', // 🤖 @@ -1918,6 +1922,8 @@ export class RPCAdapter { context.subagentSuccess ?? false, context.subagentDuration ?? 0, context.subagentError, + normalizeAgentRunTerminalStatus(context.subagentStatus), + context.subagentReason, ); break; case 'permission-request': @@ -2190,7 +2196,9 @@ export class RPCAdapter { subagentType: string, success: boolean, duration: number, - error?: string + error?: string, + status?: HookSubagentStopNotificationParams['status'], + reason?: HookSubagentStopNotificationParams['reason'] ): void { if (this.notificationsSealed) return; const params = { @@ -2200,6 +2208,8 @@ export class RPCAdapter { success, duration, error, + ...(status === undefined ? {} : { status }), + ...(status === 'partial' && reason === 'iteration_limit' ? { reason } : {}), timestamp: createTimestamp(), } satisfies HookSubagentStopNotificationParams; writeNotification(RPC_NOTIFICATIONS.HOOK_SUBAGENT_STOP, params); diff --git a/src/modes/rpc/blueprintAnswer.ts b/src/modes/rpc/blueprintAnswer.ts index c2d94af56..afe43ed4e 100644 --- a/src/modes/rpc/blueprintAnswer.ts +++ b/src/modes/rpc/blueprintAnswer.ts @@ -10,7 +10,6 @@ import { readdir, readlink, realpath, - stat, } from 'node:fs/promises'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -36,6 +35,7 @@ import { verifyBlueprintLocalModelArtifact, } from '../../providers/BlueprintLocalProvider.js'; import { runtimeVersion } from '../../utils/runtimeVersion.js'; +import { readRepoHead } from '../../session/peers/RepoStateReader.js'; export const BLUEPRINT_ANSWER_CONTRACT_VERSION = 1 as const; @@ -675,51 +675,8 @@ async function fileArtifact(file: string): Promise<{ } async function readRepositoryCommit(packageRoot: string): Promise { - const dotGit = path.join(packageRoot, '.git'); - let gitDirectory = dotGit; - try { - const metadata = await stat(dotGit); - if (!metadata.isDirectory()) { - const pointer = (await readFile(dotGit, 'utf8')).trim(); - if (!pointer.startsWith('gitdir:')) return undefined; - gitDirectory = path.resolve(packageRoot, pointer.slice('gitdir:'.length).trim()); - } - } catch { - try { - const pointer = (await readFile(dotGit, 'utf8')).trim(); - if (!pointer.startsWith('gitdir:')) return undefined; - gitDirectory = path.resolve(packageRoot, pointer.slice('gitdir:'.length).trim()); - } catch { - return undefined; - } - } - - let head: string; - try { - head = (await readFile(path.join(gitDirectory, 'HEAD'), 'utf8')).trim(); - } catch { - return undefined; - } - if (/^[a-f0-9]{40}$/u.test(head)) return head; - if (!head.startsWith('ref: ')) return undefined; - - const reference = head.slice('ref: '.length); - const candidates = [path.join(gitDirectory, reference)]; - try { - const common = (await readFile(path.join(gitDirectory, 'commondir'), 'utf8')).trim(); - candidates.push(path.resolve(gitDirectory, common, reference)); - } catch { - // Normal checkouts have no commondir file. - } - for (const candidate of candidates) { - try { - const commit = (await readFile(candidate, 'utf8')).trim(); - if (/^[a-f0-9]{40}$/u.test(commit)) return commit; - } catch { - // Try the next worktree/common-dir location. - } - } - return undefined; + const head = await readRepoHead(packageRoot); + return head && /^[a-f0-9]{40}$/u.test(head.sha) ? head.sha : undefined; } export async function inspectBlueprintCliIdentity( diff --git a/src/modes/rpc/types.ts b/src/modes/rpc/types.ts index 449a503be..47f397d38 100644 --- a/src/modes/rpc/types.ts +++ b/src/modes/rpc/types.ts @@ -6,6 +6,7 @@ import type { PermissionPromptDecision, PermissionPromptResult } from '../../permissions/types.js'; import type { AuthUser, McpServerConfigEntry, ToolRegistryEntry } from '../../types.js'; import type { AgentDefinition } from '../../core/agents/AgentRegistry.js'; +import type { AgentRunTerminalStatus } from '../../core/agents/AgentRunStore.js'; import type { ExperimentConstraintConfig, ExperimentRetentionConfig, @@ -966,6 +967,8 @@ export interface HookSubagentStopNotificationParams { subagentName: string; subagentType: string; success: boolean; + status?: AgentRunTerminalStatus; + reason?: 'iteration_limit'; duration: number; error?: string; timestamp: string; diff --git a/src/providers/AnthropicProvider.ts b/src/providers/AnthropicProvider.ts index 97fd39b55..12754234d 100644 --- a/src/providers/AnthropicProvider.ts +++ b/src/providers/AnthropicProvider.ts @@ -10,6 +10,7 @@ import Anthropic, { APIError as AnthropicAPIError, APIUserAbortError, } from "@anthropic-ai/sdk"; +import { randomUUID } from 'node:crypto'; import type { Message, MessageCreateParamsNonStreaming, @@ -42,6 +43,7 @@ import { ApiError, classifyApiError, type ApiErrorCode } from "./errors.js"; import { normalizeProviderFinishReason } from "./finishReason.js"; import { getProviderModelIds } from "./modelCatalog.js"; import { normalizeLLMUsage } from "./usage.js"; +import { buildOpenCodeGoHeaders } from './openCodeGo.js'; const DEFAULT_BASE_URL = "https://api.anthropic.com"; const DEFAULT_MAX_TOKENS = 16_000; @@ -191,16 +193,19 @@ export class AnthropicProvider implements LLMProvider { private readonly client: Anthropic; private readonly apiKey: string; private readonly reasoningEffort: AnthropicSettings["reasoningEffort"]; + private readonly baseUrl: string; + private readonly auxiliarySessionId = randomUUID(); private model: string; constructor(settings: AnthropicSettings, networkSettings?: NetworkSettings) { this.apiKey = settings.apiKey; this.model = settings.model; this.reasoningEffort = settings.reasoningEffort; + this.baseUrl = settings.baseUrl ?? DEFAULT_BASE_URL; const configuredRetries = networkSettings?.maxRetries ?? DEFAULT_MAX_RETRIES; this.client = new Anthropic({ apiKey: settings.apiKey, - baseURL: settings.baseUrl ?? DEFAULT_BASE_URL, + baseURL: this.baseUrl, maxRetries: Math.min(Math.max(0, configuredRetries), MAX_ALLOWED_RETRIES), timeout: resolveAnthropicTimeout(networkSettings), }); @@ -263,6 +268,7 @@ export class AnthropicProvider implements LLMProvider { try { const message = await this.client.messages.create(params, { signal: request.signal, + headers: buildOpenCodeGoHeaders(this.baseUrl, request, this.auxiliarySessionId), }); const text = message.content .filter((block) => block.type === "text") diff --git a/src/providers/BedrockProvider.ts b/src/providers/BedrockProvider.ts index aaf763250..ae73df22b 100644 --- a/src/providers/BedrockProvider.ts +++ b/src/providers/BedrockProvider.ts @@ -435,6 +435,7 @@ function classifyBedrockError(error: unknown): ApiError { if (code) { const friendly: Record = { content_blocked: 'The safety check blocked this request.', + storage_full: 'Local storage is full. Free disk space before continuing. Recent session data may not have been saved.', auth_failed: "AWS Bedrock credentials were not found or were rejected. Configure AWS credentials, AWS_PROFILE, instance metadata, or choose Bedrock API key auth.", access_denied: diff --git a/src/providers/LLMGatewayClient.ts b/src/providers/LLMGatewayClient.ts index c259f98a2..e70e19226 100644 --- a/src/providers/LLMGatewayClient.ts +++ b/src/providers/LLMGatewayClient.ts @@ -585,7 +585,7 @@ export class LLMGatewayClient { const bodyRecord = body && typeof body === "object" && !Array.isArray(body) ? body as Record : undefined; - const blocked = readContentBlockedError(bodyRecord?.error, status, response.headers?.get('x-autohand-request-id')); + const blocked = readContentBlockedError(bodyRecord?.error ?? bodyRecord?.message, status, response.headers?.get('x-autohand-request-id')); if (blocked) return blocked; const arrayDetail = Array.isArray(body) ? coerceErrorDetail(body) : ""; errorDetail = structuredError?.message diff --git a/src/providers/LLMProvider.ts b/src/providers/LLMProvider.ts index b817aec5e..eda76a308 100644 --- a/src/providers/LLMProvider.ts +++ b/src/providers/LLMProvider.ts @@ -53,4 +53,7 @@ export interface LLMProvider { * Providers that do not implement this are treated as legacy/fallback. */ getCapabilities?(): LLMProviderCapabilities; + + /** Effective runtime context limit, when the provider can report it. */ + getContextWindow?(signal?: AbortSignal): Promise; } diff --git a/src/providers/LlamaCppProvider.ts b/src/providers/LlamaCppProvider.ts index 37e53c7e0..21b540c2e 100644 --- a/src/providers/LlamaCppProvider.ts +++ b/src/providers/LlamaCppProvider.ts @@ -9,6 +9,8 @@ import type { LLMRequest, LLMResponse, LLMToolCall, ProviderSettings, FunctionDe import { normalizeLLMUsage } from './usage.js'; import { normalizeProviderFinishReason } from './finishReason.js'; import { ApiError, classifyApiError } from './errors.js'; +import { discardResponseBody } from '../utils/responseBody.js'; +import { resolveRuntimeContextWindow } from './runtimeContextWindow.js'; import { getProviderModelIds, getProviderRuntimeDefaultModel, @@ -48,6 +50,9 @@ interface LlamaCppChatResponse { export class LlamaCppProvider implements LLMProvider { private baseUrl: string; private model: string; + private contextGeneration = 0; + private runtimeContext?: { model: string; value: number | undefined; expiresAt: number }; + private runtimeContextRequest?: { generation: number; promise: Promise }; private static readonly DEFAULT_MODEL = getProviderRuntimeDefaultModel('llamacpp', 'local'); @@ -66,13 +71,76 @@ export class LlamaCppProvider implements LLMProvider { } setModel(model: string): void { + if (this.model !== model) { + this.contextGeneration += 1; + this.runtimeContext = undefined; + this.runtimeContextRequest = undefined; + } this.model = model; } + async getContextWindow(signal?: AbortSignal): Promise { + if (signal?.aborted) return undefined; + const cached = this.runtimeContext; + if (cached?.model === this.model && cached.expiresAt > Date.now()) return cached.value; + let pending = this.runtimeContextRequest; + if (!pending || pending.generation !== this.contextGeneration) { + const generation = this.contextGeneration; + const model = this.model; + const previous = cached?.model === model ? cached.value : undefined; + const request = { + generation, + promise: this.discoverContextWindow(model).then((value) => { + const effective = value ?? previous; + if (this.contextGeneration === generation) { + this.runtimeContext = { model, value: effective, expiresAt: Date.now() + 30000 }; + } + return effective; + }), + }; + this.runtimeContextRequest = request; + request.promise = request.promise.finally(() => { + if (this.runtimeContextRequest === request) this.runtimeContextRequest = undefined; + }); + pending = request; + } + const ready = pending.promise; + return signal + ? resolveRuntimeContextWindow(async () => ready, signal) + : ready; + } + + private async discoverContextWindow(model: string): Promise { + let response: Response | undefined; + try { + response = await fetch( + `${this.baseUrl.replace(/\/+$/, '')}/props?model=${encodeURIComponent(model)}&autoload=false`, + { signal: AbortSignal.timeout(2000) }, + ); + if (!response.ok) { + discardResponseBody(response); + return undefined; + } + const payload: unknown = await response.json(); + if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return undefined; + const settings = (payload as Record).default_generation_settings; + if (!settings || typeof settings !== 'object' || Array.isArray(settings)) return undefined; + const contextWindow = (settings as Record).n_ctx; + return typeof contextWindow === 'number' && Number.isSafeInteger(contextWindow) && contextWindow > 0 + ? contextWindow + : undefined; + } catch { + if (response) discardResponseBody(response); + return undefined; + } + } + async listModels(): Promise { + let response: Response | undefined; try { - const response = await fetch(`${this.baseUrl}/v1/models`); + response = await fetch(`${this.baseUrl}/v1/models`, { signal: AbortSignal.timeout(2000) }); if (!response.ok) { + discardResponseBody(response); return mergeModelIds(this.model ? [this.model] : [], getProviderModelIds('llamacpp')); } const data = await response.json() as { data?: { id: string }[] }; @@ -81,13 +149,15 @@ export class LlamaCppProvider implements LLMProvider { mergeModelIds(this.model ? [this.model] : [], getProviderModelIds('llamacpp')), ); } catch { + if (response) discardResponseBody(response); return mergeModelIds(this.model ? [this.model] : [], getProviderModelIds('llamacpp')); } } async isAvailable(): Promise { try { - const response = await fetch(`${this.baseUrl}/health`); + const response = await fetch(`${this.baseUrl}/health`, { signal: AbortSignal.timeout(2000) }); + discardResponseBody(response); return response.ok; } catch { return false; @@ -176,11 +246,12 @@ export class LlamaCppProvider implements LLMProvider { try { errorBody = await response.text(); } catch { - // Ignore error reading body + discardResponseBody(response); } + const baseError = classifyApiError(response.status, errorBody, response.headers); const lowerBody = errorBody.toLowerCase(); - if (body.tools && response.status === 400 && ( + if (body.tools && response.status === 400 && baseError.code === 'invalid_request' && ( lowerBody.includes('tool') || lowerBody.includes('function') || lowerBody.includes('schema') @@ -196,9 +267,8 @@ export class LlamaCppProvider implements LLMProvider { ); } - const baseError = classifyApiError(response.status, errorBody, response.headers); return new ApiError( - `llama.cpp API error: ${response.status} ${response.statusText}${errorBody ? `\n${errorBody}` : ''}`, + `llama.cpp API error: ${response.status} ${response.statusText}\n${baseError.message}`, baseError.code, baseError.httpStatus, baseError.retryable, diff --git a/src/providers/OpenAIProvider.ts b/src/providers/OpenAIProvider.ts index d23e5dd96..e8bc64bcf 100644 --- a/src/providers/OpenAIProvider.ts +++ b/src/providers/OpenAIProvider.ts @@ -5,13 +5,14 @@ */ import type { LLMProvider, LLMProviderCapabilities } from './LLMProvider.js'; -import type { ContentPart, MultimodalMessage, LLMRequest, LLMResponse, LLMToolCall, FunctionDefinition, ReasoningEffort, OpenAISettings, OpenAIChatGPTAuth } from '../types.js'; +import type { ContentPart, MultimodalMessage, LLMRequest, LLMResponse, LLMToolCall, FunctionDefinition, ReasoningEffort, OpenAISettings, OpenAIChatGPTAuth, ProviderReasoningBlock } 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 { normalizeOutboundMessages, normalizePromptRoles, normalizeToolCallHistory } from './messagePayload.js'; import { usesMaxCompletionTokens } from './openAICompatibleShared.js'; +import { discardResponseBody } from '../utils/responseBody.js'; import { getProviderDefaultModel, getProviderModelIds, @@ -52,6 +53,7 @@ type OpenAIProviderMessage = { name?: string; tool_call_id?: string; tool_calls?: LLMToolCall[]; + reasoningBlocks?: ProviderReasoningBlock[]; }; type OpenAIChatContentPart = @@ -93,8 +95,14 @@ interface OpenAIResponsesMessage { content?: Array; } +interface OpenAIResponsesReasoningBlock extends ProviderReasoningBlock { + type: 'reasoning'; + encrypted_content: string; +} + interface OpenAIResponsesResponse { id: string; + status?: string; created_at?: number; output?: OpenAIResponsesOutputItem[]; output_text?: string; @@ -186,8 +194,29 @@ const OPENAI_CHATGPT_FRIENDLY_MESSAGES: Partial> = 'The request timed out. The ChatGPT Codex service may be experiencing high load.', }; -function mustDisableChatCompletionReasoningForTools(model: string, hasTools: boolean): boolean { - return hasTools && /^gpt-5\.(?:4|6)(?:$|[-_.])/iu.test(model); +function requiresReasoning(model: string): boolean { + return /^gpt-6(?:-astra|\.1-sol)(?:$|[-_.])/iu.test(model); +} + +function requiresResponsesForTools(error: ApiError): boolean { + if (error.code !== 'invalid_request' || error.httpStatus !== 400) return false; + const detail = error.rawDetail ?? ''; + return /\b(?:function tools|function calling|tool calling)\b/iu.test(detail) + && /\b(?:not supported|requires?|only supported)\b/iu.test(detail) + && /\b(?:use|requires?)[^\n]{0,160}(?:\/(?:v1\/)?responses\b|responses api\b)/iu.test(detail); +} + +function isOfficialOpenAIApi(baseUrl: string): boolean { + try { + return new URL(baseUrl).origin === new URL(OPENAI_API_BASE_URL).origin; + } catch { + return false; + } +} + +function isOpenAIReasoningBlock(value: unknown): value is OpenAIResponsesReasoningBlock { + return !!value && typeof value === 'object' && 'type' in value && value.type === 'reasoning' + && 'encrypted_content' in value && typeof value.encrypted_content === 'string'; } export class OpenAIProvider implements LLMProvider { @@ -248,15 +277,26 @@ export class OpenAIProvider implements LLMProvider { const model = request.model || this.model; const hasTools = Boolean(request.tools?.length); + const officialApi = isOfficialOpenAIApi(this.baseUrl); + if (officialApi && this.reasoningEffort === 'none' && requiresReasoning(model)) { + throw new ApiError(`${model} does not support reasoning effort "none". Select "low" or a higher effort in the OpenAI provider settings.`, 'invalid_request', 400, false); + } + if (officialApi && hasTools && usesMaxCompletionTokens(model)) { + return this.completeWithResponsesApi(request); + } + if (request.signal?.aborted) { + throw new ApiError('Request cancelled.', 'cancelled', 0, false); + } const body: Record = { model, messages: normalizeOutboundMessages(request.messages, { transformContent: (content) => this.toChatCompletionContent(content), }), - temperature: request.temperature || 0.7, + ...(!usesMaxCompletionTokens(model) || (this.reasoningEffort === 'none' && !requiresReasoning(model)) + ? { 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. - ...(usesMaxCompletionTokens(model) + ...(request.maxTokens === undefined ? {} : usesMaxCompletionTokens(model) ? { max_completion_tokens: request.maxTokens } : { max_tokens: request.maxTokens }) }; @@ -264,9 +304,7 @@ export class OpenAIProvider implements LLMProvider { // Add reasoning effort when configured (with runtime validation) if (usesMaxCompletionTokens(model) && this.reasoningEffort && VALID_REASONING_EFFORTS.has(this.reasoningEffort)) { - body.reasoning_effort = mustDisableChatCompletionReasoningForTools(model, hasTools) - ? 'none' - : this.reasoningEffort; + body.reasoning_effort = this.reasoningEffort; } // Add function calling support if tools are provided @@ -289,9 +327,12 @@ export class OpenAIProvider implements LLMProvider { let response: Response; const headers = await this.buildAuthHeaders(); - for (let swappedTokenParameter = false; ; swappedTokenParameter = true) { + const sendRequest = async (): Promise => { try { - response = await fetch(`${this.baseUrl}/chat/completions`, { + if (request.signal?.aborted) { + throw new ApiError('Request cancelled.', 'cancelled', 0, false); + } + return await fetch(`${this.baseUrl}/chat/completions`, { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -301,45 +342,43 @@ export class OpenAIProvider implements LLMProvider { signal: request.signal }); } catch (error) { + if (error instanceof ApiError) throw error; const err = error as Error; - - // User cancelled if (err.name === 'AbortError' && request.signal?.aborted) { throw new ApiError('Request cancelled.', 'cancelled', 0, false); } - - // Timeout if (err.name === 'AbortError') { throw new ApiError( 'The request timed out. The OpenAI service may be experiencing high load.', 'timeout', 0, true, ); } - - // Network error throw new ApiError( `Unable to connect to ${this.baseUrl}. Please check the URL and your internet connection.`, 'network_error', 0, true, ); } - - if (response.ok) { - break; + }; + let swappedTokenParameter = false; + while (true) { + response = await sendRequest(); + if (response.ok) break; + const error = await this.buildApiError(response, request.signal); + if (hasTools && requiresResponsesForTools(error)) { + return this.completeWithResponsesApi(request); } - - const apiError = await this.buildApiError(response); - // Models we don't recognize (fine-tunes, new families) may still reject - // max_tokens. Resend once with max_completion_tokens instead of failing. - if (!swappedTokenParameter && 'max_tokens' in body - && /unsupported parameter:\s*'max_tokens'/i.test(apiError.rawDetail ?? '')) { + if (!swappedTokenParameter && body.max_tokens !== undefined + && error.code === 'invalid_request' && error.httpStatus === 400 + && /\bunsupported parameter:\s*['"]max_tokens['"]/iu.test(error.rawDetail ?? '')) { body.max_completion_tokens = body.max_tokens; delete body.max_tokens; + swappedTokenParameter = true; continue; } - throw apiError; + throw error; } - const data = await response.json() as OpenAIChatResponse; + const data = JSON.parse(await this.readResponseText(response, request.signal)) as OpenAIChatResponse; const message = data.choices[0].message; const finishReason = data.choices[0].finish_reason; @@ -370,7 +409,8 @@ export class OpenAIProvider implements LLMProvider { } private async completeWithResponsesApi(request: LLMRequest): Promise { - const instructions = this.buildCodexInstructions(request.messages); + const messages = normalizePromptRoles(normalizeToolCallHistory(request.messages)); + const instructions = this.buildCodexInstructions(messages); // The ChatGPT Codex backend supports a strict subset of the Responses API. // Unsupported parameters (max_output_tokens, temperature) are rejected. // See: https://github.com/openai/codex — ResponsesApiRequest struct. @@ -381,8 +421,13 @@ export class OpenAIProvider implements LLMProvider { stream: true, tool_choice: 'auto', parallel_tool_calls: true, - input: this.toResponsesInputItems(request.messages), + input: this.toResponsesInputItems(messages), ...(request.promptCache ? { prompt_cache_key: request.promptCache.key } : {}), + ...(this.authMode === 'api-key' && request.maxTokens !== undefined + ? { max_output_tokens: request.maxTokens } : {}), + ...(this.authMode === 'api-key' && this.reasoningEffort === 'none' + && !requiresReasoning(request.model || this.model) && request.temperature !== undefined + ? { temperature: request.temperature } : {}), }; if (this.reasoningEffort && VALID_REASONING_EFFORTS.has(this.reasoningEffort)) { @@ -391,6 +436,8 @@ export class OpenAIProvider implements LLMProvider { }; // Enable encrypted reasoning content for multi-turn conversations body.include = ['reasoning.encrypted_content']; + } else if (this.authMode === 'api-key') { + body.include = ['reasoning.encrypted_content']; } if (request.tools && request.tools.length > 0) { @@ -399,6 +446,7 @@ export class OpenAIProvider implements LLMProvider { name: tool.name, description: tool.description, parameters: tool.parameters ?? { type: 'object', properties: {} }, + ...(this.authMode === 'api-key' ? { strict: false } : {}), })); if (request.toolChoice === 'required') { @@ -416,6 +464,9 @@ export class OpenAIProvider implements LLMProvider { const headers = await this.buildAuthHeaders(); const sendRequest = async (requestBody: Record): Promise => { try { + if (request.signal?.aborted) { + throw new ApiError('Request cancelled.', 'cancelled', 0, false); + } return await fetch(`${this.baseUrl}/responses`, { method: 'POST', headers: { @@ -426,6 +477,7 @@ export class OpenAIProvider implements LLMProvider { signal: request.signal, }); } catch (error) { + if (error instanceof ApiError) throw error; const err = error as Error; if (err.name === 'AbortError' && request.signal?.aborted) { throw new ApiError('Request cancelled.', 'cancelled', 0, false); @@ -433,7 +485,9 @@ export class OpenAIProvider implements LLMProvider { if (err.name === 'AbortError') { throw new ApiError( - 'The request timed out. The ChatGPT Codex service may be experiencing high load.', + this.authMode === 'chatgpt' + ? 'The request timed out. The ChatGPT Codex service may be experiencing high load.' + : 'The request timed out. The OpenAI service may be experiencing high load.', 'timeout', 0, true, ); } @@ -447,7 +501,7 @@ export class OpenAIProvider implements LLMProvider { let response = await sendRequest(body); if (!response.ok) { - const error = await this.buildApiError(response); + const error = await this.buildApiError(response, request.signal); if (!request.promptCache || !isPromptCacheKeyRejection(error)) { throw error; } @@ -456,45 +510,70 @@ export class OpenAIProvider implements LLMProvider { delete fallbackBody.prompt_cache_key; response = await sendRequest(fallbackBody); if (!response.ok) { - throw await this.buildApiError(response); + throw await this.buildApiError(response, request.signal); } } - const data = await this.parseCodexStream(response); - const toolCalls = this.extractResponsesToolCalls(data.output); + const data = await this.parseCodexStream(response, request.signal); + const incomplete = Boolean(data.incomplete_details?.reason || data.status === 'incomplete' + || data.output?.some(item => item?.type === 'function_call' && 'status' in item + && item.status !== undefined && item.status !== 'completed')); + const toolCalls = incomplete ? [] : this.extractResponsesToolCalls(data.output); const content = this.extractResponsesContent(data); const usage = normalizeLLMUsage(data.usage, 'openai-responses'); + const reasoningBlocks = data.output?.filter(isOpenAIReasoningBlock); return { id: data.id, created: data.created_at ?? Math.floor(Date.now() / 1000), content, toolCalls, - finishReason: toolCalls.length > 0 - ? 'tool_calls' - : (data.incomplete_details?.reason ? 'length' : 'stop'), + finishReason: incomplete ? 'length' : toolCalls.length > 0 ? 'tool_calls' : 'stop', usage, + ...(reasoningBlocks?.length ? { reasoningBlocks } : {}), raw: data, }; } - private async buildApiError(response: Response): Promise { - let errorDetail = ''; + private async readResponseText(response: Response, signal?: AbortSignal): Promise { + if (signal?.aborted) { + discardResponseBody(response); + throw new ApiError('Request cancelled.', 'cancelled', 0, false); + } try { - const body = (await response.json()) as Record; - const errObj = body?.error as Record | undefined; - errorDetail = (errObj?.message ?? body?.detail ?? body?.error ?? '') as string; - if (typeof errorDetail === 'object') { - errorDetail = JSON.stringify(errorDetail); + const text = await response.text(); + if (signal?.aborted) { + throw new ApiError('Request cancelled.', 'cancelled', 0, false); } - } catch { - try { - errorDetail = await response.text(); - } catch { - // Ignore + return text; + } catch (error) { + if (error instanceof ApiError) throw error; + if (signal?.aborted) throw new ApiError('Request cancelled.', 'cancelled', 0, false); + if (error instanceof Error && (error.name === 'AbortError' || error.name === 'TimeoutError')) { + throw new ApiError('The response body timed out. Please try again.', 'timeout', 0, true); } + throw new ApiError('The response body could not be read. Check the connection and try again.', 'network_error', 0, true); } + } + private async buildApiError(response: Response, signal?: AbortSignal): Promise { + const text = await this.readResponseText(response, signal); + let detail: unknown = text; + try { + const body: unknown = JSON.parse(text); + if (body && typeof body === 'object' && !Array.isArray(body)) { + const fields = body as Record; + const error = fields.error; + detail = error && typeof error === 'object' && !Array.isArray(error) + ? (error as Record).message ?? fields.detail ?? fields.message ?? error + : error ?? fields.detail ?? fields.message ?? text; + } else { + detail = body ?? text; + } + } catch { + detail = text; + } + const errorDetail = typeof detail === 'string' ? detail : JSON.stringify(detail) ?? text; return this.withOpenAIMessage(classifyApiError(response.status, errorDetail, response.headers)); } @@ -521,8 +600,8 @@ export class OpenAIProvider implements LLMProvider { * Parse an SSE stream from the ChatGPT Codex backend and extract the * `response.completed` event payload as the full response object. */ - private async parseCodexStream(response: Response): Promise { - const text = await response.text(); + private async parseCodexStream(response: Response, signal?: AbortSignal): Promise { + const text = await this.readResponseText(response, signal); let currentEvent = ''; let streamedResponseId: string | undefined; let completedData: OpenAIResponsesResponse | null = null; @@ -581,6 +660,9 @@ export class OpenAIProvider implements LLMProvider { if (eventType === 'response.incomplete') { completedData = this.extractCodexStreamResponse(eventData); + if (completedData && !completedData.incomplete_details?.reason) { + completedData.incomplete_details = { reason: 'response_incomplete' }; + } break; } @@ -901,6 +983,10 @@ export class OpenAIProvider implements LLMProvider { return items; } + if (msg.role === 'assistant' && msg.reasoningBlocks) { + items.push(...msg.reasoningBlocks.filter(isOpenAIReasoningBlock)); + } + if (msg.role === 'tool' && msg.tool_call_id) { if (!matchedToolCallIds.has(msg.tool_call_id)) { return items; @@ -957,7 +1043,8 @@ export class OpenAIProvider implements LLMProvider { } return output - .filter((entry): entry is OpenAIResponsesFunctionCall => entry?.type === 'function_call') + .filter((entry): entry is OpenAIResponsesFunctionCall => entry?.type === 'function_call' + && (!('status' in entry) || entry.status === undefined || entry.status === 'completed')) .map((toolCall, index) => ({ id: toolCall.call_id ?? `call_${index + 1}`, type: 'function' as const, diff --git a/src/providers/OpenRouterClient.ts b/src/providers/OpenRouterClient.ts index f660fac41..b133e25c8 100644 --- a/src/providers/OpenRouterClient.ts +++ b/src/providers/OpenRouterClient.ts @@ -23,6 +23,7 @@ import { normalizeOpenRouterModelId } from "./modelCatalog.js"; import { normalizeOutboundMessages } from "./messagePayload.js"; import { normalizeLLMUsage } from "./usage.js"; import { normalizeProviderFinishReason } from "./finishReason.js"; +import { discardResponseBody } from "../utils/responseBody.js"; /** * Sanitize messages for API consumption. @@ -215,6 +216,24 @@ function asErrorRecord(value: unknown): Record | undefined { : undefined; } +interface OpenRouterCompletion { + id?: string; + created?: number; + error?: unknown; + usage?: unknown; + choices?: Array<{ + error?: unknown; + finish_reason?: unknown; + message?: { + content?: string; + tool_calls?: Array<{ + id: string; + function?: { name?: string; arguments?: string }; + }>; + }; + }>; +} + export class OpenRouterClient { private readonly apiKey: string; private readonly baseUrl: string; @@ -397,89 +416,66 @@ export class OpenRouterClient { signal?: AbortSignal, preSerializedBody?: string ): Promise { - let response: Response; + const deadline = AbortSignal.timeout(this.timeout); + const combinedSignal = signal ? AbortSignal.any([signal, deadline]) : deadline; + let response: Response | undefined; try { - // Create timeout controller - const timeoutController = new AbortController(); - const timeoutId = setTimeout( - () => timeoutController.abort(), - this.timeout - ); + response = await fetch(`${this.baseUrl}/chat/completions`, { + method: "POST", + headers, + body: preSerializedBody ?? JSON.stringify(payload), + signal: combinedSignal, + }); - // Combine user signal with timeout - const combinedSignal = signal - ? AbortSignal.any([signal, timeoutController.signal]) - : timeoutController.signal; + if (!response.ok) { + throw await this.buildApiError(response); + } - try { - response = await fetch(`${this.baseUrl}/chat/completions`, { - method: "POST", - headers, - body: preSerializedBody ?? JSON.stringify(payload), - signal: combinedSignal, - }); - } finally { - clearTimeout(timeoutId); + const json = await response.json() as OpenRouterCompletion; + const embeddedError = json?.error ?? json?.choices?.find(choice => choice?.error)?.error; + if (embeddedError) { + throw await this.buildApiError(response, { error: embeddedError }); } + const message = json?.choices?.[0]?.message; + const toolCalls: LLMToolCall[] | undefined = Array.isArray(message?.tool_calls) + ? message.tool_calls.map(call => ({ + id: call.id, + type: "function" as const, + function: { + name: call.function?.name ?? "", + arguments: call.function?.arguments ?? "{}", + }, + })) + : undefined; + + return { + id: json.id ?? "autohand-local", + created: json.created ?? Date.now(), + content: message?.content ?? "", + toolCalls, + finishReason: normalizeProviderFinishReason(json?.choices?.[0]?.finish_reason), + usage: normalizeLLMUsage(json?.usage), + raw: json, + }; } catch (error) { - const err = error as Error; - - // Timeout - if (err.name === "AbortError") { + if (signal?.aborted) { + throw new ApiError('Request cancelled.', 'cancelled', 0, false); + } + if (deadline.aborted) { throw new ApiError( - "The request timed out. The OpenRouter service may be experiencing high load.", + OPENROUTER_FRIENDLY_MESSAGES.timeout!, 'timeout', 0, true, ); } - - // Network error - friendly message + if (response) throw error; throw new ApiError( - "Unable to connect to OpenRouter. Please check your internet connection.", + OPENROUTER_FRIENDLY_MESSAGES.network_error!, 'network_error', 0, true, ); + } finally { + if (response && !response.bodyUsed) discardResponseBody(response); } - - if (!response.ok) { - throw await this.buildApiError(response); - } - - const json = (await response.json()) as any; - const embeddedError = json?.error ?? json?.choices?.find((choice: { error?: unknown }) => choice?.error)?.error; - if (embeddedError) { - throw await this.buildApiError(response, { error: embeddedError }); - } - const message = json?.choices?.[0]?.message; - const text = message?.content ?? ""; - const finishReason = json?.choices?.[0]?.finish_reason; - - // Parse tool calls if present - let toolCalls: LLMToolCall[] | undefined; - if (message?.tool_calls && Array.isArray(message.tool_calls)) { - toolCalls = message.tool_calls.map((tc: any) => { - const rawArgs = tc.function?.arguments; - return { - id: tc.id, - type: "function" as const, - function: { - name: tc.function?.name ?? "", - arguments: rawArgs ?? "{}", - }, - }; - }); - } - - const usage = normalizeLLMUsage(json?.usage); - - return { - id: json.id ?? "autohand-local", - created: json.created ?? Date.now(), - content: text, - toolCalls, - finishReason: normalizeProviderFinishReason(finishReason), - usage, - raw: json, - }; } private async buildApiError(response: Response, payload?: unknown): Promise { @@ -488,8 +484,9 @@ export class OpenRouterClient { // Try to get the actual error message from the response let errorDetail = ""; let errorType: string | undefined; + const responseText = payload === undefined ? await response.text() : undefined; try { - const body = asErrorRecord(payload ?? await response.clone().json()); + const body = asErrorRecord(payload ?? JSON.parse(responseText ?? '')); const error = asErrorRecord(body?.error); const metadata = asErrorRecord(error?.metadata); const detail = error?.message ?? body?.error ?? body?.message; @@ -510,12 +507,7 @@ export class OpenRouterClient { errorDetail = errorDetail ? `${errorDetail}\n${upstreamDetail}` : upstreamDetail; } } catch { - // Fallback to raw text if JSON parsing fails - try { - errorDetail = await response.text(); - } catch { - // Ignore - } + errorDetail = responseText ?? ''; } const transient = errorType ? OPENROUTER_TRANSIENT_ERROR_TYPES[errorType] : undefined; diff --git a/src/providers/ProviderFactory.ts b/src/providers/ProviderFactory.ts index c5f026bbe..94db5b355 100644 --- a/src/providers/ProviderFactory.ts +++ b/src/providers/ProviderFactory.ts @@ -219,7 +219,7 @@ export class ProviderFactory { if (!config.openrouter) { return new UnconfiguredProvider('openrouter'); } - return new OpenRouterProvider(config.openrouter); + return new OpenRouterProvider(config.openrouter, config.network); default: return new UnconfiguredProvider(providerName); diff --git a/src/providers/azure/tokenManager.ts b/src/providers/azure/tokenManager.ts index 2e0c60138..be0dc4164 100644 --- a/src/providers/azure/tokenManager.ts +++ b/src/providers/azure/tokenManager.ts @@ -5,6 +5,7 @@ */ import type { AzureAuthMethod } from '../../types.js'; +import { ApiError } from '../errors.js'; const EXPIRY_BUFFER_MS = 5 * 60 * 1000; // 5 minutes const IMDS_ENDPOINT = 'http://169.254.169.254/metadata/identity/oauth2/token'; @@ -103,11 +104,16 @@ export class AzureTokenManager { response = await fetch(url, { headers: { Metadata: 'true' } }); - } catch { - throw new Error( - 'Managed Identity token acquisition failed. ' + - 'This auth method only works inside Azure VMs, App Service, or containers with managed identity enabled.' + } catch (cause) { + const error = new ApiError( + 'Managed Identity authentication failed before a model request was sent. ' + + 'The Azure identity token endpoint could not be reached. Check the host identity and connectivity, then retry.', + 'auth_failed', + 0, + false, ); + Object.defineProperty(error, 'cause', { value: cause, configurable: true, writable: true }); + throw error; } if (!response.ok) { diff --git a/src/providers/errors.ts b/src/providers/errors.ts index 2706d73fa..6ac0648c9 100644 --- a/src/providers/errors.ts +++ b/src/providers/errors.ts @@ -22,6 +22,7 @@ export type ApiErrorCode = | 'model_not_found' | 'invalid_request' | 'content_blocked' + | 'storage_full' | 'auth_failed' | 'payment_required' | 'access_denied' @@ -78,6 +79,8 @@ export const FRIENDLY_MESSAGES: Record = { 'The request was malformed and could not be processed.', content_blocked: 'The safety check blocked this request.', + storage_full: + 'Local storage is full. Free disk space before continuing. Recent session data may not have been saved.', auth_failed: 'Authentication failed. Please verify your API key in ~/.autohand/config.json.', payment_required: @@ -98,19 +101,53 @@ export const FRIENDLY_MESSAGES: Record = { 'An unexpected error occurred. Please try again.', }; +const LEGACY_CONTENT_BLOCKED_MESSAGE = "This message was blocked by Autohand's safety check. Rephrase it and try again."; +const LEGACY_MANAGED_IDENTITY_FAILURE_MESSAGE = 'Managed Identity token acquisition failed. This auth method only works inside Azure VMs, App Service, or containers with managed identity enabled.'; + +function isLegacyContentBlockedMessage(message: unknown): boolean { + return message === LEGACY_CONTENT_BLOCKED_MESSAGE + || message === `${FRIENDLY_MESSAGES.invalid_request}\n${LEGACY_CONTENT_BLOCKED_MESSAGE}`; +} + +export function readStorageCapacityError(value: unknown): ApiError | undefined { + let current = value; + for (let depth = 0; depth < 4; depth++) { + if (current instanceof ApiError) { + if (current.code === 'storage_full') return current; + if (current.httpStatus !== 0) return undefined; + } + const record = current && typeof current === 'object' + ? current as Record : undefined; + const message = typeof current === 'string' + ? current : typeof record?.message === 'string' ? record.message : ''; + if (record?.code === 'ENOSPC' || record?.code === 'EDQUOT' + || /^(?:Error:\s*)?(?:ENOSPC|EDQUOT):/.test(message) + || message === FRIENDLY_MESSAGES.storage_full + || message.startsWith(`${FRIENDLY_MESSAGES.storage_full}\n`)) { + const display = message.startsWith(FRIENDLY_MESSAGES.storage_full) + ? message : `${FRIENDLY_MESSAGES.storage_full}${message ? `\n${message}` : ''}`; + return new ApiError(display, 'storage_full', 0, false, undefined, message); + } + current = record?.cause; + if (!current) break; + } + return undefined; +} + export function readContentBlockedError( value: unknown, httpStatus: number, requestId?: string | null, ): ApiError | undefined { - if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined; - const error = value as Record; - if (error.type !== 'content_blocked') return undefined; - const categories = Array.isArray(error.categories) + const error = value && typeof value === 'object' && !Array.isArray(value) + ? value as Record : undefined; + if (error?.type !== 'content_blocked' + && !isLegacyContentBlockedMessage(typeof value === 'string' ? value : error?.message)) return undefined; + const categories = Array.isArray(error?.categories) ? [...new Set(error.categories.filter((category): category is string => typeof category === 'string' && /^S(?:[1-9]|1[0-4])$/.test(category)))].slice(0, 14) : []; - const candidate = requestId ?? error.requestId; + const candidate = requestId ?? error?.requestId; const id = typeof candidate === 'string' && /^[a-zA-Z0-9._:-]{1,128}$/.test(candidate) ? candidate : undefined; const details = [ @@ -141,6 +178,23 @@ const MODEL_NOT_FOUND_PATTERNS = [ "\" was not found", ] as const; +function isUnconfiguredModel(body: string): boolean { + const pattern = /^model (?:"[^"\r\n]+"|'[^'\r\n]+'|[a-z0-9][a-z0-9_./:@+-]*) is not configured\.?$/i; + if (pattern.test(body.trim())) return true; + if (!body.includes('autohand_router_error')) return false; + try { + const parsed: unknown = JSON.parse(body); + const record = parsed && typeof parsed === 'object' && !Array.isArray(parsed) + ? parsed as Record : undefined; + const error = record?.error && typeof record.error === 'object' && !Array.isArray(record.error) + ? record.error as Record : undefined; + return error?.type === 'autohand_router_error' + && typeof error.message === 'string' && pattern.test(error.message.trim()); + } catch { + return false; + } +} + /** * Patterns that indicate a context/payload overflow in a 400 body. * These must be narrow enough to avoid false positives for generic errors @@ -162,6 +216,8 @@ const CONTEXT_OVERFLOW_PATTERNS = [ 'tokens per minute', '(tpm)', 'prefill memory guard rejected', + 'exceed_context_size_error', + 'exceeds the available context size', ] as const; /** Patterns that indicate cancellation in status-0 / unknown errors. */ @@ -257,6 +313,20 @@ export function classifyApiError( headers?: Headers, ): ApiError { const lower = errorBody.toLowerCase(); + if (httpStatus === 0) { + const storage = readStorageCapacityError(errorBody); + if (storage) return storage; + if (errorBody === LEGACY_MANAGED_IDENTITY_FAILURE_MESSAGE) { + return new ApiError( + 'Managed Identity token acquisition failed. Check the selected Azure identity configuration and endpoint connectivity before retrying.', + 'auth_failed', 0, false, undefined, errorBody, + ); + } + } + if ((httpStatus === 0 || httpStatus === 400) && isLegacyContentBlockedMessage(errorBody)) { + const blocked = readContentBlockedError(errorBody, httpStatus, headers?.get('x-autohand-request-id')); + if (blocked) return blocked; + } if (httpStatus === 0 && (errorBody === FRIENDLY_MESSAGES.content_blocked || errorBody.startsWith(`${FRIENDLY_MESSAGES.content_blocked}\n`))) { return new ApiError(errorBody, 'content_blocked', httpStatus, false); @@ -314,7 +384,7 @@ export function classifyApiError( } // 1. Model-not-found check runs FIRST - if (matchesAny(lower, MODEL_NOT_FOUND_PATTERNS)) { + if (matchesAny(lower, MODEL_NOT_FOUND_PATTERNS) || isUnconfiguredModel(errorBody)) { return makeError('model_not_found', httpStatus, false, errorBody, headers); } @@ -347,7 +417,7 @@ export function classifyApiError( if (httpStatus === 0 || httpStatus === undefined) { // Check model-not-found patterns before overflow. Some providers prepend // stale or generic friendly text ahead of the real "invalid model ID" body. - if (matchesAny(lower, MODEL_NOT_FOUND_PATTERNS)) { + if (matchesAny(lower, MODEL_NOT_FOUND_PATTERNS) || isUnconfiguredModel(errorBody)) { return makeError('model_not_found', httpStatus, false, errorBody, headers); } if (matchesAny(lower, RATE_LIMIT_PATTERNS)) { diff --git a/src/providers/messagePayload.ts b/src/providers/messagePayload.ts index 08df9144c..033c9f8ca 100644 --- a/src/providers/messagePayload.ts +++ b/src/providers/messagePayload.ts @@ -12,7 +12,7 @@ * on the way out rather than depended upon upstream. */ -import type { MultimodalMessage } from "../types.js"; +import type { LLMToolCall, MultimodalMessage } from "../types.js"; export const EMPTY_TOOL_RESULT_PLACEHOLDER = "(no output)"; export const UNANSWERED_TOOL_CALL_PLACEHOLDER = @@ -78,6 +78,70 @@ export function isBlankContent(content: unknown): boolean { return content === null || content === undefined; } +function hasValidToolArguments(call: LLMToolCall): boolean { + try { + const argumentsValue: unknown = JSON.parse(call.function.arguments); + return typeof argumentsValue === "object" && argumentsValue !== null && !Array.isArray(argumentsValue); + } catch { + return false; + } +} + +export function normalizeToolCallHistory(messages: readonly MultimodalMessage[]): MultimodalMessage[] { + const normalized: MultimodalMessage[] = []; + const invalidCalls = new Map(); + let recoveredResults: MultimodalMessage[] = []; + const flushRecoveredResults = (): void => { + normalized.push(...recoveredResults); + recoveredResults = []; + }; + + for (const message of messages) { + if (message.role === "tool") { + const invalidName = message.tool_call_id ? invalidCalls.get(message.tool_call_id) : undefined; + if (invalidName !== undefined) { + recoveredResults.push({ + role: "user", + content: `[Recovered tool result: ${invalidName}; historical arguments were invalid]\n${toTextOnlyContent(message.content)}`, + }); + } else { + normalized.push(message); + } + continue; + } + + flushRecoveredResults(); + if (message.role !== "assistant" || !message.tool_calls?.length) { + normalized.push(message); + continue; + } + + const validCalls = message.tool_calls.filter(hasValidToolArguments); + if (validCalls.length === message.tool_calls.length) { + normalized.push(message); + continue; + } + const validSet = new Set(validCalls); + const invalidNames: string[] = []; + for (const call of message.tool_calls) { + if (validSet.has(call)) continue; + invalidCalls.set(call.id, call.function.name); + invalidNames.push(call.function.name); + } + const notice = `[Historical tool calls omitted from native replay because their arguments were not valid JSON objects: ${invalidNames.join(", ")}.]`; + const content = Array.isArray(message.content) + ? [...message.content, { type: "text" as const, text: notice }] + : [message.content, notice].filter(Boolean).join("\n"); + normalized.push({ + ...message, + content, + tool_calls: validCalls.length ? validCalls : undefined, + }); + } + flushRecoveredResults(); + return normalized; +} + /** Keep runtime context at its conversation position instead of moving it into the cached prefix. */ export function normalizePromptRoles(messages: readonly MultimodalMessage[]): MultimodalMessage[] { const normalized: MultimodalMessage[] = []; @@ -157,7 +221,7 @@ export function normalizeOutboundMessages( messages: MultimodalMessage[], options: NormalizeOutboundMessagesOptions = {}, ): Record[] { - messages = normalizePromptRoles(messages); + messages = normalizePromptRoles(normalizeToolCallHistory(messages)); const transformContent = options.transformContent ?? ((content) => content); const knownToolCallIds = new Set(); const answeredToolCallIds = new Set(); diff --git a/src/providers/openCodeGo.ts b/src/providers/openCodeGo.ts new file mode 100644 index 000000000..f4b2be91c --- /dev/null +++ b/src/providers/openCodeGo.ts @@ -0,0 +1,35 @@ +/** + * @license + * Copyright 2026 Autohand AI LLC + * SPDX-License-Identifier: Apache-2.0 + */ +import { createHash } from 'node:crypto'; +import packageJson from '../../package.json' with { type: 'json' }; +import type { LLMRequest } from '../types.js'; + +const ANTHROPIC_GO_BASE_PATHS = new Set([ + '/inference/go/anthropic', + '/inference/go/anthropic/v1', + '/zen/go', + '/zen/go/v1', +]); + +export function buildOpenCodeGoHeaders( + baseUrl: string, + request: Pick, + fallbackSessionId: string, +): Record | undefined { + try { + const url = new URL(baseUrl); + if (url.origin !== 'https://opencode.ai' || url.username || url.password + || url.search || url.hash || !ANTHROPIC_GO_BASE_PATHS.has(url.pathname.replace(/\/+$/, ''))) return undefined; + } catch { + return undefined; + } + const identity = request.sessionId || request.promptCache?.key || fallbackSessionId; + const digest = createHash('sha256').update('autohand/opencode-go-session/v1\0').update(identity).digest('hex'); + return { + 'User-Agent': `autohand-cli/${packageJson.version}`, + 'x-opencode-session': `autohand_${digest}`, + }; +} diff --git a/src/providers/runtimeContextWindow.ts b/src/providers/runtimeContextWindow.ts new file mode 100644 index 000000000..b795db44e --- /dev/null +++ b/src/providers/runtimeContextWindow.ts @@ -0,0 +1,22 @@ +export async function resolveRuntimeContextWindow( + discover: (signal: AbortSignal) => Promise, + signal?: AbortSignal, +): Promise { + if (signal?.aborted) return undefined; + const deadline = AbortSignal.timeout(2000); + const boundedSignal = signal ? AbortSignal.any([signal, deadline]) : deadline; + if (boundedSignal.aborted) return undefined; + let abort: () => void = () => {}; + const cancelled = new Promise((resolve) => { + abort = () => resolve(undefined); + boundedSignal.addEventListener('abort', abort, { once: true }); + }); + try { + const value = await Promise.race([discover(boundedSignal), cancelled]); + return typeof value === 'number' && Number.isSafeInteger(value) && value > 0 ? value : undefined; + } catch { + return undefined; + } finally { + boundedSignal.removeEventListener('abort', abort); + } +} diff --git a/src/reporting/AutoReportManager.ts b/src/reporting/AutoReportManager.ts index dfe3aa803..821757d74 100644 --- a/src/reporting/AutoReportManager.ts +++ b/src/reporting/AutoReportManager.ts @@ -10,7 +10,7 @@ import crypto from 'node:crypto'; import type { AutohandConfig } from '../types.js'; import type { ErrorReport } from './types.js'; import { AutoReportClient } from './AutoReportClient.js'; -import { ApiError, classifyApiError } from '../providers/errors.js'; +import { ApiError, classifyApiError, readStorageCapacityError } from '../providers/errors.js'; import type { ApiErrorCode } from '../providers/errors.js'; import { isAutohandDebugEnabled } from '../utils/debugLog.js'; @@ -48,6 +48,7 @@ export class AutoReportManager { 'auth_failed', // Bad API key — user config issue 'payment_required', // Account billing issue 'content_blocked', // Safety verdicts are request rejections, not CLI crashes + 'storage_full', 'access_denied', // API key lacks permissions 'model_not_found', // Wrong model name — user config issue ]); @@ -61,6 +62,7 @@ export class AutoReportManager { } private getOperationalErrorCode(error: Error): ApiErrorCode | null { + if (readStorageCapacityError(error)) return 'storage_full'; if (error instanceof ApiError) { return AutoReportManager.OPERATIONAL_API_ERROR_CODES.has(error.code) ? error.code diff --git a/src/session/ProjectManager.ts b/src/session/ProjectManager.ts index 2ce2ed9b0..2494cceaf 100644 --- a/src/session/ProjectManager.ts +++ b/src/session/ProjectManager.ts @@ -6,6 +6,7 @@ import fs from 'fs-extra'; import path from 'node:path'; import crypto from 'node:crypto'; +import { open } from 'node:fs/promises'; import type { ProjectIndex, ProjectKnowledge, @@ -83,7 +84,7 @@ export class ProjectManager { const projectDir = path.join(this.projectsDir, projectHash); const failuresPath = path.join(projectDir, 'failures.jsonl'); - await fs.appendFile(failuresPath, JSON.stringify(failure) + '\n'); + await this.appendLogRecord(failuresPath, failure); await this.updateKnowledge(projectHash); } @@ -92,7 +93,7 @@ export class ProjectManager { const projectDir = path.join(this.projectsDir, projectHash); const successesPath = path.join(projectDir, 'successes.jsonl'); - await fs.appendFile(successesPath, JSON.stringify(success) + '\n'); + await this.appendLogRecord(successesPath, success); await this.updateKnowledge(projectHash); } @@ -101,20 +102,11 @@ export class ProjectManager { const projectDir = path.join(this.projectsDir, projectHash); const failuresPath = path.join(projectDir, 'failures.jsonl'); - if (!(await fs.pathExists(failuresPath))) { - return []; - } - - const content = await fs.readFile(failuresPath, 'utf-8'); const cutoff = new Date(); cutoff.setDate(cutoff.getDate() - lookbackDays); - return content - .trim() - .split('\n') - .filter(line => line) - .map(line => JSON.parse(line) as FailureRecord) - .filter(f => new Date(f.timestamp) >= cutoff); + return (await this.loadJsonl(failuresPath)) + .filter(failure => new Date(failure.timestamp) >= cutoff); } async getKnowledge(projectPath: string): Promise { @@ -210,11 +202,29 @@ export class ProjectManager { } const content = await fs.readFile(filePath, 'utf-8'); - return content - .trim() - .split('\n') - .filter(line => line) - .map(line => JSON.parse(line) as T); + const records: T[] = []; + for (const line of content.split('\n')) { + if (!line.trim()) continue; + try { + records.push(JSON.parse(line) as T); + } catch (error) { + if (!(error instanceof SyntaxError)) throw error; + } + } + return records; + } + + private async appendLogRecord(filePath: string, record: FailureRecord | SuccessRecord): Promise { + const handle = await open(filePath, 'a+'); + try { + const { size } = await handle.stat(); + const tail = Buffer.alloc(1); + if (size > 0) await handle.read(tail, 0, 1, size - 1); + const separator = size > 0 && tail[0] !== 10 ? '\n' : ''; + await handle.writeFile(`${separator}${JSON.stringify(record)}\n`, 'utf8'); + } finally { + await handle.close(); + } } private generateSummary(failures: FailureRecord[], successes: SuccessRecord[]): string { diff --git a/src/skills/SkillsRegistry.ts b/src/skills/SkillsRegistry.ts index fdf6ea190..c7eb46160 100644 --- a/src/skills/SkillsRegistry.ts +++ b/src/skills/SkillsRegistry.ts @@ -9,6 +9,7 @@ import fs from 'fs-extra'; import path from 'node:path'; import { randomUUID } from 'node:crypto'; import { statSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; import { SkillParser } from './SkillParser.js'; import { estimateTokens } from '../core/context/tokenizer.js'; import { resolveEmbeddedBuiltinAssetDirectory } from './embeddedBuiltinAssets.js'; @@ -51,6 +52,15 @@ import { const SIMILARITY_THRESHOLD = 0.3; const BUILTIN_SKILLS_DIR = 'builtin'; +export function resolveBuiltinSkillDirectories(moduleUrl = import.meta.url): string[] { + const moduleDir = path.dirname(fileURLToPath(moduleUrl)); + return [ + path.join(moduleDir, BUILTIN_SKILLS_DIR), + path.join(moduleDir, 'skills', BUILTIN_SKILLS_DIR), + path.join(moduleDir, '..', 'skills', BUILTIN_SKILLS_DIR), + ]; +} + export interface SkillSearchLocation { /** Stable host-assigned authority for a custom library; independent of its current path. */ authority?: string; @@ -612,12 +622,7 @@ export class SkillsRegistry { if (this.options.builtinSkillDirectories) { return this.options.builtinSkillDirectories; } - const moduleDir = path.dirname(new URL(import.meta.url).pathname); - return [ - path.join(moduleDir, BUILTIN_SKILLS_DIR), - path.join(moduleDir, 'skills', BUILTIN_SKILLS_DIR), - path.join(moduleDir, '..', 'skills', BUILTIN_SKILLS_DIR), - ]; + return resolveBuiltinSkillDirectories(); } private getUserSkillLocations(): SkillSearchLocation[] { diff --git a/src/testing/scenarios/localModelContextScenario.ts b/src/testing/scenarios/localModelContextScenario.ts new file mode 100644 index 000000000..ee3622994 --- /dev/null +++ b/src/testing/scenarios/localModelContextScenario.ts @@ -0,0 +1,75 @@ +import { once } from 'node:events'; +import { createServer } from 'node:http'; +import type { Session } from 'tuistory'; + +export async function createLocalModelContextFixture(): Promise<{ + baseUrl: string; + metadataRequests: string[]; + completions: Record[]; + close(): Promise; +}> { + const metadataRequests: string[] = []; + const completions: Record[] = []; + const server = createServer(async (request, response) => { + try { + response.setHeader('content-type', 'application/json'); + if (request.url?.startsWith('/props')) { + metadataRequests.push(request.url); + response.end(JSON.stringify({ + default_generation_settings: { n_ctx: 32768 }, + total_slots: 4, + meta: { n_ctx_train: 131072 }, + })); + return; + } + if (request.method === 'GET') { + response.end(JSON.stringify({ data: [{ id: 'canvas-pirate' }] })); + return; + } + let body = ''; + for await (const chunk of request) body += String(chunk); + const payload: unknown = JSON.parse(body); + if (!payload || typeof payload !== 'object' || Array.isArray(payload)) throw new Error('Invalid fixture request'); + completions.push(payload as Record); + response.end(JSON.stringify({ + id: 'local-context-fixture', + created: 1, + choices: [{ message: { role: 'assistant', content: 'LOCAL_CONTEXT_READY' }, finish_reason: 'stop' }], + usage: { prompt_tokens: 64, completion_tokens: 8, total_tokens: 72 }, + })); + } catch { + response.destroy(); + } + }); + try { + server.listen(0, '127.0.0.1'); + await once(server, 'listening'); + const address = server.address(); + if (!address || typeof address === 'string') throw new Error('Local model fixture did not bind'); + return { + baseUrl: `http://127.0.0.1:${address.port}`, + metadataRequests, + completions, + close: async () => { + server.closeAllConnections(); + await new Promise((resolve, reject) => server.close(error => error ? reject(error) : resolve())); + }, + }; + } catch (error) { + server.closeAllConnections(); + await new Promise(resolve => server.close(() => resolve())); + throw error; + } +} + +export async function inspectLocalModelContext(session: Session): Promise { + await session.waitForText('❯', { timeout: 20000 }); + await session.type('hello'); + await session.waitForText('❯ hello'); + await session.press('enter'); + await session.waitForText('LOCAL_CONTEXT_READY', { timeout: 20000 }); + await session.waitForText('Completed in', { timeout: 10000 }); + await session.type('local context draft'); + await session.waitForText('❯ local context draft'); + return session.text({ immediate: true }); +} diff --git a/src/testing/scenarios/memoryResourceScenario.ts b/src/testing/scenarios/memoryResourceScenario.ts index a3cba5175..88d1d340d 100644 --- a/src/testing/scenarios/memoryResourceScenario.ts +++ b/src/testing/scenarios/memoryResourceScenario.ts @@ -40,3 +40,34 @@ nodeFs.mkdir = async function(target, ...options) { arm: async () => writeFile(armedPath, ''), }; } + +export async function prepareSessionDiskFullScenario( + workspaceRoot: string, + autohandHome: string, + responseMarker: string, +): Promise<{ preload: string; attemptsPath: string; recover: () => Promise }> { + const attemptsPath = path.join(workspaceRoot, 'session-disk-full.attempts'); + const recoveredPath = path.join(workspaceRoot, 'session-disk-full.recovered'); + const preloadPath = path.join(workspaceRoot, 'session-disk-full.mjs'); + await writeFile(preloadPath, `import { promises as nodeFs, existsSync, readFileSync, appendFileSync } from 'node:fs'; +import { basename, dirname, join } from 'node:path'; +const originalOpen = nodeFs.open; +nodeFs.open = async function(target, ...options) { + const file = String(target); + const conversation = join(dirname(file), 'conversation.jsonl'); + if (!existsSync(${JSON.stringify(recoveredPath)}) && file.startsWith(${JSON.stringify(autohandHome + path.sep)}) + && basename(file).startsWith('.metadata.json.') && existsSync(conversation)) { + const lines = readFileSync(conversation, 'utf8').trim().split('\\n'); + const last = JSON.parse(lines[lines.length - 1] || '{}'); + if (last.role === 'assistant' && String(last.content).includes(${JSON.stringify(responseMarker)})) { + appendFileSync(${JSON.stringify(attemptsPath)}, 'session metadata blocked\\n'); + throw Object.assign(new Error('ENOSPC: no space left on device, open ' + file), { + code: 'ENOSPC', syscall: 'open', path: file, + }); + } + } + return originalOpen.call(this, target, ...options); +}; +`); + return { preload: pathToFileURL(preloadPath).href, attemptsPath, recover: () => writeFile(recoveredPath, '') }; +} diff --git a/src/testing/scenarios/openRouterDeadlineScenario.ts b/src/testing/scenarios/openRouterDeadlineScenario.ts new file mode 100644 index 000000000..58e73ab11 --- /dev/null +++ b/src/testing/scenarios/openRouterDeadlineScenario.ts @@ -0,0 +1,60 @@ +import { once } from 'node:events'; +import { createServer } from 'node:http'; + +export type OpenRouterDeadlineMode = 'headers' | 'body' | 'error-body' | 'sse' | 'success'; + +export async function createOpenRouterDeadlineFixture(mode: OpenRouterDeadlineMode): Promise<{ + baseUrl: string; + committedResponses(): number; + activeCompletions(): number; + close(): Promise; +}> { + let committed = 0; + let active = 0; + const server = createServer(async (request, response) => { + try { + for await (const chunk of request) void chunk; + if (request.method === 'GET' && request.url?.endsWith('/models')) { + response.writeHead(200, { 'content-type': 'application/json' }); + response.end(JSON.stringify({ data: [{ id: 'openai/gpt-4o-mini' }] })); + return; + } + active++; + response.once('close', () => { active--; }); + if (mode === 'headers') return; + response.writeHead(mode === 'error-body' ? 400 : 200, { + 'content-type': mode === 'sse' ? 'text/event-stream' : 'application/json', + }); + response.flushHeaders(); + committed++; + if (mode === 'success') { + response.end(JSON.stringify({ + id: 'deadline-fixture', choices: [{ message: { content: 'ok' }, finish_reason: 'stop' }], + })); + return; + } + response.write(mode === 'sse' ? ': waiting for provider\n\n' : '{"choices":['); + } catch { + response.destroy(); + } + }); + try { + server.listen(0, '127.0.0.1'); + await once(server, 'listening'); + const address = server.address(); + if (!address || typeof address === 'string') throw new Error('OpenRouter deadline fixture did not bind.'); + return { + baseUrl: `http://127.0.0.1:${address.port}`, + committedResponses: () => committed, + activeCompletions: () => active, + close: async () => { + server.closeAllConnections(); + await new Promise((resolve, reject) => server.close(error => error ? reject(error) : resolve())); + }, + }; + } catch (error) { + server.closeAllConnections(); + await new Promise(resolve => server.close(() => resolve())); + throw error; + } +} diff --git a/src/testing/scenarios/projectLogRecoveryScenario.ts b/src/testing/scenarios/projectLogRecoveryScenario.ts new file mode 100644 index 000000000..3acf33154 --- /dev/null +++ b/src/testing/scenarios/projectLogRecoveryScenario.ts @@ -0,0 +1,12 @@ +import { writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { ProjectManager } from '../../session/ProjectManager.js'; + +export async function prepareDamagedProjectLog(autohandHome: string, workspaceRoot: string): Promise { + const projects = path.join(autohandHome, 'projects'); + const manager = new ProjectManager(projects); + const hash = await manager.getOrCreateProject(workspaceRoot); + const failures = path.join(projects, hash, 'failures.jsonl'); + await writeFile(failures, '{"tool":"interrupted historical record'); + return failures; +} diff --git a/src/testing/scenarios/sessionLifecycleScenario.ts b/src/testing/scenarios/sessionLifecycleScenario.ts index 1bda7382e..b3cf9432a 100644 --- a/src/testing/scenarios/sessionLifecycleScenario.ts +++ b/src/testing/scenarios/sessionLifecycleScenario.ts @@ -2,6 +2,7 @@ import { createServer } from 'node:http'; import type { Session } from 'tuistory'; import { writeFile } from 'node:fs/promises'; import path from 'node:path'; +import { pathToFileURL } from 'node:url'; export async function createSessionLifecycleServer(options: { stallDecisions?: boolean } = {}) { const chats: Record[] = []; @@ -62,7 +63,7 @@ for (const name of ['spawn', 'fork', 'spawnSync']) { } syncBuiltinESMExports(); `); - return { preload, log }; + return { preload: pathToFileURL(preload).href, log }; } export async function submitSessionCommand(session: Session, text: string, expected: string): Promise { diff --git a/src/testing/scenarios/subagentIterationSettingsScenario.ts b/src/testing/scenarios/subagentIterationSettingsScenario.ts new file mode 100644 index 000000000..113fbca2a --- /dev/null +++ b/src/testing/scenarios/subagentIterationSettingsScenario.ts @@ -0,0 +1,16 @@ +import type { Session } from 'tuistory'; + +export async function configureSubagentIterationLimit(session: Session, limit: number): Promise { + await session.text({ timeout: 20_000, waitFor: text => text.includes('❯') }); + const invalid = '/settings agent.subagentMaxIterations 0'; + await session.type(invalid); + await session.text({ timeout: 20_000, waitFor: text => text.includes(invalid) }); + await session.press('enter'); + const validation = await session.text({ timeout: 20_000, waitFor: text => text.includes('Sub-agent iteration limit must be an integer between 1 and 1000') }); + const valid = `/settings agent.subagentMaxIterations ${limit}`; + await session.type(valid); + await session.text({ timeout: 20_000, waitFor: text => text.includes(valid) }); + await session.press('enter'); + await session.text({ timeout: 20_000, waitFor: text => text.includes(`Set agent.subagentMaxIterations = ${limit}`) }); + return validation; +} diff --git a/src/testing/scenarios/workspaceCaptureBudgetPreload.mjs b/src/testing/scenarios/workspaceCaptureBudgetPreload.mjs new file mode 100644 index 000000000..96034c5fe --- /dev/null +++ b/src/testing/scenarios/workspaceCaptureBudgetPreload.mjs @@ -0,0 +1,12 @@ +import childProcess from 'node:child_process'; +import { syncBuiltinESMExports } from 'node:module'; + +const originalExecFile = childProcess.execFile; +childProcess.execFile = (command, args, options, callback) => { + if (command === 'git' && Array.isArray(args) && args[0] === 'add' + && options?.env?.GIT_INDEX_FILE?.includes('autohand-change-index-')) { + return originalExecFile(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], options, callback); + } + return originalExecFile(command, args, options, callback); +}; +syncBuiltinESMExports(); diff --git a/src/testing/snapshots/checkpoint-capture-budget.txt b/src/testing/snapshots/checkpoint-capture-budget.txt new file mode 100644 index 000000000..ce068c20d --- /dev/null +++ b/src/testing/snapshots/checkpoint-capture-budget.txt @@ -0,0 +1,2 @@ +CHECKPOINT_FOOTER_ANSWER: Repository facts verified. +Completed in · diff --git a/src/types.ts b/src/types.ts index c16e53a7e..c04724f1d 100644 --- a/src/types.ts +++ b/src/types.ts @@ -444,6 +444,8 @@ export interface UISettings { export interface AgentSettings { /** Maximum iterations per user request (default: 100) */ maxIterations?: number; + /** Default delegated-agent model request cap, including its final summary (default: 30; 1..1000). */ + subagentMaxIterations?: number; /** Limits for one run, shared with in-process sub-agents; CLI flags --max-requests, --max-tokens, --max-duration win. */ budget?: { /** Model requests before the run stops. */ @@ -1282,6 +1284,7 @@ export interface InlineAgentDefinition { systemPrompt: string; tools: string[]; model?: string; + maxIterations?: number; /** Reasoning depth requested by the inline definition. */ reasoning?: 'none' | 'low' | 'medium' | 'high' | 'xhigh'; /** Skills active for the agent from its first request. */ @@ -1596,6 +1599,8 @@ export interface PromptCacheDirective { export interface LLMRequest { messages: MultimodalMessage[]; + /** Logical conversation identity for provider routing; never part of the model payload. */ + sessionId?: string; temperature?: number; maxTokens?: number; stream?: boolean; @@ -1952,8 +1957,8 @@ export type AgentAction = } | { type: 'delete_memory'; id: string; level?: 'user' | 'project' } | { type: 'create_meta_tool'; name: string; description: string; parameters: Record; handler: string; scope?: 'user' | 'project' } - | { type: 'delegate_task'; agent_name: string; task: string } - | { type: 'delegate_parallel'; tasks: Array<{ agent_name: string; task: string }> } + | { type: 'delegate_task'; agent_name: string; task: string; maxIterations?: number; research_only?: boolean } + | { type: 'delegate_parallel'; maxIterations?: number; research_only?: boolean; tasks: Array<{ agent_name: string; task: string; maxIterations?: number; research_only?: boolean }> } | { type: 'orchestrate_specialists'; objective: string; requested_roles: string[] } | { type: 'install_specialist_roster'; plan_id: string; agent_names: string[] } | { type: 'compose_team'; objective: string; team_name?: string } @@ -2166,6 +2171,8 @@ export type ToolActionOutcome = { | { success: true; output?: string; + status?: 'completed' | 'partial'; + reason?: 'iteration_limit'; /** Local artifact paths for runtime-only multimodal handoff; never image bytes or URLs. */ imagePaths?: string[]; /** Opaque runtime-only image handles; never image bytes or URLs. */ diff --git a/src/ui/ink/TaskActivityPanel.tsx b/src/ui/ink/TaskActivityPanel.tsx index 19df5f380..ba6ea9b16 100644 --- a/src/ui/ink/TaskActivityPanel.tsx +++ b/src/ui/ink/TaskActivityPanel.tsx @@ -9,7 +9,7 @@ import React, { memo, useMemo } from 'react'; import { buildTaskPanelModel, type TaskPanelRow } from '../taskPanelModel.js'; import { TaskPanel } from './TaskPanel.js'; -export type ActivityItemStatus = 'pending' | 'in_progress' | 'completed' | 'failed'; +export type ActivityItemStatus = 'pending' | 'in_progress' | 'completed' | 'partial' | 'failed'; export type ActivityItemKind = 'todo' | 'subagent'; export interface ActivityItem { @@ -43,7 +43,7 @@ function TaskActivityPanelComponent({ items, maxVisible, terminalRows }: TaskAct () => buildTaskPanelModel( todos.map((item): TaskPanelRow => ({ title: item.label, - status: item.status, + status: item.status === 'partial' ? 'pending' : item.status, blockedBy: [], })), { maxRows: effectiveMaxVisible }, diff --git a/src/utils/patchValidator.ts b/src/utils/patchValidator.ts index d3fcc5249..1f09da95b 100644 --- a/src/utils/patchValidator.ts +++ b/src/utils/patchValidator.ts @@ -3,6 +3,227 @@ * Copyright 2025 Autohand AI LLC * SPDX-License-Identifier: Apache-2.0 */ +import { applyPatch, parsePatch, type StructuredPatchHunk } from 'diff'; + +interface PatchEnvelope { + body: string; + target?: string; +} + +const NUMBERED_HUNK = /^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/; +const HUNK_HEADER = /^@@(?:\s|$)/; + +function unwrapPatchEnvelope(patch: string): PatchEnvelope { + const lines = patch.replace(/\r\n/g, '\n').split('\n'); + const body: string[] = []; + let target: string | undefined; + let foundHunk = false; + for (const line of lines) { + const update = /^\*\*\* Update File:\s*(.+)$/i.exec(line); + if (update) { + if (target !== undefined) throw new Error('apply_patch supports only one file per patch.'); + if (foundHunk) throw new Error('apply_patch: the Update File target must precede the hunks.'); + target = update[1].trim(); + } else if (/^\*\*\* (?:Begin Patch|End Patch)$/i.test(line)) { + continue; + } else if (/^\*\*\* /.test(line)) { + throw new Error('apply_patch supports a single Update File envelope; use write_file, delete_path or rename_path for other file operations.'); + } else { + foundHunk ||= HUNK_HEADER.test(line); + body.push(line); + } + } + return { body: body.join('\n'), target }; +} + +export function extractPatchTarget(patch: string): string | undefined { + return unwrapPatchEnvelope(patch).target; +} + +function sourceLines(source: string): string[] { + if (source.length === 0) return []; + const lines = source.split('\n').map(line => line.endsWith('\r') ? line.slice(0, -1) : line); + if (source.endsWith('\n')) lines.pop(); + return lines; +} + +function matchesPatchLine(actual: string | undefined, operation: string, expected: string): boolean { + return actual !== undefined && (operation === ' ' ? actual.trim() === expected.trim() : actual === expected); +} + +function describeHunkFailure( + lines: string[], + hunk: Pick, + index: number, + reason: string, +): Error { + const expected = hunk.lines.find(line => line.startsWith('-'))?.slice(1) + ?? hunk.lines.find(line => line.startsWith(' '))?.slice(1) + ?? ''; + const reference = expected.trim().slice(0, 160); + let nearest = Math.max(0, Math.min(lines.length - 1, hunk.oldStart - 1)); + let bestScore = -1; + for (let lineIndex = 0; lineIndex < lines.length; lineIndex++) { + const actual = lines[lineIndex].trim().slice(0, 160); + let prefix = 0; + while (prefix < reference.length && prefix < actual.length && reference[prefix] === actual[prefix]) prefix++; + const score = actual === reference ? 2 : prefix / Math.max(reference.length, actual.length, 1); + if (score > bestScore) { + bestScore = score; + nearest = lineIndex; + } + } + const nearby = lines.slice(Math.max(0, nearest - 2), nearest + 3) + .map((line, offset) => `${Math.max(0, nearest - 2) + offset + 1}: ${line.slice(0, 200)}`) + .join('\n'); + return new Error(`apply_patch: hunk ${index} ${reason}. Expected ${JSON.stringify(expected.slice(0, 160))}.` + + `\nNearby source:\n${nearby || '(empty file)'}\nThe file may have changed; re-read it before retrying the patch.`); +} + +function normalizeAnchoredHunks(source: string, patch: string): string { + const actualLines = sourceLines(source); + const exact = new Map(); + const whitespace = new Map(); + for (let index = 0; index < actualLines.length; index++) { + const actual = actualLines[index]; + const exactPositions = exact.get(actual) ?? []; + exactPositions.push(index); + exact.set(actual, exactPositions); + const key = actual.trim(); + const whitespacePositions = whitespace.get(key) ?? []; + whitespacePositions.push(index); + whitespace.set(key, whitespacePositions); + } + const lines = patch.split('\n'); + const result: string[] = []; + let hunkIndex = 0; + let delta = 0; + for (let index = 0; index < lines.length;) { + const header = lines[index]; + if (!HUNK_HEADER.test(header)) { + result.push(header); + index++; + continue; + } + hunkIndex++; + let end = index + 1; + while (end < lines.length && !HUNK_HEADER.test(lines[end]) + && !/^(?:diff\s|Index:|={3,})/.test(lines[end]) + && !(lines[end].startsWith('--- ') && lines[end + 1]?.startsWith('+++ '))) end++; + const body = lines.slice(index + 1, end); + while (body.at(-1) === '') body.pop(); + const normalizedBody = body.map(line => line === '' ? ' ' : line); + for (const line of normalizedBody) { + if (!/^[ +\-\\]/.test(line)) { + throw new Error(`apply_patch: hunk ${hunkIndex} contains an invalid line. Prefix context with a space, additions with + and removals with -.`); + } + } + const oldLines = normalizedBody.filter(line => line.startsWith(' ') || line.startsWith('-')); + const newCount = normalizedBody.filter(line => line.startsWith(' ') || line.startsWith('+')).length; + const numbered = NUMBERED_HUNK.exec(header); + let normalizedHeader = header; + if (!numbered) { + if (header !== '@@') { + throw new Error(`apply_patch: hunk ${hunkIndex} has an invalid header. Use @@ or @@ -start,count +start,count @@.`); + } + if (!normalizedBody.some(line => line.startsWith('+') || line.startsWith('-'))) { + throw new Error('apply_patch: no additions or deletions were supplied; the patch makes no changes.'); + } + let matches: number[]; + if (oldLines.length === 0) { + matches = actualLines.length === 0 ? [0] : []; + } else { + const anchors = oldLines.map((line, oldIndex) => ({ + oldIndex, + positions: (line.startsWith(' ') ? whitespace.get(line.slice(1).trim()) : exact.get(line.slice(1))) ?? [], + })).sort((left, right) => left.positions.length - right.positions.length); + matches = []; + for (const position of anchors[0].positions) { + const start = position - anchors[0].oldIndex; + if (start < 0 || start + oldLines.length > actualLines.length) continue; + if (oldLines.every((line, offset) => matchesPatchLine(actualLines[start + offset], line[0], line.slice(1)))) matches.push(start); + if (matches.length > 1) break; + } + } + if (matches.length !== 1) { + throw describeHunkFailure(actualLines, { lines: normalizedBody, oldStart: 1 }, hunkIndex, + matches.length > 1 ? 'has ambiguous context; include more context' : 'could not match its context; include existing lines for an insertion'); + } + const oldStart = oldLines.length === 0 ? 0 : matches[0] + 1; + const newStart = newCount === 0 ? matches[0] + delta : matches[0] + delta + 1; + normalizedHeader = `@@ -${oldStart},${oldLines.length} +${newStart},${newCount} @@`; + } + delta += newCount - oldLines.length; + result.push(normalizedHeader, ...normalizedBody); + index = end; + } + if (hunkIndex === 0) throw new Error('apply_patch: no hunks found. Use @@ with context or @@ -start,count +start,count @@ headers.'); + return result.join('\n'); +} + +function parsedHunksCoverPatch(patch: string, hunks: StructuredPatchHunk[]): boolean { + let hunkIndex = -1; + let offset = 0; + for (const line of patch.split('\n')) { + if (HUNK_HEADER.test(line)) { + if (hunkIndex >= 0 && offset !== hunks[hunkIndex]?.lines.length) return false; + hunkIndex++; + offset = 0; + if (!hunks[hunkIndex]) return false; + } else if (hunkIndex >= 0) { + const expected = hunks[hunkIndex].lines; + if (offset < expected.length) { + if (line !== expected[offset]) return false; + offset++; + } else if (line !== '') { + return false; + } + } + } + return hunkIndex + 1 === hunks.length && offset === hunks[hunkIndex]?.lines.length; +} + +export function applyValidatedPatch(source: string, patch: string): string { + const body = unwrapPatchEnvelope(patch).body; + const hasAnchoredHunks = body.split('\n').some(line => HUNK_HEADER.test(line) && !NUMBERED_HUNK.test(line)); + let parsed: ReturnType | undefined; + if (!hasAnchoredHunks) { + try { + parsed = parsePatch(body); + if (parsed.length === 1 && !parsedHunksCoverPatch(body, parsed[0].hunks)) parsed = undefined; + } catch { + // Models can miscount numbered headers; repair those before retrying. + } + } + if (!parsed?.some(file => file.hunks.length > 0)) { + const normalized = normalizeAnchoredHunks(source, body); + try { + parsed = parsePatch(validateAndFixPatch(normalized)); + } catch (error) { + throw new Error(`apply_patch: invalid patch: ${error instanceof Error ? error.message : String(error)}`); + } + } + if (parsed.length !== 1) throw new Error('apply_patch supports only one file per patch.'); + const structured = parsed[0]; + if (!structured.hunks.some(hunk => hunk.lines.some(line => line.startsWith('+') || line.startsWith('-')))) { + throw new Error('apply_patch: no additions or deletions were supplied; the patch makes no changes.'); + } + const options = { compareLine: (_line: number, actual: string, operation: string, expected: string) => matchesPatchLine(actual, operation, expected) }; + const updated = applyPatch(source, structured, options); + if (updated === false) { + let diagnosticSource = source; + for (let index = 0; index < structured.hunks.length; index++) { + const hunk = structured.hunks[index]; + const candidate = applyPatch(diagnosticSource, { ...structured, hunks: [hunk] }, options); + if (candidate === false || index === structured.hunks.length - 1) { + throw describeHunkFailure(sourceLines(source), hunk, index + 1, 'could not match its context'); + } + diagnosticSource = candidate; + } + } + if (updated === false || updated === source) throw new Error('apply_patch: the patch left the file unchanged; no changes were applied.'); + return updated; +} /** * Validates and fixes a unified diff patch by correcting hunk header line counts. @@ -33,7 +254,7 @@ export function validateAndFixPatch(patch: string): string { while (i < lines.length) { const nextLine = lines[i]; // Stop at next hunk header, file header, or separator - if (nextLine.match(/^@@ /) || nextLine.match(/^(---|\+\+\+|Index:|diff\s)/) || nextLine === '===================================================================') { + if (HUNK_HEADER.test(nextLine) || nextLine.match(/^(---|\+\+\+|Index:|diff\s)/) || nextLine === '===================================================================') { break; } hunkLines.push(nextLine); @@ -71,10 +292,9 @@ export function validateAndFixPatch(patch: string): string { // Format: @@ -oldStart,oldCount +newStart,newCount @@ let correctedHeader: string; if (oldCount === 0) { - // Special case: if oldCount is 0, we only show the start - correctedHeader = `@@ -${parseInt(oldStart) + 1} +${newStart},${newCount} @@`; + correctedHeader = `@@ -${oldStart},0 +${newStart},${newCount} @@`; } else if (newCount === 0) { - correctedHeader = `@@ -${oldStart},${oldCount} +${parseInt(newStart) + 1} @@`; + correctedHeader = `@@ -${oldStart},${oldCount} +${newStart},0 @@`; } else { correctedHeader = `@@ -${oldStart},${oldCount} +${newStart},${newCount} @@`; } @@ -115,4 +335,4 @@ export function stripPatchHeaders(patch: string): string { } return result.join('\n'); -} \ No newline at end of file +} diff --git a/tests/actionExecutor-validation.spec.ts b/tests/actionExecutor-validation.spec.ts index 998004166..d9953924b 100644 --- a/tests/actionExecutor-validation.spec.ts +++ b/tests/actionExecutor-validation.spec.ts @@ -19,6 +19,9 @@ import type { AgentRuntime } from '../src/types.js'; */ const mockFileActionManager = { + inspectPath: vi.fn().mockImplementation(async (target: string) => ({ + kind: 'missing', requestedPath: target, resolvedPath: `/test/${target}`, + })), readFile: vi.fn().mockResolvedValue(''), writeFile: vi.fn().mockResolvedValue(undefined), appendFile: vi.fn().mockResolvedValue(undefined), @@ -302,6 +305,15 @@ describe('actionExecutor input validation', () => { expect(result).toContain('apply_patch requires a "path" argument'); }); + it('infers an envelope path before validating a direct tool action', async () => { + const patch = '*** Begin Patch\n*** Update File: src/example.ts\n@@\n-old\n+new\n*** End Patch'; + + const result = await executor.executeForTool({ type: 'apply_patch', path: '', patch }); + + expect(result).toEqual(expect.objectContaining({ success: true })); + expect(mockFileActionManager.applyPatch).toHaveBeenCalledWith('src/example.ts', patch); + }); + it('apply_patch returns error when patch is missing', async () => { const action = { type: 'apply_patch', diff --git a/tests/actions/filesystem.test.ts b/tests/actions/filesystem.test.ts index 087ffd18e..4cb569fbb 100644 --- a/tests/actions/filesystem.test.ts +++ b/tests/actions/filesystem.test.ts @@ -7,7 +7,7 @@ import { existsSync } from 'node:fs'; import fs from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; -import { afterEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { FileActionManager } from '../../src/actions/filesystem.js'; describe('FileActionManager home paths', () => { @@ -121,3 +121,133 @@ describe('FileActionManager undo', () => { } }); }); + +describe('FileActionManager patch application', () => { + let workspaceRoot: string; + let files: FileActionManager; + + beforeEach(async () => { + workspaceRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'autohand-fs-patch-')); + files = new FileActionManager(workspaceRoot); + }); + + afterEach(async () => { + await fs.rm(workspaceRoot, { recursive: true, force: true }); + }); + + it.each([ + ['anchored', '@@\n import AppKit\n+import Combine\n'], + ['enveloped', '*** Begin Patch\n*** Update File: A.swift\n@@\n import AppKit\n+import Combine\n*** End Patch\n'], + ['headered anchored', '--- a/A.swift\n+++ b/A.swift\n@@\n import AppKit\n+import Combine\n'], + ['offset numbered', '@@ -13,1 +13,2 @@\n import AppKit\n+import Combine\n'], + ])('applies %s hunks instead of reporting an unchanged file', async (_format, patch) => { + await fs.writeFile(path.join(workspaceRoot, 'A.swift'), 'import AppKit\n\nfinal class A {}\n'); + + await files.applyPatch('A.swift', patch); + + await expect(fs.readFile(path.join(workspaceRoot, 'A.swift'), 'utf8')) + .resolves.toBe('import AppKit\nimport Combine\n\nfinal class A {}\n'); + }); + + it('applies multiple context-anchored hunks in order', async () => { + await fs.writeFile(path.join(workspaceRoot, 'A.swift'), 'import AppKit\n\nvar x = 1\n\nfunc f() {}\n'); + + await files.applyPatch('A.swift', '@@\n import AppKit\n+import Combine\n@@\n-var x = 1\n+var x = 2\n'); + + await expect(fs.readFile(path.join(workspaceRoot, 'A.swift'), 'utf8')) + .resolves.toBe('import AppKit\nimport Combine\n\nvar x = 2\n\nfunc f() {}\n'); + }); + + it.each(['@@', '@@ -1,2 +1,3 @@'])('tolerates indentation and trailing whitespace in %s context while preserving actual context', async (header) => { + await fs.writeFile(path.join(workspaceRoot, 'A.swift'), ' func f() { \n print(1)\n }\n'); + + await files.applyPatch('A.swift', `${header}\n func f() {\n+ print(0)\n print(1)\n`); + + await expect(fs.readFile(path.join(workspaceRoot, 'A.swift'), 'utf8')) + .resolves.toBe(' func f() { \n print(0)\n print(1)\n }\n'); + }); + + it.each([ + ['', /no hunks/i], + ['--- a/A.swift\n+++ b/A.swift\n', /no hunks/i], + ['not a patch', /no hunks/i], + ['@@\n import AppKit\n', /no changes|no additions or deletions/i], + ['@@ -1 +1 @@\n-import AppKit\n+import AppKit\n', /no changes|unchanged/i], + ])('rejects a patch that cannot change the file: %j', async (patch, error) => { + const target = path.join(workspaceRoot, 'A.swift'); + await fs.writeFile(target, 'import AppKit\n'); + + await expect(files.applyPatch('A.swift', patch)).rejects.toThrow(error); + + await expect(fs.readFile(target, 'utf8')).resolves.toBe('import AppKit\n'); + }); + + it('rejects ambiguous anchored context without modifying either occurrence', async () => { + const current = 'var x = 1\n\nvar x = 1\n'; + await fs.writeFile(path.join(workspaceRoot, 'A.swift'), current); + + await expect(files.applyPatch('A.swift', '@@\n-var x = 1\n+var x = 2\n')) + .rejects.toThrow(/hunk 1.*ambiguous/i); + + await expect(fs.readFile(path.join(workspaceRoot, 'A.swift'), 'utf8')).resolves.toBe(current); + }); + + it('rejects labeled unnumbered headers instead of editing a different function', async () => { + const current = 'function earlier() {\n old();\n}\nfunction target() {\n unrelated();\n}\n'; + const target = path.join(workspaceRoot, 'example.ts'); + await fs.writeFile(target, current); + + await expect(files.applyPatch('example.ts', '@@ function target() {\n- old();\n+ replacement();\n')) + .rejects.toThrow(/hunk 1.*invalid header.*Use @@/i); + + await expect(fs.readFile(target, 'utf8')).resolves.toBe(current); + }); + + it('does not tolerate a different removal line even when context whitespace may vary', async () => { + const current = 'func f() {\n print(1)\n}\n'; + await fs.writeFile(path.join(workspaceRoot, 'A.swift'), current); + + await expect(files.applyPatch('A.swift', '@@\n func f() {\n- print(1)\n+ print(2)\n }\n')) + .rejects.toThrow(/hunk 1/i); + + await expect(fs.readFile(path.join(workspaceRoot, 'A.swift'), 'utf8')).resolves.toBe(current); + }); + + it('reports the failed hunk with nearby numbered source lines and applies no earlier hunks', async () => { + const current = 'import AppKit\n\nvar x = 1\n'; + await fs.writeFile(path.join(workspaceRoot, 'A.swift'), current); + + await expect(files.applyPatch('A.swift', '@@\n import AppKit\n+import Combine\n@@\n-var x = 9\n+var x = 2\n')) + .rejects.toThrow(/hunk 2[\s\S]*var x = 9[\s\S]*3: var x = 1/i); + + await expect(fs.readFile(path.join(workspaceRoot, 'A.swift'), 'utf8')).resolves.toBe(current); + }); + + it('rejects an envelope targeting a different file', async () => { + await fs.writeFile(path.join(workspaceRoot, 'A.swift'), 'import AppKit\n'); + + await expect(files.applyPatch('A.swift', '*** Begin Patch\n*** Update File: B.swift\n@@\n import AppKit\n+import Combine\n*** End Patch\n')) + .rejects.toThrow(/target.*does not match/i); + + await expect(fs.readFile(path.join(workspaceRoot, 'A.swift'), 'utf8')).resolves.toBe('import AppKit\n'); + }); + + it('accepts different path spellings that resolve to the same envelope target', async () => { + await fs.writeFile(path.join(workspaceRoot, 'A.swift'), 'import AppKit\n'); + const alias = path.join(workspaceRoot, 'alias'); + await fs.symlink(workspaceRoot, alias, process.platform === 'win32' ? 'junction' : 'dir'); + + await files.applyPatch(path.join(alias, 'A.swift'), '*** Begin Patch\n*** Update File: A.swift\n@@\n import AppKit\n+import Combine\n*** End Patch'); + + await expect(fs.readFile(path.join(workspaceRoot, 'A.swift'), 'utf8')).resolves.toBe('import AppKit\nimport Combine\n'); + }); + + it('rejects multiple file updates instead of silently applying only one', async () => { + await fs.writeFile(path.join(workspaceRoot, 'A.swift'), 'import AppKit\n'); + + await expect(files.applyPatch('A.swift', '*** Begin Patch\n*** Update File: A.swift\n@@\n import AppKit\n+import Combine\n*** Update File: B.swift\n@@\n-old\n+new\n*** End Patch\n')) + .rejects.toThrow(/single.file|one file/i); + + await expect(fs.readFile(path.join(workspaceRoot, 'A.swift'), 'utf8')).resolves.toBe('import AppKit\n'); + }); +}); diff --git a/tests/config/subagentIterationSettings.test.ts b/tests/config/subagentIterationSettings.test.ts new file mode 100644 index 000000000..a63da25b6 --- /dev/null +++ b/tests/config/subagentIterationSettings.test.ts @@ -0,0 +1,70 @@ +import fs from 'fs-extra'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { loadConfig, saveConfig } from '../../src/config.js'; +import { setConfigSetting, SETTINGS_REGISTRY } from '../../src/commands/settings.js'; +import { AgentConfigSchema, AgentRegistry, parseInlineAgents } from '../../src/core/agents/AgentRegistry.js'; +import { resolveSubagentIterationLimit } from '../../src/core/agents/subagentIterations.js'; + +let root: string; +beforeEach(async () => { root = await fs.mkdtemp(path.join(os.tmpdir(), 'autohand-subagent-limit-')); }); +afterEach(async () => { await fs.remove(root); }); + +describe('sub-agent iteration configuration', () => { + it('has a separate 30-request default and applies definition then per-call limits', () => { + expect(resolveSubagentIterationLimit(undefined, undefined)).toBe(30); + expect(resolveSubagentIterationLimit(50, 30)).toBe(50); + expect(resolveSubagentIterationLimit(50, 30, 10)).toBe(10); + expect(resolveSubagentIterationLimit(50, 30, 100)).toBe(50); + expect(SETTINGS_REGISTRY.find(setting => setting.key === 'agent.subagentMaxIterations')?.defaultValue).toBe(30); + }); + + it.each(['json', 'yaml', 'toml'])('persists a validated default in %s without changing the main-agent limit', async format => { + const configPath = path.join(root, `config.${format}`); + await saveConfig({ configPath, provider: 'openai', agent: { maxIterations: 300 } }); + const config = await loadConfig(configPath, undefined, { initializeTheme: false }); + setConfigSetting(config, 'agent.subagentMaxIterations', '60'); + await saveConfig(config); + const reloaded = await loadConfig(configPath, undefined, { initializeTheme: false }); + expect(reloaded.agent).toMatchObject({ maxIterations: 300, subagentMaxIterations: 60 }); + }); + + it.each([0, -1, 1.5, 1001, '30', true, null])('rejects an invalid persisted default %j', async value => { + const configPath = path.join(root, 'config.json'); + await fs.writeJson(configPath, { agent: { subagentMaxIterations: value } }); + await expect(loadConfig(configPath, undefined, { initializeTheme: false })).rejects.toThrow('agent.subagentMaxIterations must be an integer between 1 and 1000'); + }); + + it.each(['0', '-1', '1.5', '1001', 'NaN', 'Infinity'])('rejects an invalid setting %s', value => { + expect(() => setConfigSetting({ configPath: path.join(root, 'config.json') }, 'agent.subagentMaxIterations', value)).toThrow('integer between 1 and 1000'); + }); + + it('preserves inline definition limits in the runtime registry', () => { + const definitions = parseInlineAgents({ reviewer: { description: 'Review', prompt: 'Review.', maxIterations: 70 } }); + const registry = AgentRegistry.getInstance(); + registry.setSessionAgents(definitions); + try { expect(registry.getAgent('reviewer')?.maxIterations).toBe(70); } + finally { registry.clearSessionAgents(); } + }); + + it.each([0, 1.5, 1001, '30', null])('rejects an invalid agent-definition cap %j', maxIterations => { + expect(() => parseInlineAgents({ reviewer: { description: 'Review', prompt: 'Review.', maxIterations } })).toThrow(); + expect(AgentConfigSchema.safeParse({ description: 'Review', systemPrompt: 'Review.', tools: [], maxIterations }).success).toBe(false); + }); + + it('reads maxIterations from agent frontmatter and skips an invalid definition', async () => { + const directory = path.join(root, 'agents'); + await fs.ensureDir(directory); + await fs.writeFile(path.join(directory, 'issue706-valid.md'), '---\ndescription: Review\ntools: read_file\nmaxIterations: 80\n---\nReview source.\n'); + await fs.writeFile(path.join(directory, 'issue706-invalid.md'), '---\ndescription: Invalid\nmaxIterations: -2\n---\nReview source.\n'); + const registry = AgentRegistry.getInstance(); + const original = registry.getExternalPaths(); + registry.setExternalPaths([directory]); + try { + await registry.loadAgents(); + expect(registry.getAgent('issue706-valid')?.maxIterations).toBe(80); + expect(registry.getAgent('issue706-invalid')).toBeUndefined(); + } finally { registry.setExternalPaths(original); await registry.loadAgents(); } + }); +}); diff --git a/tests/core/agent/AgentCommandRuntime.askFollowup.test.ts b/tests/core/agent/AgentCommandRuntime.askFollowup.test.ts index da3270213..cd7b441e6 100644 --- a/tests/core/agent/AgentCommandRuntime.askFollowup.test.ts +++ b/tests/core/agent/AgentCommandRuntime.askFollowup.test.ts @@ -26,7 +26,7 @@ interface FollowupTestHost { getNotificationGuards: () => Record; withModalPause: (callback: () => Promise) => Promise; consecutiveCancellations: number; - followupQuestionCallback?: (question: string, suggestedAnswers?: string[]) => Promise; + followupQuestionCallback?: (question: string, suggestedAnswers?: string[]) => Promise; } function createHost(overrides: Partial = {}): FollowupTestHost { @@ -223,6 +223,18 @@ describe('agent follow-up question routing', () => { expect(host.withModalPause).toHaveBeenCalledOnce(); }); + it('keeps an explicit callback cancellation distinct from an unavailable answer channel', async () => { + const host = createHost({ + followupQuestionCallback: vi.fn(async () => null), + consecutiveCancellations: 1, + }); + + await expect(executeAgentAskFollowupQuestion(host, 'Which workflow?')) + .resolves.toContain('User cancelled this question'); + expect(host.consecutiveCancellations).toBe(2); + expect(showQuestionModal).not.toHaveBeenCalled(); + }); + it('preserves question cancellation and modal cleanup under yes', async () => { vi.mocked(showQuestionModal).mockResolvedValue(null); const host = createHost({ runtime: { options: { yes: true }, config: { ui: {} } }, consecutiveCancellations: 1 }); diff --git a/tests/core/agent/AgentDependencyComposer.contextWindow.test.ts b/tests/core/agent/AgentDependencyComposer.contextWindow.test.ts new file mode 100644 index 000000000..835835961 --- /dev/null +++ b/tests/core/agent/AgentDependencyComposer.contextWindow.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, it, vi } from 'vitest'; +import { AutohandAgent } from '../../../src/core/agent.js'; +import type { FileActionManager } from '../../../src/actions/filesystem.js'; +import type { LLMProvider } from '../../../src/providers/LLMProvider.js'; +import type { AgentRuntime } from '../../../src/types.js'; +import type { ContextOrchestrator } from '../../../src/core/context/orchestrator.js'; + +interface AgentContextState { + llm: LLMProvider; + contextWindow: number; + contextOrchestrator: ContextOrchestrator; +} + +function createAgent( + getContextWindow: () => Promise, + configuredWindow?: number, + options: AgentRuntime['options'] = {}, +): { state: AgentContextState; runtime: AgentRuntime } { + const provider: LLMProvider = { + getName: () => 'llamacpp', + complete: vi.fn(), + isAvailable: vi.fn(async () => true), + listModels: vi.fn(async () => ['canvas-pirate']), + setModel: vi.fn(), + getContextWindow, + }; + const files = { + root: '/test/workspace', + setTurnProvider: vi.fn(), + readFile: vi.fn(), + writeFile: vi.fn(), + } as unknown as FileActionManager; + const runtime: AgentRuntime = { + config: { + configPath: '/test/config.json', + provider: 'llamacpp', + llamacpp: { model: 'canvas-pirate', contextWindow: configuredWindow }, + permissions: { mode: 'unrestricted' }, + ui: { useInkRenderer: false }, + }, + workspaceRoot: '/test/workspace', + options: { model: 'canvas-pirate', ...options }, + }; + const agent = new AutohandAgent(provider, files, runtime); + return { state: agent as unknown as AgentContextState, runtime }; +} + +describe('AgentDependencyComposer runtime context discovery', () => { + it('keeps startup synchronous while applying the server context when it arrives', async () => { + const metadata = Promise.withResolvers(); + const discovery = vi.fn(() => metadata.promise); + const { state } = createAgent(discovery); + expect(state.contextWindow).toBe(128000); + expect(discovery).toHaveBeenCalledOnce(); + const setContextWindow = vi.spyOn(state.contextOrchestrator, 'setContextWindow'); + + metadata.resolve(4096); + await vi.waitFor(() => expect(state.contextWindow).toBe(4096)); + expect(setContextWindow).toHaveBeenCalledWith(4096); + }); + + it.each([1024, 32768])('caps configured context %s by the actual server limit', async (configured) => { + const { state } = createAgent(async () => 4096, configured); + await vi.waitFor(() => expect(state.contextWindow).toBe(Math.min(configured, 4096))); + }); + + it('uses an authoritative server limit larger than a model-name estimate', async () => { + const { state } = createAgent(async () => 262144); + await vi.waitFor(() => expect(state.contextWindow).toBe(262144)); + }); + + it('ignores an old provider result after the user switches providers', async () => { + const metadata = Promise.withResolvers(); + const { state, runtime } = createAgent(() => metadata.promise); + state.llm = { ...state.llm, getName: () => 'openai' }; + runtime.config.provider = 'openai'; + metadata.resolve(4096); + await metadata.promise; + await Promise.resolve(); + expect(state.contextWindow).toBe(128000); + }); + + it('ignores an old model result even when the provider instance is reused', async () => { + const metadata = Promise.withResolvers(); + const { state, runtime } = createAgent(() => metadata.promise); + runtime.options.model = 'another-model'; + metadata.resolve(4096); + await metadata.promise; + await Promise.resolve(); + expect(state.contextWindow).toBe(128000); + }); + + it('retains the configured fallback when metadata is unavailable', async () => { + const { state } = createAgent(async () => undefined, 8192); + await Promise.resolve(); + expect(state.contextWindow).toBe(8192); + }); + + it('retains the fallback when metadata discovery fails', async () => { + const { state } = createAgent(async () => { throw new Error('offline'); }, 8192); + await Promise.resolve(); + await Promise.resolve(); + expect(state.contextWindow).toBe(8192); + }); + + it.each([{ bare: true }, { daemon: false }])('does not make optional metadata calls for %j', (options) => { + const discovery = vi.fn(async () => 4096); + createAgent(discovery, undefined, options); + expect(discovery).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/core/agent/AgentDependencyComposer.outcomes.test.ts b/tests/core/agent/AgentDependencyComposer.outcomes.test.ts index 52abcca5f..9c47df868 100644 --- a/tests/core/agent/AgentDependencyComposer.outcomes.test.ts +++ b/tests/core/agent/AgentDependencyComposer.outcomes.test.ts @@ -966,6 +966,17 @@ describe('AgentDependencyComposer typed tool outcomes', () => { expect(continuation).toHaveBeenCalledOnce(); }); + it.each(['*', 'orchestrate_specialists'])('honors the run denial %s before automatic specialist preparation', async (pattern) => { + const { internals } = createAgent({ disallowedTools: [pattern] }, 'unrestricted', true); + const continuation = vi.fn().mockResolvedValue(undefined); + internals.specialistOrchestrator.continueInterview = continuation; + + const result = await internals.prepareSpecialists('The primary user is an engineer.'); + + expect(result).toBeUndefined(); + expect(continuation).not.toHaveBeenCalled(); + }); + it('routes a later user answer through the active specialist interview', async () => { const { internals } = createAgent({}, 'unrestricted', true); internals.activeAbortController = new AbortController(); diff --git a/tests/core/agent/AgentPeerRuntime.test.ts b/tests/core/agent/AgentPeerRuntime.test.ts index 3e7337578..322e8d3b3 100644 --- a/tests/core/agent/AgentPeerRuntime.test.ts +++ b/tests/core/agent/AgentPeerRuntime.test.ts @@ -120,6 +120,7 @@ describe('root peer lifecycle and recorded context', () => { }); it('routes child resource events to the exact child inbox while leaving its parent inbox separate', async () => { + const deliveryDeadlineMs = 5_000; const lead = await start({ sessionId: 'resource-lead', policy: { enabled: true, allowResourceControl: true } }); const worker = await start({ sessionId: 'resource-worker' }); const child = worker.runtime.bindRun('event-child', 'event-child'); @@ -127,12 +128,24 @@ describe('root peer lifecycle and recorded context', () => { await lead.runtime.coordinator.coordinate({ operation: 'set_controller', resource, controller: lead.runtime.messaging.self.peerId, participants: [worker.runtime.messaging.self.peerId], profile: 'strict' }); const request = await child.coordinator.coordinate({ operation: 'request', resource, reason: 'child event delivery' }); - await lead.runtime.coordinator.coordinate({ operation: 'grant', requestId: request.requestId! }); await vi.waitFor(async () => expect((await child.messaging.messages({ consume: false })).events).toContainEqual(expect.objectContaining({ - type: 'resource', resource, requestId: request.requestId, state: 'reserved', to: child.messaging.self.peerId, - }))); - expect((await worker.runtime.messaging.messages({ consume: false })).events.filter(event => event.requestId === request.requestId)).toEqual([]); - await child.close(); + type: 'resource', resource, requestId: request.requestId, state: 'queued', to: child.messaging.self.peerId, + })), { timeout: deliveryDeadlineMs }); + const cursor = (await child.messaging.messages({ consume: false })).cursor; + const stopWaiting = new AbortController(); + const waiting = child.messaging.messages({ after: cursor, waitMs: deliveryDeadlineMs, signal: stopWaiting.signal }); + void waiting.catch(() => {}); + try { + await lead.runtime.coordinator.coordinate({ operation: 'grant', requestId: request.requestId! }); + expect((await waiting).events).toContainEqual(expect.objectContaining({ + type: 'resource', resource, requestId: request.requestId, state: 'reserved', to: child.messaging.self.peerId, + })); + expect((await worker.runtime.messaging.messages({ consume: false })).events.filter(event => event.requestId === request.requestId)).toEqual([]); + } finally { + stopWaiting.abort(); + await waiting.catch(() => {}); + await child.close(); + } }); it('retires nested peers with their parent and refuses publication from an ended parent', async () => { diff --git a/tests/core/agent/CodeModeRunner.test.ts b/tests/core/agent/CodeModeRunner.test.ts index f358fe34b..63d7a36e3 100644 --- a/tests/core/agent/CodeModeRunner.test.ts +++ b/tests/core/agent/CodeModeRunner.test.ts @@ -85,6 +85,18 @@ describe('compact provider tool limits', () => { }); describe('runCodeModeScript', () => { + it.each([false, true])('lets real sandbox code branch on partial worker metadata (bounded observation: %s)', async compact => { + const { host } = createHost(call => ({ tool: call.tool, success: true, status: 'partial', reason: 'iteration_limit', output: 'Findings remain available.', + ...(compact ? { modelOutput: 'Bounded partial findings.' } : {}), + })); + const result = await runCodeModeScript(host, { toolCallId: 'partial-script', script: ` + const worker = await tools.delegate_task({agent_name:'reviewer',task:'Review source.'}); + if (worker.status !== 'partial') throw new Error('Partial worker status was lost'); + return {status:worker.status,reason:worker.reason,findings:worker.output}; + ` }); + expect(result.success).toBe(true); + expect(JSON.parse(result.output ?? '{}').result).toEqual({ status: 'partial', reason: 'iteration_limit', findings: compact ? 'Bounded partial findings.' : 'Findings remain available.' }); + }); it('forwards nested image evidence for model inspection without putting image bytes in script output', async () => { const imagePaths = ['/tmp/verified-frame.png']; const imageRefs = ['tool-image:11111111-1111-4111-8111-111111111111']; diff --git a/tests/core/agent/InputTurnCoordinator.test.ts b/tests/core/agent/InputTurnCoordinator.test.ts index 5cd2b3c52..2396d7bc1 100644 --- a/tests/core/agent/InputTurnCoordinator.test.ts +++ b/tests/core/agent/InputTurnCoordinator.test.ts @@ -13,6 +13,16 @@ import { ConversationManager } from '../../../src/core/conversationManager.js'; import { classifyApiError } from '../../../src/providers/errors.js'; describe('isAgentRetryableSessionError', () => { + it.each(['ENOSPC', 'EDQUOT'])('does not replay a provider turn when local saving fails with %s', code => { + const error = Object.assign(new Error('Session write failed'), { code }); + expect(isAgentRetryableSessionError(error)).toBe(false); + expect(isAgentRetryableSessionError(new Error('Session persistence failed', { cause: error }))).toBe(false); + }); + + it('does not session-retry a disk failure that crossed a text-only transport', () => { + expect(isAgentRetryableSessionError(new Error('ENOSPC: no space left on device, write'))).toBe(false); + }); + // Rate limits are terminal for the SESSION retry loop. ApiError.retryable means // "retryable eventually at transport level", which is not the same as "retry this // turn right now" — burning the retry budget on a quota that resets tomorrow just diff --git a/tests/core/agent/InstructionRunner.command-mode.test.ts b/tests/core/agent/InstructionRunner.command-mode.test.ts index 2d9c58d5e..e69c44edb 100644 --- a/tests/core/agent/InstructionRunner.command-mode.test.ts +++ b/tests/core/agent/InstructionRunner.command-mode.test.ts @@ -23,6 +23,7 @@ import { ApiError, classifyApiError, waitForProviderRetry } from '../../../src/p import { getPlanModeManager } from '../../../src/commands/plan.js'; import { PermissionManager } from '../../../src/permissions/PermissionManager.js'; import { ProviderNotConfiguredError } from '../../../src/providers/ProviderFactory.js'; +import { AzureTokenManager } from '../../../src/providers/azure/tokenManager.js'; function overrideStreamTTY( stream: NodeJS.ReadStream | NodeJS.WriteStream, @@ -1147,6 +1148,25 @@ describe('InstructionRunner command mode UI', () => { } }); + it('does not replay the real session loop after Azure managed-identity token acquisition fails', async () => { + const host = createHost(); + const manager = new AzureTokenManager(); + const fetch = vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('IMDS unavailable')); + host.isRetryableSessionError = isAgentRetryableSessionError; + host.shouldUsePassiveSessionRetry = shouldUsePassiveAgentSessionRetry; + host.runReactLoop = vi.fn(async () => { + await manager.getToken({ authMethod: 'managed-identity' }); + return { status: 'completed' as const }; + }); + vi.spyOn(console, 'log').mockImplementation(() => {}); + vi.spyOn(console, 'error').mockImplementation(() => {}); + expect(await new InstructionRunner(host).run('Complete using Azure managed identity')).toBe(false); + expect(host.runReactLoop).toHaveBeenCalledTimes(1); + expect(fetch).toHaveBeenCalledTimes(1); + expect(host.sleep).not.toHaveBeenCalled(); + expect(host.injectContinuationMessage).not.toHaveBeenCalled(); + }); + it('notifies the host once so rate-limit and session-error hooks can fire', async () => { const host = createHost(); const notifySessionFailure = vi.fn(); diff --git a/tests/core/agent/ProviderConfigManager.remoteModelChange.test.ts b/tests/core/agent/ProviderConfigManager.remoteModelChange.test.ts index 0717b6df2..e9ee777c0 100644 --- a/tests/core/agent/ProviderConfigManager.remoteModelChange.test.ts +++ b/tests/core/agent/ProviderConfigManager.remoteModelChange.test.ts @@ -147,6 +147,63 @@ describe('ProviderConfigManager.applyModelChangeRemote', () => { expect(setActiveProvider).toHaveBeenCalledWith('openrouter'); }); + it.each([4096, 262144])('refreshes the llama.cpp server limit to %s without waiting before switching', async (limit) => { + const metadata = Promise.withResolvers(); + const nextProvider: LLMProvider = { + ...createMockLlm(), + getName: () => 'llamacpp', + getContextWindow: vi.fn(() => metadata.promise), + }; + let activeProvider = createMockLlm(); + const contextUpdates = vi.fn(); + runtime.config.llamacpp = { model: 'old-model', baseUrl: 'http://localhost:8080' }; + mockCreate.mockReturnValue(nextProvider); + manager = new ProviderConfigManager( + runtime, () => activeProvider, + (provider) => { activeProvider = provider; }, + () => runtime.config.provider ?? 'openrouter', setActiveProvider, + () => undefined, setDelegator, new TelemetryManager({ enabled: false }), + {} as ActionExecutor, contextUpdates, vi.fn(), vi.fn(), + ); + + const result = await manager.applyModelChangeRemote('llamacpp', 'canvas-pirate'); + expect(result.status).toBe('applied'); + expect(nextProvider.getContextWindow).toHaveBeenCalledOnce(); + metadata.resolve(limit); + await vi.waitFor(() => expect(contextUpdates).toHaveBeenLastCalledWith(limit)); + }); + + it('does not apply a stale context limit after a second model switch', async () => { + const firstMetadata = Promise.withResolvers(); + const secondMetadata = Promise.withResolvers(); + const firstProvider: LLMProvider = { + ...createMockLlm(), getName: () => 'llamacpp', getContextWindow: () => firstMetadata.promise, + }; + const secondProvider: LLMProvider = { + ...createMockLlm(), getName: () => 'llamacpp', getContextWindow: () => secondMetadata.promise, + }; + let activeProvider = createMockLlm(); + const contextUpdates = vi.fn(); + runtime.config.llamacpp = { model: 'old-model', baseUrl: 'http://localhost:8080' }; + mockCreate.mockReturnValueOnce(firstProvider).mockReturnValueOnce(secondProvider); + manager = new ProviderConfigManager( + runtime, () => activeProvider, + (provider) => { activeProvider = provider; }, + () => runtime.config.provider ?? 'openrouter', setActiveProvider, + () => undefined, setDelegator, new TelemetryManager({ enabled: false }), + {} as ActionExecutor, contextUpdates, vi.fn(), vi.fn(), + ); + + await manager.applyModelChangeRemote('llamacpp', 'canvas-pirate'); + await manager.applyModelChangeRemote('llamacpp', 'another-model'); + secondMetadata.resolve(8192); + await vi.waitFor(() => expect(contextUpdates).toHaveBeenLastCalledWith(8192)); + firstMetadata.resolve(4096); + await firstMetadata.promise; + await Promise.resolve(); + expect(contextUpdates).toHaveBeenLastCalledWith(8192); + }); + it('preserves live subagent observers after switching the provider model', async () => { const registry = AgentRegistry.getInstance(); vi.spyOn(registry, 'loadAgents').mockResolvedValue(); diff --git a/tests/core/agent/ReactLoopRunnerStatus.test.ts b/tests/core/agent/ReactLoopRunnerStatus.test.ts index 445133552..435d059e6 100644 --- a/tests/core/agent/ReactLoopRunnerStatus.test.ts +++ b/tests/core/agent/ReactLoopRunnerStatus.test.ts @@ -14,6 +14,7 @@ vi.mock('../../../src/core/agent/WorkspaceChangeCapture.js', async (importOrigin return { ...actual, WorkspaceChangeCapture: { + pending: vi.fn(async () => null), create: vi.fn(async () => ({ hasExceededBudget: () => false, begin: async () => ({ token: 'stub-checkpoint' }), @@ -41,8 +42,137 @@ import type { } from '../../../src/types.js'; import { ReactionParser } from '../../../src/core/agent/ReactionParser.js'; import { PromptCacheToolSet } from '../../../src/core/agent/PromptCache.js'; +import { WorkspaceChangeCapture, WorkspaceChangeCaptureBudgetError } from '../../../src/core/agent/WorkspaceChangeCapture.js'; describe('ReactLoopRunner composer status', () => { + it('reviews completion with disclosed session evidence after the workspace diff exceeds its budget', async () => { + vi.mocked(WorkspaceChangeCapture.pending).mockClear(); + vi.mocked(WorkspaceChangeCapture.pending).mockRejectedValueOnce(new WorkspaceChangeCaptureBudgetError('git add -A', 3000)); + const complete = vi.fn().mockResolvedValueOnce({ content: JSON.stringify({ toolCalls: [ + { tool: 'read_file', args: { path: 'package.json' } }, + ] }) }).mockResolvedValueOnce({ content: 'Repository facts verified.' }) + .mockResolvedValueOnce({ content: JSON.stringify({ toolCalls: [ + { tool: 'read_file', args: { path: 'package.json' } }, + ] }) }).mockResolvedValue({ content: 'Repository facts verified.' }); + const host = createReactLoopTestHost(complete, new ReactionParser()); + host.runtime.commandOutputCaptured = true; + host.toolManager.execute = vi.fn().mockResolvedValue([{ tool: 'read_file', success: true, output: 'Verified package' }]); + const review = vi.fn(async () => ({ verdict: 'approved' as const, feedback: 'Session evidence reviewed; workspace diff unavailable.' })); + host.createCheckpointAdvisor = () => ({ observeFailure: () => false, review }); + + expect(await runAgentReactLoop(host, new AbortController())).toEqual({ status: 'completed' }); + expect(review).toHaveBeenCalledWith('completion', expect.objectContaining({ + context: expect.stringContaining('Workspace diff unavailable'), + })); + expect(review).toHaveBeenCalledWith('completion', expect.objectContaining({ + context: expect.stringContaining('git add -A exceeded the 3000 ms workspace change capture budget'), + })); + expect(review).toHaveBeenCalledWith('completion', expect.objectContaining({ + context: expect.stringContaining('Do not assume the workspace has no changes'), + })); + expect(host.workspaceChangeCaptureDisabled).toBe(true); + expect(host.emitOutput).toHaveBeenCalledWith(expect.objectContaining({ type: 'message', content: 'Repository facts verified.' })); + expect(host.conversation.addSystemNote).toHaveBeenCalledWith(expect.stringContaining('workspace diff unavailable')); + + expect(await runAgentReactLoop(host, new AbortController())).toEqual({ status: 'completed' }); + expect(WorkspaceChangeCapture.pending).toHaveBeenCalledOnce(); + expect(review).toHaveBeenCalledTimes(2); + expect(review).toHaveBeenLastCalledWith('completion', expect.objectContaining({ + context: expect.stringContaining('Workspace diff unavailable'), + })); + }); + + it.each(['changes_requested', 'unavailable'] as const)('preserves a real %s verdict after a completion capture timeout', async verdict => { + vi.mocked(WorkspaceChangeCapture.pending).mockClear(); + vi.mocked(WorkspaceChangeCapture.pending).mockRejectedValueOnce(new WorkspaceChangeCaptureBudgetError('git add -A', 3000)); + const complete = vi.fn().mockResolvedValueOnce({ content: JSON.stringify({ toolCalls: [ + { tool: 'read_file', args: { path: 'package.json' } }, + ] }) }).mockResolvedValue({ content: 'Repository facts verified.' }); + const host = createReactLoopTestHost(complete, new ReactionParser()); + host.runtime.commandOutputCaptured = true; + host.toolManager.execute = vi.fn().mockResolvedValue([{ tool: 'read_file', success: true, output: 'Verified package' }]); + const review = vi.fn(async () => ({ verdict, feedback: 'Review still needs verification evidence.' })); + host.createCheckpointAdvisor = () => ({ observeFailure: () => false, review }); + + expect(await runAgentReactLoop(host, new AbortController())).toEqual({ status: 'incomplete', reason: 'advisor_review' }); + expect(review).toHaveBeenCalledTimes(verdict === 'unavailable' ? 2 : 3); + expect(WorkspaceChangeCapture.pending).toHaveBeenCalledOnce(); + expect(host.emitOutput).toHaveBeenCalledWith(expect.objectContaining({ type: 'message', + content: expect.stringContaining('Review still needs verification evidence.'), + })); + }); + + it('keeps a non-budget workspace capture error blocking completion review', async () => { + const complete = vi.fn().mockResolvedValueOnce({ content: JSON.stringify({ toolCalls: [ + { tool: 'read_file', args: { path: 'package.json' } }, + ] }) }).mockResolvedValue({ content: 'Repository facts verified.' }); + const host = createReactLoopTestHost(complete, new ReactionParser()); + host.runtime.commandOutputCaptured = true; + host.captureAdvisorDiff = async () => { throw new Error('Git index is corrupt'); }; + const review = vi.fn(async () => ({ verdict: 'approved' as const, feedback: 'Unexpected approval' })); + host.createCheckpointAdvisor = () => ({ observeFailure: () => false, review }); + + expect(await runAgentReactLoop(host, new AbortController())).toEqual({ status: 'incomplete', reason: 'advisor_review' }); + expect(review).not.toHaveBeenCalled(); + expect(host.workspaceChangeCaptureDisabled).not.toBe(true); + expect(host.emitOutput).toHaveBeenCalledWith(expect.objectContaining({ type: 'message', + content: expect.stringContaining('Git index is corrupt'), + })); + }); + + it.each([ + { omittedFiles: 1, patch: '' }, + { omittedFiles: 0, patch: '[diff truncated]' }, + ])('keeps an explicitly truncated workspace diff blocking completion review: %j', async ({ omittedFiles, patch }) => { + vi.mocked(WorkspaceChangeCapture.pending).mockResolvedValueOnce({ omittedFiles, files: [{ + path: 'large.ts', kind: 'modified', additions: 1, deletions: 1, binary: false, patch, + }] }); + const complete = vi.fn().mockResolvedValueOnce({ content: JSON.stringify({ toolCalls: [ + { tool: 'read_file', args: { path: 'package.json' } }, + ] }) }).mockResolvedValue({ content: 'Repository facts verified.' }); + const host = createReactLoopTestHost(complete, new ReactionParser()); + host.runtime.commandOutputCaptured = true; + const review = vi.fn(async () => ({ verdict: 'approved' as const, feedback: 'Unexpected approval' })); + host.createCheckpointAdvisor = () => ({ observeFailure: () => false, review }); + + expect(await runAgentReactLoop(host, new AbortController())).toEqual({ status: 'incomplete', reason: 'advisor_review' }); + expect(review).not.toHaveBeenCalled(); + expect(host.workspaceChangeCaptureDisabled).not.toBe(true); + }); + + it('keeps commit execution blocked when its workspace capture exceeds the budget', async () => { + const complete = vi.fn().mockResolvedValue({ content: JSON.stringify({ toolCalls: [ + { tool: 'git_commit', args: { message: 'Must not commit unreviewed changes' } }, + ] }) }); + const host = createReactLoopTestHost(complete, new ReactionParser()); + host.runtime.commandOutputCaptured = true; + host.captureAdvisorDiff = async () => { throw new WorkspaceChangeCaptureBudgetError('git add -A', 3000); }; + const review = vi.fn(async () => ({ verdict: 'approved' as const, feedback: 'Unexpected approval' })); + host.createCheckpointAdvisor = () => ({ observeFailure: () => false, review }); + + expect(await runAgentReactLoop(host, new AbortController())).toEqual({ status: 'incomplete', reason: 'advisor_review' }); + expect(review).not.toHaveBeenCalled(); + expect(host.toolManager.execute).not.toHaveBeenCalled(); + }); + + it('preserves cancellation while a completion capture exceeds its budget', async () => { + const complete = vi.fn().mockResolvedValueOnce({ content: JSON.stringify({ toolCalls: [ + { tool: 'read_file', args: { path: 'package.json' } }, + ] }) }).mockResolvedValue({ content: 'Repository facts verified.' }); + const host = createReactLoopTestHost(complete, new ReactionParser()); + host.runtime.commandOutputCaptured = true; + const controller = new AbortController(); + host.captureAdvisorDiff = async () => { + controller.abort(); + throw new WorkspaceChangeCaptureBudgetError('git add -A', 3000); + }; + const review = vi.fn(async () => ({ verdict: 'approved' as const, feedback: 'Unexpected approval' })); + host.createCheckpointAdvisor = () => ({ observeFailure: () => false, review }); + + await expect(runAgentReactLoop(host, controller)).rejects.toMatchObject({ name: 'AbortError' }); + expect(review).not.toHaveBeenCalled(); + }); + 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' } }, @@ -81,6 +211,103 @@ describe('ReactLoopRunner composer status', () => { expect(complete).not.toHaveBeenCalled(); }); + it.each([32768, 262144])('waits for first-turn runtime context %s before budgeting a fast request', async (limit) => { + const complete = vi.fn().mockResolvedValue({ id: 'local', created: 1, content: 'Done.', raw: {} }); + const host = createReactLoopTestHost(complete, new ReactionParser()); + const metadata = Promise.withResolvers(); + host.llm.getContextWindow = vi.fn(() => metadata.promise); + const prepareRequest = vi.mocked(host.contextOrchestrator.prepareRequest); + const turn = runAgentReactLoop(host, new AbortController()); + try { + await vi.waitFor(() => expect(host.llm.getContextWindow).toHaveBeenCalledOnce()); + expect(prepareRequest).not.toHaveBeenCalled(); + expect(complete).not.toHaveBeenCalled(); + metadata.resolve(limit); + await turn; + expect(host.contextWindow).toBe(limit); + expect(complete).toHaveBeenCalledWith(expect.objectContaining({ maxTokens: Math.min(16000, limit / 4) })); + } finally { + metadata.resolve(limit); + await turn; + } + }); + + it('rejects a prompt larger than the discovered4096-token context before provider spending', async () => { + const complete = vi.fn().mockResolvedValue({ id: 'unexpected', created: 1, content: 'Done.', raw: {} }); + const host = createReactLoopTestHost(complete, new ReactionParser()); + host.llm.getContextWindow = async () => 4096; + host.getMessagesWithImages = async () => [ + { role: 'system', content: 'Policy' }, + { role: 'user', content: 'uncroppable content '.repeat(4000) }, + ]; + await expect(runAgentReactLoop(host, new AbortController())).rejects.toThrow(/configured context limit \(4096 tokens\)/i); + expect(complete).not.toHaveBeenCalled(); + }); + + it.each([{ bare: true }, { daemon: false }])('keeps optional metadata off for %j requests', async (options) => { + const complete = vi.fn().mockResolvedValue({ id: 'local', created: 1, content: 'Done.', raw: {} }); + const host = createReactLoopTestHost(complete, new ReactionParser()); + Object.assign(host.runtime.options, options); + host.llm.getContextWindow = vi.fn(async () => 4096); + await runAgentReactLoop(host, new AbortController()); + expect(host.llm.getContextWindow).not.toHaveBeenCalled(); + }); + + it('ignores stale metadata after a provider switches while preflight is pending', async () => { + const complete = vi.fn().mockResolvedValue({ id: 'local', created: 1, content: 'Done.', raw: {} }); + const host = createReactLoopTestHost(complete, new ReactionParser()); + const metadata = Promise.withResolvers(); + const oldProvider = host.llm; + oldProvider.getContextWindow = vi.fn(() => metadata.promise); + const turn = runAgentReactLoop(host, new AbortController()); + try { + await vi.waitFor(() => expect(oldProvider.getContextWindow).toHaveBeenCalledOnce()); + host.llm = { ...oldProvider, getContextWindow: undefined }; + metadata.resolve(4096); + await turn; + expect(host.contextWindow).toBe(128000); + } finally { + metadata.resolve(4096); + await turn; + } + }); + + it('cancels pending metadata without sending or budgeting a request', async () => { + const complete = vi.fn().mockResolvedValue({ id: 'unexpected', created: 1, content: 'Done.', raw: {} }); + const host = createReactLoopTestHost(complete, new ReactionParser()); + host.llm.getContextWindow = vi.fn(() => new Promise(() => {})); + const controller = new AbortController(); + const turn = runAgentReactLoop(host, controller); + await vi.waitFor(() => expect(host.llm.getContextWindow).toHaveBeenCalledOnce()); + controller.abort(); + await expect(turn).resolves.toMatchObject({ status: 'aborted' }); + expect(host.contextOrchestrator.prepareRequest).not.toHaveBeenCalled(); + expect(complete).not.toHaveBeenCalled(); + }); + + it('refreshes metadata after the model changes during preflight', async () => { + const complete = vi.fn().mockResolvedValue({ id: 'local', created: 1, content: 'Done.', raw: {} }); + const host = createReactLoopTestHost(complete, new ReactionParser()); + const metadata = Promise.withResolvers(); + const oldProvider = host.llm; + oldProvider.getContextWindow = vi.fn(() => metadata.promise); + const nextDiscovery = vi.fn(async () => 32768); + const turn = runAgentReactLoop(host, new AbortController()); + try { + await vi.waitFor(() => expect(oldProvider.getContextWindow).toHaveBeenCalledOnce()); + host.runtime.options.model = 'new-local-model'; + host.llm = { ...oldProvider, getContextWindow: nextDiscovery }; + metadata.resolve(4096); + await turn; + expect(nextDiscovery).toHaveBeenCalledOnce(); + expect(host.contextWindow).toBe(32768); + expect(complete).toHaveBeenCalledWith(expect.objectContaining({ model: 'new-local-model', maxTokens: 8192 })); + } finally { + metadata.resolve(4096); + await turn; + } + }); + it('stops before a lead request when specialist preparation already denied unattended approval', async () => { const llmComplete = vi.fn().mockResolvedValue({ id: 'unexpected', created: 1, raw: {}, content: '{"finalResponse":"Should not run"}' }); const host = createReactLoopTestHost(llmComplete, new ReactionParser()); @@ -367,6 +594,7 @@ describe('ReactLoopRunner composer status', () => { await runAgentReactLoop(host, new AbortController()); expect(llmComplete.mock.calls[0]?.[0]?.promptCache).toBeUndefined(); + expect(llmComplete.mock.calls[0]?.[0]?.sessionId).toBe('session-123'); }); it('uses a stable opaque session cache key even when the active provider changes', async () => { diff --git a/tests/core/agent/SimpleChatHandler.test.ts b/tests/core/agent/SimpleChatHandler.test.ts index 3d82c7b07..78bb34e99 100644 --- a/tests/core/agent/SimpleChatHandler.test.ts +++ b/tests/core/agent/SimpleChatHandler.test.ts @@ -8,6 +8,7 @@ import { SimpleChatHandler, type SimpleChatAgent } from '../../../src/core/agent import type { LLMMessage, LLMRequest } from '../../../src/types.js'; import type { LLMProvider } from '../../../src/providers/LLMProvider.js'; import { ReactionParser } from '../../../src/core/agent/ReactionParser.js'; +import { AnthropicProvider } from '../../../src/providers/AnthropicProvider.js'; const sessionId = 'session-123'; @@ -56,6 +57,31 @@ describe('SimpleChatHandler prompt cache affinity', () => { expect(complete).toHaveBeenCalledWith(expect.not.objectContaining({ promptCache: expect.anything(), })); + expect(complete).toHaveBeenCalledWith(expect.objectContaining({ sessionId })); + }); + + it('changes the Go routing header with the active conversation even when prompt caching is disabled', async () => { + const { agent } = createAgent(); + let activeSession = 'original-session'; + agent.getSessionManager = () => ({ getCurrentSession: () => ({ metadata: { sessionId: activeSession } }) }); + agent.isPromptCachingEnabled = () => false; + const fetch = vi.spyOn(globalThis, 'fetch').mockImplementation(async () => Response.json({ + id: 'go-response', type: 'message', role: 'assistant', model: 'minimax-m3', + content: [{ type: 'text', text: 'Hello' }], stop_reason: 'end_turn', + usage: { input_tokens: 3, output_tokens: 1 }, + })); + agent.llm = new AnthropicProvider({ apiKey: 'fixture', model: 'minimax-m3', baseUrl: 'https://opencode.ai/inference/go/anthropic' }); + vi.spyOn(console, 'log').mockImplementation(() => {}); + const handler = new SimpleChatHandler(agent); + await expect(handler.handle('Hello')).resolves.toBe(true); + activeSession = 'new-session'; + await expect(handler.handle('Hello again')).resolves.toBe(true); + const ids = fetch.mock.calls.map(([, init]) => new Headers(init?.headers).get('x-opencode-session')); + expect(ids[0]).toMatch(/^autohand_[a-f0-9]{64}$/); + expect(ids[1]).not.toBe(ids[0]); + for (const [, init] of fetch.mock.calls) { + expect(String(init?.body)).not.toMatch(/original-session|new-session|sessionId/); + } }); it('omits prompt-cache metadata without an active session', async () => { diff --git a/tests/core/agents/AgentDelegator.test.ts b/tests/core/agents/AgentDelegator.test.ts index a08f450dc..b784a12dc 100644 --- a/tests/core/agents/AgentDelegator.test.ts +++ b/tests/core/agents/AgentDelegator.test.ts @@ -66,7 +66,7 @@ describe('AgentDelegator typed outcomes', () => { const delegator = new AgentDelegator(provider, { executeForTool } as unknown as ActionExecutor, { featureConfig: { provider: 'autohandai', autohandai: { plan: 'cloud', model: 'moa' } }, }); - expect((await delegator.delegateTaskForTool('reader', 'Inspect source')).success).toBe(true); + expect((await delegator.delegateTaskForTool('reader', 'Inspect source', { researchOnly: true })).success).toBe(true); expect(executeForTool).not.toHaveBeenCalled(); expect(requests[0].tools?.map(tool => tool.name)).toEqual(['run_tool_script', 'tool_search']); expect(JSON.stringify(requests[1].messages)).toContain('Unknown tool'); diff --git a/tests/core/agents/AgentRunLifecycle.test.ts b/tests/core/agents/AgentRunLifecycle.test.ts index 6d61cccda..2d48b1f68 100644 --- a/tests/core/agents/AgentRunLifecycle.test.ts +++ b/tests/core/agents/AgentRunLifecycle.test.ts @@ -27,6 +27,16 @@ function harness(executeHooks = vi.fn().mockResolvedValue([])) { } describe('subagent lifecycle controls', () => { + it('carries the capped-run reason through the real store and terminal lifecycle hook', async () => { + const { handle, executeHooks } = harness(); + const store = new AgentRunStore({ onLifecycleEvent: handle }); + store.start({ id: 'limited', source: 'delegate', name: 'reviewer', task: 'Review source.' }); + store.finish('limited', { status: 'partial', reason: 'iteration_limit', result: 'Findings remain available.' }); + await store.waitForLifecycle('limited'); + expect(executeHooks).toHaveBeenCalledWith('subagent-stop', expect.objectContaining({ + subagentId: 'limited', subagentStatus: 'partial', subagentReason: 'iteration_limit', subagentSuccess: false, + })); + }); it('queues hook messages through the real store without recursive action events or deadlock', async () => { const executeHooks = vi.fn().mockResolvedValue(results({ additionalContext: 'Check tests' })); const store: AgentRunStore = new AgentRunStore({ onLifecycleEvent: createAgentRunLifecycleHandler({ diff --git a/tests/core/agents/AgentRunStore.test.ts b/tests/core/agents/AgentRunStore.test.ts index faa5eef68..17cb9a612 100644 --- a/tests/core/agents/AgentRunStore.test.ts +++ b/tests/core/agents/AgentRunStore.test.ts @@ -7,6 +7,14 @@ import { describe, expect, it, vi } from 'vitest'; import { AgentRunStore } from '../../../src/core/agents/AgentRunStore.js'; describe('AgentRunStore', () => { + it('settles partial workers and preserves their report without accepting further messages', async () => { + const store = new AgentRunStore(); + store.start({ id: 'limited', source: 'delegate', name: 'reviewer', task: 'Review.' }); + store.registerMessage('limited', () => true); + store.finish('limited', { status: 'partial', reason: 'iteration_limit', result: 'Found two issues; checks remain.' }); + expect(store.getSnapshot().runs[0]).toMatchObject({ status: 'partial', reason: 'iteration_limit', output: 'Found two issues; checks remain.', messageable: false, cancellable: false }); + expect(await store.sendMessage('limited', 'Continue')).toBe(false); + }); it('queues messages only for the selected active run and rejects unavailable targets', async () => { const store = new AgentRunStore(); for (const id of ['first', 'second']) store.start({ id, source: 'delegate', name: 'reader', task: id }); diff --git a/tests/core/agents/SpecialistOrchestrator.test.ts b/tests/core/agents/SpecialistOrchestrator.test.ts index 5e71959bf..e65a27277 100644 --- a/tests/core/agents/SpecialistOrchestrator.test.ts +++ b/tests/core/agents/SpecialistOrchestrator.test.ts @@ -13,6 +13,7 @@ import { detectSpecialistRequest, createSpecialistRequest, SpecialistOrchestrator, + formatSpecialistResults, type SpecialistPlan, } from '../../../src/core/agents/SpecialistOrchestrator.js'; @@ -460,6 +461,31 @@ describe('SpecialistOrchestrator turn results', () => { }); describe('SpecialistOrchestrator execution', () => { + it('does not complete an interview from a capped worker summary that claims complete', async () => { + const delegate = delegator(); + vi.mocked(delegate.delegateTaskForTool).mockResolvedValue({ success: true, status: 'partial', reason: 'iteration_limit', output: '{"complete":true,"remaining":"verification"}' }); + const orchestrator = new SpecialistOrchestrator(delegate, { registry: registryWith([]), offline: true }); + const result = await orchestrator.execute({ + objective: 'Interview', requestedRoles: ['product-interviewer'], + selectedAgents: [{ requestedRole: 'product-interviewer', agentName: 'product-interviewer', source: 'builtin', matchReason: 'fixture' }], + source: 'intent', matchReason: 'fixture', executionMode: 'interview', unresolvedRoles: [], + }); + expect(result.completed).toBe(false); + expect(orchestrator.hasActiveInterview()).toBe(true); + }); + it('keeps a partial specialist report available without declaring the roster complete', async () => { + const delegate = delegator(); + vi.mocked(delegate.delegateParallelForTool).mockResolvedValue({ success: true, status: 'partial', reason: 'iteration_limit', output: 'Findings remain useful; verification remains.' }); + const orchestrator = new SpecialistOrchestrator(delegate, { registry: registryWith([]), offline: true }); + const result = await orchestrator.execute({ + objective: 'Review', requestedRoles: ['reviewer'], + selectedAgents: [{ requestedRole: 'reviewer', agentName: 'reviewer', source: 'builtin', matchReason: 'fixture' }], + source: 'intent', matchReason: 'fixture', executionMode: 'parallel', unresolvedRoles: [], + }); + expect(result.completed).toBe(false); + expect(formatSpecialistResults(result)).toContain('"status": "partial"'); + expect(formatSpecialistResults(result)).toContain('Findings remain useful'); + }); it.each(['parallel', 'serial'] as const)('stops later %s specialists after an unattended approval denial', async executionMode => { const delegate = delegator(); const tracker = new UnattendedApprovalTracker(); diff --git a/tests/core/agents/SubAgent.codeModeImages.test.ts b/tests/core/agents/SubAgent.codeModeImages.test.ts new file mode 100644 index 000000000..3d0f8753e --- /dev/null +++ b/tests/core/agents/SubAgent.codeModeImages.test.ts @@ -0,0 +1,51 @@ +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import sharp from 'sharp'; +import { describe, expect, it, vi } from 'vitest'; +import { SubAgent } from '../../../src/core/agents/SubAgent.js'; +import type { LLMProvider } from '../../../src/providers/LLMProvider.js'; +import type { ActionExecutor } from '../../../src/core/actionExecutor.js'; +import type { LoadedConfig } from '../../../src/types.js'; + +describe('Code Mode image hint through the real worker runtime', () => { + it('lets real QuickJS detect image evidence while attaching the actual image to the next model request exactly once', async () => { + const workspace = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), 'autohand-script-image-hint-'))); + try { + const frame = path.join(workspace, '.autohand', 'test-evidence', 'run-fixture', 'frame-001.png'); + await fs.mkdir(path.dirname(frame), { recursive: true }); + await fs.writeFile(frame, await sharp({ create: { width: 8, height: 8, channels: 3, background: '#ff0000' } }).png().toBuffer()); + const requests: Array[0]> = []; + const provider: LLMProvider = { + getName: () => 'openai', getCapabilities: () => ({ nativeToolCalling: true }), listModels: async () => [], isAvailable: async () => true, setModel: () => {}, + complete: async request => { + requests.push(structuredClone(request)); + return requests.length === 1 + ? { id: 'capture', created: 0, raw: null, content: '', toolCalls: [{ id: 'capture-script', type: 'function', function: { name: 'run_tool_script', arguments: JSON.stringify({ script: "const frame = await tools.capture_test_evidence({url:'http://127.0.0.1/'}); return {sawImages:frame.images,needsVisualInspection:true};" }) } }] } + : { id: 'vision', created: 0, raw: null, content: 'The fixture contains a red square.' }; + }, + }; + const executeForTool = vi.fn(async () => ({ success: true as const, output: 'Fixture capture available; visual inspection pending.', imagePaths: [frame, frame] })); + const agent = new SubAgent({ name: 'visual-reader', source: 'session', path: '', description: 'Inspect evidence', systemPrompt: 'Capture and inspect the local fixture.', tools: ['capture_test_evidence'] }, provider, { executeForTool } as unknown as ActionExecutor, { + clientContext: 'cli', depth: 1, maxDepth: 1, workspaceRoot: workspace, projectMemoryEnabled: false, + confirmApproval: async () => true, + featureConfig: { provider: 'openai', features: { codeMode: true } } as LoadedConfig, + }); + await expect(agent.run('Inspect the fixture.')).resolves.toBe('The fixture contains a red square.'); + expect(executeForTool).toHaveBeenCalledOnce(); + expect(requests).toHaveLength(2); + const observation = requests[1].messages.find(message => message.role === 'tool'); + expect(JSON.parse(String(observation?.content)).result).toMatchObject({ sawImages: true, needsVisualInspection: true }); + expect(observation?.content).not.toContain('base64'); + const images: string[] = []; + for (const message of requests[1].messages) { + if (Array.isArray(message.content)) for (const part of message.content) { + if (part.type === 'image_url') images.push(part.image_url.url); + } + } + expect(images).toHaveLength(1); + const pixels = await sharp(Buffer.from(images[0].split(',')[1], 'base64')).raw().toBuffer(); + expect([...pixels.subarray(0, 3)]).toEqual([255, 0, 0]); + } finally { await fs.rm(workspace, { recursive: true, force: true }); } + }); +}); diff --git a/tests/core/agents/SubAgent.iterationLimit.test.ts b/tests/core/agents/SubAgent.iterationLimit.test.ts new file mode 100644 index 000000000..9bcb70aa7 --- /dev/null +++ b/tests/core/agents/SubAgent.iterationLimit.test.ts @@ -0,0 +1,136 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { SubAgent } from '../../../src/core/agents/SubAgent.js'; +import { AgentDelegator } from '../../../src/core/agents/AgentDelegator.js'; +import { AgentRegistry, type AgentDefinition } from '../../../src/core/agents/AgentRegistry.js'; +import type { LLMProvider } from '../../../src/providers/LLMProvider.js'; +import type { ActionExecutor } from '../../../src/core/actionExecutor.js'; +import type { LoadedConfig } from '../../../src/types.js'; + +function setup(options: { limit?: number; definitionLimit?: number; native?: boolean; researchOnly?: boolean; provider?: string; refusesSummary?: boolean; summaryError?: string } = {}) { + const requests: Array[0]> = []; + const provider = options.provider ?? 'openai'; + const complete = vi.fn(async (request: Parameters[0]) => { + requests.push(structuredClone(request)); + const summary = !request.tools && options.native !== false; + const finalInstruction = request.messages.some(message => typeof message.content === 'string' && message.content.includes('[Iteration limit]')); + if ((summary || finalInstruction) && options.summaryError) throw new Error(options.summaryError); + if ((summary || finalInstruction) && !options.refusesSummary) { + return { id: 'summary', created: 0, raw: null, content: options.native === false ? '{"finalResponse":"Found two issues; validation remains."}' : 'Found two issues; validation remains.' }; + } + const arguments_ = { path: `file-${requests.length}.ts` }; + return { id: 'read', created: 0, raw: null, content: options.native === false ? JSON.stringify({ reflection: 'The prior output established a finding; another file remains.', toolCalls: [{ tool: 'read_file', args: arguments_ }] }) : '', + ...(options.native !== false ? { toolCalls: [{ id: `read-${requests.length}`, type: 'function' as const, function: { name: 'read_file', arguments: JSON.stringify(arguments_) } }] } : {}) }; + }); + const executeForTool = vi.fn(async () => ({ success: true as const, output: 'Observed source evidence.' })); + const definition: AgentDefinition = { name: 'reviewer', description: 'Review source', systemPrompt: 'Inspect source.', tools: ['read_file', 'write_file'], source: 'session', path: '', ...(options.definitionLimit ? { maxIterations: options.definitionLimit } : {}) }; + const config = { provider, autohandai: { plan: 'cloud', model: 'moa' }, agent: { maxIterations: 100, ...(options.limit ? { subagentMaxIterations: options.limit } : {}) }, features: { codeMode: false } } as LoadedConfig; + const llm: LLMProvider = { getName: () => provider, complete, getCapabilities: () => ({ nativeToolCalling: options.native !== false }), listModels: async () => [], isAvailable: async () => true, setModel: () => {} }; + const executor = { executeForTool } as unknown as ActionExecutor; + const agent = new SubAgent(definition, llm, executor, { clientContext: 'cli', depth: 1, maxDepth: 1, featureConfig: config, researchOnly: options.researchOnly, isPromptCachingEnabled: () => false }); + return { agent, definition, config, llm, executor, requests, complete, executeForTool }; +} + +afterEach(() => vi.restoreAllMocks()); + +describe('sub-agent iteration limits', () => { + it('defaults independently to 30 requests and preserves a partial summary on its final tools-withheld request', async () => { + const state = setup(); + await expect(state.agent.run('Review a large change.')).resolves.toContain('Found two issues'); + expect(state.requests).toHaveLength(30); + expect(state.requests[28].tools).toBeDefined(); + expect(state.requests[29].tools).toBeUndefined(); + expect(state.executeForTool).toHaveBeenCalledTimes(29); + expect(state.requests[0].sessionId).toEqual(expect.any(String)); + expect(new Set(state.requests.map(request => request.sessionId)).size).toBe(1); + expect(state.requests[0].promptCache).toBeUndefined(); + expect(state.agent.getCompletionState()).toEqual({ status: 'partial', reason: 'iteration_limit' }); + }); + + it.each([true, false])('uses the definition limit and keeps the work in native=%s mode', async native => { + const state = setup({ definitionLimit: 3, limit: 5, native }); + await expect(state.agent.run('Review a large change.')).resolves.toContain('Found two issues'); + expect(state.requests).toHaveLength(3); + expect(state.executeForTool).toHaveBeenCalledTimes(2); + expect(state.requests[2].tools).toBeUndefined(); + expect(JSON.stringify(state.requests[2].messages)).toContain('[Iteration limit]'); + }); + + it('caps a requested per-call limit at the configured limit and permits a lower limit', async () => { + const bounded = setup({ limit: 3 }); + await bounded.agent.run('Review.', { maxIterations: 100 }); + expect(bounded.requests).toHaveLength(3); + const smaller = setup({ limit: 8 }); + await smaller.agent.run('Review.', { maxIterations: 2 }); + expect(smaller.requests).toHaveLength(2); + }); + + it('never executes a tool emitted despite the final tools-withheld request and retains observed evidence', async () => { + const state = setup({ limit: 2, refusesSummary: true }); + const output = await state.agent.run('Review.'); + expect(output).toContain('Observed source evidence.'); + expect(output).toContain('iteration limit'); + expect(state.executeForTool).toHaveBeenCalledOnce(); + expect(state.requests).toHaveLength(2); + }); + + it('keeps prior observations when the reserved summary request fails and discloses the error', async () => { + const state = setup({ limit: 2, summaryError: 'Summary provider unavailable.' }); + const output = await state.agent.run('Review.'); + expect(output).toContain('Observed source evidence.'); + expect(output).toContain('Summary provider unavailable.'); + expect(state.agent.getCompletionState()).toEqual({ status: 'partial', reason: 'iteration_limit' }); + expect(state.requests).toHaveLength(2); + }); + + it('keeps cancellation authoritative on the final summary request', async () => { + const controller = new AbortController(); + const state = setup({ limit: 1 }); + state.complete.mockImplementationOnce(async () => { controller.abort(); throw new Error('Cancelled while summarizing.'); }); + await expect(state.agent.run('Review.', { signal: controller.signal })).rejects.toThrow('Cancelled while summarizing.'); + expect(state.agent.getCompletionState()).toBeUndefined(); + }); + + it('retains an implementer definition under orchestration and restricts only explicitly requested research workers', async () => { + const normal = setup({ limit: 2, provider: 'autohandai' }); + await normal.agent.run('Review.'); + expect(normal.requests[0].tools?.map(tool => tool.name)).toContain('write_file'); + expect(JSON.stringify(normal.requests[0].messages[0])).not.toContain('You are a read-only research worker'); + const research = setup({ limit: 2, provider: 'autohandai', researchOnly: true }); + await research.agent.run('Review.'); + expect(research.requests[0].tools?.map(tool => tool.name)).not.toContain('write_file'); + expect(JSON.stringify(research.requests[0].messages[0])).toContain('You are a read-only research worker'); + }); + + it('returns typed partial outcomes and one status per parallel worker without failing the delegation', async () => { + const state = setup({ limit: 2 }); + const registry = AgentRegistry.getInstance(); + vi.spyOn(registry, 'loadAgents').mockResolvedValue(); + vi.spyOn(registry, 'getAgent').mockReturnValue(state.definition); + const onStop = vi.fn(); + const delegator = new AgentDelegator(state.llm, state.executor, { featureConfig: state.config, onSubagentStop: onStop }); + await expect(delegator.delegateTaskForTool('reviewer', 'Review.', { maxIterations: 1 })).resolves.toMatchObject({ success: true, status: 'partial', reason: 'iteration_limit', output: expect.stringContaining('Found two issues') }); + expect(onStop).toHaveBeenCalledWith(expect.objectContaining({ status: 'partial', reason: 'iteration_limit', success: false })); + const parallel = await delegator.delegateParallelForTool([{ agent_name: 'reviewer', task: 'Review first.', maxIterations: 1 }, { agent_name: 'reviewer', task: 'Review second.', maxIterations: 1 }]); + expect(parallel).toMatchObject({ success: true, status: 'partial', reason: 'iteration_limit' }); + expect(parallel.output?.match(/\[reviewer\] Partial/g)).toHaveLength(2); + }); + + it.each([0, -1, 1.5, 1001, Number.NaN, Number.POSITIVE_INFINITY])('rejects invalid per-call limit %s before any child request', async maxIterations => { + const state = setup(); + const delegator = new AgentDelegator(state.llm, state.executor); + await expect(delegator.delegateTaskForTool('reviewer', 'Review.', { maxIterations })).resolves.toMatchObject({ success: false, kind: 'validation' }); + await expect(delegator.delegateParallelForTool([{ agent_name: 'reviewer', task: 'Review.', maxIterations }])).resolves.toMatchObject({ success: false, kind: 'validation' }); + expect(state.complete).not.toHaveBeenCalled(); + }); + + it('does not let a parallel entry relax an explicit research-only scope or shared request cap', async () => { + const state = setup({ limit: 10 }); + const registry = AgentRegistry.getInstance(); + vi.spyOn(registry, 'loadAgents').mockResolvedValue(); + vi.spyOn(registry, 'getAgent').mockReturnValue(state.definition); + const delegator = new AgentDelegator(state.llm, state.executor, { featureConfig: state.config }); + await delegator.delegateParallelForTool([{ agent_name: 'reviewer', task: 'Review.', maxIterations: 8, research_only: false }], { maxIterations: 2, researchOnly: true }); + expect(state.requests).toHaveLength(2); + expect(state.requests[0].tools?.map(tool => tool.name)).not.toContain('write_file'); + }); +}); diff --git a/tests/core/agents/SubAgent.test.ts b/tests/core/agents/SubAgent.test.ts index b9d87e6f6..be3b98880 100644 --- a/tests/core/agents/SubAgent.test.ts +++ b/tests/core/agents/SubAgent.test.ts @@ -413,8 +413,9 @@ describe('SubAgent', () => { }, {} as ActionExecutor, { clientContext: 'cli', depth: 1, maxDepth: 1, getPendingInstructions: () => pending.splice(0), }); - await expect(agent.run('Read')).rejects.toThrow('within 10 iterations'); - expect(complete).toHaveBeenCalledTimes(10); + await expect(agent.run('Read')).resolves.toContain('Partial result: iteration limit reached'); + expect(complete).toHaveBeenCalledTimes(30); + expect(agent.getCompletionState()).toEqual({ status: 'partial', reason: 'iteration_limit' }); }); it('publishes tool progress and cumulative token usage for the live run inspector', async () => { @@ -443,7 +444,7 @@ describe('SubAgent', () => { })); }); - it('reports iteration exhaustion as a failure instead of a completed answer', async () => { + it('reports iteration exhaustion as partial work instead of discarding observations or claiming completion', async () => { let iteration = 0; const agent = new SubAgent({ name: 'reader', description: 'Read source', systemPrompt: 'Inspect source.', @@ -460,7 +461,8 @@ describe('SubAgent', () => { clientContext: 'cli', depth: 1, maxDepth: 1, }); - await expect(agent.run('Keep reading')).rejects.toThrow('within 10 iterations'); + await expect(agent.run('Keep reading')).resolves.toContain('contents 29'); + expect(agent.getCompletionState()).toEqual({ status: 'partial', reason: 'iteration_limit' }); }); it('does not accept a progress update as the delegated task result', async () => { diff --git a/tests/core/agents/partialDelegationPipeline.test.ts b/tests/core/agents/partialDelegationPipeline.test.ts new file mode 100644 index 000000000..abd923d75 --- /dev/null +++ b/tests/core/agents/partialDelegationPipeline.test.ts @@ -0,0 +1,60 @@ +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { ActionExecutor } from '../../../src/core/actionExecutor.js'; +import { FileActionManager } from '../../../src/actions/filesystem.js'; +import { AgentDelegator } from '../../../src/core/agents/AgentDelegator.js'; +import { AgentRegistry } from '../../../src/core/agents/AgentRegistry.js'; +import { ToolManager, CODE_MODE_TOOL_DEFINITION, DEFAULT_TOOL_DEFINITIONS } from '../../../src/core/toolManager.js'; +import { runCodeModeScript } from '../../../src/core/agent/CodeModeRunner.js'; +import { PermissionManager } from '../../../src/permissions/PermissionManager.js'; +import type { LLMProvider } from '../../../src/providers/LLMProvider.js'; +import type { LoadedConfig } from '../../../src/types.js'; + +const directories: string[] = []; +afterEach(async () => { vi.restoreAllMocks(); await Promise.all(directories.splice(0).map(directory => fs.rm(directory, { recursive: true, force: true }))); }); + +describe('partial delegation through the real tool pipeline', () => { + it.each(['native', 'code-mode'] as const)('preserves typed partial work through %s dispatch, child tools and the final report', async route => { + const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'autohand-partial-pipeline-')); + directories.push(directory); + await fs.writeFile(path.join(directory, 'fixture.txt'), 'REAL_FILESYSTEM_FINDING'); + const config = { configPath: '', provider: 'openai', features: { codeMode: true }, agent: { subagentMaxIterations: 2 } } as LoadedConfig; + const requests: Array[0]> = []; + const provider: LLMProvider = { + getName: () => 'openai', setModel: () => {}, listModels: async () => [], isAvailable: async () => true, + getCapabilities: () => ({ nativeToolCalling: true }), + complete: async request => { + requests.push(structuredClone(request)); + return request.tools + ? { id: 'read', created: 0, raw: null, content: '', toolCalls: [{ id: 'read-script', type: 'function', function: { name: 'run_tool_script', arguments: JSON.stringify({ script: "return await tools.read_file({path:'fixture.txt'});" }) } }] } + : { id: 'partial', created: 0, raw: null, content: 'Inspected fixture; further validation remains.' }; + }, + }; + const registry = AgentRegistry.getInstance(); + vi.spyOn(registry, 'loadAgents').mockResolvedValue(); + vi.spyOn(registry, 'getAgent').mockReturnValue({ name: 'reader', source: 'session', path: '', description: 'Read fixture', systemPrompt: 'Read assigned fixture.', tools: ['read_file'] }); + const permissionManager = new PermissionManager({ mode: 'unrestricted' }); + const executor = new ActionExecutor({ runtime: { workspaceRoot: directory, config, options: {} }, files: new FileActionManager(directory), permissionManager, resolveWorkspacePath: value => path.resolve(directory, value), confirmDangerousAction: async () => true }); + const delegator = new AgentDelegator(provider, executor, { workspaceRoot: directory, projectMemoryEnabled: false, featureConfig: config, maxDepth: 1, authorization: { permissionManager } }); + const manager: ToolManager = new ToolManager({ + definitions: [...DEFAULT_TOOL_DEFINITIONS.filter(definition => definition.name === 'delegate_task'), CODE_MODE_TOOL_DEFINITION], + authorization: { permissionManager }, confirmApproval: async () => true, + executor: async (action, context) => { + if (action.type === 'delegate_task') return delegator.delegateTaskForTool(action.agent_name, action.task, { signal: context?.signal }); + if (action.type === 'run_tool_script') return runCodeModeScript(manager, { script: action.script, toolCallId: context?.toolCallId ?? 'outer-script', signal: context?.signal, context }); + throw new Error('Unexpected outer tool'); + }, + }); + const [result] = await manager.execute([route === 'native' + ? { tool: 'delegate_task', args: { agent_name: 'reader', task: 'Read fixture and verify all behavior.' } } + : { tool: 'run_tool_script', args: { script: "const worker = await tools.delegate_task({agent_name:'reader',task:'Read fixture and verify all behavior.'}); if (worker.status !== 'partial') throw new Error('Partial status was lost in the real tool pipeline'); return {status:worker.status,reason:worker.reason,output:worker.output};" } }]); + expect(result.success).toBe(true); + const delivered = route === 'native' ? result : JSON.parse(result.output ?? '{}').result; + expect(delivered).toMatchObject({ status: 'partial', reason: 'iteration_limit', output: expect.stringContaining('Inspected fixture') }); + expect(requests).toHaveLength(2); + expect(JSON.stringify(requests[1].messages)).toContain('REAL_FILESYSTEM_FINDING'); + expect(requests[1].tools).toBeUndefined(); + }); +}); diff --git a/tests/core/codemode/QuickJsSandbox.test.ts b/tests/core/codemode/QuickJsSandbox.test.ts index 84a80f40b..40e108a5b 100644 --- a/tests/core/codemode/QuickJsSandbox.test.ts +++ b/tests/core/codemode/QuickJsSandbox.test.ts @@ -57,6 +57,14 @@ describe('runSandboxedScript', () => { }); describe('results', () => { + it('forwards only the existing boolean image hint without admitting image-like payloads', async () => { + const recorded = recordingExecutor(call => ({ ok: true, output: 'Observation.', images: call.tool === 'real' ? true : 'IMAGE_PAYLOAD_MUST_NOT_CROSS' } as unknown as SandboxToolOutcome)); + const result = await run('const real = await tools.real({}); const malformed = await tools.malformed({}); return {real,malformed};', { + toolNames: ['real', 'malformed'], executeToolCalls: recorded.execute, + }); + expect(value(result)).toEqual({ real: { ok: true, output: 'Observation.', images: true }, malformed: { ok: true, output: 'Observation.' } }); + expect(result.result).not.toContain('IMAGE_PAYLOAD'); + }); it('returns the script value as JSON text', async () => { const result = await run('return { answer: 42, items: [1, "two"] };'); diff --git a/tests/core/teams/TeamModelPolicy.test.ts b/tests/core/teams/TeamModelPolicy.test.ts index 671beceea..f47975893 100644 --- a/tests/core/teams/TeamModelPolicy.test.ts +++ b/tests/core/teams/TeamModelPolicy.test.ts @@ -10,9 +10,9 @@ import { ProviderFactory } from '../../../src/providers/ProviderFactory.js'; import type { AutohandConfig } from '../../../src/types.js'; describe('resolveTeamModelAssignment', () => { - it('keeps research workers on Fantail even when a definition requests Moa reasoning', () => { + it('honors definition models and reasoning with orchestration enabled', () => { const cloud: AutohandConfig = { provider: 'autohandai', autohandai: { plan: 'cloud', model: 'moa' } }; - expect(resolveTeamModelAssignment({ config: cloud, active: { provider: 'autohandai', model: 'moa' }, agentModel: 'moa', agentReasoning: 'high', environment: {} })).toEqual({ provider: 'autohandai', model: 'fantail', source: 'agent-nature' }); + expect(resolveTeamModelAssignment({ config: cloud, active: { provider: 'autohandai', model: 'moa' }, agentModel: 'moa', agentReasoning: 'high', environment: {} })).toEqual({ provider: 'autohandai', model: 'moa', source: 'agent-definition', reasoningEffort: 'high' }); expect(resolveTeamModelAssignment({ config: { ...cloud, teams: { defaultModel: 'moa' } }, active: { provider: 'autohandai', model: 'moa' }, environment: {} }).model).toBe('moa'); }); it('applies free account entitlements to explicit worker model selections', () => { diff --git a/tests/core/toolManager.patchEnvelope.test.ts b/tests/core/toolManager.patchEnvelope.test.ts new file mode 100644 index 000000000..b400c6567 --- /dev/null +++ b/tests/core/toolManager.patchEnvelope.test.ts @@ -0,0 +1,116 @@ +/** + * @license + * Copyright 2026 Autohand AI LLC + * SPDX-License-Identifier: Apache-2.0 + */ +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { FileActionManager } from '../../src/actions/filesystem.js'; +import { ActionExecutor } from '../../src/core/actionExecutor.js'; +import { DEFAULT_TOOL_DEFINITIONS, ToolManager } from '../../src/core/toolManager.js'; +import { PermissionManager } from '../../src/permissions/PermissionManager.js'; +import type { HookExecutionResult } from '../../src/core/HookManager.js'; +import type { AgentAction, ToolExecutionContext } from '../../src/types.js'; + +const directories: string[] = []; + +afterEach(async () => { + await Promise.all(directories.splice(0).map(directory => fs.rm(directory, { recursive: true, force: true }))); +}); + +function envelope(target: string): string { + return `*** Begin Patch\n*** Update File: ${target}\n@@\n-ORIGINAL\n+CHANGED\n*** End Patch`; +} + +async function pipeline(stage: 'pre-tool' | 'permission-request' | 'alternative' | 'plain', target: string) { + const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'autohand-patch-authorization-')); + directories.push(directory); + await fs.writeFile(path.join(directory, 'safe.txt'), 'ORIGINAL\n'); + await fs.writeFile(path.join(directory, '.env'), 'ORIGINAL\n'); + const permissionManager = new PermissionManager({ mode: stage === 'pre-tool' || stage === 'plain' ? 'unrestricted' : 'interactive' }); + const executor = new ActionExecutor({ + runtime: { workspaceRoot: directory, config: { configPath: '' }, options: {} }, + files: new FileActionManager(directory), + permissionManager, + resolveWorkspacePath: value => path.resolve(directory, value), + confirmDangerousAction: async () => true, + }); + await executor.executeForTool({ type: 'read_file', path: 'safe.txt' }); + const dispatch = vi.fn((action: AgentAction, context?: ToolExecutionContext) => executor.executeForTool(action, context)); + const confirmApproval = vi.fn().mockResolvedValue(stage === 'alternative' + ? { decision: 'alternative', alternative: target } + : { decision: 'allow_once' }); + const rewritten = async (): Promise => [{ + hook: { event: stage === 'pre-tool' ? 'pre-tool' : 'permission-request', command: 'fixture' }, + success: true, + duration: 0, + response: { updatedInput: { path: '', patch: envelope(target) } }, + }]; + const definition = DEFAULT_TOOL_DEFINITIONS.find(tool => tool.name === 'apply_patch'); + if (!definition) throw new Error('Missing apply_patch definition'); + const manager = new ToolManager({ + definitions: [definition], + executor: dispatch, + confirmApproval, + authorization: { + permissionManager, + resolvePermissionContext: action => executor.getPermissionContext(action), + ...(stage === 'pre-tool' ? { runPreToolHooks: rewritten } : {}), + ...(stage === 'permission-request' ? { runPermissionRequestHooks: rewritten } : {}), + }, + }); + return { directory, manager, executor, dispatch, confirmApproval }; +} + +describe('patch envelope authorization through the real tool pipeline', () => { + it.each(['managed', 'direct'] as const)('blocks a protected envelope target behind an explicit file alias in the %s pipeline', async (route) => { + const { directory, manager, executor, dispatch } = await pipeline('plain', 'safe.txt'); + const alias = path.join(directory, 'safe-alias'); + if (process.platform === 'win32') { + await fs.link(path.join(directory, '.env'), alias); + } else { + await fs.symlink(path.join(directory, '.env'), alias, 'file'); + } + await executor.executeForTool({ type: 'read_file', path: 'safe-alias' }); + const patch = envelope('.env'); + + const result = route === 'managed' + ? (await manager.execute([{ tool: 'apply_patch', args: { path: 'safe-alias', patch } }]))[0] + : await executor.executeForTool({ type: 'apply_patch', path: 'safe-alias', patch }); + + expect(result).toMatchObject({ success: false, kind: 'authorization' }); + expect(dispatch).not.toHaveBeenCalled(); + await expect(fs.readFile(path.join(directory, '.env'), 'utf8')).resolves.toBe('ORIGINAL\n'); + await expect(fs.readFile(alias, 'utf8')).resolves.toBe('ORIGINAL\n'); + }); + + it.each(['pre-tool', 'permission-request', 'alternative'] as const)('blocks a protected target introduced by %s input rewriting', async (stage) => { + const { directory, manager, dispatch, confirmApproval } = await pipeline(stage, '.env'); + + const [result] = await manager.execute([{ tool: 'apply_patch', args: { + path: 'safe.txt', patch: stage === 'alternative' ? envelope('safe.txt') : '@@\n-ORIGINAL\n+SAFE CHANGE\n', + } }]); + + expect(result).toMatchObject({ success: false, kind: 'authorization' }); + if (stage === 'alternative') expect(confirmApproval).toHaveBeenCalledOnce(); + expect(dispatch).not.toHaveBeenCalled(); + await expect(fs.readFile(path.join(directory, '.env'), 'utf8')).resolves.toBe('ORIGINAL\n'); + await expect(fs.readFile(path.join(directory, 'safe.txt'), 'utf8')).resolves.toBe('ORIGINAL\n'); + }); + + it.each(['pre-tool', 'permission-request', 'alternative'] as const)('authorizes the final target before executing %s rewritten input', async (stage) => { + const { directory, manager, dispatch, confirmApproval } = await pipeline(stage, 'safe.txt'); + + const [result] = await manager.execute([{ tool: 'apply_patch', args: { + path: 'safe.txt', patch: stage === 'alternative' ? envelope('safe.txt') : '@@\n-ORIGINAL\n+SAFE CHANGE\n', + } }]); + + expect(result).toMatchObject({ success: true }); + if (stage === 'alternative') expect(confirmApproval).toHaveBeenCalledOnce(); + expect(dispatch).toHaveBeenCalledWith(expect.objectContaining({ path: 'safe.txt' }), expect.objectContaining({ approvalHandled: true })); + await expect(fs.readFile(path.join(directory, 'safe.txt'), 'utf8')).resolves.toBe('CHANGED\n'); + await expect(fs.readFile(path.join(directory, '.env'), 'utf8')).resolves.toBe('ORIGINAL\n'); + }); +}); diff --git a/tests/discoveryCliCommand.spec.ts b/tests/discoveryCliCommand.spec.ts index c1ffc5b40..e39ebf0e5 100644 --- a/tests/discoveryCliCommand.spec.ts +++ b/tests/discoveryCliCommand.spec.ts @@ -3,6 +3,7 @@ import { createServer, type Server } from 'node:http'; import { chmod, mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import path from 'node:path'; +import { pathToFileURL } from 'node:url'; import { promisify } from 'node:util'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; @@ -14,7 +15,7 @@ let temporary: string; let workspace: string; let server: Server | undefined; beforeEach(async () => { - temporary = await mkdtemp(path.join(tmpdir(), 'native-discovery-test-')); + temporary = await mkdtemp(path.join(tmpdir(), 'native-discovery-test with spaces-')); workspace = path.join(temporary, 'repository with spaces'); await mkdir(workspace); await mkdir(path.join(temporary, 'discovery-home')); @@ -171,7 +172,7 @@ globalThis.fetch = async (input, init) => { '--no-behavior', '--json', ], - { NODE_OPTIONS: `--import ${preload}` } + { NODE_OPTIONS: `--import ${pathToFileURL(preload).href}` } ) ).stdout ); diff --git a/tests/import/CursorImporter.uri.test.ts b/tests/import/CursorImporter.uri.test.ts new file mode 100644 index 000000000..21ab7ffe6 --- /dev/null +++ b/tests/import/CursorImporter.uri.test.ts @@ -0,0 +1,169 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import os from 'node:os'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; +import type { SessionIndex, SessionMetadata } from '../../src/session/types.js'; + +const mocks = vi.hoisted(() => { + const prepare = vi.fn(); + const close = vi.fn(); + return { + prepare, + close, + database: vi.fn(function DatabaseSync() { return { prepare, close }; }), + fs: { + pathExists: vi.fn(), readdir: vi.fn(), readJson: vi.fn(), + ensureDir: vi.fn(), writeJson: vi.fn(), writeFile: vi.fn(), copy: vi.fn(), + }, + atomicWriteJson: vi.fn(), + withFileLock: vi.fn(async (_lockPath: string, operation: () => Promise) => operation()), + }; +}); + +vi.mock('node:sqlite', () => ({ DatabaseSync: mocks.database })); +vi.mock('fs-extra', () => ({ default: mocks.fs })); +vi.mock('../../src/utils/atomicFile.js', () => ({ + atomicWriteJson: mocks.atomicWriteJson, + withFileLock: mocks.withFileLock, +})); + +import { CursorImporter } from '../../src/import/importers/CursorImporter.js'; + +const sourceHome = path.join(os.tmpdir(), 'autohand-cursor-uri-fixture'); +const chatsDirectory = path.join(sourceHome, 'chats'); +const hashDirectory = path.join(chatsDirectory, 'fixture-hash'); +const databasePath = path.join(hashDirectory, 'fixture-id', 'store.db'); +const projectRoot = path.join(os.tmpdir(), 'autohand-cursor-project-fixture'); +const userMessage = Buffer.from(JSON.stringify({ role: 'user', content: 'Preserve this fixture conversation.' })); + +function uriBlob(uri: string, delimiter = 0): Buffer { + return Buffer.concat([Buffer.from([0x3b]), Buffer.from(uri), Buffer.from([delimiter, 0xff])]); +} + +function prepareFixture(blobs: Buffer[]): CursorImporter { + mocks.prepare.mockImplementation((sql: string) => { + if (sql.includes('meta')) return { get: () => ({ value: Buffer.from(JSON.stringify({ + agentId: 'fixture-id', name: 'Fixture project', createdAt: 1772503000000, lastUsedModel: 'fixture-model', + })).toString('hex') }) }; + return { all: () => [...blobs, userMessage].map(data => ({ data })) }; + }); + return new CursorImporter({ sourceHome }); +} + +async function importProject(blobs: Buffer[]): Promise<{ metadata: SessionMetadata; index: SessionIndex }> { + const result = await prepareFixture(blobs).import(['sessions']); + expect(result.imported.get('sessions')).toEqual({ success: 1, failed: 0, skipped: 0 }); + expect(mocks.database).toHaveBeenCalledWith(databasePath, { readOnly: true }); + expect(mocks.close).toHaveBeenCalledOnce(); + const metadataCall = mocks.fs.writeJson.mock.calls.find(([file]) => String(file).endsWith('metadata.json')); + expect(metadataCall).toBeDefined(); + const metadata = metadataCall?.[1] as SessionMetadata; + const index = mocks.atomicWriteJson.mock.calls[0]?.[1] as SessionIndex; + expect(mocks.fs.writeFile.mock.calls[0]?.[1]).toContain('Preserve this fixture conversation.'); + return { metadata, index }; +} + +function expectProjectAssociation(metadata: SessionMetadata, index: SessionIndex, expected: string): void { + expect(metadata.projectPath).toBe(expected); + expect(metadata.projectName).toBe(path.basename(expected)); + expect(index.sessions).toEqual([expect.objectContaining({ id: metadata.sessionId, projectPath: expected })]); + expect(index.byProject).toEqual({ [expected]: [metadata.sessionId] }); +} + +beforeEach(() => { + vi.clearAllMocks(); + mocks.fs.pathExists.mockImplementation(async (file: string) => [sourceHome, chatsDirectory, databasePath].includes(file)); + mocks.fs.readdir.mockImplementation(async (directory: string) => { + const name = directory === chatsDirectory ? 'fixture-hash' : directory === hashDirectory ? 'fixture-id' : undefined; + return name ? [{ name, isDirectory: () => true }] : []; + }); + mocks.fs.readJson.mockResolvedValue({}); + mocks.fs.ensureDir.mockResolvedValue(undefined); + mocks.fs.writeJson.mockResolvedValue(undefined); + mocks.fs.writeFile.mockResolvedValue(undefined); + mocks.atomicWriteJson.mockResolvedValue(undefined); +}); + +describe('Cursor binary-blob project file URIs', () => { + it.each([ + 'Documents - igor/autohand/cli-3', 'project#draft', '100%complete', '日本語-é', + 'Documents - igor/project#100%日本語', 'literal%20folder', "Igor's project", + ])('preserves the complete native project association for %s', async (suffix) => { + const expected = path.join(projectRoot, suffix); + const { metadata, index } = await importProject([uriBlob(pathToFileURL(expected).href)]); + expectProjectAssociation(metadata, index, expected); + }); + + it.each([0, 0x12, 0x1f, 0x7f])('respects binary terminator %s', async (delimiter) => { + const expected = path.join(projectRoot, 'project with spaces'); + const { metadata, index } = await importProject([uriBlob(pathToFileURL(expected).href, delimiter)]); + expectProjectAssociation(metadata, index, expected); + }); + + it('accepts a complete file URI at the end of a blob', async () => { + const expected = path.join(projectRoot, 'last-project'); + const { metadata, index } = await importProject([Buffer.from(pathToFileURL(expected).href)]); + expectProjectAssociation(metadata, index, expected); + }); + + it.each([ + 'file:///wrong/project%GG', 'file:///wrong/project%', 'file:///wrong/%E6', + 'file:///wrong/project#fragment', 'file:///wrong/project?query=1', + 'file:///wrong/project#', 'file:///wrong/project?', + 'file:///wrong/project broken/name', 'file:///wrong/project%00', 'file:///wrong/project%2Fchild', + ])('does not accept a partial parent from malformed URI %s', async (uri) => { + const { metadata, index } = await importProject([uriBlob(uri)]); + expectProjectAssociation(metadata, index, process.cwd()); + }); + + it('skips malformed candidates before a later complete URI', async () => { + const expected = path.join(projectRoot, 'complete-project'); + const { metadata, index } = await importProject([Buffer.concat([ + uriBlob('file:///wrong/project%GG'), uriBlob(pathToFileURL(expected).href), + ])]); + expectProjectAssociation(metadata, index, expected); + }); + + it('accepts a complete JSON-quoted URI without consuming the closing delimiter', async () => { + const expected = path.join(projectRoot, 'quoted project'); + const { metadata, index } = await importProject([Buffer.from(JSON.stringify({ folderUri: pathToFileURL(expected).href }))]); + expectProjectAssociation(metadata, index, expected); + }); + + it('keeps an established association when a later blob has a malformed URI', async () => { + const expected = path.join(projectRoot, 'complete-project'); + const { metadata, index } = await importProject([ + uriBlob(pathToFileURL(expected).href), uriBlob('file:///wrong/project%GG'), + ]); + expectProjectAssociation(metadata, index, expected); + }); + + it('does not accept a URI cut off by the bounded blob scan', async () => { + const { metadata, index } = await importProject([uriBlob(`file:///project/${'a'.repeat(100010)}`)]); + expect(metadata.projectPath === process.cwd()).toBe(true); + expect(index.byProject).toHaveProperty(process.cwd()); + }); + + it.skipIf(process.platform !== 'win32')('preserves native Windows UNC project paths', async () => { + const expected = '\\\\server\\share\\Project Name'; + const { metadata, index } = await importProject([uriBlob('file://server/share/Project%20Name')]); + expectProjectAssociation(metadata, index, expected); + }); + + it('preserves existing import deduplication without rewriting old history', async () => { + const existingIndex: SessionIndex = { + sessions: [{ id: 'existing', projectPath: '/previous/project', createdAt: '2026-01-01T00:00:00Z', + importedFrom: { source: 'cursor', originalId: 'fixture-id' } }], + byProject: { '/previous/project': ['existing'] }, + }; + const importer = prepareFixture([uriBlob(pathToFileURL(path.join(projectRoot, 'correct path')).href)]); + mocks.fs.pathExists.mockImplementation(async (file: string) => + file.endsWith('index.json') || [sourceHome, chatsDirectory, databasePath].includes(file)); + mocks.fs.readJson.mockResolvedValue(existingIndex); + const result = await importer.import(['sessions']); + expect(result.imported.get('sessions')).toEqual({ success: 0, failed: 0, skipped: 1 }); + expect(mocks.fs.writeJson).not.toHaveBeenCalled(); + expect(mocks.atomicWriteJson).not.toHaveBeenCalled(); + expect(existingIndex.byProject).toEqual({ '/previous/project': ['existing'] }); + }); +}); diff --git a/tests/integration/promptLifecycle.integration.spec.ts b/tests/integration/promptLifecycle.integration.spec.ts index 397d3c866..b78edf093 100644 --- a/tests/integration/promptLifecycle.integration.spec.ts +++ b/tests/integration/promptLifecycle.integration.spec.ts @@ -8,6 +8,7 @@ import { existsSync } from 'node:fs'; import { mkdir, mkdtemp, realpath, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import path from 'node:path'; +import { pathToFileURL } from 'node:url'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; const root = path.resolve(import.meta.dirname, '../..'); @@ -25,7 +26,7 @@ let configuration: string; let child: ChildProcess | undefined; beforeEach(async () => { - temporary = await realpath(await mkdtemp(path.join(tmpdir(), 'autohand-prompt-lifecycle-'))); + temporary = await realpath(await mkdtemp(path.join(tmpdir(), 'autohand-prompt-lifecycle with spaces-'))); workspace = path.join(temporary, 'repo'); await mkdir(workspace); await mkdir(path.join(temporary, 'home')); @@ -117,7 +118,7 @@ function startCli(args: string[], preload: string, env: Record = AUTOHAND_API_KEY: '', AUTOHAND_NO_BANNER: '1', AUTOHAND_DISABLE_AUTO_REPORT: '1', - NODE_OPTIONS: `--import ${preload}`, + NODE_OPTIONS: `--import ${pathToFileURL(preload).href}`, ...env, }, }); diff --git a/tests/mobile/AgentMobileTurnLifecycle.test.ts b/tests/mobile/AgentMobileTurnLifecycle.test.ts index b9b66ad0b..beab268dc 100644 --- a/tests/mobile/AgentMobileTurnLifecycle.test.ts +++ b/tests/mobile/AgentMobileTurnLifecycle.test.ts @@ -124,7 +124,7 @@ describe('mobile instruction lifecycle', () => { expect(relayB.finishClaimedTurn).not.toHaveBeenCalled(); }); - it('routes follow-up questions through the claimed turn origin relay only', async () => { + it('routes mobile questions through the origin relay and restores the existing ACP answer channel', async () => { const agent = Object.create(AutohandAgent.prototype) as any; installMobileSessionBoundaryFixtures(agent); agent.sessionManager = { @@ -156,6 +156,8 @@ describe('mobile instruction lifecycle', () => { agent.mobileTurnFailureMessage = null; agent.lastAssistantResponseForNotification = ''; agent.runtime = { options: {} }; + const acpAnswer = vi.fn().mockResolvedValue('Answer from ACP'); + agent.setFollowupQuestionCallback(acpAnswer); agent.peerAwaitingInputCount = 0; agent.consecutiveCancellations = 0; agent.instructionRunner = { @@ -184,7 +186,10 @@ describe('mobile instruction lifecycle', () => { ['Staging', 'Production'], ); expect(relayB.requestFollowupQuestion).not.toHaveBeenCalled(); - expect(agent.followupQuestionCallback).toBeUndefined(); + expect(agent.followupQuestionCallback).toBe(acpAnswer); + await expect(agent.executeAskFollowupQuestion('Back in ACP?')) + .resolves.toBe('Answer from ACP'); + expect(acpAnswer).toHaveBeenCalledWith('Back in ACP?', undefined); }); it('terminalizes the claimed turn when preview setup fails before execution', async () => { diff --git a/tests/modes/acp/adapter.test.ts b/tests/modes/acp/adapter.test.ts index 7d028d1aa..acd1c6e41 100644 --- a/tests/modes/acp/adapter.test.ts +++ b/tests/modes/acp/adapter.test.ts @@ -69,6 +69,7 @@ const { initializeForRPC: vi.fn().mockResolvedValue(undefined), setOutputListener: vi.fn(), setConfirmationCallback: vi.fn(), + setFollowupQuestionCallback: vi.fn(), connectAcpMcpServers: vi.fn().mockResolvedValue(undefined), applyAcpMode: vi.fn(), applyAcpModel: vi.fn(), @@ -187,6 +188,14 @@ vi.mock("../../../package.json", () => ({ // --------------------------------------------------------------------------- import { AutohandAcpAdapter } from "../../../src/modes/acp/adapter.js"; +import { executeAgentAskFollowupQuestion } from "../../../src/core/agent/AgentCommandRuntime.js"; +import { + AgentSideConnection as SdkAgentSideConnection, + client, + methods, + ndJsonStream, + PROTOCOL_VERSION, +} from "@agentclientprotocol/sdk"; // --------------------------------------------------------------------------- // Helpers @@ -208,6 +217,7 @@ function makeConfig(overrides: Partial = {}): LoadedConfig { function makeConnection(): AgentSideConnection { return { requestPermission: vi.fn(), + request: vi.fn(), sessionUpdate: vi.fn().mockResolvedValue(undefined), extNotification: vi.fn().mockResolvedValue(undefined), } as unknown as AgentSideConnection; @@ -573,6 +583,20 @@ describe("AutohandAcpAdapter", () => { ); }); + it('forwards partial worker status and reason while retaining the legacy success boolean', () => { + lifecycleListener!({ event: 'subagent-stop', subagentId: 'limited', subagentName: 'reviewer', subagentType: 'task', + subagentSuccess: false, subagentDuration: 20, subagentStatus: 'partial', subagentReason: 'iteration_limit', + subagentMessage: 'PRIVATE_WORKER_REPORT_NOT_FOR_STOP_EVENT', + }); + expect(connection.extNotification).toHaveBeenCalledWith('autohand.hook.subagentStop', expect.objectContaining({ + subagentId: 'limited', success: false, status: 'partial', reason: 'iteration_limit', + })); + const payload = vi.mocked(connection.extNotification).mock.calls.find(([method, params]) => method === 'autohand.hook.subagentStop' && params.subagentId === 'limited')?.[1]; + expect(JSON.stringify(payload)).not.toContain('PRIVATE_WORKER_REPORT'); + expect(payload).not.toHaveProperty('result'); + expect(payload).not.toHaveProperty('output'); + }); + it("forwards program status with the ACP session identity and no terminal escapes", () => { lifecycleListener!({ event: 'program-status', programStatus: { state: 'blocked', kind: 'question', app: 'autohand', id: 'autohand-1', message: 'Choose an answer', @@ -793,6 +817,221 @@ describe("AutohandAcpAdapter", () => { ); }); + describe("follow-up elicitation", () => { + async function askFollowup(question = "Which environment?", choices?: string[]) { + const callback = mockAgent.setFollowupQuestionCallback.mock.calls.at(-1)?.[0]; + const host = { + runtime: { options: {}, config: { ui: {} }, isRpcMode: true }, + followupQuestionCallback: callback, + consecutiveCancellations: 0, + }; + return executeAgentAskFollowupQuestion(host, question, choices); + } + + async function createElicitingSession() { + await adapter.initialize(makeInitRequest({ clientCapabilities: { elicitation: { form: {} } } })); + return adapter.newSession(makeNewSessionRequest()); + } + + it("collects a real answer through the advertised form capability", async () => { + vi.mocked(connection.request).mockResolvedValue({ action: "accept", content: { answer: "Staging" } }); + const session = await createElicitingSession(); + + await expect(askFollowup("Which environment?", ["Staging", "Production"])) + .resolves.toBe("Staging"); + expect(connection.request).toHaveBeenCalledWith(methods.client.elicitation.create, { + mode: "form", + sessionId: session.sessionId, + message: "Which environment?", + requestedSchema: { + type: "object", + properties: { + answer: { + type: "string", + title: "Answer", + description: "Which environment?\nSuggested answers:\n- Staging\n- Production", + minLength: 1, + }, + }, + required: ["answer"], + }, + }, { cancellationSignal: expect.any(AbortSignal) }); + }); + + it.each(["answer", "cancel"] as const)("round-trips a follow-up over the SDK's serialized channel: %s", async outcome => { + let agentInput: ReadableStreamDefaultController | undefined; + let clientInput: ReadableStreamDefaultController | undefined; + const agentMessages: string[] = []; + const toAgent = new ReadableStream({ start(controller) { agentInput = controller; } }); + const toClient = new ReadableStream({ start(controller) { clientInput = controller; } }); + const agentOutput = new WritableStream({ + write(chunk) { + agentMessages.push(new TextDecoder().decode(chunk)); + clientInput?.enqueue(chunk); + }, + }); + const clientOutput = new WritableStream({ write(chunk) { agentInput?.enqueue(chunk); } }); + let wireAdapter: AutohandAcpAdapter | undefined; + const wireAgent = new SdkAgentSideConnection(conn => { + wireAdapter = new AutohandAcpAdapter(conn); + return wireAdapter; + }, ndJsonStream(agentOutput, toAgent)); + let peerFormRetired = false; + const elicitation = vi.fn(async ({ signal }: { signal: AbortSignal }) => { + if (outcome === "answer") { + return { action: "accept", content: { answer: "A typed client answer" } }; + } + return new Promise<{ action: "cancel" }>(resolve => { + signal.addEventListener("abort", () => { + peerFormRetired = true; + resolve({ action: "cancel" }); + }, { once: true }); + }); + }); + const wireClient = client() + .onNotification(methods.client.session.update, async () => {}) + .onRequest(methods.client.elicitation.create, elicitation) + .connect(ndJsonStream(clientOutput, toClient)); + try { + await wireClient.agent.request(methods.agent.initialize, { protocolVersion: PROTOCOL_VERSION, clientCapabilities: { elicitation: { form: {} } } }); + const session = await wireClient.agent.request(methods.agent.session.new, { cwd: "/workspace", mcpServers: [] }); + + const answer = askFollowup("Which environment?", ["Staging", "Production"]); + if (outcome === "cancel") { + await vi.waitFor(() => expect(elicitation).toHaveBeenCalledOnce()); + await wireClient.agent.notify(methods.agent.session.cancel, { sessionId: session.sessionId }); + await expect(answer).resolves.toContain("User cancelled this question"); + await vi.waitFor(() => expect(peerFormRetired).toBe(true), { timeout: 300, interval: 10 }); + const messages = agentMessages.join("").trim().split("\n").map(line => JSON.parse(line)); + const request = messages.find(message => message.method === methods.client.elicitation.create); + expect(messages).toContainEqual(expect.objectContaining({ + method: methods.protocol.cancelRequest, params: { requestId: request?.id }, + })); + await expect(wireClient.agent.request(methods.agent.session.list, {})).resolves.toMatchObject({ sessions: [] }); + expect(wireClient.signal.aborted).toBe(false); + expect(wireAgent.signal.aborted).toBe(false); + } else { + await expect(answer).resolves.toBe("A typed client answer"); + } + expect(elicitation).toHaveBeenCalledWith(expect.objectContaining({ + params: expect.objectContaining({ sessionId: session.sessionId, mode: "form" }), + })); + expect(agentMessages.join("")).toContain('"method":"elicitation/create"'); + } finally { + await wireAdapter?.dispose(); + agentInput?.close(); + clientInput?.close(); + wireClient.close(); + await Promise.all([wireAgent.closed, wireClient.closed]); + } + }); + + it("preserves free-text answers returned by the client", async () => { + vi.mocked(connection.request).mockResolvedValue({ action: "accept", content: { answer: "Use the local sandbox" } }); + await createElicitingSession(); + + await expect(askFollowup("Which environment?", ["Staging", "Production"])) + .resolves.toBe("Use the local sandbox"); + }); + + it("omits enum constraints for open questions", async () => { + vi.mocked(connection.request).mockResolvedValue({ action: "accept", content: { answer: "The API times out" } }); + await createElicitingSession(); + + await expect(askFollowup("What failed?")) + .resolves.toBe("The API times out"); + expect(connection.request).toHaveBeenCalledWith(methods.client.elicitation.create, expect.objectContaining({ + requestedSchema: expect.objectContaining({ + properties: { + answer: { type: "string", title: "Answer", description: "What failed?", minLength: 1 }, + }, + }), + }), { cancellationSignal: expect.any(AbortSignal) }); + }); + + it.each([{}, { elicitation: { url: {} } }])("keeps unsupported clients unanswered without attempting elicitation: %j", async clientCapabilities => { + await adapter.initialize(makeInitRequest({ clientCapabilities })); + await adapter.newSession(makeNewSessionRequest()); + + await expect(askFollowup()).resolves.toMatch(/^/); + expect(mockAgent.setFollowupQuestionCallback).not.toHaveBeenCalled(); + expect(connection.request).not.toHaveBeenCalled(); + }); + + it.each(["decline", "cancel"] as const)("maps client %s to question cancellation", async action => { + vi.mocked(connection.request).mockResolvedValue({ action }); + await createElicitingSession(); + + await expect(askFollowup()).resolves.toContain("User cancelled this question"); + }); + + it.each([ + { action: "accept" }, + { action: "accept", content: { answer: " " } }, + { action: "accept", content: { answer: true } }, + { action: "future_action" }, + ])("does not invent an answer from an unusable client response: %j", async response => { + vi.mocked(connection.request).mockResolvedValue(response); + await createElicitingSession(); + + await expect(askFollowup()).resolves.toMatch(/^/); + }); + + it("keeps a transport failure distinct from user cancellation", async () => { + vi.mocked(connection.request).mockRejectedValue(new Error("Transport closed")); + await createElicitingSession(); + + await expect(askFollowup()).resolves.toMatch(/^/); + }); + + it("ends a cancelled session question without waiting for or accepting a late client answer", async () => { + let answer: ((value: { action: "accept"; content: { answer: string } }) => void) | undefined; + vi.mocked(connection.request).mockImplementation(() => new Promise(resolve => { answer = resolve; })); + const session = await createElicitingSession(); + const result = askFollowup(); + let settled = false; + void result.then(() => { settled = true; }); + await vi.waitFor(() => expect(connection.request).toHaveBeenCalledOnce()); + try { + await adapter.cancel({ sessionId: session.sessionId }); + await vi.waitFor(() => expect(settled).toBe(true), { timeout: 200, interval: 10 }); + await expect(result).resolves.toContain("User cancelled this question"); + } finally { + answer?.({ action: "accept", content: { answer: "Production" } }); + } + }); + + it.each(["loadSession", "resumeSession"] as const)("does not let a retired callback question the replacement %s", async method => { + const session = await createElicitingSession(); + const oldCallback = mockAgent.setFollowupQuestionCallback.mock.calls.at(-1)?.[0]; + await adapter[method]({ sessionId: session.sessionId, cwd: "/workspace", mcpServers: [] }); + + await expect(oldCallback("A stale question")).resolves.toBeNull(); + expect(connection.request).not.toHaveBeenCalled(); + }); + + it.each(["loadSession", "resumeSession", "dispose", "shutdown"] as const)("releases an outstanding question when %s retires its session", async method => { + let reply: ((value: { action: "accept"; content: { answer: string } }) => void) | undefined; + vi.mocked(connection.request).mockImplementation(() => new Promise(resolve => { reply = resolve; })); + const session = await createElicitingSession(); + const question = askFollowup(); + let settled = false; + void question.then(() => { settled = true; }); + await vi.waitFor(() => expect(connection.request).toHaveBeenCalledOnce()); + try { + if (method === "loadSession" || method === "resumeSession") { + await adapter[method]({ sessionId: session.sessionId, cwd: "/workspace", mcpServers: [] }); + } else { + await adapter[method](); + } + await vi.waitFor(() => expect(settled).toBe(true), { timeout: 200, interval: 10 }); + await expect(question).resolves.toContain("User cancelled this question"); + } finally { + reply?.({ action: "accept", content: { answer: "A stale answer" } }); + } + }); + }); + it("uses original workspace when worktree option is not enabled", async () => { await adapter.newSession(makeNewSessionRequest()); diff --git a/tests/modes/rpc/blueprintAnswer.spec.ts b/tests/modes/rpc/blueprintAnswer.spec.ts index f333ed521..9cfaced37 100644 --- a/tests/modes/rpc/blueprintAnswer.spec.ts +++ b/tests/modes/rpc/blueprintAnswer.spec.ts @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ import { readFile } from 'node:fs/promises'; -import { access, mkdtemp, rm } from 'node:fs/promises'; +import { access, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -126,6 +126,52 @@ describe('Blueprint answer-only contract', () => { expect(identity.identityHash).toMatch(/^[a-f0-9]{64}$/u); }); + it.each(['checkout', 'worktree'])('binds packed repository refs in a %s', async (kind) => { + const directory = await mkdtemp(path.join(tmpdir(), 'autohand identity refs ')); + const packageRoot = path.join(directory, 'package'); + const common = path.join(directory, 'common.git'); + const gitDirectory = kind === 'worktree' ? path.join(common, 'worktrees', 'session') : path.join(packageRoot, '.git'); + const commit = 'a'.repeat(40); + try { + await mkdir(path.join(packageRoot, 'src'), { recursive: true }); + await writeFile(path.join(packageRoot, 'package.json'), JSON.stringify({ name: 'autohand-cli', version: '1.2.3' })); + await writeFile(path.join(packageRoot, 'src', 'index.ts'), 'export {};\n'); + await mkdir(gitDirectory, { recursive: true }); + await writeFile(path.join(gitDirectory, 'HEAD'), 'ref: refs/heads/main\n'); + if (kind === 'worktree') { + await writeFile(path.join(packageRoot, '.git'), `gitdir: ${gitDirectory}\n`); + await writeFile(path.join(gitDirectory, 'commondir'), '../..\n'); + } + await writeFile(path.join(kind === 'worktree' ? common : gitDirectory, 'packed-refs'), + `# pack-refs with: peeled fully-peeled sorted\n${'b'.repeat(40)} refs/heads/main-other\n${commit} refs/heads/main\n^${'c'.repeat(40)}\n`); + + const identity = await inspectBlueprintCliIdentity(path.join(packageRoot, 'src', 'index.ts')); + expect(identity.package).toMatchObject({ name: 'autohand-cli', version: '1.2.3', commit }); + expect(identity.identityHash).toMatch(/^[a-f0-9]{64}$/u); + } finally { + await rm(directory, { recursive: true, force: true }); + } + }); + + it('prefers the current loose ref over a stale packed ref', async () => { + const directory = await mkdtemp(path.join(tmpdir(), 'autohand identity loose ')); + const commit = 'd'.repeat(40); + try { + await mkdir(path.join(directory, 'src'), { recursive: true }); + await mkdir(path.join(directory, '.git', 'refs', 'heads'), { recursive: true }); + await writeFile(path.join(directory, 'package.json'), JSON.stringify({ name: 'autohand-cli', version: '1.2.3' })); + await writeFile(path.join(directory, 'src', 'index.ts'), 'export {};\n'); + await writeFile(path.join(directory, '.git', 'HEAD'), 'ref: refs/heads/main\n'); + await writeFile(path.join(directory, '.git', 'refs', 'heads', 'main'), commit + '\n'); + await writeFile(path.join(directory, '.git', 'packed-refs'), `${'e'.repeat(40)} refs/heads/main\n`); + + const identity = await inspectBlueprintCliIdentity(path.join(directory, 'src', 'index.ts')); + expect(identity.package.commit).toBe(commit); + } finally { + await rm(directory, { recursive: true, force: true }); + } + }); + it.each([ { name: 'Autohand AI cloud', diff --git a/tests/modes/rpc/handlers.spec.ts b/tests/modes/rpc/handlers.spec.ts index a0173846b..008e65816 100644 --- a/tests/modes/rpc/handlers.spec.ts +++ b/tests/modes/rpc/handlers.spec.ts @@ -421,6 +421,19 @@ describe('RPC Adapter - P2 Handlers', () => { ); }); + it('keeps local storage errors nonrecoverable through the existing generic RPC error shape', async () => { + const outputListener = mockAgent.setOutputListener.mock.calls[0]?.[0]; + const message = 'Local storage is full. Free disk space before continuing. Recent session data may not have been saved.\nENOSPC: no space left on device, write'; + mockAgent.runInstruction.mockImplementationOnce(async () => { + outputListener({ type: 'error', content: message }); + return false; + }); + await adapter.handlePrompt('req_storage_full', { message: 'Continue the task' }); + expect(writeNotification).toHaveBeenCalledWith('autohand.error', expect.objectContaining({ + code: -32000, errorType: 'unknown', recoverable: false, message, + })); + }); + it('emits prompt lifecycle metadata without sensitive content in debug mode', async () => { process.env.AUTOHAND_DEBUG = '1'; const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true); diff --git a/tests/modes/rpc/hookLifecycle.integration.test.ts b/tests/modes/rpc/hookLifecycle.integration.test.ts index 4938e600d..54f7525f4 100644 --- a/tests/modes/rpc/hookLifecycle.integration.test.ts +++ b/tests/modes/rpc/hookLifecycle.integration.test.ts @@ -202,6 +202,22 @@ describe('HookManager to RPC lifecycle integration', () => { ]); }); + it('distinguishes a partial worker from failure without sending its transcript', async () => { + const { hookManager } = createHarness(); + vi.mocked(writeNotification).mockClear(); + await hookManager.executeHooks('subagent-stop', { subagentId: 'limited', subagentName: 'reviewer', subagentType: 'task', + subagentSuccess: false, subagentDuration: 20, subagentStatus: 'partial', subagentReason: 'iteration_limit', + subagentMessage: 'PRIVATE_WORKER_REPORT_NOT_FOR_STOP_EVENT', + }); + expect(hookNotifications()).toEqual([['autohand.hook.subagentStop', expect.objectContaining({ + subagentId: 'limited', success: false, status: 'partial', reason: 'iteration_limit', + })]]); + const payload = hookNotifications()[0][1]; + expect(JSON.stringify(payload)).not.toContain('PRIVATE_WORKER_REPORT'); + expect(payload).not.toHaveProperty('result'); + expect(payload).not.toHaveProperty('output'); + }); + it('maps every hook lifecycle event to its exact RPC payload once', async () => { const { hookManager } = createHarness(); vi.mocked(writeNotification).mockClear(); diff --git a/tests/patchValidator.spec.ts b/tests/patchValidator.spec.ts index f281a6c3e..d3e1f0a12 100644 --- a/tests/patchValidator.spec.ts +++ b/tests/patchValidator.spec.ts @@ -4,7 +4,69 @@ * SPDX-License-Identifier: Apache-2.0 */ import { describe, it, expect } from 'vitest'; -import { validateAndFixPatch, stripPatchHeaders } from '../src/utils/patchValidator.js'; +import { createTwoFilesPatch } from 'diff'; +import { applyValidatedPatch, extractPatchTarget, validateAndFixPatch, stripPatchHeaders } from '../src/utils/patchValidator.js'; + +describe('applyValidatedPatch', () => { + it.each([ + ['empty-file creation', '', 'new\n'], + ['whole-file deletion', 'old\n', ''], + ['no final newline', 'old', 'new'], + ['adding a final newline', 'same', 'same\n'], + ['removing a final newline', 'same\n', 'same'], + ['Windows line endings', 'old\r\nkeep\r\n', 'new\r\nkeep\r\n'], + ['header-like source lines', '-- heading\nkeep\n', '++ heading\nkeep\n'], + ])('preserves standard unified patch semantics for %s', (_name, current, expected) => { + const patch = createTwoFilesPatch('a/example.txt', 'b/example.txt', current, expected); + + expect(applyValidatedPatch(current, patch)).toBe(expected); + }); + + it('applies distant numbered hunks from a standard generated diff', () => { + const middle = Array.from({ length: 30 }, (_value, index) => `line ${index}`).join('\n'); + const current = `first\n${middle}\nlast\n`; + const expected = `updated first\n${middle}\nupdated last\n`; + + expect(applyValidatedPatch(current, createTwoFilesPatch('a/file', 'b/file', current, expected))).toBe(expected); + }); + + it('does not silently ignore changes after an unprefixed blank line and understated counts', () => { + const current = 'one\n\nthree\n'; + const patch = '@@ -1,1 +1,1 @@\n-one\n+ONE\n\n-three\n+THREE\n'; + + expect(applyValidatedPatch(current, patch)).toBe('ONE\n\nTHREE\n'); + }); + + it('validates trailing context that an understated header would otherwise leave unparsed', () => { + const current = 'one\n\ncurrent context\n'; + const patch = '@@ -1,1 +1,1 @@\n-one\n+ONE\n\n stale context\n'; + + expect(() => applyValidatedPatch(current, patch)).toThrow(/hunk 1.*could not match/i); + }); + + it('rejects an unanchored insertion into an existing file', () => { + expect(() => applyValidatedPatch('existing\n', '@@\n+inserted\n')).toThrow(/hunk 1.*include existing lines/i); + }); + + it('rejects multiple unified file patches', () => { + const patch = createTwoFilesPatch('a/one', 'b/one', 'old\n', 'new\n') + + createTwoFilesPatch('a/two', 'b/two', 'old\n', 'new\n'); + + expect(() => applyValidatedPatch('old\n', patch)).toThrow(/one file/i); + }); +}); + +describe('extractPatchTarget', () => { + it('extracts a target only from a single Update File directive', () => { + expect(extractPatchTarget('*** Begin Patch\n*** Update File: src/file.ts\n@@\n-old\n+new\n*** End Patch')) + .toBe('src/file.ts'); + expect(extractPatchTarget('--- a/file.ts\n+++ b/file.ts\n@@ -1 +1 @@\n-old\n+new')).toBeUndefined(); + }); + + it.each(['*** Add File: one', '*** Delete File: one', '*** Move to: two'])('rejects an unsupported file operation: %s', (directive) => { + expect(() => extractPatchTarget(`*** Begin Patch\n${directive}\n*** End Patch`)).toThrow(/single Update File/i); + }); +}); describe('validateAndFixPatch', () => { it('should return unchanged patch if line counts are correct', () => { @@ -117,7 +179,7 @@ describe('validateAndFixPatch', () => { +line2 +line3`; const result = validateAndFixPatch(patch); - expect(result).toContain('@@ -1 +1,3 @@'); + expect(result).toContain('@@ -0,0 +1,3 @@'); }); it('should handle pure deletion', () => { @@ -126,7 +188,7 @@ describe('validateAndFixPatch', () => { -line2 -line3`; const result = validateAndFixPatch(patch); - expect(result).toContain('@@ -1,3 +1 @@'); + expect(result).toContain('@@ -1,3 +0,0 @@'); }); }); @@ -161,4 +223,4 @@ describe('stripPatchHeaders', () => { const result = stripPatchHeaders(patch); expect(result).toBe(patch); }); -}); \ No newline at end of file +}); diff --git a/tests/providers/AnthropicProvider.test.ts b/tests/providers/AnthropicProvider.test.ts index 7fb192a57..504bce1af 100644 --- a/tests/providers/AnthropicProvider.test.ts +++ b/tests/providers/AnthropicProvider.test.ts @@ -21,6 +21,68 @@ describe("AnthropicProvider", () => { vi.restoreAllMocks(); }); + describe('OpenCode Go conversation routing', () => { + const response = () => jsonResponse({ + id: 'go-response', type: 'message', role: 'assistant', model: 'minimax-m3', + content: [{ type: 'text', text: 'Done' }], stop_reason: 'end_turn', + usage: { input_tokens: 3, output_tokens: 1 }, + }); + + it.each([ + 'https://opencode.ai/inference/go/anthropic', + 'https://opencode.ai/inference/go/anthropic/', + 'https://opencode.ai/zen/go', + ])('sends opaque stable conversation routing with Autohand identity only to %s', async baseUrl => { + const fetch = vi.spyOn(globalThis, 'fetch').mockImplementation(async () => response()); + const provider = new AnthropicProvider({ apiKey: 'fixture', model: 'minimax-m3', baseUrl }); + const first = { messages: [{ role: 'user' as const, content: 'Hello' }], sessionId: 'private-session-a' }; + await provider.complete(first); + await provider.complete({ ...first, messages: [{ role: 'user', content: 'Continue' }] }); + await provider.complete({ ...first, sessionId: 'private-session-b' }); + const headers = fetch.mock.calls.map(([, init]) => new Headers(init?.headers)); + const key = headers[0]?.get('x-opencode-session'); + expect(key).toMatch(/^autohand_[a-f0-9]{64}$/); + expect(key).not.toContain('private-session'); + expect(headers[1]?.get('x-opencode-session')).toBe(key); + expect(headers[2]?.get('x-opencode-session')).not.toBe(key); + expect(headers[0]?.get('user-agent')).toMatch(/^autohand-cli\/[0-9]/); + expect(headers[0]?.get('user-agent')).not.toMatch(/claude|opencode|anthropic/i); + for (const [, init] of fetch.mock.calls) { + expect(JSON.parse(String(init?.body))).not.toHaveProperty('sessionId'); + expect(String(init?.body)).not.toContain('private-session'); + } + }); + + it.each([ + 'https://api.anthropic.com', + 'https://custom.example/inference/go/anthropic', + 'https://opencode.ai.evil.example/inference/go/anthropic', + 'http://opencode.ai/inference/go/anthropic', + 'https://opencode.ai/inference/anthropic', + 'https://opencode.ai/inference/go/anthropic-extra', + ])('does not forward Go conversation identity to unrelated endpoint %s', async baseUrl => { + const fetch = vi.spyOn(globalThis, 'fetch').mockResolvedValue(response()); + const provider = new AnthropicProvider({ apiKey: 'fixture', model: 'minimax-m3', baseUrl }); + const request = { messages: [{ role: 'user' as const, content: 'Hello' }], sessionId: 'private-session-a' }; + await provider.complete(request); + const headers = new Headers(fetch.mock.calls[0]?.[1]?.headers); + expect(headers.has('x-opencode-session')).toBe(false); + }); + + it('keeps no-session auxiliary calls stable only within their provider instance', async () => { + const fetch = vi.spyOn(globalThis, 'fetch').mockImplementation(async () => response()); + const settings = { apiKey: 'fixture', model: 'minimax-m3', baseUrl: 'https://opencode.ai/inference/go/anthropic' }; + const first = new AnthropicProvider(settings); + await first.complete({ messages: [] }); + await first.complete({ messages: [] }); + await new AnthropicProvider(settings).complete({ messages: [] }); + const ids = fetch.mock.calls.map(([, init]) => new Headers(init?.headers).get('x-opencode-session')); + expect(ids[0]).toMatch(/^autohand_[a-f0-9]{64}$/); + expect(ids[1]).toBe(ids[0]); + expect(ids[2]).not.toBe(ids[0]); + }); + }); + it("uses the Messages API for system prompts, tool use, tool results, and usage", async () => { const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(jsonResponse({ id: "msg_123", diff --git a/tests/providers/AzureProvider.test.ts b/tests/providers/AzureProvider.test.ts index 8eab34f96..eab255ea7 100644 --- a/tests/providers/AzureProvider.test.ts +++ b/tests/providers/AzureProvider.test.ts @@ -64,4 +64,26 @@ describe('AzureProvider', () => { expect(response.content).toBe('Azure response'); expect(response.finishReason).toBe('stop'); }); + + it('keeps managed identity and stored credentials intact after a pre-request auth failure, then permits explicit retry', async () => { + const fetch = vi.spyOn(globalThis, 'fetch') + .mockRejectedValueOnce(new Error('IMDS connection failed')) + .mockResolvedValueOnce(Response.json({ access_token: 'fixture-managed-token', expires_in: 3600 })) + .mockResolvedValueOnce(Response.json({ + id: 'azure-retry', choices: [{ message: { content: 'Explicit retry succeeded' }, finish_reason: 'stop' }], + })); + const settings: AzureSettings = { ...defaultSettings, authMethod: 'managed-identity', apiKey: 'preserve-configured-key' }; + const provider = new AzureProvider(settings, { maxRetries: 3, retryDelay: 1 }); + await expect(provider.complete({ messages: [{ role: 'user', content: 'Hello' }] })) + .rejects.toMatchObject({ code: 'auth_failed', retryable: false }); + expect(fetch).toHaveBeenCalledTimes(1); + expect(settings.authMethod).toBe('managed-identity'); + expect(settings.apiKey).toBe('preserve-configured-key'); + await expect(provider.complete({ messages: [{ role: 'user', content: 'Try again' }] })) + .resolves.toMatchObject({ content: 'Explicit retry succeeded' }); + expect(fetch).toHaveBeenCalledTimes(3); + const headers = new Headers(fetch.mock.calls[2]?.[1]?.headers); + expect(headers.get('authorization')).toBe('Bearer fixture-managed-token'); + expect(headers.has('api-key')).toBe(false); + }); }); diff --git a/tests/providers/LLMGatewayStreaming.test.ts b/tests/providers/LLMGatewayStreaming.test.ts index 459049e99..beb19d649 100644 --- a/tests/providers/LLMGatewayStreaming.test.ts +++ b/tests/providers/LLMGatewayStreaming.test.ts @@ -8,6 +8,45 @@ const client = () => new LLMGatewayClient({ apiKey: 'fixture', model: 'moa' }, { afterEach(() => { vi.unstubAllGlobals(); vi.restoreAllMocks(); }); describe('cloud inference streaming', () => { + it.each([ + "This message was blocked by Autohand's safety check. Rephrase it and try again.", + { message: "This message was blocked by Autohand's safety check. Rephrase it and try again." }, + ])('withholds a preceding tool batch when a stream ends with a legacy safety verdict: %j', async error => { + const tool = { index: 0, id: 'call-withheld', type: 'function', function: { name: 'run_command', arguments: '{}' } }; + const fetch = vi.fn().mockResolvedValue(new Response( + event({ choices: [{ delta: { tool_calls: [tool] }, finish_reason: 'tool_calls' }] }) + event({ error }), + { headers: { 'content-type': 'text/event-stream', 'x-autohand-request-id': 'legacy-stream-request' } }, + )); + vi.stubGlobal('fetch', fetch); + const cloud = new LLMGatewayClient({ apiKey: 'fixture', model: 'fantail' }, { maxRetries: 2, retryDelay: 1 }, { + serviceName: 'Autohand AI', credentialName: 'Autohand API key', accountName: 'Autohand account', + }, 'autohandai'); + const completion = cloud.complete({ messages: [], stream: true }); + await expect(completion).rejects.toMatchObject({ code: 'content_blocked', retryable: false }); + await expect(completion).rejects.toThrow('Request ID: legacy-stream-request'); + await expect(completion).rejects.not.toThrow(/malformed|network|Check \/usage/); + expect(fetch).toHaveBeenCalledOnce(); + }); + + it.each([ + { error: { message: "This message was blocked by Autohand's safety check. Rephrase it and try again." } }, + { error: "This message was blocked by Autohand's safety check. Rephrase it and try again." }, + { message: "This message was blocked by Autohand's safety check. Rephrase it and try again." }, + ])('keeps legacy HTTP safety rejections distinct from malformed requests: %j', async body => { + const fetch = vi.fn().mockResolvedValue(Response.json(body, { + status: 400, headers: { 'x-autohand-request-id': 'legacy-http-request' }, + })); + vi.stubGlobal('fetch', fetch); + const cloud = new LLMGatewayClient({ apiKey: 'fixture', model: 'fantail' }, { maxRetries: 2, retryDelay: 1 }, { + serviceName: 'Autohand AI', credentialName: 'Autohand API key', accountName: 'Autohand account', + }, 'autohandai'); + const completion = cloud.complete({ messages: [] }); + await expect(completion).rejects.toMatchObject({ code: 'content_blocked', retryable: false }); + await expect(completion).rejects.toThrow('Request ID: legacy-http-request'); + await expect(completion).rejects.not.toThrow(/malformed|network|Check \/usage/); + expect(fetch).toHaveBeenCalledOnce(); + }); + it.each(['', '{"choices":'])('does not replay accepted malformed HTTP JSON and identifies its transport stage: %j', async body => { const fetch = vi.fn().mockImplementation(() => new Response(body, { status: 200, headers: { 'content-type': 'application/json' } })); vi.stubGlobal('fetch', fetch); diff --git a/tests/providers/LlamaCppProvider.test.ts b/tests/providers/LlamaCppProvider.test.ts index 92870bbb9..7b8447516 100644 --- a/tests/providers/LlamaCppProvider.test.ts +++ b/tests/providers/LlamaCppProvider.test.ts @@ -23,6 +23,7 @@ describe('LlamaCppProvider', () => { afterEach(() => { vi.clearAllMocks(); + vi.unstubAllGlobals(); }); it('defaults to local when config model is empty', async () => { @@ -115,6 +116,206 @@ describe('LlamaCppProvider', () => { expect((err as ApiError).httpStatus).toBe(400); }); + it('classifies the reported server context rejection as a context overflow', async () => { + const rawDetail = JSON.stringify({ + error: { + code: 400, + message: 'request (16605 tokens) exceeds the available context size (4096 tokens), try increasing it', + type: 'exceed_context_size_error', + n_prompt_tokens: 16605, + n_ctx: 4096, + }, + }); + const fetchMock = vi.fn().mockResolvedValue(new Response(rawDetail, { status: 400 })); + vi.stubGlobal('fetch', fetchMock); + + const error = await provider.complete({ messages: [{ role: 'user', content: 'hello' }] }) + .catch((caught: unknown) => caught); + + expect(error).toBeInstanceOf(ApiError); + expect(error).toMatchObject({ code: 'context_overflow', retryable: false, rawDetail }); + expect(error).toHaveProperty('message', expect.stringContaining('4096')); + expect(error).toHaveProperty('message', expect.stringContaining('/new')); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it.each(['gemma4:12b', 'hadad/LFM2.5-1.2B:Q4_K_M'])( + 'preserves the requested model when the router has not configured %s', + async (model) => { + const rawDetail = JSON.stringify({ error: { + code: null, + message: `model ${model} is not configured`, + param: null, + type: 'autohand_router_error', + } }); + const fetchMock = vi.fn().mockResolvedValue(new Response(rawDetail, { status: 400 })); + vi.stubGlobal('fetch', fetchMock); + + const error = await provider.complete({ model, messages: [{ role: 'user', content: 'hello' }] }) + .catch((caught: unknown) => caught); + + expect(error).toMatchObject({ code: 'model_not_found', retryable: false, rawDetail }); + expect(error).toHaveProperty('message', expect.stringContaining('/model')); + const options = fetchMock.mock.calls[0]?.[1] as RequestInit; + expect(JSON.parse(String(options.body))).toHaveProperty('model', model); + expect(fetchMock).toHaveBeenCalledTimes(1); + }, + ); + + describe('runtime context discovery', () => { + it('coalesces startup and first-turn discovery and reuses settled metadata', async () => { + const response = Promise.withResolvers(); + const fetchMock = vi.fn(() => response.promise); + vi.stubGlobal('fetch', fetchMock); + const startup = provider.getContextWindow(); + const firstTurn = provider.getContextWindow(); + expect(fetchMock).toHaveBeenCalledOnce(); + response.resolve(Response.json({ default_generation_settings: { n_ctx: 4096 } })); + await expect(Promise.all([startup, firstTurn])).resolves.toEqual([4096, 4096]); + await expect(provider.getContextWindow()).resolves.toBe(4096); + expect(fetchMock).toHaveBeenCalledOnce(); + }); + + it('does not repeatedly probe an unsupported metadata endpoint', async () => { + const fetchMock = vi.fn(async () => new Response(null, { status: 404 })); + vi.stubGlobal('fetch', fetchMock); + await expect(provider.getContextWindow()).resolves.toBeUndefined(); + await expect(provider.getContextWindow()).resolves.toBeUndefined(); + expect(fetchMock).toHaveBeenCalledOnce(); + }); + + it('does not cache an earlier model result over the current model', async () => { + const oldResponse = Promise.withResolvers(); + const fetchMock = vi.fn() + .mockReturnValueOnce(oldResponse.promise) + .mockResolvedValueOnce(Response.json({ default_generation_settings: { n_ctx: 8192 } })); + vi.stubGlobal('fetch', fetchMock); + const old = provider.getContextWindow(); + provider.setModel('new-model'); + await expect(provider.getContextWindow()).resolves.toBe(8192); + oldResponse.resolve(Response.json({ default_generation_settings: { n_ctx: 4096 } })); + await old; + await expect(provider.getContextWindow()).resolves.toBe(8192); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it('refreshes expired context metadata and keeps the last known limit on failure', async () => { + vi.useFakeTimers(); + try { + const baseline = vi.getTimerCount(); + const fetchMock = vi.fn() + .mockResolvedValueOnce(Response.json({ default_generation_settings: { n_ctx: 4096 } })) + .mockRejectedValueOnce(new Error('server restarting')) + .mockResolvedValueOnce(Response.json({ default_generation_settings: { n_ctx: 8192 } })); + vi.stubGlobal('fetch', fetchMock); + await expect(provider.getContextWindow()).resolves.toBe(4096); + vi.advanceTimersByTime(30001); + await expect(provider.getContextWindow()).resolves.toBe(4096); + vi.advanceTimersByTime(30001); + await expect(provider.getContextWindow()).resolves.toBe(8192); + expect(fetchMock).toHaveBeenCalledTimes(3); + expect(vi.getTimerCount()).toBe(baseline); + } finally { + vi.useRealTimers(); + } + }); + + it('uses the active slot context instead of a model training limit or total context', async () => { + const fetchMock = vi.fn().mockResolvedValue(Response.json({ + default_generation_settings: { n_ctx: 4096 }, + total_slots: 4, + n_ctx: 16384, + meta: { n_ctx_train: 131072 }, + })); + vi.stubGlobal('fetch', fetchMock); + + await expect(provider.getContextWindow()).resolves.toBe(4096); + + const [url, options] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(url).toBe('http://localhost:8080/props?model=local&autoload=false'); + expect(options.signal).toBeInstanceOf(AbortSignal); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it('queries the selected router model without loading it', async () => { + provider.setModel('hadad/LFM2.5-1.2B:Q4_K_M'); + const fetchMock = vi.fn().mockResolvedValue(Response.json({ + default_generation_settings: { n_ctx: 8192 }, + })); + vi.stubGlobal('fetch', fetchMock); + + await expect(provider.getContextWindow()).resolves.toBe(8192); + expect(fetchMock).toHaveBeenCalledWith( + 'http://localhost:8080/props?model=hadad%2FLFM2.5-1.2B%3AQ4_K_M&autoload=false', + expect.objectContaining({ signal: expect.any(AbortSignal) }), + ); + }); + + it.each([0, -1, '4096', null, 4096.5])('ignores invalid active context %s', async (n_ctx) => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(Response.json({ + default_generation_settings: { n_ctx }, + meta: { n_ctx_train: 131072 }, + }))); + await expect(provider.getContextWindow()).resolves.toBeUndefined(); + }); + + it('discards unsupported metadata responses without changing the fallback', async () => { + const cancel = vi.fn(); + const body = new ReadableStream({ cancel }); + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(body, { status: 404 }))); + + await expect(provider.getContextWindow()).resolves.toBeUndefined(); + expect(cancel).toHaveBeenCalledOnce(); + }); + + it('keeps metadata discovery optional when the server is unavailable', async () => { + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('ECONNREFUSED'))); + await expect(provider.getContextWindow()).resolves.toBeUndefined(); + }); + + it('does not issue discovery after cancellation', async () => { + const controller = new AbortController(); + controller.abort(); + const fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + + await expect(provider.getContextWindow(controller.signal)).resolves.toBeUndefined(); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('cancels a metadata body that fails while being read', async () => { + const cancel = vi.fn(async () => {}); + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + ok: true, + json: async () => { throw new Error('metadata aborted'); }, + body: { cancel }, + })); + await expect(provider.getContextWindow()).resolves.toBeUndefined(); + expect(cancel).toHaveBeenCalledOnce(); + }); + }); + + describe('metadata response cleanup', () => { + it('cancels an unsupported model-list body before returning fallbacks', async () => { + const cancel = vi.fn(); + const fetchMock = vi.fn().mockResolvedValue(new Response(new ReadableStream({ cancel }), { status: 404 })); + vi.stubGlobal('fetch', fetchMock); + await expect(provider.listModels()).resolves.toEqual(['local']); + expect(cancel).toHaveBeenCalledOnce(); + expect(fetchMock).toHaveBeenCalledWith( + 'http://localhost:8080/v1/models', + expect.objectContaining({ signal: expect.any(AbortSignal) }), + ); + }); + + it.each([200, 503])('cancels an unused health response body for status %s', async (status) => { + const cancel = vi.fn(); + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(new ReadableStream({ cancel }), { status }))); + await expect(provider.isAvailable()).resolves.toBe(status === 200); + expect(cancel).toHaveBeenCalledOnce(); + }); + }); + describe('finish reason normalization', () => { const mockJson = (body: unknown) => { diff --git a/tests/providers/OpenAIProvider.test.ts b/tests/providers/OpenAIProvider.test.ts index 1e77694f5..dd84e4858 100644 --- a/tests/providers/OpenAIProvider.test.ts +++ b/tests/providers/OpenAIProvider.test.ts @@ -5,10 +5,12 @@ */ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { createServer } from 'node:http'; +import { once } from 'node:events'; import { OpenAIProvider } from '../../src/providers/OpenAIProvider.js'; import { ApiError } from '../../src/providers/errors.js'; import { UNANSWERED_TOOL_CALL_PLACEHOLDER } from '../../src/providers/messagePayload.js'; -import type { MultimodalMessage } from '../../src/types.js'; +import type { FunctionDefinition, MultimodalMessage } from '../../src/types.js'; /** * Build a mock SSE response body from a `response.completed` payload. @@ -99,7 +101,7 @@ describe('OpenAIProvider', () => { expect(sentBody.prompt_cache_key).toBeUndefined(); }); - it.each(['gpt-5.4', 'gpt-5.4-2026-03-05', 'gpt-5.6-sol'])('disables Chat Completions reasoning for %s tool calls', async (model) => { + it.each(['gpt-5.4', 'gpt-5.4-2026-03-05', 'gpt-5.6-sol'])('preserves configured reasoning for custom %s tool calls', async (model) => { const gpt56Provider = new OpenAIProvider({ baseUrl: 'http://localhost:9999', apiKey: 'test-key', @@ -130,7 +132,7 @@ describe('OpenAIProvider', () => { reasoning_effort?: string; tools?: unknown[]; }; - expect(sentBody.reasoning_effort).toBe('none'); + expect(sentBody.reasoning_effort).toBe('high'); expect(sentBody.tools).toHaveLength(1); }); @@ -1604,6 +1606,306 @@ describe('OpenAIProvider', () => { }); }); + describe('API-key reasoning compatibility', () => { + const tools: FunctionDefinition[] = [{ + name: 'read_file', + description: 'Read a file', + parameters: { + type: 'object', + properties: { path: { type: 'string', description: 'File path' } }, + }, + }]; + const responsePayload = { + id: 'resp-api-key', + created_at: 123, + output: [{ type: 'message', role: 'assistant', content: [{ type: 'output_text', text: 'OK' }] }], + usage: { input_tokens: 25, output_tokens: 7, total_tokens: 32 }, + }; + const rejected = (message: string, status = 400) => new Response(JSON.stringify({ error: { message } }), { + status, + headers: { 'Content-Type': 'application/json' }, + }); + const chatResponse = () => new Response(JSON.stringify({ + id: 'chat-compatible', created: 1, + choices: [{ message: { role: 'assistant', content: 'OK' }, finish_reason: 'stop' }], + }), { headers: { 'Content-Type': 'application/json' } }); + + it('normalizes instruction text parts and keeps volatile context at the request tail', async () => { + const official = new OpenAIProvider({ apiKey: 'test-key', model: 'gpt-6-astra' }); + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(wrappedResponsesSseResponse(responsePayload)); + const messages: MultimodalMessage[] = [ + { role: 'system', content: [{ type: 'text', text: 'STABLE_SYSTEM_TEXT_PART' }] }, + { role: 'user', content: 'Inspect' }, + { role: 'system', content: 'LATE_SYSTEM_NOTE' }, + { role: 'system', runtimeContextKey: 'workspace', content: 'VOLATILE_RUNTIME_CONTEXT' }, + { role: 'user', content: 'Continue' }, + ]; + const original = structuredClone(messages); + await official.complete({ messages, tools }); + const body = JSON.parse(String(fetchSpy.mock.calls[0][1]?.body)); + expect(body.instructions).toContain('STABLE_SYSTEM_TEXT_PART'); + expect(body.instructions).not.toContain('LATE_SYSTEM_NOTE'); + expect(body.instructions).not.toContain('VOLATILE_RUNTIME_CONTEXT'); + expect(body.input).toEqual([ + { type: 'message', role: 'user', content: [{ type: 'input_text', text: 'Inspect' }] }, + { type: 'message', role: 'user', content: [{ type: 'input_text', text: '\nLATE_SYSTEM_NOTE\n' }] }, + { type: 'message', role: 'user', content: [{ type: 'input_text', text: 'Continue' }] }, + { type: 'message', role: 'user', content: [{ type: 'input_text', text: '\nVOLATILE_RUNTIME_CONTEXT\n' }] }, + ]); + expect(messages).toEqual(original); + }); + + it('reads plaintext capability errors once before the same-host Responses fallback', async () => { + const rejection = new Response('Function calling is not supported here. Use the Responses API.', { status: 400 }); + const textRead = vi.spyOn(rejection, 'text'); + const jsonRead = vi.spyOn(rejection, 'json'); + const fetchSpy = vi.spyOn(globalThis, 'fetch') + .mockResolvedValueOnce(rejection) + .mockResolvedValueOnce(wrappedResponsesSseResponse(responsePayload)); + await expect(provider.complete({ messages: [{ role: 'user', content: 'Inspect' }], tools })).resolves.toMatchObject({ content: 'OK' }); + expect(fetchSpy.mock.calls.map(call => call[0])).toEqual(['http://localhost:9999/chat/completions', 'http://localhost:9999/responses']); + expect(textRead).toHaveBeenCalledOnce(); + expect(jsonRead).not.toHaveBeenCalled(); + }); + + it('retains a plaintext invalid-request detail without retrying it', async () => { + const detail = 'The configured gateway does not support this request shape.'; + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(new Response(detail, { status: 400 })); + await expect(provider.complete({ messages: [{ role: 'user', content: 'Inspect' }], tools })) + .rejects.toMatchObject({ code: 'invalid_request', rawDetail: detail }); + expect(fetchSpy).toHaveBeenCalledOnce(); + }); + + it.each([200, 400])('reports structured cancellation while reading a status %i body after headers', async (status) => { + const originalFetch = globalThis.fetch; + const server = createServer((request, response) => { + request.resume(); + response.writeHead(status, { 'content-type': status === 200 ? 'text/event-stream' : 'application/json' }); + response.flushHeaders(); + response.write(status === 200 ? 'event: response.created\ndata: {"id":"fixture"}\n\n' : '{"error":'); + }); + server.listen(0, '127.0.0.1'); + await once(server, 'listening'); + const address = server.address(); + if (!address || typeof address === 'string') throw new Error('Missing local fixture port'); + let headersArrived: () => void = () => {}; + const ready = new Promise(resolve => { headersArrived = resolve; }); + const controller = new AbortController(); + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation(async (url, init) => { + expect(String(url)).toBe('https://api.openai.com/v1/responses'); + const response = await originalFetch(`http://127.0.0.1:${address.port}/responses`, init); + headersArrived(); + return response; + }); + try { + const official = new OpenAIProvider({ apiKey: 'test-key', model: 'gpt-6-astra' }); + const outcome = official.complete({ messages: [{ role: 'user', content: 'Inspect' }], tools, signal: controller.signal }) + .then(value => ({ value }), error => ({ error })); + await ready; + controller.abort(); + await expect(outcome).resolves.toMatchObject({ error: { name: 'ApiError', code: 'cancelled', retryable: false } }); + expect(fetchSpy).toHaveBeenCalledOnce(); + } finally { + controller.abort(); + server.closeAllConnections(); + await new Promise(resolve => { server.close(() => resolve()); }); + } + expect(server.listening).toBe(false); + }); + + it.each([ + { reason: 'max_output_tokens', status: 'incomplete' }, + { reason: 'max_output_tokens', status: 'completed' }, + { reason: undefined, status: 'in_progress' }, + ])('does not expose unfinished function calls when output is $reason / $status', async ({ reason, status }) => { + const official = new OpenAIProvider({ apiKey: 'test-key', model: 'gpt-6-astra' }); + const event = reason ? 'response.incomplete' : 'response.completed'; + vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(new Response( + `event: ${event}\ndata: ${JSON.stringify({ type: event, response: { + ...responsePayload, + ...(reason ? { incomplete_details: { reason } } : {}), + output: [{ type: 'function_call', call_id: 'unfinished_call', status, name: 'read_file', arguments: '{"path":"package.json"}' }], + } })}\n\n`, + { headers: { 'content-type': 'text/event-stream' } }, + )); + const result = await official.complete({ messages: [{ role: 'user', content: 'Inspect' }], tools, maxTokens: 16 }); + expect(result.finishReason).toBe('length'); + expect(result.toolCalls).toEqual([]); + }); + + it.each(['gpt-5.4', 'gpt-5.5', 'gpt-5.6-terra', 'gpt-6-astra', 'gpt-6-sol', 'gpt-6.1-sol', 'gpt-6.1-terra', 'gpt-6-luna'])( + 'uses Responses for official %s tool requests without lowering reasoning', async (model) => { + const official = new OpenAIProvider({ apiKey: 'test-key', model, reasoningEffort: 'high' }); + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation(async (url) => ( + String(url).endsWith('/responses') + ? wrappedResponsesSseResponse(responsePayload) + : rejected(`Function tools with reasoning_effort are not supported for ${model} in /v1/chat/completions. Use /v1/responses.`) + )); + const result = await official.complete({ + messages: [{ role: 'user', content: 'Read package.json' }], + tools, + toolChoice: { type: 'function', function: { name: 'read_file' } }, + maxTokens: 256, + temperature: 0.2, + }); + expect(result.content).toBe('OK'); + expect(fetchSpy).toHaveBeenCalledTimes(1); + expect(fetchSpy.mock.calls[0][0]).toBe('https://api.openai.com/v1/responses'); + const body = JSON.parse(String(fetchSpy.mock.calls[0][1]?.body)); + expect(body).toMatchObject({ + model, max_output_tokens: 256, store: false, + reasoning: { effort: 'high' }, + tool_choice: { type: 'function', name: 'read_file' }, + tools: [{ type: 'function', name: 'read_file', strict: false }], + }); + expect(body.temperature).toBeUndefined(); + expect(body.max_tokens).toBeUndefined(); + expect(body.max_completion_tokens).toBeUndefined(); + }, + ); + + it('replays encrypted reasoning and tool outputs with multimodal input on the next Responses turn', async () => { + const official = new OpenAIProvider({ apiKey: 'test-key', model: 'gpt-6-astra', reasoningEffort: 'high' }); + const reasoning = { type: 'reasoning', id: 'rs_1', summary: [], encrypted_content: 'opaque-signed-reasoning' }; + const fetchSpy = vi.spyOn(globalThis, 'fetch') + .mockResolvedValueOnce(wrappedResponsesSseResponse({ + ...responsePayload, + output: [reasoning, { type: 'function_call', call_id: 'call_1', name: 'read_file', arguments: '{"path":"package.json"}' }], + })) + .mockResolvedValueOnce(wrappedResponsesSseResponse(responsePayload)); + const controller = new AbortController(); + const first = await official.complete({ messages: [{ role: 'user', content: 'Inspect' }], tools, signal: controller.signal }); + expect(first.reasoningBlocks).toEqual([reasoning]); + const messages: MultimodalMessage[] = [ + { role: 'user', content: 'Inspect' }, + { role: 'assistant', content: first.content, tool_calls: first.toolCalls, reasoningBlocks: first.reasoningBlocks }, + { role: 'tool', tool_call_id: 'call_1', content: 'file contents' }, + { role: 'user', content: [{ type: 'text', text: 'Compare the image' }, { type: 'image_url', image_url: { url: 'data:image/png;base64,AAAA' } }] }, + ]; + const original = structuredClone(messages); + const second = await official.complete({ messages, tools, signal: controller.signal }); + expect(second.usage).toMatchObject({ promptTokens: 25, completionTokens: 7, totalTokens: 32 }); + const body = JSON.parse(String(fetchSpy.mock.calls[1][1]?.body)); + expect(body.input).toContainEqual(reasoning); + expect(body.input).toContainEqual({ type: 'function_call', call_id: 'call_1', name: 'read_file', arguments: '{"path":"package.json"}' }); + expect(body.input).toContainEqual({ type: 'function_call_output', call_id: 'call_1', output: 'file contents' }); + expect(body.input).toContainEqual({ type: 'message', role: 'user', content: [ + { type: 'input_text', text: 'Compare the image' }, { type: 'input_image', image_url: 'data:image/png;base64,AAAA' }, + ] }); + expect(fetchSpy.mock.calls[1][1]?.signal).toBe(controller.signal); + expect(messages).toEqual(original); + }); + + it('omits foreign reasoning blocks and malformed calls from Responses history', async () => { + const official = new OpenAIProvider({ apiKey: 'test-key', model: 'gpt-6-astra', reasoningEffort: 'high' }); + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(wrappedResponsesSseResponse(responsePayload)); + await official.complete({ tools, messages: [ + { role: 'user', content: 'Inspect' }, + { role: 'assistant', content: '', reasoningBlocks: [{ type: 'thinking', thinking: 'foreign private thought', signature: 'anthropic-signature' }], tool_calls: [ + { id: 'bad_call', type: 'function', function: { name: 'read_file', arguments: '{"path":"unfinished' } }, + ] }, + { role: 'tool', tool_call_id: 'bad_call', content: 'Previously observed output' }, + ] }); + const body = JSON.parse(String(fetchSpy.mock.calls[0][1]?.body)); + expect(body.input.some((item: { type: string }) => item.type === 'function_call')).toBe(false); + expect(JSON.stringify(body.input)).not.toContain('anthropic-signature'); + expect(JSON.stringify(body.input)).toContain('Previously observed output'); + }); + + it('preserves valid zero sampling with explicitly disabled reasoning', async () => { + const official = new OpenAIProvider({ apiKey: 'test-key', model: 'gpt-6-sol', reasoningEffort: 'none' }); + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(wrappedResponsesSseResponse(responsePayload)); + await official.complete({ messages: [{ role: 'user', content: 'Inspect' }], tools, temperature: 0 }); + const body = JSON.parse(String(fetchSpy.mock.calls[0][1]?.body)); + expect(body.reasoning).toEqual({ effort: 'none' }); + expect(body.temperature).toBe(0); + }); + + it('omits unsupported sampling for GPT-6 Astra text-only requests (#746)', async () => { + const official = new OpenAIProvider({ apiKey: 'test-key', model: 'gpt-6-astra' }); + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation(async (_url, init) => { + const body = JSON.parse(String(init?.body)); + return body.temperature === undefined ? chatResponse() : rejected("Unsupported value: 'temperature' does not support 0.2 with this model. Only the default (1) value is supported."); + }); + await expect(official.complete({ messages: [{ role: 'user', content: 'Hello' }], temperature: 0.2 })).resolves.toMatchObject({ content: 'OK' }); + expect(JSON.parse(String(fetchSpy.mock.calls[0][1]?.body)).temperature).toBeUndefined(); + }); + + it('preserves an explicit zero sampling temperature for a legacy model', async () => { + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(chatResponse()); + await provider.complete({ messages: [{ role: 'user', content: 'Hello' }], temperature: 0 }); + expect(JSON.parse(String(fetchSpy.mock.calls[0][1]?.body)).temperature).toBe(0); + }); + + it('keeps custom gateways on Chat Completions until they explicitly require Responses', async () => { + const gateway = new OpenAIProvider({ baseUrl: 'https://gateway.example/v1', apiKey: 'gateway-key', model: 'gpt-6-astra', reasoningEffort: 'high' }); + const fetchSpy = vi.spyOn(globalThis, 'fetch') + .mockResolvedValueOnce(rejected('Function tools with reasoning_effort are not supported for gpt-6-astra in /v1/chat/completions. To use function tools, use /v1/responses.')) + .mockResolvedValueOnce(wrappedResponsesSseResponse(responsePayload)); + await expect(gateway.complete({ messages: [{ role: 'user', content: 'Inspect' }], tools, maxTokens: 128 })).resolves.toMatchObject({ content: 'OK' }); + expect(fetchSpy.mock.calls.map(call => call[0])).toEqual(['https://gateway.example/v1/chat/completions', 'https://gateway.example/v1/responses']); + const body = JSON.parse(String(fetchSpy.mock.calls[1][1]?.body)); + expect(body.reasoning).toEqual({ effort: 'high' }); + expect(body.max_output_tokens).toBe(128); + expect(fetchSpy.mock.calls[1][1]?.headers).toMatchObject({ Authorization: 'Bearer gateway-key' }); + }); + + it('does not repeat or redirect a failed custom Responses fallback', async () => { + const fetchSpy = vi.spyOn(globalThis, 'fetch') + .mockResolvedValueOnce(rejected('Function calling is not supported by Chat Completions. Use the Responses API.')) + .mockResolvedValueOnce(rejected('Responses endpoint unavailable', 404)); + await expect(provider.complete({ messages: [{ role: 'user', content: 'Inspect' }], tools })).rejects.toBeInstanceOf(ApiError); + expect(fetchSpy.mock.calls.map(call => call[0])).toEqual(['http://localhost:9999/chat/completions', 'http://localhost:9999/responses']); + }); + + it.each([400, 401, 403])('does not switch endpoints for an unrelated status %i rejection', async (status) => { + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(rejected('Invalid request mentions /v1/responses in user data', status)); + await expect(provider.complete({ messages: [{ role: 'user', content: 'Inspect' }], tools })).rejects.toBeInstanceOf(ApiError); + expect(fetchSpy).toHaveBeenCalledTimes(1); + }); + + it('honors cancellation before attempting a compatibility fallback', async () => { + const controller = new AbortController(); + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementationOnce(async () => { + controller.abort(); + return rejected('Function tools with reasoning_effort are not supported in /v1/chat/completions. Use /v1/responses.'); + }); + await expect(provider.complete({ messages: [{ role: 'user', content: 'Inspect' }], tools, signal: controller.signal })).rejects.toMatchObject({ code: 'cancelled' }); + expect(fetchSpy).toHaveBeenCalledTimes(1); + }); + + it('retries an unknown model token parameter once without mutating the request', async () => { + const fetchSpy = vi.spyOn(globalThis, 'fetch') + .mockResolvedValueOnce(rejected("Unsupported parameter: 'max_tokens' is not supported with this model. Use 'max_completion_tokens' instead.")) + .mockResolvedValueOnce(chatResponse()); + const request = { model: 'vendor-next', maxTokens: 256, messages: [{ role: 'user' as const, content: 'Hello' }] }; + const original = structuredClone(request); + await expect(provider.complete(request)).resolves.toMatchObject({ content: 'OK' }); + expect(fetchSpy).toHaveBeenCalledTimes(2); + const body = JSON.parse(String(fetchSpy.mock.calls[1][1]?.body)); + expect(body.max_completion_tokens).toBe(256); + expect(body.max_tokens).toBeUndefined(); + expect(request).toEqual(original); + }); + + it('stops after a repeated token rejection and never retries an absent token field', async () => { + const message = "Unsupported parameter: 'max_tokens' is not supported with this model. Use 'max_completion_tokens' instead."; + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation(async () => rejected(message)); + await expect(provider.complete({ model: 'vendor-next', maxTokens: 256, messages: [{ role: 'user', content: 'Hello' }] })).rejects.toMatchObject({ code: 'invalid_request' }); + expect(fetchSpy).toHaveBeenCalledTimes(2); + fetchSpy.mockClear(); + await expect(provider.complete({ model: 'vendor-next', messages: [{ role: 'user', content: 'Hello' }] })).rejects.toMatchObject({ code: 'invalid_request' }); + expect(fetchSpy).toHaveBeenCalledTimes(1); + }); + + it('rejects unsupported none reasoning on Astra before sending a billable request', async () => { + const official = new OpenAIProvider({ apiKey: 'test-key', model: 'gpt-6-astra', reasoningEffort: 'none' }); + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation(async () => rejected('Unexpected request', 400)); + await expect(official.complete({ messages: [{ role: 'user', content: 'Inspect' }], tools })).rejects.toMatchObject({ code: 'invalid_request', retryable: false }); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + }); + describe('Chat Completions finish reason normalization', () => { const mockJson = (body: unknown) => vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce( new Response(JSON.stringify(body), { status: 200, headers: { 'Content-Type': 'application/json' } }), diff --git a/tests/providers/OpenRouterDeadline.integration.test.ts b/tests/providers/OpenRouterDeadline.integration.test.ts new file mode 100644 index 000000000..8e4116cc3 --- /dev/null +++ b/tests/providers/OpenRouterDeadline.integration.test.ts @@ -0,0 +1,67 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { OpenRouterProvider } from '../../src/providers/OpenRouterProvider.js'; +import { createOpenRouterDeadlineFixture } from '../../src/testing/scenarios/openRouterDeadlineScenario.js'; + +describe('OpenRouter response deadlines', () => { + afterEach(() => { vi.restoreAllMocks(); vi.useRealTimers(); }); + + it.each(['headers', 'body', 'error-body', 'sse'] as const)( + 'bounds a stalled %s response and releases its socket', async mode => { + const fixture = await createOpenRouterDeadlineFixture(mode); + const provider = new OpenRouterProvider( + { apiKey: 'fixture-key', model: 'openai/gpt-4o-mini', baseUrl: fixture.baseUrl }, + { timeout: 200, maxRetries: 0 }, + ); + let headersReceived = false; + const realFetch = globalThis.fetch; + vi.spyOn(globalThis, 'fetch').mockImplementation(async (...args) => { + const response = await realFetch(...args); + headersReceived = true; + return response; + }); + try { + await expect(provider.complete({ + messages: [{ role: 'user', content: 'Reply OK' }], + signal: AbortSignal.timeout(1500), + })).rejects.toMatchObject({ code: 'timeout', retryable: true, message: expect.stringContaining('timed out') }); + if (mode !== 'headers') { + expect(fixture.committedResponses()).toBe(1); + expect(headersReceived).toBe(true); + } + await expect.poll(fixture.activeCompletions, { timeout: 1000 }).toBe(0); + } finally { + await fixture.close(); + } + }, + ); + + it('preserves caller cancellation after response headers', async () => { + const fixture = await createOpenRouterDeadlineFixture('body'); + const controller = new AbortController(); + const provider = new OpenRouterProvider( + { model: 'openai/gpt-4o-mini', baseUrl: fixture.baseUrl }, { timeout: 3000, maxRetries: 0 }, + ); + try { + const completion = provider.complete({ messages: [{ role: 'user', content: 'hello' }], signal: controller.signal }); + const result = expect(completion).rejects.toMatchObject({ code: 'cancelled', retryable: false }); + await expect.poll(fixture.committedResponses).toBe(1); + controller.abort(); + await result; + await expect.poll(fixture.activeCompletions).toBe(0); + } finally { + controller.abort(); + await fixture.close(); + } + }); + + it('returns normal completion and does not retain JavaScript timers', async () => { + vi.useFakeTimers(); + const baseline = vi.getTimerCount(); + vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(new Response(JSON.stringify({ + choices: [{ message: { content: 'ok' }, finish_reason: 'stop' }], + }))); + const provider = new OpenRouterProvider({ model: 'openai/gpt-4o-mini' }, { timeout: 200, maxRetries: 0 }); + await expect(provider.complete({ messages: [{ role: 'user', content: 'hello' }] })).resolves.toMatchObject({ content: 'ok' }); + expect(vi.getTimerCount()).toBe(baseline); + }); +}); diff --git a/tests/providers/ProviderFactory.test.ts b/tests/providers/ProviderFactory.test.ts index e90b47686..0de0daf87 100644 --- a/tests/providers/ProviderFactory.test.ts +++ b/tests/providers/ProviderFactory.test.ts @@ -12,6 +12,7 @@ vi.mock("../../src/utils/platform", () => ({ })); import { ProviderFactory } from "../../src/providers/ProviderFactory"; +import { createOpenRouterDeadlineFixture } from '../../src/testing/scenarios/openRouterDeadlineScenario.js'; describe("ProviderFactory", () => { afterEach(() => { @@ -80,6 +81,23 @@ describe("ProviderFactory", () => { }); describe("create()", () => { + it('forwards the configured OpenRouter deadline and retry limit', async () => { + const fixture = await createOpenRouterDeadlineFixture('body'); + const provider = ProviderFactory.create({ + provider: 'openrouter', + openrouter: { model: 'openai/gpt-4o-mini', baseUrl: fixture.baseUrl }, + network: { timeout: 200, maxRetries: 0 }, + }); + try { + await expect(provider.complete({ + messages: [{ role: 'user', content: 'hello' }], signal: AbortSignal.timeout(1500), + })).rejects.toMatchObject({ code: 'timeout', retryable: true }); + expect(fixture.committedResponses()).toBe(1); + } finally { + await fixture.close(); + } + }); + it("should create OllamaProvider when ollama is configured", () => { const config: AutohandConfig = { provider: "ollama", diff --git a/tests/providers/apiErrors.test.ts b/tests/providers/apiErrors.test.ts index 25539f887..353ebf36a 100644 --- a/tests/providers/apiErrors.test.ts +++ b/tests/providers/apiErrors.test.ts @@ -8,13 +8,59 @@ import { describe, it, expect } from "vitest"; import { classifyApiError, readContentBlockedError, + readStorageCapacityError, ApiError, FRIENDLY_MESSAGES, type ApiErrorCode, } from "../../src/providers/errors.js"; describe("classifyApiError", () => { + describe('reported local capacity failures', () => { + it.each(['ENOSPC: no space left on device, write', 'EDQUOT: disk quota exceeded, open'])('does not retry %s', message => { + const error = classifyApiError(0, message); + expect(error).toMatchObject({ code: 'storage_full', httpStatus: 0, retryable: false }); + expect(error.message).toContain('Free disk space before continuing'); + expect(error.message).toContain('may not have been saved'); + expect(error.message).not.toContain('malformed'); + }); + + it('does not classify remote HTTP errors or incidental ENOSPC mentions as local disk failures', () => { + expect(classifyApiError(503, 'ENOSPC: provider storage unavailable').code).toBe('server_error'); + expect(classifyApiError(400, 'The field ENOSPC is invalid').code).toBe('invalid_request'); + expect(classifyApiError(0, 'The documentation mentions ENOSPC').code).toBe('unknown'); + }); + }); + + describe('reported llama.cpp router rejections', () => { + it.each([0, 400])('recognizes the available context rejection at status %s', status => { + const detail = 'request (16605 tokens) exceeds the available context size (4096 tokens), try increasing it'; + expect(classifyApiError(status, detail)).toMatchObject({ code: 'context_overflow', retryable: false }); + expect(classifyApiError(status, '{"error":{"type":"exceed_context_size_error"}}').code).toBe('context_overflow'); + }); + + it.each(['gemma4:12b', 'hadad/LFM2.5-1.2B:Q4_K_M'])('recognizes the unconfigured router model %s', model => { + expect(classifyApiError(400, `model ${model} is not configured`)).toMatchObject({ code: 'model_not_found', retryable: false }); + const body = JSON.stringify({ error: { type: 'autohand_router_error', message: `model ${model} is not configured` } }); + expect(classifyApiError(400, body)).toMatchObject({ code: 'model_not_found', retryable: false, rawDetail: body }); + }); + + it('keeps unrelated model configuration failures as invalid requests', () => { + expect(classifyApiError(400, 'model configuration requires context metadata').code).toBe('invalid_request'); + expect(classifyApiError(400, 'model gemma4:12b is not configured to enable images').code).toBe('invalid_request'); + expect(classifyApiError(401, 'model gemma4:12b is not configured').code).toBe('auth_failed'); + expect(classifyApiError(400, JSON.stringify({ error: { type: 'other', message: 'model gemma4:12b is not configured' } })).code).toBe('invalid_request'); + }); + }); + describe('reported provider rejections', () => { + it('stops replay for the exact legacy managed-identity pre-request failure', () => { + const detail = 'Managed Identity token acquisition failed. This auth method only works inside Azure VMs, App Service, or containers with managed identity enabled.'; + expect(classifyApiError(0, detail)).toMatchObject({ code: 'auth_failed', retryable: false }); + expect(classifyApiError(0, detail).message).not.toContain('verify your API key'); + expect(classifyApiError(0, 'The documentation describes Managed Identity token acquisition').code).toBe('unknown'); + expect(classifyApiError(503, detail).code).toBe('server_error'); + }); + it('recognizes an incorrect API key returned with HTTP 400', () => { const error = classifyApiError(400, 'Incorrect API key provided. You can obtain an API key from https://console.x.ai.'); expect(error).toMatchObject({ code: 'auth_failed', httpStatus: 400, retryable: false }); @@ -553,6 +599,8 @@ describe("classifyApiError", () => { describe("FRIENDLY_MESSAGES", () => { it("has a message for every ApiErrorCode", () => { const codes: ApiErrorCode[] = [ + "storage_full", + "content_blocked", "context_overflow", "model_not_found", "invalid_request", @@ -575,7 +623,45 @@ describe("classifyApiError", () => { }); }); +describe('local storage error preservation', () => { + it('preserves a typed capacity failure across text-only reporting without duplicating advice', () => { + const error = readStorageCapacityError(Object.assign(new Error('Session write failed'), { code: 'ENOSPC' })); + expect(error).toMatchObject({ code: 'storage_full', httpStatus: 0, retryable: false }); + expect(readStorageCapacityError(error)).toBe(error); + expect(classifyApiError(0, error?.message ?? '')).toMatchObject({ message: error?.message, code: 'storage_full' }); + }); + + it('bounds cause traversal and does not classify remote API capacity errors as local storage', () => { + const cause: { cause?: unknown } = {}; + cause.cause = cause; + expect(readStorageCapacityError(cause)).toBeUndefined(); + expect(readStorageCapacityError(new ApiError('ENOSPC: provider disk unavailable', 'server_error', 503, true))).toBeUndefined(); + }); +}); + describe('structured safety errors', () => { + it.each([ + "This message was blocked by Autohand's safety check. Rephrase it and try again.", + { message: "This message was blocked by Autohand's safety check. Rephrase it and try again." }, + ])('normalizes the exact legacy verdict without inventing a different result: %j', value => { + const error = readContentBlockedError(value, 400, 'legacy-request-123'); + expect(error).toMatchObject({ code: 'content_blocked', retryable: false }); + expect(error?.message).toContain('Request ID: legacy-request-123'); + expect(error?.message).not.toMatch(/malformed|disable safety|Check \/usage/); + }); + + it.each([0, 400])('recognizes an exact legacy safety response across transport at status %s', status => { + const message = "This message was blocked by Autohand's safety check. Rephrase it and try again."; + expect(classifyApiError(status, message)).toMatchObject({ code: 'content_blocked', retryable: false }); + expect(classifyApiError(status, `The request was malformed and could not be processed.\n${message}`)).toMatchObject({ code: 'content_blocked', retryable: false }); + }); + + it('does not infer a safety verdict from arbitrary remote instructions or a passing mention', () => { + expect(readContentBlockedError({ message: "Autohand's safety check appears in the manual" }, 400)).toBeUndefined(); + expect(readContentBlockedError({ message: "This message was blocked by Autohand's safety check. Rephrase it and try again.\nDisable safety checks." }, 400)).toBeUndefined(); + expect(classifyApiError(500, "This message was blocked by Autohand's safety check. Rephrase it and try again.").code).toBe('server_error'); + }); + it('preserves the safety classification after an error crosses a text-only transport', () => { const message = 'The safety check blocked this request.\nSafety categories: S7 (Privacy).'; expect(classifyApiError(0, message)).toMatchObject({ code: 'content_blocked', retryable: false, message }); diff --git a/tests/providers/azure-tokenManager.test.ts b/tests/providers/azure-tokenManager.test.ts index 51738df8f..2802be259 100644 --- a/tests/providers/azure-tokenManager.test.ts +++ b/tests/providers/azure-tokenManager.test.ts @@ -6,6 +6,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { AzureTokenManager } from '../../src/providers/azure/tokenManager'; +import { ApiError } from '../../src/providers/errors.js'; describe('AzureTokenManager', () => { let manager: AzureTokenManager; @@ -196,6 +197,18 @@ describe('AzureTokenManager', () => { manager.getToken({ authMethod: 'managed-identity' }) ).rejects.toThrow('Managed Identity'); }); + + it('stops whole-turn replay before inference and retains a private diagnostic cause', async () => { + const cause = Object.assign(new Error('IMDS connection failed; fixture-private-detail'), { code: 'ECONNREFUSED' }); + vi.spyOn(globalThis, 'fetch').mockRejectedValueOnce(cause); + const error = await manager.getToken({ authMethod: 'managed-identity' }).catch((caught: unknown) => caught); + expect(error).toBeInstanceOf(ApiError); + expect(error).toMatchObject({ code: 'auth_failed', httpStatus: 0, retryable: false, cause }); + expect(error).toHaveProperty('message', expect.stringContaining('before a model request')); + expect(String(error)).not.toContain('fixture-private-detail'); + expect(JSON.stringify(error)).not.toContain('fixture-private-detail'); + expect(fetch).toHaveBeenCalledTimes(1); + }); }); describe('getAuthHeaders', () => { diff --git a/tests/providers/messagePayload.test.ts b/tests/providers/messagePayload.test.ts new file mode 100644 index 000000000..a321e940c --- /dev/null +++ b/tests/providers/messagePayload.test.ts @@ -0,0 +1,79 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { normalizeOutboundMessages, normalizeToolCallHistory } from '../../src/providers/messagePayload.js'; +import { CustomOpenAICompatibleProvider } from '../../src/providers/CustomOpenAICompatibleProvider.js'; +import type { LLMToolCall, MultimodalMessage } from '../../src/types.js'; + +afterEach(() => { vi.restoreAllMocks(); }); + +function call(id: string, args: string): LLMToolCall { + return { id, type: 'function', function: { name: 'read_file', arguments: args } }; +} + +function damagedHistory(args: string): MultimodalMessage[] { + return [ + { role: 'user', content: 'Inspect the files' }, + { role: 'assistant', content: '', tool_calls: [call('damaged-call', args)] }, + { role: 'tool', tool_call_id: 'damaged-call', content: 'The tool arguments were invalid; no action ran.' }, + { role: 'user', content: 'Continue using the valid observations' }, + ]; +} + +describe('malformed native tool history', () => { + it('preserves opaque reasoning blocks and does not reserialize valid arguments', () => { + const valid: MultimodalMessage = { + role: 'assistant', content: '', tool_calls: [call('valid', '{ "path": "package.json" }')], + reasoningBlocks: [{ type: 'reasoning', id: 'opaque-reasoning', encrypted_content: 'opaque-payload' }], + }; + expect(normalizeToolCallHistory([valid])[0]).toBe(valid); + const invalid: MultimodalMessage = { ...valid, tool_calls: [call('invalid', '{"path":"unfinished')] }; + const normalized = normalizeToolCallHistory([invalid]); + expect(normalized[0]?.reasoningBlocks).toBe(valid.reasoningBlocks); + expect(normalized[0]?.tool_calls).toBeUndefined(); + expect(normalizeToolCallHistory(normalized)).toEqual(normalized); + }); + + it.each(['{"path":"unfinished', '', 'null', '[]', '"string"'])( + 'does not replay %j as a native function call or invent executable arguments', args => { + const history = damagedHistory(args); + const before = structuredClone(history); + const payload = normalizeOutboundMessages(history); + expect(payload.some(message => message.tool_calls !== undefined)).toBe(false); + expect(payload.some(message => message.role === 'tool')).toBe(false); + expect(JSON.stringify(payload)).toContain('invalid'); + expect(JSON.stringify(payload)).toContain('no action ran'); + expect(history).toEqual(before); + }, + ); + + it('keeps valid call arguments exactly and places recovered observations after native results', () => { + const validArguments = '{ "path": "package.json" }'; + const history: MultimodalMessage[] = [ + { role: 'user', content: 'Inspect the files' }, + { role: 'assistant', content: 'Inspecting', tool_calls: [ + call('valid-call', validArguments), call('invalid-call', '{"path":"unfinished'), + ] }, + { role: 'tool', tool_call_id: 'invalid-call', content: 'Invalid arguments were rejected.' }, + { role: 'tool', tool_call_id: 'valid-call', content: 'Existing package information' }, + { role: 'user', content: 'Continue' }, + ]; + const payload = normalizeOutboundMessages(history); + expect(payload.map(message => message.role)).toEqual(['user', 'assistant', 'tool', 'user', 'user']); + expect(payload[1]?.tool_calls).toEqual([call('valid-call', validArguments)]); + expect(payload[2]).toMatchObject({ role: 'tool', tool_call_id: 'valid-call' }); + expect(payload[3]?.content).toContain('Invalid arguments were rejected.'); + }); + + it('repairs invalid argument history at the actual custom-provider request boundary', async () => { + const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(new Response(JSON.stringify({ + choices: [{ message: { content: 'Recovered' }, finish_reason: 'stop' }], + }))); + const provider = new CustomOpenAICompatibleProvider({ + id: 'history-fixture', displayName: 'History Fixture', apiFormat: 'openai-compatible', + baseUrl: 'https://fixture.invalid/v1', model: 'fixture-model', apiKeyRequired: false, + }, { maxRetries: 0 }); + await provider.complete({ messages: damagedHistory('{"path":"unterminated') }); + const payload = JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body)) as { messages: Array> }; + expect(payload.messages.some(message => message.tool_calls !== undefined)).toBe(false); + expect(JSON.stringify(payload.messages)).toContain('no action ran'); + }); +}); diff --git a/tests/providers/openaiToolLimits.test.ts b/tests/providers/openaiToolLimits.test.ts index 02da3ea5a..b1535bd77 100644 --- a/tests/providers/openaiToolLimits.test.ts +++ b/tests/providers/openaiToolLimits.test.ts @@ -14,7 +14,19 @@ const providers: [string, () => LLMProvider][] = [ 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 fetchSpy = vi.spyOn(globalThis, "fetch").mockImplementation(async (input) => { + if (String(input).endsWith("/responses")) { + return new Response(`event: response.completed\ndata: ${JSON.stringify({ + type: "response.completed", + response: { + id: "fixture", + output: [{ type: "message", role: "assistant", content: [{ type: "output_text", text: "Done" }] }], + usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 }, + }, + })}\n\n`, { headers: { "Content-Type": "text/event-stream" } }); + } + return 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); diff --git a/tests/providers/runtimeContextWindow.test.ts b/tests/providers/runtimeContextWindow.test.ts new file mode 100644 index 000000000..9482ee247 --- /dev/null +++ b/tests/providers/runtimeContextWindow.test.ts @@ -0,0 +1,42 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { resolveRuntimeContextWindow } from '../../src/providers/runtimeContextWindow.js'; + +afterEach(() => { + vi.restoreAllMocks(); + vi.useRealTimers(); +}); + +describe('runtime context deadline', () => { + it('settles when the deadline aborts even if the provider ignores its signal', async () => { + vi.useFakeTimers(); + const baseline = vi.getTimerCount(); + const deadline = new AbortController(); + vi.spyOn(AbortSignal, 'timeout').mockReturnValue(deadline.signal); + const remove = vi.spyOn(deadline.signal, 'removeEventListener'); + const pending = resolveRuntimeContextWindow(() => new Promise(() => {})); + deadline.abort(); + await expect(pending).resolves.toBeUndefined(); + expect(AbortSignal.timeout).toHaveBeenCalledWith(2000); + expect(remove).toHaveBeenCalledWith('abort', expect.any(Function)); + expect(vi.getTimerCount()).toBe(baseline); + }); + + it('releases the abort listener after successful metadata', async () => { + const deadline = new AbortController(); + vi.spyOn(AbortSignal, 'timeout').mockReturnValue(deadline.signal); + const remove = vi.spyOn(deadline.signal, 'removeEventListener'); + await expect(resolveRuntimeContextWindow(async () => 4096)).resolves.toBe(4096); + expect(remove).toHaveBeenCalledWith('abort', expect.any(Function)); + }); + + it('cancels without waiting for a noncooperative provider', async () => { + const controller = new AbortController(); + const pending = resolveRuntimeContextWindow(() => new Promise(() => {}), controller.signal); + controller.abort(); + await expect(pending).resolves.toBeUndefined(); + }); + + it.each([0, -1, Number.NaN, Number.POSITIVE_INFINITY, 4096.5])('rejects invalid limit %s', async (value) => { + await expect(resolveRuntimeContextWindow(async () => value)).resolves.toBeUndefined(); + }); +}); diff --git a/tests/reporting/autoReport.spec.ts b/tests/reporting/autoReport.spec.ts index 81a6fa9c0..43428900d 100644 --- a/tests/reporting/autoReport.spec.ts +++ b/tests/reporting/autoReport.spec.ts @@ -656,6 +656,14 @@ describe("AutoReportManager", () => { expect(mockFetch).not.toHaveBeenCalled(); }); + it.each(['ENOSPC', 'EDQUOT'])("does not auto-report an expected local capacity failure: %s", async code => { + mockFetch.mockResolvedValue(okResponse({ success: true })); + const mgr = new AutoReportManager(makeConfig(), "0.9.10"); + await mgr.reportError(Object.assign(new Error('Session write failed'), { code })); + await mgr.reportError(new Error(`${code}: no storage available, write`)); + expect(mockFetch).not.toHaveBeenCalled(); + }); + it("skips ApiError with access_denied code", async () => { mockFetch.mockResolvedValue(okResponse({ success: true })); const mgr = new AutoReportManager(makeConfig(), "0.7.14"); diff --git a/tests/session/ProjectManagerRecovery.test.ts b/tests/session/ProjectManagerRecovery.test.ts new file mode 100644 index 000000000..18954000d --- /dev/null +++ b/tests/session/ProjectManagerRecovery.test.ts @@ -0,0 +1,104 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import fs from 'fs-extra'; +import os from 'node:os'; +import path from 'node:path'; +import { ProjectManager } from '../../src/session/ProjectManager.js'; +import type { FailureRecord, SuccessRecord } from '../../src/session/types.js'; + +const temporary: string[] = []; + +afterEach(async () => { + vi.restoreAllMocks(); + vi.useRealTimers(); + await Promise.all(temporary.splice(0).map(directory => fs.remove(directory))); +}); + +const failure: FailureRecord = { + timestamp: new Date().toISOString(), sessionId: 'previous-session', tool: 'read_file', + error: 'missing fixture', context: 'fixture', tags: ['typescript'], +}; +const success: SuccessRecord = { + timestamp: new Date().toISOString(), sessionId: 'current-session', tool: 'list_tree', + context: 'fixture', tags: ['bun'], +}; + +async function fixture(): Promise<{ manager: ProjectManager; workspace: string; directory: string }> { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'project-log-recovery-')); + temporary.push(root); + const manager = new ProjectManager(path.join(root, 'projects')); + const workspace = path.join(root, 'workspace'); + const hash = await manager.getOrCreateProject(workspace); + return { manager, workspace, directory: path.join(root, 'projects', hash) }; +} + +describe('project event-log recovery', () => { + it('records a success despite a malformed historical failure row without rewriting the log', async () => { + const { manager, workspace, directory } = await fixture(); + const original = `${JSON.stringify(failure)}\n{"tool":"interrupted\n`; + await fs.writeFile(path.join(directory, 'failures.jsonl'), original); + await expect(manager.recordSuccess(workspace, success)).resolves.toBeUndefined(); + expect(await manager.getKnowledge(workspace)).toMatchObject({ + sessionCount: 2, summary: 'Project has 1 successful operations across 1 tools.', + techStack: { languages: ['typescript'], tools: ['bun'] }, + }); + expect(await fs.readFile(path.join(directory, 'failures.jsonl'), 'utf8')).toBe(original); + }); + + it('keeps the next valid success separate from an interrupted row at end of file', async () => { + const { manager, workspace, directory } = await fixture(); + const previous = { ...success, sessionId: 'previous-session', tool: 'read_file' }; + const original = `${JSON.stringify(previous)}\n{"timestamp":"unfinished`; + const log = path.join(directory, 'successes.jsonl'); + await fs.writeFile(log, original); + await expect(manager.recordSuccess(workspace, success)).resolves.toBeUndefined(); + expect(await fs.readFile(log, 'utf8')).toBe(`${original}\n${JSON.stringify(success)}\n`); + expect(await manager.getKnowledge(workspace)).toMatchObject({ + sessionCount: 2, summary: 'Project has 2 successful operations across 2 tools.', + }); + }); + + it('recovers recent failures and duplicate detection around malformed rows', async () => { + const { manager, workspace, directory } = await fixture(); + const second = { ...failure, command: 'bun test', sessionId: 'second-session' }; + const original = `${JSON.stringify(failure)}\nnot-json\n\n${JSON.stringify(second)}\n{"error":`; + await fs.writeFile(path.join(directory, 'failures.jsonl'), original); + await expect(manager.getRecentFailures(workspace)).resolves.toEqual([failure, second]); + await expect(manager.isDuplicateFailure(workspace, 'read_file', 'bun test')).resolves.toEqual(second); + expect(await fs.readFile(path.join(directory, 'failures.jsonl'), 'utf8')).toBe(original); + }); + + it('separates a new failure from an unterminated historical row', async () => { + const { manager, workspace, directory } = await fixture(); + const log = path.join(directory, 'failures.jsonl'); + await fs.writeFile(log, '{"error":"unfinished'); + await expect(manager.recordFailure(workspace, failure)).resolves.toBeUndefined(); + await expect(manager.getRecentFailures(workspace)).resolves.toEqual([failure]); + }); + + it('preserves a valid final row without a newline and keeps normal append formatting', async () => { + const { manager, workspace, directory } = await fixture(); + const log = path.join(directory, 'successes.jsonl'); + const previous = { ...success, sessionId: 'previous-session' }; + await fs.writeFile(log, JSON.stringify(previous)); + await manager.recordSuccess(workspace, success); + await manager.recordSuccess(workspace, success); + expect(await fs.readFile(log, 'utf8')).toBe( + `${JSON.stringify(previous)}\n${JSON.stringify(success)}\n${JSON.stringify(success)}\n`, + ); + expect(await manager.getKnowledge(workspace)).toMatchObject({ + sessionCount: 2, summary: 'Project has 3 successful operations across 1 tools.', + }); + }); + + it('does not hide storage errors or retain timers after reading a damaged log', async () => { + vi.useFakeTimers(); + const baseline = vi.getTimerCount(); + const { manager, workspace, directory } = await fixture(); + await fs.writeFile(path.join(directory, 'failures.jsonl'), 'not-json\n'); + await manager.recordSuccess(workspace, success); + expect(vi.getTimerCount()).toBe(baseline); + const denied = Object.assign(new Error('fixture storage denied'), { code: 'EACCES' }); + vi.spyOn(fs, 'readFile').mockRejectedValueOnce(denied); + await expect(manager.getRecentFailures(workspace)).rejects.toBe(denied); + }); +}); diff --git a/tests/skills/SkillsRegistry.spec.ts b/tests/skills/SkillsRegistry.spec.ts index bdf819c5b..3de6c452d 100644 --- a/tests/skills/SkillsRegistry.spec.ts +++ b/tests/skills/SkillsRegistry.spec.ts @@ -6,8 +6,9 @@ import fs from 'fs-extra'; import os from 'node:os'; import path from 'node:path'; +import { pathToFileURL } from 'node:url'; import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; -import { SkillsRegistry } from '../../src/skills/SkillsRegistry.js'; +import { SkillsRegistry, resolveBuiltinSkillDirectories } from '../../src/skills/SkillsRegistry.js'; import { buildSkillSuggestions } from '../../src/ui/ink/SkillMentionDropdown.js'; describe('SkillsRegistry', () => { @@ -43,6 +44,23 @@ ${body} } describe('initialization', () => { + it.each(['with spaces', 'with#hash', 'with%percent', '日本語-é', 'spaces # % 日本語'])('loads packaged builtins from a module URL containing %s', async (segment) => { + const modulePath = path.join(tempRoot, segment, 'src', 'skills', 'SkillsRegistry.js'); + const builtinDirectory = path.join(path.dirname(modulePath), 'builtin'); + const builtinPath = await createSkill(builtinDirectory, 'path-fixture', 'Packaged skill for escaped module paths', 'Packaged fixture body.'); + const registry = new SkillsRegistry(path.join(tempRoot, segment, 'user-skills'), 'autohand-user', { + builtinSkillDirectories: resolveBuiltinSkillDirectories(pathToFileURL(modulePath).href), + embeddedBuiltinAssetsRoot: path.join(tempRoot, segment, 'embedded-fallback'), + }); + await registry.initialize(); + + expect(registry.getSkill('path-fixture')).toMatchObject({ + source: 'builtin', + path: builtinPath, + body: 'Packaged fixture body.', + }); + }); + it('initializes with empty skill list when no skills exist', async () => { const emptyDir = path.join(tempRoot, 'empty-skills'); await fs.ensureDir(emptyDir); diff --git a/tests/toolManager.spec.ts b/tests/toolManager.spec.ts index babea5c29..6129058f4 100644 --- a/tests/toolManager.spec.ts +++ b/tests/toolManager.spec.ts @@ -60,6 +60,45 @@ function defaultToolDefinition(name: ToolDefinition['name']): ToolDefinition { } describe('ToolManager', () => { + it('infers a single apply_patch envelope path before validation and approval hooks', async () => { + const executor = vi.fn().mockResolvedValue(successfulOutcome('patched')); + const runPreToolHooks = vi.fn().mockResolvedValue([]); + const manager = new ToolManager({ + executor, + definitions: [defaultToolDefinition('apply_patch')], + confirmApproval: vi.fn().mockResolvedValue({ decision: 'allow_once' }), + authorization: { permissionManager: new PermissionManager({ mode: 'unrestricted' }), runPreToolHooks }, + }); + const patch = '*** Begin Patch\n*** Update File: src/example.ts\n@@\n-old\n+new\n*** End Patch'; + + const [result] = await manager.execute([{ tool: 'apply_patch', args: { patch } }]); + + expect(result.success).toBe(true); + expect(runPreToolHooks).toHaveBeenCalledWith(expect.objectContaining({ + args: expect.objectContaining({ path: 'src/example.ts', patch }), + })); + expect(executor).toHaveBeenCalledWith(expect.objectContaining({ path: 'src/example.ts', patch }), expect.anything()); + }); + + it('enforces protected-path permissions for paths inferred from a patch envelope', async () => { + const executor = vi.fn().mockResolvedValue(successfulOutcome('should not run')); + const runPreToolHooks = vi.fn().mockResolvedValue([]); + const manager = new ToolManager({ + executor, + definitions: [defaultToolDefinition('apply_patch')], + confirmApproval: vi.fn().mockResolvedValue({ decision: 'allow_once' }), + authorization: { permissionManager: new PermissionManager({ mode: 'unrestricted' }), runPreToolHooks }, + }); + + const [result] = await manager.execute([{ tool: 'apply_patch', args: { + patch: '*** Begin Patch\n*** Update File: .env\n@@\n-secret\n+replacement\n*** End Patch', + } }]); + + expect(result).toMatchObject({ success: false, kind: 'authorization' }); + expect(executor).not.toHaveBeenCalled(); + expect(runPreToolHooks).not.toHaveBeenCalled(); + }); + it('exposes delegation, team coordination, and tool discovery tools by default', () => { const names = new Set(DEFAULT_TOOL_DEFINITIONS.map((tool) => tool.name)); diff --git a/tests/tuistory/apply-patch-diff.tuistory.test.ts b/tests/tuistory/apply-patch-diff.tuistory.test.ts index 546a08842..c0ace2a36 100644 --- a/tests/tuistory/apply-patch-diff.tuistory.test.ts +++ b/tests/tuistory/apply-patch-diff.tuistory.test.ts @@ -21,6 +21,13 @@ import { const sessions: Session[] = []; const states: TuistoryTempState[] = []; const servers: MockNativeToolServer[] = []; +const patchCases: Array<{ format: string; patch: string; changes: boolean; inferPath?: boolean }> = [ + { format: 'numbered', patch: '@@ -1 +1 @@\n-const oldValue = true;\n+const newValue = true;', changes: true }, + { format: 'anchored', patch: '@@\n-const oldValue = true;\n+const newValue = true;', changes: true }, + { format: 'enveloped', patch: '*** Begin Patch\n*** Update File: patch-target.ts\n@@\n-const oldValue = true;\n+const newValue = true;\n*** End Patch', changes: true }, + { format: 'inferred-path envelope', patch: '*** Begin Patch\n*** Update File: patch-target.ts\n@@\n-const oldValue = true;\n+const newValue = true;\n*** End Patch', changes: true, inferPath: true }, + { format: 'invalid zero-hunk', patch: '*** Begin Patch\n*** Update File: patch-target.ts\n*** End Patch', changes: false }, +]; afterEach(async () => { for (const session of sessions.splice(0)) { @@ -36,7 +43,7 @@ afterEach(async () => { }); describe('apply_patch diff Tuistory', () => { - it('renders changed lines with the default Aurora diff palette', async () => { + it.each(patchCases)('reports the $format patch outcome and renders actual changes', async ({ patch, changes, inferPath }) => { const target = 'patch-target.ts'; const completionMarker = 'PATCH_DIFF_RENDERED'; const turns: MockNativeAssistantTurn[] = []; @@ -80,14 +87,7 @@ describe('apply_patch diff Tuistory', () => { toolCall: { id: 'call_apply_patch_target', name: 'apply_patch', - args: { - path: absoluteTarget, - patch: [ - '@@ -1 +1 @@', - '-const oldValue = true;', - '+const newValue = true;', - ].join('\n'), - }, + args: inferPath ? { patch } : { path: absoluteTarget, patch }, }, }, { content: completionMarker }, @@ -116,12 +116,17 @@ describe('apply_patch diff Tuistory', () => { }); expect(server.requests).toHaveLength(3); - await expect(fs.readFile(absoluteTarget, 'utf8')).resolves.toBe('const newValue = true;\n'); - expect(output).toContain('✔ apply_patch'); - expect(output).toContain('│ 1 - const oldValue = true;'); - expect(output).toContain('│ 1 + const newValue = true;'); - expect(rawOutput).toContain('\u001b[38;2;237;154;154m │ 1 - const oldValue = true;'); - expect(rawOutput).toContain('\u001b[38;2;134;207;163m │ 1 + const newValue = true;'); + await expect(fs.readFile(absoluteTarget, 'utf8')).resolves.toBe(changes ? 'const newValue = true;\n' : 'const oldValue = true;\n'); + if (changes) { + expect(output).toContain('✔ apply_patch'); + expect(output).toContain('│ 1 - const oldValue = true;'); + expect(output).toContain('│ 1 + const newValue = true;'); + expect(rawOutput).toContain('\u001b[38;2;237;154;154m │ 1 - const oldValue = true;'); + expect(rawOutput).toContain('\u001b[38;2;134;207;163m │ 1 + const newValue = true;'); + } else { + expect(output).toContain('no hunks found'); + expect(output).not.toContain('✔ apply_patch'); + } await exitInteractive(session); sessions.splice(sessions.indexOf(session), 1); }, 60_000); diff --git a/tests/tuistory/autohand-orchestration.tuistory.test.ts b/tests/tuistory/autohand-orchestration.tuistory.test.ts index ce195da6c..e00409476 100644 --- a/tests/tuistory/autohand-orchestration.tuistory.test.ts +++ b/tests/tuistory/autohand-orchestration.tuistory.test.ts @@ -47,7 +47,7 @@ describe('default Autohand AI orchestration', () => { } } else { leadTurns += 1; - if (leadTurns === 1) reply(response, 'Inspecting three independent scopes.', { name: 'delegate_parallel', args: { tasks: [1, 2, 3].map(index => ({ agent_name: `reader-${index}`, task: 'Inspect fixture.txt and return structured evidence.' })) } }); + if (leadTurns === 1) reply(response, 'Inspecting three independent scopes.', { name: 'delegate_parallel', args: { research_only: true, tasks: [1, 2, 3].map(index => ({ agent_name: `reader-${index}`, task: 'Inspect fixture.txt and return structured evidence.' })) } }); else if (leadTurns === 2) reply(response, 'ORCHESTRATION_VERIFIED: Three source reports agree.'); else reply(response, `ORCHESTRATION_MODE_${leadTurns === 3 ? 'OFF' : 'ON'}: The requested setting is active.`); } diff --git a/tests/tuistory/checkpoint-footer.tuistory.test.ts b/tests/tuistory/checkpoint-footer.tuistory.test.ts index 480224595..600701e01 100644 --- a/tests/tuistory/checkpoint-footer.tuistory.test.ts +++ b/tests/tuistory/checkpoint-footer.tuistory.test.ts @@ -7,6 +7,43 @@ import { createMockAutohandAINativeSequenceServer, createTempAutohandHome, exitInteractive, launchBuiltAutohand } from './helpers/autohandTuistory.js'; describe('checkpoint completion footer', () => { + it('completes an approved session-evidence review when workspace diff capture exceeds its budget', async () => { + const server = await createMockAutohandAINativeSequenceServer([ + { content: 'Reading package.json.', toolCall: { id: 'read-package', name: 'read_file', args: { path: 'package.json' } } }, + { content: 'CHECKPOINT_FOOTER_ANSWER: Repository facts verified.' }, + { content: '{"verdict":"approved","feedback":"Session evidence reviewed; workspace diff unavailable."}' }, + ]); + const state = await createTempAutohandHome({ config: { + provider: 'autohandai', + autohandai: { plan: 'cloud', authMode: 'api-key', apiKey: 'fixture', model: 'moa', orchestration: true, baseUrl: server.baseUrl }, + features: { autohand_inference: true, automaticSpecialists: false }, + agent: { autoMemory: false, maxIterations: 5, sessionRetryLimit: 0 }, + network: { maxRetries: 0, retryDelay: 0 }, + ui: { silentToolOutput: true, promptSuggestions: false, showCompletionNotification: false, terminalBell: false }, + } }); + let session: Awaited> | undefined; + try { + session = await launchBuiltAutohand(['--path', state.workspaceRoot, '--config', state.configPath, '--yes'], { + autohandHome: state.autohandHome, cwd: state.workspaceRoot, cols: 120, rows: 36, + env: { NODE_OPTIONS: `--import=${pathToFileURL(path.resolve(import.meta.dirname, + '../../src/testing/scenarios/workspaceCaptureBudgetPreload.mjs')).href}` }, + }); + const screen = await runCheckpointFooterScenario(session); + expect(screen).toContain('Completed in'); + expect(screen).not.toContain('Work remains pending Moa review'); + expect(server.requests).toHaveLength(3); + expect(JSON.stringify(server.requests[2])).toContain('Workspace diff unavailable'); + expect(JSON.stringify(server.requests[2])).toContain('git add -A exceeded the 3000 ms workspace change capture budget'); + const outcome = screen.split('\n').map(line => line.trim()).filter(line => ( + line.startsWith('CHECKPOINT_FOOTER_ANSWER:') || line.startsWith('Completed in') + )).map(line => line.replace(/\d+m \d+s/, '').replace(/ · .*/, ' · ')).join('\n'); + await expect(`${outcome}\n`).toMatchFileSnapshot(path.resolve('src/testing/snapshots/checkpoint-capture-budget.txt')); + await exitInteractive(session); + } finally { + session?.close(); await server.close(); await state.cleanup(); + } + }); + it.each([ { approved: false, delayMs: 0 }, { approved: true, delayMs: 0 }, { approved: false, delayMs: 3500 }, { approved: true, delayMs: 1200 }, diff --git a/tests/tuistory/free-search.tuistory.test.ts b/tests/tuistory/free-search.tuistory.test.ts index c60e01a96..a1ae2f141 100644 --- a/tests/tuistory/free-search.tuistory.test.ts +++ b/tests/tuistory/free-search.tuistory.test.ts @@ -1,4 +1,3 @@ -import { fileURLToPath } from 'node:url'; import { describe, expect, it } from 'vitest'; import { inspectFreeSearchAllowance } from '../../src/testing/scenarios/freeSearchScenario.js'; import { createTempAutohandHome, exitInteractive, launchBuiltAutohand } from './helpers/autohandTuistory.js'; @@ -12,7 +11,7 @@ describe('Free web search allowance in the built terminal', () => { } }); let session: Awaited> | undefined; try { - const preload = fileURLToPath(new URL('../../src/testing/fixtures/free-search-fetch.mjs', import.meta.url)); + const preload = new URL('../../src/testing/fixtures/free-search-fetch.mjs', import.meta.url).href; session = await launchBuiltAutohand(['--path', state.workspaceRoot, '--config', state.configPath], { autohandHome: state.autohandHome, cwd: state.workspaceRoot, cols: 140, rows: 45, env: { NODE_OPTIONS: `--import=${preload}`, AUTOHAND_TEST_SEARCH_REMAINING: String(remaining) }, diff --git a/tests/tuistory/local-model-context.tuistory.test.ts b/tests/tuistory/local-model-context.tuistory.test.ts new file mode 100644 index 000000000..88558dcbc --- /dev/null +++ b/tests/tuistory/local-model-context.tuistory.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest'; +import type { Session } from 'tuistory'; +import { createLocalModelContextFixture, inspectLocalModelContext } from '../../src/testing/scenarios/localModelContextScenario.js'; +import { createTempAutohandHome, exitInteractive, launchBuiltAutohand } from './helpers/autohandTuistory.js'; + +describe('local model runtime context', () => { + it('uses the server context in requests and the composer while keeping input available', async () => { + const server = await createLocalModelContextFixture(); + const state = await createTempAutohandHome({ config: { + provider: 'llamacpp', + llamacpp: { baseUrl: server.baseUrl, model: 'canvas-pirate' }, + agent: { autoMemory: false, maxIterations: 2, sessionRetryLimit: 0 }, + network: { maxRetries: 0, retryDelay: 0 }, + ui: { terminalBell: false, showCompletionNotification: false, promptSuggestions: false }, + } }); + let session: Session | undefined; + try { + session = await launchBuiltAutohand([ + '--config', state.configPath, '--path', state.workspaceRoot, '--yes', + ], { autohandHome: state.autohandHome, cwd: state.workspaceRoot }); + const screen = await inspectLocalModelContext(session); + expect(server.metadataRequests).toContain('/props?model=canvas-pirate&autoload=false'); + expect(server.completions[0]).toMatchObject({ model: 'canvas-pirate', max_tokens: 8192 }); + expect(screen).toContain('32.8k'); + expect(screen).toContain('local context draft'); + await exitInteractive(session); + session = undefined; + } finally { + session?.close(); + await server.close(); + await state.cleanup(); + } + }); +}); diff --git a/tests/tuistory/native-context-crop.tuistory.test.ts b/tests/tuistory/native-context-crop.tuistory.test.ts index 12abb5fbe..aeb9921d5 100644 --- a/tests/tuistory/native-context-crop.tuistory.test.ts +++ b/tests/tuistory/native-context-crop.tuistory.test.ts @@ -80,7 +80,7 @@ describe('Native context crop history', () => { expect(continuation).not.toHaveProperty('reasoning_effort'); } else { expect(continuation).not.toHaveProperty('max_tokens'); - expect(continuation?.reasoning_effort).toBe(model === 'gpt-5.4' ? 'none' : 'high'); + expect(continuation?.reasoning_effort).toBe('high'); } } expect(continuation?.messages).toEqual(expect.arrayContaining([ diff --git a/tests/tuistory/partial-delegation.tuistory.test.ts b/tests/tuistory/partial-delegation.tuistory.test.ts index 4560f1b33..2865f465e 100644 --- a/tests/tuistory/partial-delegation.tuistory.test.ts +++ b/tests/tuistory/partial-delegation.tuistory.test.ts @@ -10,7 +10,7 @@ interface WireRequest { } describe('partial delegation findings', () => { - it('delivers a successful sibling report after another research worker exhausts its iterations', async () => { + it.each([{ maxIterations: undefined, codeMode: false }, { maxIterations: 2, codeMode: true }])('returns partial work and a successful sibling (Code Mode: $codeMode)', async ({ maxIterations, codeMode }) => { let leadTurns = 0; let failedTurns = 0; let leadObservation = ''; @@ -43,10 +43,13 @@ describe('partial delegation findings', () => { } else { leadTurns += 1; if (leadTurns === 1) { - reply(response, 'Inspecting independent code and docs scopes.', { name: 'delegate_parallel', args: { tasks: [ - { agent_name: 'unfinished', task: 'Inspect source files.' }, - { agent_name: 'finished', task: 'Inspect documentation.' }, - ] } }); + const tasks = [ + { agent_name: 'unfinished', task: 'Inspect source files.', research_only: true, ...(maxIterations === undefined ? {} : { maxIterations }) }, + { agent_name: 'finished', task: 'Inspect documentation.', research_only: true }, + ]; + reply(response, 'Inspecting independent code and docs scopes.', codeMode + ? { name: 'run_tool_script', args: { script: `const workers = await tools.delegate_parallel(${JSON.stringify({ tasks })}); if (workers.status !== 'partial' || workers.reason !== 'iteration_limit') throw new Error('Partial worker metadata was lost'); return {status: workers.status, reason: workers.reason, output: workers.output};` } } + : { name: 'delegate_parallel', args: { tasks } }); } else { leadObservation = body.messages.filter(message => message.role === 'tool').map(message => message.content).join('\n'); reply(response, leadObservation.includes('PARTIAL_DOC_SOURCE') @@ -63,8 +66,8 @@ describe('partial delegation findings', () => { provider: 'autohandai', autohandai: { plan: 'cloud', authMode: 'api-key', apiKey: 'fixture', model: 'moa', orchestration: true, baseUrl: `http://127.0.0.1:${address.port}` }, - features: { autohand_inference: true, automaticSpecialists: false }, - agent: { autoMemory: false, maxIterations: 5, sessionRetryLimit: 0 }, + features: { autohand_inference: true, automaticSpecialists: false, codeMode }, + agent: { autoMemory: false, maxIterations: 5, subagentMaxIterations: 3, sessionRetryLimit: 0 }, network: { maxRetries: 0, retryDelay: 0 }, ui: { promptSuggestions: false, showCompletionNotification: false, terminalBell: false }, } }); @@ -83,7 +86,12 @@ describe('partial delegation findings', () => { }); expect(await runPartialDelegationScenario(session)).toContain('SIBLING_REPORT_RECOVERED'); expect(leadObservation).toContain('PARTIAL_DOC_SOURCE'); - expect(leadObservation).toContain('Failed to complete task within 10 iterations'); + expect(leadObservation).toContain('[unfinished] Partial (iteration_limit)'); + const cap = maxIterations ?? 3; + expect(leadObservation).toContain(`Source file ${cap - 1}`); + expect(leadObservation).not.toContain(`Source file ${cap}`); + expect(failedTurns).toBe(cap); + if (codeMode) expect(leadObservation).toContain('"status": "partial"'); await exitInteractive(session); } finally { session?.close(); diff --git a/tests/tuistory/provider-recovery.tuistory.test.ts b/tests/tuistory/provider-recovery.tuistory.test.ts new file mode 100644 index 000000000..45bf96709 --- /dev/null +++ b/tests/tuistory/provider-recovery.tuistory.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it } from 'vitest'; +import { readFile } from 'node:fs/promises'; +import type { Session } from 'tuistory'; +import { createOpenRouterDeadlineFixture } from '../../src/testing/scenarios/openRouterDeadlineScenario.js'; +import { prepareDamagedProjectLog } from '../../src/testing/scenarios/projectLogRecoveryScenario.js'; +import { + createMockAutohandAINativeSequenceServer, createTempAutohandHome, exitInteractive, + launchBuiltAutohand, waitForExit, +} from './helpers/autohandTuistory.js'; + +describe('provider and project-log recovery', () => { + it.each(['interactive', 'headless'] as const)( + 'bounds an OpenRouter body stall in the %s CLI', async mode => { + const server = await createOpenRouterDeadlineFixture('body'); + const state = await createTempAutohandHome({ config: { + provider: 'openrouter', openrouter: { model: 'openai/gpt-4o-mini', baseUrl: server.baseUrl }, + features: { codeMode: false, autohand_inference: false }, + network: { timeout: 200, maxRetries: 0 }, agent: { autoMemory: false, sessionRetryLimit: 0 }, + ui: { promptSuggestions: false, showCompletionNotification: false, terminalBell: false }, + } }); + let session: Session | undefined; + try { + const args = ['--path', state.workspaceRoot, '--config', state.configPath, '--y']; + if (mode === 'headless') args.push('--prompt', 'Reply briefly', '--json', 'local'); + session = await launchBuiltAutohand(args, { autohandHome: state.autohandHome, cwd: state.workspaceRoot }); + if (mode === 'interactive') { + await session.waitForText('❯', { timeout: 15_000 }); + await session.type('Reply briefly'); + await session.waitForText('❯ Reply briefly'); + await session.press('enter'); + await session.waitForText('timed out', { timeout: 15_000 }); + await session.waitForText('Failed in', { timeout: 10_000 }); + await session.type('Continue after checking the provider'); + await session.waitForText('❯ Continue after checking the provider'); + await exitInteractive(session); + } else { + await waitForExit(session, 15_000); + expect(session.readAll()).toContain('timed out'); + expect(session.exitInfo?.exitCode).toBe(1); + } + expect(server.committedResponses()).toBeGreaterThan(0); + await expect.poll(server.activeCompletions).toBe(0); + } finally { + session?.close(); + await Promise.all([server.close(), state.cleanup()]); + } + }, + ); + + it('finishes a real tool turn despite a truncated project history row', async () => { + const server = await createMockAutohandAINativeSequenceServer([ + { content: 'Read package metadata', toolCall: { id: 'read-package', name: 'read_file', args: { path: 'package.json' } } }, + { content: 'PROJECT_HISTORY_RECOVERED' }, + ]); + const state = await createTempAutohandHome({ config: { + provider: 'openrouter', openrouter: { baseUrl: server.baseUrl }, + features: { codeMode: false, autohand_inference: false }, + network: { maxRetries: 0 }, agent: { autoMemory: false, maxIterations: 4, sessionRetryLimit: 0 }, + ui: { promptSuggestions: false, showCompletionNotification: false, terminalBell: false }, + } }); + const failures = await prepareDamagedProjectLog(state.autohandHome, state.workspaceRoot); + const original = await readFile(failures, 'utf8'); + let session: Session | undefined; + try { + session = await launchBuiltAutohand([ + '--path', state.workspaceRoot, '--config', state.configPath, '--y', + ], { autohandHome: state.autohandHome, cwd: state.workspaceRoot }); + await session.waitForText('❯', { timeout: 15_000 }); + await session.type('Read package.json and report the recovery marker'); + await session.waitForText('❯ Read package.json and report the recovery marker'); + await session.press('enter'); + await session.waitForText('PROJECT_HISTORY_RECOVERED', { timeout: 20_000 }); + await session.waitForText('Completed in', { timeout: 10_000 }); + expect(session.readAll()).not.toContain('JSON Parse error'); + expect(server.requests).toHaveLength(2); + expect(await readFile(failures, 'utf8')).toBe(original); + await session.type('The composer still works'); + await session.waitForText('❯ The composer still works'); + await exitInteractive(session); + } finally { + session?.close(); + await Promise.all([server.close(), state.cleanup()]); + } + }); +}); diff --git a/tests/tuistory/resource-errors.tuistory.test.ts b/tests/tuistory/resource-errors.tuistory.test.ts index 5b65f3182..cfeec13de 100644 --- a/tests/tuistory/resource-errors.tuistory.test.ts +++ b/tests/tuistory/resource-errors.tuistory.test.ts @@ -1,10 +1,12 @@ import { afterEach, describe, expect, it } from 'vitest'; -import { readFile } from 'node:fs/promises'; +import { readFile, readdir } from 'node:fs/promises'; +import path from 'node:path'; import type { Session } from 'tuistory'; -import { prepareMemoryDiskFullScenario } from '../../src/testing/scenarios/memoryResourceScenario.js'; +import { prepareMemoryDiskFullScenario, prepareSessionDiskFullScenario } from '../../src/testing/scenarios/memoryResourceScenario.js'; import { prepareVerboseCommandScenario } from '../../src/testing/scenarios/commandOutputScenario.js'; import { createMockOpenRouterServer, + createMockOpenRouterSequenceServer, createTempAutohandHome, exitInteractive, launchBuiltAutohand, @@ -66,6 +68,49 @@ describe('memory storage exhaustion', () => { }); }); +describe('session storage exhaustion', () => { + it('stops without replaying a completed provider response when metadata cannot be saved', async () => { + const marker = 'SESSION_RESPONSE_PRESERVED_ON_DISK'; + const provider = await createMockOpenRouterSequenceServer([ + JSON.stringify({ finalResponse: marker, toolCalls: [] }), + ]); + server = provider; + state = await createTempAutohandHome({ config: { + openrouter: { baseUrl: provider.baseUrl }, + agent: { autoMemory: false, sessionRetryLimit: 3, sessionRetryDelay: 1 }, + ui: { promptSuggestions: false, showCompletionNotification: false, terminalBell: false }, + } }); + const scenario = await prepareSessionDiskFullScenario(state.workspaceRoot, state.autohandHome, marker); + session = await launchBuiltAutohand([ + '--path', state.workspaceRoot, '--config', state.configPath, '--y', + ], { + autohandHome: state.autohandHome, + cwd: state.workspaceRoot, + env: { NODE_OPTIONS: [process.env.NODE_OPTIONS, `--import=${scenario.preload}`].filter(Boolean).join(' ') }, + }); + await session.waitForText('❯', { timeout: 20_000 }); + await session.type('Reply with the session response marker'); + await session.press('enter'); + await session.waitForText('Free disk space before continuing', { timeout: 20_000 }); + const output = session.readAll(); + expect(output).toContain('Recent session data may not have been saved'); + expect(output).not.toContain('Attempting recovery'); + expect(provider.requests).toHaveLength(1); + expect(await readFile(scenario.attemptsPath, 'utf8')).toContain('session metadata blocked'); + const sessionsRoot = path.join(state.autohandHome, 'sessions'); + const sessionEntries = await readdir(sessionsRoot, { withFileTypes: true }); + const transcripts = await Promise.all(sessionEntries.filter(entry => entry.isDirectory()) + .map(entry => readFile(path.join(sessionsRoot, entry.name, 'conversation.jsonl'), 'utf8'))); + expect(transcripts.join('\n')).toContain(marker); + await session.type('! printf "%s%s\\n" STORAGE_COMPOSER_ RECOVERED'); + await session.press('enter'); + await session.waitForText('STORAGE_COMPOSER_RECOVERED', { timeout: 10_000 }); + expect(provider.requests).toHaveLength(1); + await scenario.recover(); + await exitInteractive(session); + }); +}); + describe('large immediate shell output', () => { it.each([ { renderer: 'Ink', legacy: '0', marker: '[earlier live output truncated]' }, diff --git a/tests/tuistory/session-lifecycle.tuistory.test.ts b/tests/tuistory/session-lifecycle.tuistory.test.ts index 2fd48c246..0f4fa79d3 100644 --- a/tests/tuistory/session-lifecycle.tuistory.test.ts +++ b/tests/tuistory/session-lifecycle.tuistory.test.ts @@ -32,7 +32,9 @@ describe('foreground CLI session lifecycle', () => { traces: { enabled: true, cloudSync: true, consentVersion: 1 }, mcp: { enabled: true, servers: [{ name: 'forbidden-helper', transport: 'stdio', command: 'forbidden-background-helper' }] }, } }); - const audit = await createBackgroundProcessAudit(state.workspaceRoot); + const auditDirectory = path.join(state.workspaceRoot, 'process audit with spaces'); + await fs.ensureDir(auditDirectory); + const audit = await createBackgroundProcessAudit(auditDirectory); session = await launchBuiltAutohand(['--no-daemon', '--path', state.workspaceRoot, '--config', state.configPath], { autohandHome: state.autohandHome, cwd: state.workspaceRoot, env: { NODE_OPTIONS: `--import=${audit.preload}`, AUTOHAND_AHTRACES_EXECUTABLE: 'forbidden-traces-helper' }, diff --git a/tests/tuistory/subagent-iteration-settings.tuistory.test.ts b/tests/tuistory/subagent-iteration-settings.tuistory.test.ts new file mode 100644 index 000000000..d0d920753 --- /dev/null +++ b/tests/tuistory/subagent-iteration-settings.tuistory.test.ts @@ -0,0 +1,21 @@ +import fs from 'fs-extra'; +import { describe, expect, it } from 'vitest'; +import { configureSubagentIterationLimit } from '../../src/testing/scenarios/subagentIterationSettingsScenario.js'; +import { createTempAutohandHome, exitInteractive, launchBuiltAutohand } from './helpers/autohandTuistory.js'; + +describe('sub-agent iteration settings Tuistory', () => { + it('rejects an invalid cap, saves a separate worker cap and returns to the composer', async () => { + const state = await createTempAutohandHome({ config: { + agent: { maxIterations: 300 }, features: { automaticSpecialists: false }, ui: { promptSuggestions: false }, + } }); + let session: Awaited> | undefined; + try { + session = await launchBuiltAutohand(['--path', state.workspaceRoot, '--config', state.configPath, '--yes'], { + autohandHome: state.autohandHome, cwd: state.workspaceRoot, cols: 120, rows: 36, + }); + expect(await configureSubagentIterationLimit(session, 60)).toContain('integer between 1 and 1000'); + expect(await fs.readJson(state.configPath)).toMatchObject({ agent: { maxIterations: 300, subagentMaxIterations: 60 } }); + await exitInteractive(session); + } finally { session?.close(); await state.cleanup(); } + }, 90_000); +}); diff --git a/tests/ui/ink/AgentRunsPanel.test.tsx b/tests/ui/ink/AgentRunsPanel.test.tsx index 3e8e67e5f..d6043b0e0 100644 --- a/tests/ui/ink/AgentRunsPanel.test.tsx +++ b/tests/ui/ink/AgentRunsPanel.test.tsx @@ -24,6 +24,17 @@ function panel(snapshot: AgentRunsSnapshot, onClose = vi.fn(), onCancel = vi.fn( } describe('AgentRunsPanel', () => { + it('shows a capped worker as partial and settled, with its findings available', async () => { + const view = panel({ updatedAt: 200, runs: [run('reviewer', { + status: 'partial', finishedAt: 200, cancellable: false, + output: 'Found two issues; verification remains.', + })] }); + await vi.waitFor(() => expect(view.lastFrame()).toContain('partial')); + expect(view.lastFrame()).toContain('0 active'); + expect(view.lastFrame()).not.toContain('Waiting for model'); + view.stdin.write('\r'); + await vi.waitFor(() => expect(view.lastFrame()).toContain('Found two issues')); + }); it('routes inspector messages through the active agent UI without submitting a main-agent instruction', async () => { const onMessageAgentRun = vi.fn(async () => true); const onInstruction = vi.fn(); diff --git a/tests/ui/terminalMarkdown.test.ts b/tests/ui/terminalMarkdown.test.ts index b429c9962..d3a01c679 100644 --- a/tests/ui/terminalMarkdown.test.ts +++ b/tests/ui/terminalMarkdown.test.ts @@ -5,6 +5,9 @@ */ import { stripVTControlCharacters } from 'node:util'; import { readFileSync } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; import stringWidth from 'string-width'; import { markdownRenderingSample } from '../../src/testing/scenarios/markdownRenderingScenario.js'; import { afterEach, describe, expect, it, vi } from 'vitest'; @@ -21,6 +24,12 @@ vi.mock('terminal-link', () => ({ const plain = (markdown: string) => renderMarkdownForTerminal(markdown, { color: false, hyperlinks: false, width: 60 }); +function imageLinkTarget(rendered: string): string { + const target = /\u001b\]8;;(file:[^\u0007]*)\u0007Image: Preview/.exec(rendered)?.[1]; + if (!target) throw new Error('Expected an OSC file hyperlink for Image: Preview'); + return target; +} + afterEach(() => { setTerminalMarkdownPreference(() => true); }); @@ -47,10 +56,27 @@ describe('renderMarkdownForTerminal', () => { const rendered = renderMarkdownForTerminal('[Docs](https://example.test)\n\n![Preview](./preview.png)', { color: false }); expect(rendered).toContain('\u001b]8;;https://example.test\u0007Docs'); - expect(rendered).toContain(`\u001b]8;;file://${process.cwd()}/preview.png\u0007Image: Preview`); + expect(fileURLToPath(imageLinkTarget(rendered))).toBe(path.resolve('preview.png')); expect(stripVTControlCharacters(rendered)).toBe('Docs\n\nImage: Preview'); }); + it('round-trips local image links from a checkout containing spaces and reserved or Unicode characters', () => { + const checkout = path.join(os.tmpdir(), 'autohand markdown #100% café'); + const expectedImage = path.join(checkout, 'preview.png'); + const cwd = vi.spyOn(process, 'cwd').mockReturnValue(checkout); + try { + const rendered = renderMarkdownForTerminal('![Preview](./preview.png)', { color: false }); + const target = imageLinkTarget(rendered); + + expect(fileURLToPath(target)).toBe(expectedImage); + expect(new URL(target).hash).toBe(''); + expect(new URL(target).search).toBe(''); + expect(stripVTControlCharacters(rendered)).toBe('Image: Preview'); + } finally { + cwd.mockRestore(); + } + }); + it('never emits active links for executable schemes or terminal control sequences from content', () => { const rendered = renderMarkdownForTerminal('[Run](javascript:alert)\n\nhello\u001b]52;c;c2VjcmV0\u0007 world', { color: false });