diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5a09a904d7..4b5fcc7564 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -151,6 +151,20 @@ jobs: # Merge queue refs are ephemeral; check out the event head SHA directly so # slow hosted runners do not fail after the queue branch is removed. # Non-PR/non-queue events keep github.ref. + # Pre-checkout cleanup: remove .qwen/ left by a previous job on a + # reused runner. Without this, git checkout fails with "unable to + # unlink old '.qwen/...': Permission denied" when the stale files + # have restrictive permissions (e.g. from a verify/tmux job that + # ran chmod -R a-w .qwen/). + - name: 'Clean stale .qwen before checkout' + if: "${{ needs.classify_pr.outputs.skip_ci != 'true' }}" + run: |- + if [ -d .qwen ]; then + chmod -R u+w .qwen 2>/dev/null || true + rm -rf .qwen + fi + + - name: 'Checkout' id: 'checkout' if: "${{ needs.classify_pr.outputs.skip_ci != 'true' }}" diff --git a/CHANGELOG.md b/CHANGELOG.md index f2a700a7fb..b20a6175ce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,221 @@ are listed; nightly and preview pre-releases are intentionally omitted. > [GitHub Releases](https://github.com/QwenLM/qwen-code/releases). Do not edit it > by hand — run `npm run changelog` to regenerate. +## [0.21.1](https://github.com/QwenLM/qwen-code/releases/tag/v0.21.1) - 2026-07-28 + +### Highlights + +_See the complete change list below._ + +### Breaking Changes + +No known breaking changes. + +### Complete Change List + +#### Features + +- feat(core): Align GenAI content telemetry fields ([#7667](https://github.com/QwenLM/qwen-code/pull/7667)) by @doudouOUC +- feat(core): add Goal v3 runtime orchestration ([#7664](https://github.com/QwenLM/qwen-code/pull/7664)) by @qqqys +- feat(triage): stop in-agent CI polling, finalize evidence and approval after CI completes ([#7693](https://github.com/QwenLM/qwen-code/pull/7693)) by @wenshao +- feat(serve): expose workspace Channel management API ([#7637](https://github.com/QwenLM/qwen-code/pull/7637)) by @qqqys +- feat(web-shell): add read-only GitHub pull requests panel ([#7683](https://github.com/QwenLM/qwen-code/pull/7683)) by @wenshao +- Adds a retrieval-only context_search tool for external corpora, configured by administrators without automatic recall or write capabilities. ([#7586](https://github.com/QwenLM/qwen-code/pull/7586)) by @doudouOUC +- Introduces the qwen review comment-status subcommand to quickly triage existing inline comments and reduce API calls during review runs. ([#7690](https://github.com/QwenLM/qwen-code/pull/7690)) by @wenshao +- Enforces a strict write contract for review submissions and adds a tripwire to detect and flag any bypass attempts via terminal commands. ([#7691](https://github.com/QwenLM/qwen-code/pull/7691)) by @wenshao +- Enables hot-reloading of workspace trust changes in the running daemon, applying new policies immediately without requiring a process restart. ([#7268](https://github.com/QwenLM/qwen-code/pull/7268)) by @doudouOUC +- Adds retryInitialDelayMs and retryMaxDelayMs settings to configure stream-side rate-limit retry delays for better provider compatibility. ([#7674](https://github.com/QwenLM/qwen-code/pull/7674)) by @jay666mnj +- Updates the /stats command and Session tab to display generation timing metrics including TTFT, duration, output tokens, and TPS. ([#7677](https://github.com/QwenLM/qwen-code/pull/7677)) by @destire-mio +- Adds a GitHub channel adapter that polls notifications and responds to mentions by posting comments using a signal-based wakeup architecture. ([#7632](https://github.com/QwenLM/qwen-code/pull/7632)) by @OrbitZore +- Adds optional retryInitialDelayMs and retryMaxDelayMs settings to configure SSE stream rate-limit retry delays for specific provider quota windows. ([#7666](https://github.com/QwenLM/qwen-code/pull/7666)) by @hogeheer499-commits +- Introduces an overridable default-disabled state for skills, allowing soft defaults that yield to explicit enablement while hard disables remain absolute. ([#7357](https://github.com/QwenLM/qwen-code/pull/7357)) by @samuelhsin +- feat(webui): add workspace Channel management hook ([#7728](https://github.com/QwenLM/qwen-code/pull/7728)) by @qqqys +- feat(review): redefine medium effort as a balanced verified pass ([#7733](https://github.com/QwenLM/qwen-code/pull/7733)) by @wenshao +- feat(review): mutation-test the tests in the test-coverage pass (Agent 5) ([#7735](https://github.com/QwenLM/qwen-code/pull/7735)) by @wenshao +- feat(review): borrow maintainer review lenses into the agent briefs ([#7736](https://github.com/QwenLM/qwen-code/pull/7736)) by @wenshao +- feat(web-shell): persist terminal history pagination errors ([#7709](https://github.com/QwenLM/qwen-code/pull/7709)) by @PratikWayase +- feat(triage): add sandboxed /verify deep-verification lane ([#7710](https://github.com/QwenLM/qwen-code/pull/7710)) by @wenshao +- feat(review): give the verifier a probe capability — run a runnable claim, don't just read it ([#7756](https://github.com/QwenLM/qwen-code/pull/7756)) by @wenshao +- feat(core): add model grade selection for subagent spawn (#7685) ([#7702](https://github.com/QwenLM/qwen-code/pull/7702)) by @yiliang114 +- feat(dingtalk): support outbound image delivery ([#7698](https://github.com/QwenLM/qwen-code/pull/7698)) by @qqqys +- feat(web-shell): allow widening sidebar up to half the window width ([#7778](https://github.com/QwenLM/qwen-code/pull/7778)) by @wenshao +- feat(core): add Goal v3 worker tools ([#7729](https://github.com/QwenLM/qwen-code/pull/7729)) by @qqqys +- feat(review): script-lint as a deterministic gate — compose-review reads the report, no agent ([#7751](https://github.com/QwenLM/qwen-code/pull/7751)) by @wenshao +- feat(web-shell): add monitor task details ([#7817](https://github.com/QwenLM/qwen-code/pull/7817)) by @ytahdn +- feat(web-shell): Scope voice to composer workspace ([#7754](https://github.com/QwenLM/qwen-code/pull/7754)) by @doudouOUC +- feat(hooks): Add submitted prompt provenance ([#7762](https://github.com/QwenLM/qwen-code/pull/7762)) by @doudouOUC +- feat(autofix): retry deterministic rejection once ([#7796](https://github.com/QwenLM/qwen-code/pull/7796)) by @qqqys +- feat(web-shell): add Channel management page ([#7793](https://github.com/QwenLM/qwen-code/pull/7793)) by @qqqys +- feat(acp): add session-scoped runtime MCP ([#7847](https://github.com/QwenLM/qwen-code/pull/7847)) by @qqqys +- feat(core): persist and replay Goal v3 state ([#7815](https://github.com/QwenLM/qwen-code/pull/7815)) by @qqqys +- feat(core): add full-resolution image zoom tool ([#7809](https://github.com/QwenLM/qwen-code/pull/7809)) by @qqqys +- feat(web-shell): add composer footer renderer ([#7856](https://github.com/QwenLM/qwen-code/pull/7856)) by @dreamWB +- feat: Gate session writer lease behind opt-in ([#7894](https://github.com/QwenLM/qwen-code/pull/7894)) by @doudouOUC +- feat(ci): Deduplicate E2E failure issues by commenting on existing issue ([#7792](https://github.com/QwenLM/qwen-code/pull/7792)) by @yiliang114 +- feat(triage): add revert-pattern high-risk path detection ([#7414](https://github.com/QwenLM/qwen-code/pull/7414)) by @yiliang114 +- feat(web-shell): add native workspace folder picker ([#7849](https://github.com/QwenLM/qwen-code/pull/7849)) by @qqqys +- feat(web-shell): add Channel configuration flows ([#7893](https://github.com/QwenLM/qwen-code/pull/7893)) by @qqqys +- feat(core): Add ARMS session user ID ([#7921](https://github.com/QwenLM/qwen-code/pull/7921)) by @doudouOUC +- feat(channels): expose loop tools in daemon sessions ([#7891](https://github.com/QwenLM/qwen-code/pull/7891)) by @qqqys +- feat(web-shell): honor voice hold mode ([#7839](https://github.com/QwenLM/qwen-code/pull/7839)) by @callmeYe +- feat(web-shell): manage Channel pairing requests ([#7909](https://github.com/QwenLM/qwen-code/pull/7909)) by @qqqys +- feat(channels): dispatch GitHub notifications by reason ([#7826](https://github.com/QwenLM/qwen-code/pull/7826)) by @yiliang114 +- feat(web-shell): add git branch picker, commit dialog, and create PR flow ([#7731](https://github.com/QwenLM/qwen-code/pull/7731)) by @wenshao +- feat(triage): surface the sandboxed lanes on the CI path ([#7917](https://github.com/QwenLM/qwen-code/pull/7917)) by @wenshao +- feat(web-shell): suggest BTW for side questions ([#7935](https://github.com/QwenLM/qwen-code/pull/7935)) by @carffuca +- feat(triage): make the verify report readable in Chinese ([#7918](https://github.com/QwenLM/qwen-code/pull/7918)) by @wenshao + +#### Bug Fixes + +- fix(cli): measure insight days and hours in local time everywhere ([#7670](https://github.com/QwenLM/qwen-code/pull/7670)) by @ComplexSimply +- fix(ci): don't fail triage cleanup when there is nothing to clean ([#7688](https://github.com/QwenLM/qwen-code/pull/7688)) by @wenshao +- fix(ci): update qwen in the runner's active npm prefix ([#7689](https://github.com/QwenLM/qwen-code/pull/7689)) by @yiliang114 +- fix(acp): sweep review worktree leases at the end of each prompt turn ([#7694](https://github.com/QwenLM/qwen-code/pull/7694)) by @wenshao +- fix(core): write a status sidecar so models stop misreading quiet background shells ([#7669](https://github.com/QwenLM/qwen-code/pull/7669)) by @ComplexSimply +- fix(core): exit_plan_mode returns guidance error from execute() instead of permission deny ([#7673](https://github.com/QwenLM/qwen-code/pull/7673)) by @qwen-code-dev-bot +- fix(core): tell the model when the user manually exits plan mode ([#7682](https://github.com/QwenLM/qwen-code/pull/7682)) by @zjunothing +- fix(core): give plugins from the same repository distinct extension ids ([#7676](https://github.com/QwenLM/qwen-code/pull/7676)) by @zjunothing +- fix(core): allow reading saved plan files without a confirmation prompt ([#7678](https://github.com/QwenLM/qwen-code/pull/7678)) by @zjunothing +- fix(cli): clear stale retry error when agent auto-recovers mid-turn ([#7681](https://github.com/QwenLM/qwen-code/pull/7681)) by @qwen-code-dev-bot +- fix(mcp): harden OAuth callback handling ([#7510](https://github.com/QwenLM/qwen-code/pull/7510)) by @gauravyad86 +- fix(web-shell): add :focus-visible outline to GitHub PR list rows ([#7704](https://github.com/QwenLM/qwen-code/pull/7704)) by @wenshao +- fix(triage): resolve stage comment ids by marker at patch time, harden model injection ([#7703](https://github.com/QwenLM/qwen-code/pull/7703)) by @wenshao +- fix(triage): resolve finalize PRs from the open-PR list, not the commit association ([#7706](https://github.com/QwenLM/qwen-code/pull/7706)) by @wenshao +- Fixes inline math recognition to correctly handle single-character expressions, escaped dollars, and code spans across prose and tables. ([#7701](https://github.com/QwenLM/qwen-code/pull/7701)) by @CubeLander +- Fixes desktop file size display to correctly render terabyte and larger values instead of showing undefined for huge files. ([#7623](https://github.com/QwenLM/qwen-code/pull/7623)) by @chinesepowered +- Ensures const-derived enums are correctly stringified during OpenAPI 3.0 conversion to meet strict schema requirements. ([#7547](https://github.com/QwenLM/qwen-code/pull/7547)) by @chinesepowered +- Fixes schema conversion to properly handle nested objects even when property names match JSON Schema constraint keywords like maximum. ([#7546](https://github.com/QwenLM/qwen-code/pull/7546)) by @chinesepowered +- Silences xterm.js parser diagnostic logs in headless shell terminals to prevent error messages from leaking when commands emit invalid ANSI escape sequences. ([#7663](https://github.com/QwenLM/qwen-code/pull/7663)) by @mvanhorn +- Hardens the review skill by declaring the optional host argument in CommentStatusArgs and improving error handling for authentication or network failures. ([#7708](https://github.com/QwenLM/qwen-code/pull/7708)) by @wenshao +- Ensures StopFailure hooks fire correctly when loop detection triggers an early return during the streaming loop instead of only on API errors. ([#7592](https://github.com/QwenLM/qwen-code/pull/7592)) by @qwen-code-dev-bot +- Enables the Changes and History dialogs for worktree sessions in the Web Shell by threading the correct git working directory through the entire stack. ([#7695](https://github.com/QwenLM/qwen-code/pull/7695)) by @wenshao +- Strictly parses the heatmapDays query parameter to reject malformed values like negative numbers or decimals, falling back to the default instead of clamping invalid input. ([#7218](https://github.com/QwenLM/qwen-code/pull/7218)) by @VectorPeak +- Raises default live journal caps to 10,000 events and 8 MiB while exposing --max-journal-events and --max-journal-bytes flags for configuration. ([#7715](https://github.com/QwenLM/qwen-code/pull/7715)) by @wenshao +- Allows pinning and grouping secondary workspace sessions in the Web Shell sidebar without requiring the workspace to be locked first. ([#7716](https://github.com/QwenLM/qwen-code/pull/7716)) by @wenshao +- Corrects the review submit-gate audit to avoid falsely claiming a bypass when detecting valid same-account writes from other sources like triage bots. ([#7718](https://github.com/QwenLM/qwen-code/pull/7718)) by @wenshao +- Fixes QQ Bot session restoration by returning the input session ID from AcpBridge and ensuring session patching runs even when validation fails. ([#7722](https://github.com/QwenLM/qwen-code/pull/7722)) by @Eric-GoodBoy-Tech +- fix(core): prevent updates to extension-provided agents ([#7245](https://github.com/QwenLM/qwen-code/pull/7245)) by @destire-mio +- fix(core): fall back to system rg when bundled ripgrep cannot run ([#7203](https://github.com/QwenLM/qwen-code/pull/7203)) by @harjothkhara +- fix(core): avoid required tools in DashScope thinking ([#7661](https://github.com/QwenLM/qwen-code/pull/7661)) by @hogeheer499-commits +- fix(channels): use username as senderId in GitHub adapter to fix allowlist gate ([#7727](https://github.com/QwenLM/qwen-code/pull/7727)) by @OrbitZore +- fix(web-shell): parse 256-color and truecolor SGR sequences in parseAnsi ([#7620](https://github.com/QwenLM/qwen-code/pull/7620)) by @chinesepowered +- fix(triage): only the bot's own approval counts as already approved ([#7737](https://github.com/QwenLM/qwen-code/pull/7737)) by @wenshao +- fix(core): stop humanReadableCron naming intervals that never happen ([#7529](https://github.com/QwenLM/qwen-code/pull/7529)) by @chinesepowered +- fix(cli): keep IME cursor aligned after footer updates ([#7711](https://github.com/QwenLM/qwen-code/pull/7711)) by @water-in-stone +- fix(core): redact the plan argument from history after an approved exit_plan_mode ([#7197](https://github.com/QwenLM/qwen-code/pull/7197)) by @zjunothing +- fix(review): correct the borrowed lenses and vacuous-test severity (follow-up to #7735/#7736) ([#7746](https://github.com/QwenLM/qwen-code/pull/7746)) by @wenshao +- fix(core): reliably deliver manual plan-exit notices ([#7744](https://github.com/QwenLM/qwen-code/pull/7744)) by @doudouOUC +- fix(review): recover bilingual register from the live PR when the plan omits the Han flag ([#7739](https://github.com/QwenLM/qwen-code/pull/7739)) by @wenshao +- fix(cli): complete repeated skill slash commands ([#7720](https://github.com/QwenLM/qwen-code/pull/7720)) by @Sparkle6979 +- fix(cli): handle escaped dollars around inline math ([#7741](https://github.com/QwenLM/qwen-code/pull/7741)) by @CubeLander +- fix(cli): show tool descriptions in multi-tool compact summaries ([#7589](https://github.com/QwenLM/qwen-code/pull/7589)) by @ovochouovo +- fix(ci): rename triage status marker to avoid duplicate-guard collision ([#7723](https://github.com/QwenLM/qwen-code/pull/7723)) by @yiliang114 +- fix(core): route id-less continuation chunks to a colliding tool-call opener's slot ([#6981](https://github.com/QwenLM/qwen-code/pull/6981)) by @he-yufeng +- fix(autofix): answer every review thread, resolve the ones actually fixed ([#7758](https://github.com/QwenLM/qwen-code/pull/7758)) by @wenshao +- fix(core): treat properties as a name map in toOpenAPI30 ([#7760](https://github.com/QwenLM/qwen-code/pull/7760)) by @chinesepowered +- fix(core): keep leading whitespace in gitignore patterns ([#7763](https://github.com/QwenLM/qwen-code/pull/7763)) by @chinesepowered +- fix(core): stop trailing slash from anchoring nested gitignore patterns ([#7764](https://github.com/QwenLM/qwen-code/pull/7764)) by @chinesepowered +- fix(core): keep the model name when a model id carries a variant tag ([#7766](https://github.com/QwenLM/qwen-code/pull/7766)) by @chinesepowered +- fix(weixin): create the account credential file already private ([#7726](https://github.com/QwenLM/qwen-code/pull/7726)) by @chinesepowered +- fix(core): stop rewriting backslash escapes in gitignore patterns ([#7765](https://github.com/QwenLM/qwen-code/pull/7765)) by @chinesepowered +- fix(core): scope the timeout veto to the fragment it appears in ([#7776](https://github.com/QwenLM/qwen-code/pull/7776)) by @chinesepowered +- fix(core): decline sed patterns whose bracket expression starts with ] ([#7775](https://github.com/QwenLM/qwen-code/pull/7775)) by @chinesepowered +- fix(web-shell): stabilize mobile voice input ([#7806](https://github.com/QwenLM/qwen-code/pull/7806)) by @ytahdn +- fix(test): give every E2E case a clean directory again ([#7811](https://github.com/QwenLM/qwen-code/pull/7811)) by @wenshao +- fix(web-shell): allow shell commands in new tasks without a session ([#7724](https://github.com/QwenLM/qwen-code/pull/7724)) by @wenshao +- fix(triage): carry the /verify lane's hardening across to /tmux ([#7753](https://github.com/QwenLM/qwen-code/pull/7753)) by @wenshao +- fix(web-shell): render task notifications as system messages ([#7822](https://github.com/QwenLM/qwen-code/pull/7822)) by @ytahdn +- fix(ci): add default bash shell to container jobs in qwen-triage ([#7838](https://github.com/QwenLM/qwen-code/pull/7838)) by @qwen-code-dev-bot +- fix(web-shell): preserve pasted text in composer ([#7824](https://github.com/QwenLM/qwen-code/pull/7824)) by @ytahdn +- fix(ci): add git safe.directory for container jobs ([#7843](https://github.com/QwenLM/qwen-code/pull/7843)) by @qwen-code-dev-bot +- fix(ci): add --init to container jobs to reap zombie processes ([#7848](https://github.com/QwenLM/qwen-code/pull/7848)) by @qwen-code-dev-bot +- fix(core): wait for output stream flush before settling background shells ([#7833](https://github.com/QwenLM/qwen-code/pull/7833)) by @ComplexSimply +- fix(scripts): retry model calls and surface degraded release notes ([#7535](https://github.com/QwenLM/qwen-code/pull/7535)) by @he-yufeng +- fix(ci): prevent worktree cleanup from failing the verify job ([#7857](https://github.com/QwenLM/qwen-code/pull/7857)) by @qwen-code-dev-bot +- fix(release): publish all channel packages, not just channel-base ([#7845](https://github.com/QwenLM/qwen-code/pull/7845)) by @yiliang114 +- fix(scripts): harden retry classification and preserve deadline error context ([#7854](https://github.com/QwenLM/qwen-code/pull/7854)) by @yiliang114 +- fix(core): fast-fail permanent quota-exhaustion 429s instead of silent retry ([#7842](https://github.com/QwenLM/qwen-code/pull/7842)) by @yiliang114 +- fix(web-shell): isolate history and session drafts ([#7810](https://github.com/QwenLM/qwen-code/pull/7810)) by @ytahdn +- fix(triage): retry a transient npm ci before blaming the PR for it ([#7884](https://github.com/QwenLM/qwen-code/pull/7884)) by @wenshao +- fix(webui): fall back to history_truncated marker recordId for transcript pagination anchor ([#7829](https://github.com/QwenLM/qwen-code/pull/7829)) by @wenshao +- fix(review): give the review retry the remaining time budget ([#7852](https://github.com/QwenLM/qwen-code/pull/7852)) by @wenshao +- fix(integration): configure Docker sandbox networking for submitted-prompt provenance test (#7879) ([#7881](https://github.com/QwenLM/qwen-code/pull/7881)) by @qwen-code-dev-bot +- fix(cli): report a genuine $0.00 cost instead of N/A ([#7784](https://github.com/QwenLM/qwen-code/pull/7784)) by @chinesepowered +- fix(core): reject socks5h and socks4a proxy URLs ([#7786](https://github.com/QwenLM/qwen-code/pull/7786)) by @chinesepowered +- fix(core): correct the character classes in checkContentLoop ([#7788](https://github.com/QwenLM/qwen-code/pull/7788)) by @chinesepowered +- fix(web-shell): make /copy with a bare index work ([#7789](https://github.com/QwenLM/qwen-code/pull/7789)) by @chinesepowered +- fix(core): decline combined sed flags where -i is not last ([#7790](https://github.com/QwenLM/qwen-code/pull/7790)) by @chinesepowered +- fix(core): pass the Grep pattern behind -e so a leading dash is not an option ([#7863](https://github.com/QwenLM/qwen-code/pull/7863)) by @chinesepowered +- fix(core): cap a bare Error's message like every other getErrorMessage path ([#7865](https://github.com/QwenLM/qwen-code/pull/7865)) by @chinesepowered +- fix(cli): make wrapToVisualLines count zero-width characters like its sibling ([#7873](https://github.com/QwenLM/qwen-code/pull/7873)) by @chinesepowered +- fix(core): charge the separator and ellipsis to the preview budget ([#7874](https://github.com/QwenLM/qwen-code/pull/7874)) by @chinesepowered +- fix(cli): do not count a partial trailing line when re-opening a split fence ([#7875](https://github.com/QwenLM/qwen-code/pull/7875)) by @chinesepowered +- fix(cli): make /copy select the code block ([#7883](https://github.com/QwenLM/qwen-code/pull/7883)) by @chinesepowered +- fix(test): Restore first-output benchmark measurement validity and correct its artifact schema ([#7820](https://github.com/QwenLM/qwen-code/pull/7820)) by @doudouOUC +- fix(daemon): harden Todo Stop Guard continuations ([#7821](https://github.com/QwenLM/qwen-code/pull/7821)) by @doudouOUC +- fix(core): read the stash reflog from the common git dir ([#7774](https://github.com/QwenLM/qwen-code/pull/7774)) by @chinesepowered +- fix(core): keep Draft 4 boolean exclusive bounds in toOpenAPI30 ([#7782](https://github.com/QwenLM/qwen-code/pull/7782)) by @chinesepowered +- fix(core): apply maxDepth to flat-format memory imports ([#7851](https://github.com/QwenLM/qwen-code/pull/7851)) by @chinesepowered +- fix(core): render a thought part's reasoning instead of the boolean flag ([#7866](https://github.com/QwenLM/qwen-code/pull/7866)) by @chinesepowered +- fix(core): bridge tool-result images for text-only models ([#7484](https://github.com/QwenLM/qwen-code/pull/7484)) by @LaZzyMan +- fix(cli): patch ink to clear staticNode on indirect subtree removal ([#7816](https://github.com/QwenLM/qwen-code/pull/7816)) by @chiga0 +- fix(core): auto-retry transient network errors during API calls ([#7898](https://github.com/QwenLM/qwen-code/pull/7898)) by @chiga0 +- fix(cli): hide stale sticky todos from previous turns ([#7900](https://github.com/QwenLM/qwen-code/pull/7900)) by @chiga0 +- fix(serve): Release managed session writer locks on shutdown ([#7812](https://github.com/QwenLM/qwen-code/pull/7812)) by @doudouOUC +- fix(cli): add polling fallback for git branch name display ([#7830](https://github.com/QwenLM/qwen-code/pull/7830)) by @qwen-code-dev-bot +- fix(cli): remove redundant 'Read file' prefix from @mention tool card ([#7902](https://github.com/QwenLM/qwen-code/pull/7902)) by @qwen-code-dev-bot +- fix(safe-mode): preserve caller-supplied top-tier MCP servers ([#7827](https://github.com/QwenLM/qwen-code/pull/7827)) by @VitaliBabkin +- fix(ci): keep the post-merge E2E signal on main alive ([#7795](https://github.com/QwenLM/qwen-code/pull/7795)) by @wenshao +- fix(core): short-circuit the flush wait when the output stream cannot flush ([#7905](https://github.com/QwenLM/qwen-code/pull/7905)) by @ComplexSimply +- fix(core): track quotes inside a command substitution in splitCommands ([#7870](https://github.com/QwenLM/qwen-code/pull/7870)) by @chinesepowered +- fix(core): count the per-server and per-tool always-allow outcomes as approvals ([#7869](https://github.com/QwenLM/qwen-code/pull/7869)) by @chinesepowered +- fix(web-shell): report intended workspace to host when starting a new chat ([#7910](https://github.com/QwenLM/qwen-code/pull/7910)) by @wenshao +- fix(cli): default to virtualized terminal history ([#5738](https://github.com/QwenLM/qwen-code/pull/5738)) by @ZevGit +- fix(ci): restore workspace ownership in cleanup to prevent EACCES ([#7931](https://github.com/QwenLM/qwen-code/pull/7931)) by @qwen-code-dev-bot +- fix(review): recover the resolved effort when --effort is not re-threaded ([#7855](https://github.com/QwenLM/qwen-code/pull/7855)) by @wenshao +- fix(scripts): slim release-note model prompts and log request timing ([#7941](https://github.com/QwenLM/qwen-code/pull/7941)) by @yiliang114 +- fix(release): pin channel-base dep to exact version during release bump ([#7953](https://github.com/QwenLM/qwen-code/pull/7953)) by @yiliang114 +- fix(triage): make the build-process guard diagnosable and zombie-aware ([#7858](https://github.com/QwenLM/qwen-code/pull/7858)) by @wenshao +- fix(ci): give each job its own proxy wrapper directory ([#7951](https://github.com/QwenLM/qwen-code/pull/7951)) by @wenshao + +#### Performance + +- perf(cli): cache GitHub PR list in the daemon route with a 60s TTL ([#7705](https://github.com/QwenLM/qwen-code/pull/7705)) by @wenshao +- perf(core): keep the volatile auto-memory section last in the system prompt ([#7651](https://github.com/QwenLM/qwen-code/pull/7651)) by @DragonnZhang +- perf(web-shell): paint the composer git chip before git status completes ([#7680](https://github.com/QwenLM/qwen-code/pull/7680)) by @wenshao +- perf(core): Lazy-load first-use dependencies ([#7686](https://github.com/QwenLM/qwen-code/pull/7686)) by @doudouOUC +- perf(cli): replace comment-json settings parser ([#7747](https://github.com/QwenLM/qwen-code/pull/7747)) by @doudouOUC +- perf(acp): Preload providers after session creation ([#7767](https://github.com/QwenLM/qwen-code/pull/7767)) by @doudouOUC +- perf(core): add early Anthropic cache breakpoint on the stable system prefix ([#7912](https://github.com/QwenLM/qwen-code/pull/7912)) by @DragonnZhang +- perf(ci): cut the E2E suite from ~40min to ~24min ([#7798](https://github.com/QwenLM/qwen-code/pull/7798)) by @wenshao + +#### Documentation + +- docs(channels): Document loops and proactive delivery ([#7628](https://github.com/QwenLM/qwen-code/pull/7628)) by @wenshao + +#### Internal Changes + +- refactor(autofix): extract review verification runner ([#7644](https://github.com/QwenLM/qwen-code/pull/7644)) by @qqqys +- test(web-shell): capture the git-mode new-branch sub-state in the visuals suite ([#7672](https://github.com/QwenLM/qwen-code/pull/7672)) by @wenshao +- Refactors system prompt assembly into a layered builder to explicitly manage stable, context, and volatile instruction layers. ([#7707](https://github.com/QwenLM/qwen-code/pull/7707)) by @DragonnZhang +- Adds regression tests to verify that restored-session transcript pagination correctly URL-encodes boundaries and preserves state during retry attempts. ([#7657](https://github.com/QwenLM/qwen-code/pull/7657)) by @jay666mnj +- test(cli): cover bottom-stuck virtualized list behavior ([#7652](https://github.com/QwenLM/qwen-code/pull/7652)) by @jay666mnj +- ci: keep the critical-audit gate honest when npm cannot answer ([#7743](https://github.com/QwenLM/qwen-code/pull/7743)) by @wenshao +- test(integration): deflake tool-control permission cases ([#7725](https://github.com/QwenLM/qwen-code/pull/7725)) by @yiliang114 +- revert: drop the stale-base un-park recovery (#7602) ([#7640](https://github.com/QwenLM/qwen-code/pull/7640)) by @wenshao +- test(serve): Add first-output latency benchmark ([#7761](https://github.com/QwenLM/qwen-code/pull/7761)) by @doudouOUC +- test(channels): run the feishu, weixin and qqbot suites in CI ([#7853](https://github.com/QwenLM/qwen-code/pull/7853)) by @chinesepowered + +### New Contributors + +- @jay666mnj made their first contribution in [#7674](https://github.com/QwenLM/qwen-code/pull/7674) +- @harjothkhara made their first contribution in [#7203](https://github.com/QwenLM/qwen-code/pull/7203) +- @PratikWayase made their first contribution in [#7709](https://github.com/QwenLM/qwen-code/pull/7709) +- @Sparkle6979 made their first contribution in [#7720](https://github.com/QwenLM/qwen-code/pull/7720) +- @VitaliBabkin made their first contribution in [#7827](https://github.com/QwenLM/qwen-code/pull/7827) + +**Full Changelog**: https://github.com/QwenLM/qwen-code/compare/v0.21.0...v0.21.1 + ## [0.21.0](https://github.com/QwenLM/qwen-code/releases/tag/v0.21.0) - 2026-07-24 ### Highlights @@ -288,18 +503,18 @@ No known breaking changes. - fix(dingtalk): anchor @mention strip regex to start of text ([#7401](https://github.com/QwenLM/qwen-code/pull/7401)) by @qwen-code-dev-bot - fix: support context-inheriting subagents in headless mode ([#7378](https://github.com/QwenLM/qwen-code/pull/7378)) by @DragonnZhang - fix(cli): soften update-check failure UX — warning instead of error, raise timeout to 5s ([#7409](https://github.com/QwenLM/qwen-code/pull/7409)) by @ComplexSimply -- fix(test): widen daemon boot timeout from 10s to 30s for docker sandbox ([#7419](https://github.com/QwenLM/qwen-code/pull/7419)) by @qwen-code-dev-bot +- fix(test): widen daemon boot timeout from 10s to 25s for docker sandbox ([#7419](https://github.com/QwenLM/qwen-code/pull/7419)) by @qwen-code-dev-bot - fix: worktree sessions unopenable in Web Shell while actively running ([#7424](https://github.com/QwenLM/qwen-code/pull/7424)) by @wenshao - fix(web-shell): restore context tags in queued and recalled prompts ([#7312](https://github.com/QwenLM/qwen-code/pull/7312)) by @dreamWB - fix(core): resolve artifact workspacePath against workspace root in worktree sessions ([#7429](https://github.com/QwenLM/qwen-code/pull/7429)) by @wenshao - fix(cli): classify nested update-check network errors ([#7428](https://github.com/QwenLM/qwen-code/pull/7428)) by @yiliang114 -- fix(test): pin QWEN_RUNTIME_DIR in daemon integration tests ([#7439](https://github.com/QwenLM/qwen-code/pull/7439)) by @wenshao -- fix(deps): regenerate the ink@7.0.3 patch so it applies on a clean install ([#7407](https://github.com/QwenLM/qwen-code/pull/7407)) by @chiga0 -- fix(core): relax additionalProperties:false on the OpenAI wire for optional-field schemas ([#7344](https://github.com/QwenLM/qwen-code/pull/7344)) by @zjunothing -- fix(dingtalk): cap media download size to 50MB to match feishu ([#7361](https://github.com/QwenLM/qwen-code/pull/7361)) by @chinesepowered -- fix(core): allow restricted git config for extension installs ([#7293](https://github.com/QwenLM/qwen-code/pull/7293)) by @ytahdn -- fix(core): Enforce final tool response budgets ([#7323](https://github.com/QwenLM/qwen-code/pull/7323)) by @doudouOUC -- fix(acp): clear inherited sandboxSessionId for each new ACP session ([#7443](https://github.com/QwenLM/qwen-code/pull/7443)) by @wenshao +- Fixed intermittent Docker CI test failures caused by session lock files resolving outside the isolated test directory. ([#7439](https://github.com/QwenLM/qwen-code/pull/7439)) by @wenshao +- Regenerated the ink@7.0.3 patch to ensure it applies cleanly during fresh installations and prevents build errors. ([#7407](https://github.com/QwenLM/qwen-code/pull/7407)) by @chiga0 +- Relaxed OpenAI wire schema constraints for optional fields to prevent models from being forced to provide mutually exclusive arguments. ([#7344](https://github.com/QwenLM/qwen-code/pull/7344)) by @zjunothing +- Added a 50MB size cap and 30-second timeout to DingTalk media downloads to match Feishu adapter behavior. ([#7361](https://github.com/QwenLM/qwen-code/pull/7361)) by @chinesepowered +- Allowed simple-git to accept restricted protocol and global config settings required for public Git extension installations. ([#7293](https://github.com/QwenLM/qwen-code/pull/7293)) by @ytahdn +- Enforced deterministic aggregate budgeting for batches of tool responses to prevent exceeding character limits across all runtimes. ([#7323](https://github.com/QwenLM/qwen-code/pull/7323)) by @doudouOUC +- Fixed Docker CI session conflicts by clearing inherited sandbox session IDs to ensure each sub-session generates a unique identifier. ([#7443](https://github.com/QwenLM/qwen-code/pull/7443)) by @wenshao #### Performance @@ -322,11 +537,7 @@ No known breaking changes. - chore: add CODEOWNERS for cua-driver and mobile-mcp ([#7369](https://github.com/QwenLM/qwen-code/pull/7369)) by @qwen-code-dev-bot - chore: simplify CODEOWNERS to package-level rules ([#7376](https://github.com/QwenLM/qwen-code/pull/7376)) by @pomelo-nwu - chore(docs,test): batch three small docs and test fixes ([#7373](https://github.com/QwenLM/qwen-code/pull/7373)) by @ZijianZhang989 -- test(cli): pin the record_artifact workspacePath round trip ([#7434](https://github.com/QwenLM/qwen-code/pull/7434)) by @wenshao - -### New Contributors - -- @ComplexSimply made their first contribution in [#7409](https://github.com/QwenLM/qwen-code/pull/7409) +- Added tests to verify that artifact file paths resolve correctly in both ordinary and worktree sessions. ([#7434](https://github.com/QwenLM/qwen-code/pull/7434)) by @wenshao **Full Changelog**: https://github.com/QwenLM/qwen-code/compare/v0.20.0...v0.20.1 diff --git a/integration-tests/interactive/file-system-interactive.test.ts b/integration-tests/interactive/file-system-interactive.test.ts index 7bc56461e5..7c1e2f9f48 100644 --- a/integration-tests/interactive/file-system-interactive.test.ts +++ b/integration-tests/interactive/file-system-interactive.test.ts @@ -36,7 +36,7 @@ describe('Interactive file system', () => { const { ptyProcess } = rig.runInteractive(); // Wait for the app to be ready - const isReady = await rig.waitForText('Type your message', 15000); + const isReady = await rig.waitForText('Type your message'); expect( isReady, 'CLI did not start up in interactive mode correctly', @@ -47,10 +47,10 @@ describe('Interactive file system', () => { await type(ptyProcess, readPrompt); await type(ptyProcess, '\r'); - const readCall = await rig.waitForToolCall('read_file', 30000); + const readCall = await rig.waitForToolCall('read_file'); expect(readCall, 'Expected to find a read_file tool call').toBe(true); - const containsExpectedVersion = await rig.waitForText('1.0.0', 15000); + const containsExpectedVersion = await rig.waitForText('1.0.0'); expect( containsExpectedVersion, 'Expected to see version "1.0.0" in output', @@ -61,10 +61,7 @@ describe('Interactive file system', () => { await type(ptyProcess, writePrompt); await type(ptyProcess, '\r'); - const toolCall = await rig.waitForAnyToolCall( - ['write_file', 'edit'], - 30000, - ); + const toolCall = await rig.waitForAnyToolCall(['write_file', 'edit']); if (!toolCall) { printDebugInfo(rig, rig._interactiveOutput, { diff --git a/integration-tests/sdk-typescript/tool-control.test.ts b/integration-tests/sdk-typescript/tool-control.test.ts index 4d7da0038b..8f8cbbd060 100644 --- a/integration-tests/sdk-typescript/tool-control.test.ts +++ b/integration-tests/sdk-typescript/tool-control.test.ts @@ -42,6 +42,7 @@ const LOCAL_OPENAI_NO_PROXY = IS_CONTAINER_SANDBOX ? CONTAINER_SANDBOX_NO_PROXY : '127.0.0.1,localhost'; const FAKE_SERVER_OPTIONS = fakeServerHostOptions(); +const INITIAL_CONTENT = 'original content'; function fakeModelOptions(baseUrl: string) { return { @@ -76,7 +77,7 @@ describe('Tool Control Parameters (E2E)', () => { 'should only allow specified tools when coreTools is set', async () => { // Create a test file - await helper.createFile('test.txt', 'original content'); + await helper.createFile('test.txt', INITIAL_CONTENT); const q = query({ prompt: @@ -119,7 +120,7 @@ describe('Tool Control Parameters (E2E)', () => { const input = tc.toolUse.input as { content?: string }; return ( typeof input?.content === 'string' && - input.content !== 'original content' + input.content !== INITIAL_CONTENT ); }); expect(writtenContent).toBe(true); @@ -1551,7 +1552,7 @@ describe('Tool Control Parameters (E2E)', () => { it( 'should invoke canUseTool callback when using asyncGenerator as prompt', async () => { - await helper.createFile('test.txt', 'original content'); + await helper.createFile('test.txt', INITIAL_CONTENT); const resultWaiter = createResultWaiter(1); const canUseToolCalls: Array<{ @@ -1618,9 +1619,22 @@ describe('Tool Control Parameters (E2E)', () => { const writeFileResults = findToolResults(messages, 'write_file'); expect(writeFileResults.length).toBeGreaterThan(0); - // Verify file was modified - const content = await helper.readFile('test.txt'); - expect(content).toBe('updated'); + // Verify the write_file call itself requested different content + // than the original. Asserting on the tool-call arguments (rather + // than re-reading the file afterwards) avoids flakiness in + // sandboxed environments where the file write may not be + // observable from the test process by the time we check it, and + // is model-agnostic (the model may paraphrase the content). + const writeFileCalls = findToolCalls(messages, 'write_file'); + expect(writeFileCalls.length).toBeGreaterThan(0); + const writtenContent = writeFileCalls.some((tc) => { + const input = tc.toolUse.input as { content?: string }; + return ( + typeof input?.content === 'string' && + input.content !== INITIAL_CONTENT + ); + }); + expect(writtenContent).toBe(true); } finally { await q.close(); } @@ -1631,7 +1645,7 @@ describe('Tool Control Parameters (E2E)', () => { it( 'should deny tool when canUseTool returns deny with asyncGenerator prompt', async () => { - await helper.createFile('test.txt', 'original content'); + await helper.createFile('test.txt', INITIAL_CONTENT); const resultWaiter = createResultWaiter(1); // Create an async generator that yields a single message @@ -1703,7 +1717,7 @@ describe('Tool Control Parameters (E2E)', () => { // File content should remain unchanged (because write was denied) const content = await helper.readFile('test.txt'); - expect(content).toBe('original content'); + expect(content).toBe(INITIAL_CONTENT); } finally { await q.close(); } diff --git a/package-lock.json b/package-lock.json index f502a6b56d..88281327ca 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@qwen-code/qwen-code", - "version": "0.21.0", + "version": "0.21.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@qwen-code/qwen-code", - "version": "0.21.0", + "version": "0.21.1", "hasInstallScript": true, "workspaces": [ "packages/*", @@ -88,7 +88,7 @@ }, "integrations/external-context": { "name": "@qwen-code/external-context", - "version": "0.20.1", + "version": "0.21.1", "dependencies": { "@modelcontextprotocol/sdk": "^1.25.2", "undici": "^7.28.0", @@ -27517,7 +27517,7 @@ }, "packages/acp-bridge": { "name": "@qwen-code/acp-bridge", - "version": "0.21.0", + "version": "0.21.1", "dependencies": { "@agentclientprotocol/sdk": "^0.14.1", "@qwen-code/qwen-code-core": "file:../core" @@ -27532,7 +27532,7 @@ }, "packages/audio-capture": { "name": "@qwen-code/audio-capture", - "version": "0.21.0", + "version": "0.21.1", "hasInstallScript": true, "dependencies": { "node-gyp-build": "^4.8.4" @@ -27549,7 +27549,7 @@ }, "packages/channels/base": { "name": "@qwen-code/channel-base", - "version": "0.21.0", + "version": "0.21.1", "dependencies": { "@agentclientprotocol/sdk": "^0.14.1" }, @@ -27559,9 +27559,9 @@ }, "packages/channels/dingtalk": { "name": "@qwen-code/channel-dingtalk", - "version": "0.21.0", + "version": "0.21.1", "dependencies": { - "@qwen-code/channel-base": "^0.21.0", + "@qwen-code/channel-base": "0.21.1", "dingtalk-stream-sdk-nodejs": "^2.0.4" }, "devDependencies": { @@ -27570,10 +27570,10 @@ }, "packages/channels/feishu": { "name": "@qwen-code/channel-feishu", - "version": "0.21.0", + "version": "0.21.1", "dependencies": { "@larksuiteoapi/node-sdk": "^1.45.0", - "@qwen-code/channel-base": "^0.21.0" + "@qwen-code/channel-base": "0.21.1" }, "devDependencies": { "typescript": "^5.0.0" @@ -27581,10 +27581,10 @@ }, "packages/channels/github": { "name": "@qwen-code/channel-github", - "version": "0.21.0", + "version": "0.21.1", "dependencies": { "@octokit/rest": "^21.1.1", - "@qwen-code/channel-base": "^0.21.0", + "@qwen-code/channel-base": "0.21.1", "https-proxy-agent": "^7.0.6" }, "devDependencies": { @@ -27593,7 +27593,7 @@ }, "packages/channels/plugin-example": { "name": "@qwen-code/channel-plugin-example", - "version": "0.21.0", + "version": "0.21.1", "dependencies": { "@qwen-code/channel-base": "file:../base", "ws": "^8.18.0" @@ -27607,9 +27607,9 @@ }, "packages/channels/qqbot": { "name": "@qwen-code/channel-qqbot", - "version": "0.21.0", + "version": "0.21.1", "dependencies": { - "@qwen-code/channel-base": "^0.21.0", + "@qwen-code/channel-base": "0.21.1", "@tencent-connect/qqbot-connector": "^1.1.0", "ws": "^8.18.0" }, @@ -27619,9 +27619,9 @@ }, "packages/channels/telegram": { "name": "@qwen-code/channel-telegram", - "version": "0.21.0", + "version": "0.21.1", "dependencies": { - "@qwen-code/channel-base": "^0.21.0", + "@qwen-code/channel-base": "0.21.1", "grammy": "^1.41.1", "https-proxy-agent": "^7.0.6", "telegram-markdown-formatter": "^0.1.2" @@ -27632,9 +27632,9 @@ }, "packages/channels/wecom": { "name": "@qwen-code/channel-wecom", - "version": "0.21.0", + "version": "0.21.1", "dependencies": { - "@qwen-code/channel-base": "^0.21.0", + "@qwen-code/channel-base": "0.21.1", "@wecom/aibot-node-sdk": "^1.0.7" }, "devDependencies": { @@ -27643,9 +27643,9 @@ }, "packages/channels/weixin": { "name": "@qwen-code/channel-weixin", - "version": "0.21.0", + "version": "0.21.1", "dependencies": { - "@qwen-code/channel-base": "^0.21.0" + "@qwen-code/channel-base": "0.21.1" }, "devDependencies": { "typescript": "^5.0.0" @@ -27653,7 +27653,7 @@ }, "packages/chrome-extension": { "name": "@qwen-code/chrome-bridge", - "version": "0.21.0", + "version": "0.21.1", "license": "Apache-2.0", "devDependencies": { "@types/chrome": "^0.1.32", @@ -27666,7 +27666,7 @@ }, "packages/cli": { "name": "@qwen-code/qwen-code", - "version": "0.21.0", + "version": "0.21.1", "dependencies": { "@agentclientprotocol/sdk": "^0.14.1", "@google/genai": "2.6.0", @@ -27915,7 +27915,7 @@ }, "packages/core": { "name": "@qwen-code/qwen-code-core", - "version": "0.21.0", + "version": "0.21.1", "hasInstallScript": true, "dependencies": { "@anthropic-ai/sdk": "^0.36.1", @@ -30782,7 +30782,7 @@ }, "packages/vscode-ide-companion": { "name": "qwen-code-vscode-ide-companion", - "version": "0.21.0", + "version": "0.21.1", "license": "LICENSE", "dependencies": { "@agentclientprotocol/sdk": "^0.14.1", @@ -30851,7 +30851,7 @@ }, "packages/web-shell": { "name": "@qwen-code/web-shell", - "version": "0.21.0", + "version": "0.21.1", "dependencies": { "@codemirror/autocomplete": "^6.18.0", "@codemirror/commands": "^6.7.0", @@ -31583,7 +31583,7 @@ }, "packages/web-templates": { "name": "@qwen-code/web-templates", - "version": "0.21.0", + "version": "0.21.1", "devDependencies": { "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", @@ -32090,7 +32090,7 @@ }, "packages/webui": { "name": "@qwen-code/webui", - "version": "0.21.0", + "version": "0.21.1", "license": "MIT", "dependencies": { "@qwen-code/sdk": "file:../sdk-typescript", diff --git a/package.json b/package.json index 6f86ef1709..4d6e0c2a57 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@qwen-code/qwen-code", - "version": "0.21.0", + "version": "0.21.1", "engines": { "node": ">=22.0.0" }, @@ -24,7 +24,7 @@ "url": "git+https://github.com/QwenLM/qwen-code.git" }, "config": { - "sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.21.0" + "sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.21.1" }, "scripts": { "start": "node scripts/start.js", diff --git a/packages/acp-bridge/package.json b/packages/acp-bridge/package.json index d93c20c5ba..596fe78d8c 100644 --- a/packages/acp-bridge/package.json +++ b/packages/acp-bridge/package.json @@ -1,6 +1,6 @@ { "name": "@qwen-code/acp-bridge", - "version": "0.21.0", + "version": "0.21.1", "description": "Shared ACP bridge core (createHttpAcpBridge factory, BridgeClient, defaultSpawnChannelFactory, BridgeFileSystem injection seam) + primitives (EventBus, AcpChannel, in-memory channel, PermissionMediator interface) used by qwen serve, channels, IDE, TUI, and remote-control adapters.", "repository": { "type": "git", diff --git a/packages/audio-capture/package.json b/packages/audio-capture/package.json index 9d09c8adc9..fb820d43f4 100644 --- a/packages/audio-capture/package.json +++ b/packages/audio-capture/package.json @@ -1,6 +1,6 @@ { "name": "@qwen-code/audio-capture", - "version": "0.21.0", + "version": "0.21.1", "description": "Native microphone capture backend for Qwen Code voice input", "type": "module", "main": "dist/index.js", diff --git a/packages/channels/base/package.json b/packages/channels/base/package.json index 044096cb71..577f20883f 100644 --- a/packages/channels/base/package.json +++ b/packages/channels/base/package.json @@ -1,6 +1,6 @@ { "name": "@qwen-code/channel-base", - "version": "0.21.0", + "version": "0.21.1", "description": "Base channel infrastructure for Qwen Code", "type": "module", "main": "dist/index.js", diff --git a/packages/channels/dingtalk/package.json b/packages/channels/dingtalk/package.json index 66da64fca3..f277c132fd 100644 --- a/packages/channels/dingtalk/package.json +++ b/packages/channels/dingtalk/package.json @@ -1,6 +1,6 @@ { "name": "@qwen-code/channel-dingtalk", - "version": "0.21.0", + "version": "0.21.1", "description": "DingTalk channel adapter for Qwen Code", "type": "module", "main": "dist/index.js", @@ -20,7 +20,7 @@ "test:ci": "vitest run" }, "dependencies": { - "@qwen-code/channel-base": "^0.21.0", + "@qwen-code/channel-base": "0.21.1", "dingtalk-stream-sdk-nodejs": "^2.0.4" }, "devDependencies": { diff --git a/packages/channels/feishu/package.json b/packages/channels/feishu/package.json index 843f43fb0c..57c795fed3 100644 --- a/packages/channels/feishu/package.json +++ b/packages/channels/feishu/package.json @@ -1,6 +1,6 @@ { "name": "@qwen-code/channel-feishu", - "version": "0.21.0", + "version": "0.21.1", "description": "Feishu (Lark) channel adapter for Qwen Code", "type": "module", "main": "dist/index.js", @@ -20,7 +20,7 @@ "test:ci": "vitest run" }, "dependencies": { - "@qwen-code/channel-base": "^0.21.0", + "@qwen-code/channel-base": "0.21.1", "@larksuiteoapi/node-sdk": "^1.45.0" }, "devDependencies": { diff --git a/packages/channels/github/package.json b/packages/channels/github/package.json index b984dad65a..f168245397 100644 --- a/packages/channels/github/package.json +++ b/packages/channels/github/package.json @@ -1,6 +1,6 @@ { "name": "@qwen-code/channel-github", - "version": "0.21.0", + "version": "0.21.1", "description": "GitHub polling channel adapter for Qwen Code", "type": "module", "main": "dist/index.js", @@ -20,7 +20,7 @@ "test:ci": "vitest run" }, "dependencies": { - "@qwen-code/channel-base": "^0.21.0", + "@qwen-code/channel-base": "0.21.1", "@octokit/rest": "^21.1.1", "https-proxy-agent": "^7.0.6" }, diff --git a/packages/channels/plugin-example/package.json b/packages/channels/plugin-example/package.json index 5f370dbea4..d575fc841b 100644 --- a/packages/channels/plugin-example/package.json +++ b/packages/channels/plugin-example/package.json @@ -1,6 +1,6 @@ { "name": "@qwen-code/channel-plugin-example", - "version": "0.21.0", + "version": "0.21.1", "private": true, "type": "module", "main": "dist/index.js", diff --git a/packages/channels/qqbot/package.json b/packages/channels/qqbot/package.json index 51af22a993..278fef4900 100644 --- a/packages/channels/qqbot/package.json +++ b/packages/channels/qqbot/package.json @@ -1,6 +1,6 @@ { "name": "@qwen-code/channel-qqbot", - "version": "0.21.0", + "version": "0.21.1", "description": "QQ Bot (QQ机器人) channel adapter for Qwen Code", "type": "module", "main": "dist/index.js", @@ -20,7 +20,7 @@ "test:ci": "vitest run" }, "dependencies": { - "@qwen-code/channel-base": "^0.21.0", + "@qwen-code/channel-base": "0.21.1", "@tencent-connect/qqbot-connector": "^1.1.0", "ws": "^8.18.0" }, diff --git a/packages/channels/telegram/package.json b/packages/channels/telegram/package.json index 277a828138..33fbe8bfd3 100644 --- a/packages/channels/telegram/package.json +++ b/packages/channels/telegram/package.json @@ -1,6 +1,6 @@ { "name": "@qwen-code/channel-telegram", - "version": "0.21.0", + "version": "0.21.1", "description": "Telegram channel adapter for Qwen Code", "type": "module", "main": "dist/index.js", @@ -20,7 +20,7 @@ "test:ci": "vitest run" }, "dependencies": { - "@qwen-code/channel-base": "^0.21.0", + "@qwen-code/channel-base": "0.21.1", "grammy": "^1.41.1", "https-proxy-agent": "^7.0.6", "telegram-markdown-formatter": "^0.1.2" diff --git a/packages/channels/wecom/package.json b/packages/channels/wecom/package.json index 617b4de343..3a9159cb27 100644 --- a/packages/channels/wecom/package.json +++ b/packages/channels/wecom/package.json @@ -1,6 +1,6 @@ { "name": "@qwen-code/channel-wecom", - "version": "0.21.0", + "version": "0.21.1", "description": "WeCom channel adapter for Qwen Code", "type": "module", "main": "dist/index.js", @@ -20,7 +20,7 @@ "test:ci": "vitest run" }, "dependencies": { - "@qwen-code/channel-base": "^0.21.0", + "@qwen-code/channel-base": "0.21.1", "@wecom/aibot-node-sdk": "^1.0.7" }, "devDependencies": { diff --git a/packages/channels/weixin/package.json b/packages/channels/weixin/package.json index 3168773711..6f2bd53f1f 100644 --- a/packages/channels/weixin/package.json +++ b/packages/channels/weixin/package.json @@ -1,6 +1,6 @@ { "name": "@qwen-code/channel-weixin", - "version": "0.21.0", + "version": "0.21.1", "description": "WeChat (Weixin) channel adapter for Qwen Code", "type": "module", "main": "dist/index.js", @@ -28,7 +28,7 @@ "test:ci": "vitest run" }, "dependencies": { - "@qwen-code/channel-base": "^0.21.0" + "@qwen-code/channel-base": "0.21.1" }, "devDependencies": { "typescript": "^5.0.0" diff --git a/packages/chrome-extension/package.json b/packages/chrome-extension/package.json index 55fc3b57e6..de5a164d8e 100644 --- a/packages/chrome-extension/package.json +++ b/packages/chrome-extension/package.json @@ -1,6 +1,6 @@ { "name": "@qwen-code/chrome-bridge", - "version": "0.21.0", + "version": "0.21.1", "description": "Chrome extension bridge for Qwen CLI - enables AI-powered browser interactions", "private": true, "repository": { diff --git a/packages/cli/package.json b/packages/cli/package.json index cb1ac92120..6a0aea44ef 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@qwen-code/qwen-code", - "version": "0.21.0", + "version": "0.21.1", "description": "Qwen Code", "repository": { "type": "git", @@ -37,7 +37,7 @@ "dist" ], "config": { - "sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.21.0" + "sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.21.1" }, "dependencies": { "@agentclientprotocol/sdk": "^0.14.1", diff --git a/packages/cli/src/ui/hooks/useGeminiStream.ts b/packages/cli/src/ui/hooks/useGeminiStream.ts index 45b8f14150..d2948685dd 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.ts +++ b/packages/cli/src/ui/hooks/useGeminiStream.ts @@ -2264,6 +2264,8 @@ export const useGeminiStream = ( case ServerGeminiEventType.ActiveGoal: handleActiveGoalEvent(event.value); break; + case ServerGeminiEventType.GoalState: + break; default: { // enforces exhaustive switch-case const unreachable: never = event; diff --git a/packages/core/package.json b/packages/core/package.json index 477fb44575..dffe771c14 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@qwen-code/qwen-code-core", - "version": "0.21.0", + "version": "0.21.1", "description": "Qwen Code Core", "repository": { "type": "git", diff --git a/packages/core/src/config/config.test.ts b/packages/core/src/config/config.test.ts index 850fd2bbb5..2035121495 100644 --- a/packages/core/src/config/config.test.ts +++ b/packages/core/src/config/config.test.ts @@ -86,7 +86,15 @@ import { SkillManager } from '../skills/skill-manager.js'; import { HookSystem } from '../hooks/index.js'; import { GOAL_HOOK_ID_OUTPUT_KEY } from '../goals/goalHook.js'; import type { FileHistorySnapshot } from '../services/fileHistoryService.js'; -import type { ChatRecordingFailureEvent } from '../services/chatRecordingService.js'; +import type { + ChatRecord, + ChatRecordingFailureEvent, +} from '../services/chatRecordingService.js'; +import type { ResumedSessionData } from '../services/sessionService.js'; +import { + GoalPersistenceUnavailableError, + type GoalTurnHost, +} from '../goals/goal-runtime.js'; import { SessionTranscriptChangedError, SessionWriterLease, @@ -2185,6 +2193,146 @@ describe('Server Config (config.ts)', () => { expect(flush).not.toHaveBeenCalled(); }); + const resumedGoalSession = ( + status: 'active' | 'paused', + ): ResumedSessionData => { + const record: ChatRecord = { + uuid: `goal-${status}`, + parentUuid: null, + sessionId: 'resumed-session', + timestamp: new Date(0).toISOString(), + type: 'system', + subtype: 'goal_state', + provenance: 'goal_control', + cwd: '/tmp', + version: 'test', + systemPayload: { + v: 2, + cause: status === 'active' ? 'create' : 'pause', + snapshot: { + v: 2, + activity: 'idle', + goal: { + goalId: 'g-resumed', + revision: 1, + objective: 'resume me', + status, + evidenceCursor: { recordId: 'goal-active' }, + turnCount: 1, + activeTimeMs: 10, + createdAt: 1, + updatedAt: 2, + }, + }, + }, + }; + return { + conversation: { + sessionId: 'resumed-session', + projectHash: 'test', + startTime: new Date(0).toISOString(), + lastUpdated: new Date(0).toISOString(), + messages: [record], + }, + filePath: '/tmp/resumed-session.jsonl', + lastCompletedUuid: record.uuid, + }; + }; + + it('restores the complete resumed-session Goal before exposing readiness', async () => { + const config = new Config({ + ...baseParams, + chatRecording: true, + sessionData: resumedGoalSession('paused'), + }); + + const initial = await config.getGoalRuntimeReady(); + expect(initial.getSnapshot().goal).toMatchObject({ + objective: 'resume me', + status: 'paused', + }); + + config.startNewSession( + 'replacement-session', + resumedGoalSession('active'), + ); + const replacement = await config.getGoalRuntimeReady(); + expect(replacement).not.toBe(initial); + expect(replacement.getSnapshot().goal?.status).toBe('active'); + }); + + it('owns one durable Goal runtime per canonical session', async () => { + const config = new Config({ ...baseParams, chatRecording: true }); + const first = config.getGoalRuntime(); + + expect(config.getGoalRuntime()).toBe(first); + config.startNewSession('replacement-session'); + const replacement = config.getGoalRuntime(); + + expect(replacement).not.toBe(first); + await expect( + first.dispatch({ action: 'create', objective: 'stale' }), + ).rejects.toThrow('Goal runtime has been disposed'); + }); + + it('rebinds the current Goal host to every replacement runtime', async () => { + const config = new Config({ + ...baseParams, + chatRecording: true, + sessionData: resumedGoalSession('active'), + }); + const started: string[] = []; + const host: GoalTurnHost = { + startGoalTurn: vi.fn(async ({ permit }) => { + started.push(permit.goalId); + }), + preemptGoalTurn: vi.fn(), + }; + + config.bindGoalTurnHost(host); + await config.getGoalRuntimeReady(); + await vi.waitFor(() => expect(started).toEqual(['g-resumed'])); + + config.startNewSession( + 'replacement-session', + resumedGoalSession('active'), + ); + await config.getGoalRuntimeReady(); + await vi.waitFor(() => + expect(started).toEqual(['g-resumed', 'g-resumed']), + ); + }); + + it('does not expose volatile Goal state when chat recording is disabled', () => { + const config = new Config({ ...baseParams, chatRecording: false }); + + expect(() => config.getGoalRuntime()).toThrow( + GoalPersistenceUnavailableError, + ); + }); + + it('does not leak the canonical Goal runtime through subagent prototypes', async () => { + const config = new Config({ ...baseParams, chatRecording: true }); + const canonical = config.getGoalRuntime(); + const child = Object.create(config) as Config; + + expect(() => child.getGoalRuntime()).toThrow( + GoalPersistenceUnavailableError, + ); + expect(() => + child.bindGoalTurnHost({ + startGoalTurn: vi.fn(), + preemptGoalTurn: vi.fn(), + }), + ).toThrow(GoalPersistenceUnavailableError); + await expect( + child.rebaseGoalRuntimeFromActiveTranscript(), + ).rejects.toThrow(GoalPersistenceUnavailableError); + await child.shutdown(); + expect(() => canonical.getSnapshot()).not.toThrow(); + expect(config.getGoalRuntime()).toBe(canonical); + }); + it('clears the FileReadCache so a new session does not inherit prior reads', () => { // Regression guard: the file-read cache backs ReadFile's // file_unchanged placeholder, whose correctness depends on the @@ -3144,6 +3292,8 @@ describe('Server Config (config.ts)', () => { ToolNames.EDIT, ToolNames.NOTEBOOK_EDIT, ToolNames.SHELL, + ToolNames.GET_GOAL, + ToolNames.UPDATE_GOAL, ]); }); @@ -6138,6 +6288,8 @@ describe('Server Config (config.ts)', () => { ToolNames.EDIT, ToolNames.NOTEBOOK_EDIT, ToolNames.SHELL, + ToolNames.GET_GOAL, + ToolNames.UPDATE_GOAL, ]); }); @@ -6168,6 +6320,8 @@ describe('Server Config (config.ts)', () => { ToolNames.EDIT, ToolNames.NOTEBOOK_EDIT, ToolNames.SHELL, + ToolNames.GET_GOAL, + ToolNames.UPDATE_GOAL, ToolNames.STRUCTURED_OUTPUT, ]); }); diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 1f041a67f9..5e62f84928 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -158,6 +158,13 @@ import { } from '../hooks/types.js'; import { fireNotificationHook } from '../core/toolHookTriggers.js'; import { GOAL_HOOK_ID_OUTPUT_KEY } from '../goals/goalHook.js'; +import { + createGoalRuntime, + GoalPersistenceUnavailableError, + type GoalRuntime, + type GoalTurnHost, +} from '../goals/goal-runtime.js'; +import { createGoalVerifier } from '../goals/goal-verifier.js'; // Utils import { shouldAttemptBrowserLaunch } from '../utils/browser.js'; @@ -191,6 +198,7 @@ import { ChatRecordingService, type ChatRecordingFailureEvent, type ChatRecordingFailureListener, + type ChatRecord, } from '../services/chatRecordingService.js'; import { CHARS_PER_TOKEN } from '../services/tokenEstimation.js'; import { @@ -1840,6 +1848,11 @@ export class Config { private fileDiscoveryService: FileDiscoveryService | null = null; private sessionService: SessionService | undefined = undefined; private chatRecordingService: ChatRecordingService | undefined = undefined; + private goalRuntime: GoalRuntime | undefined; + private goalRuntimeReady: Promise | undefined; + private goalTurnHost: GoalTurnHost | undefined; + private goalTurnHostUnbind: (() => void) | undefined; + private goalTurnHostGeneration = 0; private readonly chatRecordingFailureListeners = new Set(); private fileCheckpointingEnabled: boolean; @@ -2364,6 +2377,7 @@ export class Config { this.chatRecordingService = this.chatRecordingEnabled ? this.createChatRecordingService() : undefined; + this.initializeGoalRuntime(this.sessionData?.conversation.messages); this.extensionManager = new ExtensionManager({ workspaceDir: this.targetDir, enabledExtensionOverrides: this.overrideExtensions, @@ -3601,6 +3615,11 @@ export class Config { if (this.chatRecordingService?.hasWriteOwnership()) { throw new SessionWriterUnavailableError(); } + if (Object.hasOwn(this, 'goalRuntime')) { + this.goalTurnHostUnbind?.(); + this.goalTurnHostUnbind = undefined; + this.goalRuntime?.dispose(); + } // Finalize the outgoing session before switching. const outgoingChatRecordingService = this.chatRecordingService; try { @@ -3627,6 +3646,7 @@ export class Config { this.chatRecordingService = this.chatRecordingEnabled ? this.createChatRecordingService() : undefined; + this.initializeGoalRuntime(this.sessionData?.conversation.messages); // The file-read cache is session-scoped: its `file_unchanged` // placeholder relies on the model having seen the prior full read // earlier in the *current* conversation. Carrying entries across @@ -4684,6 +4704,12 @@ export class Config { this.sessionProjectDirRegistered = false; } + if (Object.hasOwn(this, 'goalRuntime')) { + this.goalTurnHostUnbind?.(); + this.goalTurnHostUnbind = undefined; + this.goalRuntime?.dispose(); + } + if (!this.initialized) { // Nothing else to clean up if not initialized. return; @@ -6857,6 +6883,63 @@ export class Config { return this.chatRecordingService; } + getGoalRuntime(): GoalRuntime { + if ( + !Object.hasOwn(this, 'goalRuntime') || + !this.chatRecordingEnabled || + !this.chatRecordingService || + !this.goalRuntime + ) { + throw new GoalPersistenceUnavailableError(); + } + return this.goalRuntime; + } + + getGoalRuntimeReady(): Promise { + const runtime = this.getGoalRuntime(); + if (!Object.hasOwn(this, 'goalRuntimeReady') || !this.goalRuntimeReady) { + return Promise.reject(new GoalPersistenceUnavailableError()); + } + return this.goalRuntimeReady.then(() => runtime); + } + + async rebaseGoalRuntimeFromActiveTranscript(): Promise { + const runtime = this.getGoalRuntime(); + const recordingService = this.chatRecordingService; + if (!recordingService) { + throw new GoalPersistenceUnavailableError(); + } + const records = await recordingService.readActiveTranscriptChain(); + this.goalTurnHostUnbind?.(); + this.goalTurnHostUnbind = undefined; + runtime.dispose(); + this.initializeGoalRuntime(records); + await this.goalRuntimeReady; + } + + bindGoalTurnHost(host: GoalTurnHost): () => void { + if (!Object.hasOwn(this, 'goalRuntime')) { + throw new GoalPersistenceUnavailableError(); + } + const generation = this.goalTurnHostGeneration + 1; + this.goalTurnHostGeneration = generation; + this.goalTurnHostUnbind?.(); + this.goalTurnHost = host; + this.goalTurnHostUnbind = this.goalRuntime?.bindHost(host); + + return () => { + if ( + this.goalTurnHostGeneration !== generation || + this.goalTurnHost !== host + ) { + return; + } + this.goalTurnHostUnbind?.(); + this.goalTurnHostUnbind = undefined; + this.goalTurnHost = undefined; + }; + } + onChatRecordingFailure(listener: ChatRecordingFailureListener): () => void { this.chatRecordingFailureListeners.add(listener); return () => { @@ -6874,6 +6957,27 @@ export class Config { ); } + private initializeGoalRuntime(records?: readonly ChatRecord[]): void { + this.goalTurnHostUnbind?.(); + this.goalTurnHostUnbind = undefined; + if (!this.chatRecordingService) { + this.goalRuntime = undefined; + this.goalRuntimeReady = undefined; + return; + } + const runtime = createGoalRuntime({ + journal: this.chatRecordingService, + evidenceSource: this.chatRecordingService, + verifier: createGoalVerifier(this), + }); + this.goalRuntime = runtime; + if (this.goalTurnHost) { + this.goalTurnHostUnbind = runtime.bindHost(this.goalTurnHost); + } + this.goalRuntimeReady = runtime.restore(records ?? []).then(() => runtime); + void this.goalRuntimeReady.catch(() => undefined); + } + private notifyChatRecordingFailure(event: ChatRecordingFailureEvent): void { for (const listener of [...this.chatRecordingFailureListeners]) { try { @@ -7328,6 +7432,18 @@ export class Config { }); }; + const registerGoalWorkerTools = async (): Promise => { + if (options?.forSubAgent) return; + await registerLazy(ToolNames.GET_GOAL, async () => { + const { GetGoalTool } = await import('../goals/goal-tools.js'); + return new GetGoalTool(this); + }); + await registerLazy(ToolNames.UPDATE_GOAL, async () => { + const { UpdateGoalTool } = await import('../goals/goal-tools.js'); + return new UpdateGoalTool(this); + }); + }; + if (this.getBareMode()) { await registerLazy(ToolNames.READ_FILE, async () => { const { ReadFileTool } = await import('../tools/read-file.js'); @@ -7345,6 +7461,7 @@ export class Config { const { ShellTool } = await import('../tools/shell.js'); return new ShellTool(this); }); + await registerGoalWorkerTools(); await registerStructuredOutputIfRequested(); this.debugLogger.debug( `ToolRegistry created: ${JSON.stringify(registry.getAllToolNames())} (${registry.getAllToolNames().length} tools)`, @@ -7353,6 +7470,7 @@ export class Config { } // --- Core tools (always registered) --- + await registerGoalWorkerTools(); await registerLazy(ToolNames.TOOL_SEARCH, async () => { const { ToolSearchTool } = await import('../tools/tool-search.js'); return new ToolSearchTool(this); diff --git a/packages/core/src/core/client-goal.test.ts b/packages/core/src/core/client-goal.test.ts new file mode 100644 index 0000000000..8e57bdcf19 --- /dev/null +++ b/packages/core/src/core/client-goal.test.ts @@ -0,0 +1,1036 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { Config } from '../config/config.js'; +import type { GeminiChat } from './geminiChat.js'; +import { + createGoalRuntime, + GoalPersistenceUnavailableError, + type GoalJournal, + type GoalRuntime, +} from '../goals/goal-runtime.js'; +import type { + GoalSnapshotV2, + GoalStateCause, + GoalStateRecordPayloadV2, + GoalTurnPermit, +} from '../goals/goal-protocol.js'; +import type { ChatRecord } from '../services/chatRecordingService.js'; +import { ApprovalMode } from '../config/config.js'; + +const turnMocks = vi.hoisted(() => ({ + constructors: [] as unknown[][], + run: vi.fn(), +})); + +vi.mock('./turn.js', async (importOriginal) => { + const actual = await importOriginal(); + class MockTurn { + pendingToolCalls: unknown[] = []; + finishReason: undefined; + + constructor(...args: unknown[]) { + turnMocks.constructors.push(args); + } + + run(...args: unknown[]) { + return turnMocks.run(...args); + } + } + return { ...actual, Turn: MockTurn }; +}); + +const nextSpeakerMocks = vi.hoisted(() => ({ check: vi.fn() })); +vi.mock('../utils/nextSpeakerChecker.js', () => ({ + checkNextSpeaker: nextSpeakerMocks.check, +})); + +import { GeminiClient, SendMessageType } from './client.js'; +import { GeminiEventType, type ServerGeminiStreamEvent } from './turn.js'; + +const permit: GoalTurnPermit = { + goalId: 'goal-1', + revision: 1, + turnId: 'turn-1', +}; + +function emptyStream() { + return (async function* () {})(); +} + +async function drain(stream: AsyncGenerator) { + for await (const _event of stream) { + // Drain the client stream so its true-Stop path runs. + } +} + +async function collect(stream: AsyncGenerator) { + const events: unknown[] = []; + for await (const event of stream) events.push(event); + return events; +} + +async function collectOutcome(stream: AsyncGenerator) { + const events: unknown[] = []; + try { + for await (const event of stream) events.push(event); + return { events, error: undefined }; + } catch (error) { + return { events, error }; + } +} + +type GoalStateEvent = Extract< + ServerGeminiStreamEvent, + { type: GeminiEventType.GoalState } +>; + +function goalStateEvents(events: unknown[]): GoalStateEvent[] { + return events.filter( + (event): event is GoalStateEvent => + (event as { type?: GeminiEventType }).type === GeminiEventType.GoalState, + ); +} + +function eventIndex( + events: unknown[], + type: GeminiEventType, + predicate: (event: ServerGeminiStreamEvent) => boolean = () => true, +) { + return events.findIndex( + (event) => + (event as { type?: GeminiEventType }).type === type && + predicate(event as ServerGeminiStreamEvent), + ); +} + +function setupGoalClient() { + const order: string[] = []; + let snapshot: GoalSnapshotV2 = { + v: 2, + activity: 'running', + goal: { + goalId: permit.goalId, + revision: permit.revision, + objective: 'ship', + status: 'active', + evidenceCursor: { recordId: 'create-record' }, + turnCount: 0, + activeTimeMs: 0, + createdAt: 1, + updatedAt: 1, + }, + }; + const listeners = new Set< + (snapshot: GoalSnapshotV2, cause?: GoalStateCause) => void + >(); + const unsubscribeGoalState = vi.fn( + (listener: (snapshot: GoalSnapshotV2, cause?: GoalStateCause) => void) => + listeners.delete(listener), + ); + const publish = (cause?: GoalStateCause) => { + for (const listener of listeners) { + listener(structuredClone(snapshot), cause); + } + }; + const recorder = { + recordGoalRuntimeMessage: vi.fn(), + recordUserMessage: vi.fn(), + recordNotification: vi.fn(), + recordAttributionSnapshot: vi.fn(), + recordFileHistorySnapshot: vi.fn(), + flush: vi.fn(async () => { + order.push('flush'); + }), + }; + const runtime = { + getSnapshot: vi.fn(() => structuredClone(snapshot)), + permitForTurn: vi.fn(() => ({ ...permit })), + beginTurn: vi.fn((key: string) => { + order.push(`begin:${key}`); + return undefined; + }), + finishTurn: vi.fn(async () => { + order.push('finish'); + snapshot = { + ...snapshot, + activity: 'idle', + goal: snapshot.goal + ? { + ...snapshot.goal, + turnCount: snapshot.goal.turnCount + 1, + updatedAt: snapshot.goal.updatedAt + 1, + } + : null, + }; + publish('turn_finished'); + }), + dispatch: vi.fn(async (request: { action: string }) => { + order.push('pause'); + if (request.action === 'pause' && snapshot.goal) { + snapshot = { + ...snapshot, + goal: { + ...snapshot.goal, + status: 'paused', + updatedAt: snapshot.goal.updatedAt + 1, + }, + }; + publish('pause'); + } + return { snapshot: structuredClone(snapshot) }; + }), + subscribe: vi.fn( + ( + listener: (snapshot: GoalSnapshotV2, cause?: GoalStateCause) => void, + ) => { + listeners.add(listener); + return () => unsubscribeGoalState(listener); + }, + ), + } as unknown as GoalRuntime; + const config = { + assertCanStartTurn: vi.fn(async () => undefined), + getGoalRuntimeReady: vi.fn(async () => runtime), + getGoalRuntime: vi.fn(() => runtime), + getChatRecordingService: vi.fn(() => recorder), + getDisableAllHooks: vi.fn(() => true), + getMessageBus: vi.fn(() => undefined), + getMaxSessionTurns: vi.fn(() => 1), + getSessionTokenLimit: vi.fn(() => 0), + getIdeMode: vi.fn(() => false), + getArenaAgentClient: vi.fn(() => null), + getModel: vi.fn(() => 'test-model'), + getSkipNextSpeakerCheck: vi.fn(() => false), + getSkipLoopDetection: vi.fn(() => false), + getContentGeneratorConfig: vi.fn(() => undefined), + hasHooksForEvent: vi.fn(() => false), + getStopHookBlockingCap: vi.fn(() => 8), + isManagedMemoryAvailable: vi.fn(() => false), + getManagedAutoMemoryEnabled: vi.fn(() => false), + getMemoryManager: vi.fn(() => ({})), + getAutoSkillEnabled: vi.fn(() => false), + getSessionId: vi.fn(() => 'goal-test-session'), + getProjectRoot: vi.fn(() => '/tmp'), + getTargetDir: vi.fn(() => '/tmp'), + getClearContextOnIdle: vi.fn(() => ({ + toolResultsThresholdMinutes: 60, + toolResultsNumToKeep: 5, + })), + getApprovalMode: vi.fn(() => ApprovalMode.DEFAULT), + getSdkMode: vi.fn(() => false), + getArenaManager: vi.fn(() => null), + getFileHistoryService: vi.fn(() => ({ + makeSnapshot: vi.fn(async () => undefined), + getSnapshots: vi.fn(() => []), + })), + } as unknown as Config; + const client = new GeminiClient(config); + client['chat'] = { + getUserContentPushCount: vi.fn(() => 0), + getHistory: vi.fn(() => []), + getHistoryLength: vi.fn(() => 0), + } as unknown as GeminiChat; + client['drainPendingAddedMcpToolsReminder'] = vi.fn(); + client['drainSkillAndCommandReminders'] = vi.fn(async () => undefined); + client['drainAgentReminders'] = vi.fn(async () => undefined); + return { client, config, runtime, recorder, order, unsubscribeGoalState }; +} + +describe('GeminiClient Goal admission', () => { + beforeEach(() => { + turnMocks.constructors.length = 0; + turnMocks.run.mockReset().mockImplementation(emptyStream); + nextSpeakerMocks.check.mockReset().mockResolvedValue({ + next_speaker: 'model', + }); + }); + + it('exposes Goal as an explicit internal message type', () => { + expect(SendMessageType.Goal).toBe('goal'); + expect(GeminiEventType.GoalState).toBe('goal_state'); + }); + + it('flushes and queues real user input before finishing an exact Goal permit', async () => { + const { client, runtime, recorder, order, unsubscribeGoalState } = + setupGoalClient(); + const getQueuedGoalTurnKey = vi.fn(() => { + order.push('peek'); + return 'queued-user'; + }); + + const events = await collect( + client.sendMessageStream( + [{ text: 'Continue the Goal.' }], + new AbortController().signal, + 'goal-prompt', + { + type: SendMessageType.Goal, + goalPermit: permit, + goalTurnKey: `goal-runtime:${permit.turnId}`, + getQueuedGoalTurnKey, + }, + ), + ); + + expect(runtime.permitForTurn).toHaveBeenCalledWith( + `goal-runtime:${permit.turnId}`, + ); + expect(recorder.recordGoalRuntimeMessage).toHaveBeenCalledWith( + [{ text: 'Continue the Goal.' }], + permit, + ); + expect(turnMocks.constructors[0]?.[2]).toEqual(permit); + expect(order).toEqual(['flush', 'peek', 'begin:queued-user', 'finish']); + expect(nextSpeakerMocks.check).not.toHaveBeenCalled(); + expect(unsubscribeGoalState).toHaveBeenCalledOnce(); + expect(goalStateEvents(events).map((event) => event.cause)).toEqual([ + undefined, + 'turn_finished', + ]); + const initialGoalStateIndex = eventIndex( + events, + GeminiEventType.GoalState, + (event) => + event.type === GeminiEventType.GoalState && event.cause === undefined, + ); + const initialActiveGoalIndex = eventIndex( + events, + GeminiEventType.ActiveGoal, + (event) => + event.type === GeminiEventType.ActiveGoal && event.value !== null, + ); + expect(initialGoalStateIndex).toBeGreaterThanOrEqual(0); + expect(initialActiveGoalIndex).toBeGreaterThan(initialGoalStateIndex); + expect(events[initialActiveGoalIndex]).toEqual({ + type: GeminiEventType.ActiveGoal, + value: { + condition: 'ship', + iterations: 0, + setAt: 1, + tokensAtStart: 0, + hookId: 'goal-v2:goal-1:1', + }, + }); + }); + + it('fails closed before recording or sampling when an automatic permit is stale', async () => { + const { client, runtime, recorder } = setupGoalClient(); + vi.mocked(runtime.permitForTurn).mockReturnValue(undefined); + + await expect( + drain( + client.sendMessageStream( + [{ text: 'stale continuation' }], + new AbortController().signal, + 'goal-prompt', + { + type: SendMessageType.Goal, + goalPermit: permit, + goalTurnKey: `goal-runtime:${permit.turnId}`, + }, + ), + ), + ).rejects.toThrow('Goal turn permit is no longer valid'); + + expect(recorder.recordGoalRuntimeMessage).not.toHaveBeenCalled(); + expect(turnMocks.run).not.toHaveBeenCalled(); + }); + + it('requires an explicit permit for automatic Goal text', async () => { + const { client, recorder } = setupGoalClient(); + + await expect( + drain( + client.sendMessageStream( + [{ text: 'looks like a Goal but is not admitted' }], + new AbortController().signal, + 'goal-prompt', + { type: SendMessageType.Goal }, + ), + ), + ).rejects.toThrow('requires an exact permit'); + + expect(recorder.recordGoalRuntimeMessage).not.toHaveBeenCalled(); + }); + + it('claims an active Goal for a real user and records real-user provenance', async () => { + const { client, runtime, recorder } = setupGoalClient(); + vi.mocked(runtime.permitForTurn).mockReturnValueOnce(undefined); + vi.mocked(runtime.beginTurn).mockReturnValueOnce({ ...permit }); + + await drain( + client.sendMessageStream( + [{ text: 'user correction' }], + new AbortController().signal, + 'real-user-key', + { type: SendMessageType.UserQuery }, + ), + ); + + expect(runtime.beginTurn).toHaveBeenCalledWith('real-user-key'); + expect(recorder.recordUserMessage).toHaveBeenCalledWith( + [{ text: 'user correction' }], + permit, + ); + expect(recorder.recordGoalRuntimeMessage).not.toHaveBeenCalled(); + expect(turnMocks.constructors[0]?.[2]).toEqual(permit); + }); + + it('keeps real-user accounting when UserQuery receives a hidden automatic permit', async () => { + const { client, runtime, recorder } = setupGoalClient(); + + await drain( + client.sendMessageStream( + [{ text: 'interrupt hidden continuation' }], + new AbortController().signal, + 'real-user-key', + { + type: SendMessageType.UserQuery, + goalPermit: permit, + goalTurnKey: `goal-runtime:${permit.turnId}`, + }, + ), + ); + + expect(runtime.permitForTurn).toHaveBeenCalledWith( + `goal-runtime:${permit.turnId}`, + ); + expect(recorder.recordAttributionSnapshot).toHaveBeenCalledOnce(); + expect(client['sessionTurnCount']).toBe(1); + }); + + it('releases a hidden exact permit when UserPromptSubmit blocks before sampling', async () => { + const { client, config, runtime, recorder, order, unsubscribeGoalState } = + setupGoalClient(); + const messageBus = { + request: vi.fn(async () => ({ + output: { decision: 'block', reason: 'policy denied' }, + })), + }; + vi.mocked(config.getDisableAllHooks).mockReturnValue(false); + vi.mocked(config.getMessageBus).mockReturnValue( + messageBus as unknown as ReturnType, + ); + vi.mocked(config.hasHooksForEvent).mockImplementation( + (event) => event === 'UserPromptSubmit', + ); + + const events = await collect( + client.sendMessageStream( + [{ text: 'blocked real user' }], + new AbortController().signal, + 'real-user-key', + { + type: SendMessageType.UserQuery, + goalPermit: permit, + goalTurnKey: `goal-runtime:${permit.turnId}`, + }, + ), + ); + + expect(runtime.finishTurn).toHaveBeenCalledWith(permit); + expect(order).toEqual(['flush', 'finish']); + expect(recorder.recordUserMessage).not.toHaveBeenCalled(); + expect(turnMocks.run).not.toHaveBeenCalled(); + expect(unsubscribeGoalState).toHaveBeenCalledOnce(); + expect(goalStateEvents(events).map((event) => event.cause)).toEqual([ + undefined, + 'turn_finished', + ]); + expect( + eventIndex(events, GeminiEventType.GoalState, (event) => + event.type === GeminiEventType.GoalState + ? event.cause === 'turn_finished' + : false, + ), + ).toBeLessThan(eventIndex(events, GeminiEventType.UserPromptSubmitBlocked)); + }); + + it('pauses and releases a hidden exact permit when UserPromptSubmit throws', async () => { + const { client, config, runtime, order } = setupGoalClient(); + const messageBus = { + request: vi.fn(async () => { + throw new Error('hook exploded'); + }), + }; + vi.mocked(config.getDisableAllHooks).mockReturnValue(false); + vi.mocked(config.getMessageBus).mockReturnValue( + messageBus as unknown as ReturnType, + ); + vi.mocked(config.hasHooksForEvent).mockImplementation( + (event) => event === 'UserPromptSubmit', + ); + + await expect( + drain( + client.sendMessageStream( + [{ text: 'throwing real user' }], + new AbortController().signal, + 'real-user-key', + { + type: SendMessageType.UserQuery, + goalPermit: permit, + goalTurnKey: `goal-runtime:${permit.turnId}`, + }, + ), + ), + ).rejects.toThrow('hook exploded'); + + expect(runtime.dispatch).toHaveBeenCalledWith({ + action: 'pause', + expectedGoalId: permit.goalId, + expectedRevision: permit.revision, + }); + expect(order).toEqual(['pause', 'flush', 'finish']); + expect(turnMocks.run).not.toHaveBeenCalled(); + }); + + it('explicitly rejects unrelated background sends while Goal owns the model', async () => { + const { client, recorder } = setupGoalClient(); + + await expect( + drain( + client.sendMessageStream( + [{ text: 'background notification' }], + new AbortController().signal, + 'notification', + { type: SendMessageType.Notification }, + ), + ), + ).rejects.toThrow('active Goal requires an exact turn permit'); + + expect(recorder.recordGoalRuntimeMessage).not.toHaveBeenCalled(); + expect(turnMocks.run).not.toHaveBeenCalled(); + }); + + it('keeps ordinary turns available when Goal recovery is unsupported', async () => { + const { client, config } = setupGoalClient(); + vi.mocked(config.getGoalRuntimeReady).mockRejectedValue( + new GoalPersistenceUnavailableError( + 'Goal lifecycle record is malformed or uses an unsupported version', + ), + ); + vi.mocked(config.getSkipNextSpeakerCheck).mockReturnValue(true); + + await expect( + drain( + client.sendMessageStream( + [{ text: 'hello' }], + new AbortController().signal, + 'plain-user-turn', + { type: SendMessageType.UserQuery }, + ), + ), + ).resolves.toBeUndefined(); + + expect(turnMocks.run).toHaveBeenCalledOnce(); + }); + + it('keeps unexpected Goal initialization failures fail-closed', async () => { + const { client, config } = setupGoalClient(); + vi.mocked(config.getGoalRuntimeReady).mockRejectedValue( + new TypeError('unexpected Goal initialization failure'), + ); + + await expect( + drain( + client.sendMessageStream( + [{ text: 'hello' }], + new AbortController().signal, + 'plain-user-turn', + { type: SendMessageType.UserQuery }, + ), + ), + ).rejects.toThrow('unexpected Goal initialization failure'); + + expect(turnMocks.run).not.toHaveBeenCalled(); + }); + + it('requires the exact permit while a paused Goal turn is still running', async () => { + const { client, runtime, recorder } = setupGoalClient(); + await runtime.dispatch({ + action: 'pause', + expectedGoalId: permit.goalId, + expectedRevision: permit.revision, + }); + expect(runtime.getSnapshot()).toMatchObject({ + activity: 'running', + goal: { status: 'paused' }, + }); + + await expect( + drain( + client.sendMessageStream( + [{ text: 'background notification' }], + new AbortController().signal, + 'notification', + { type: SendMessageType.Notification }, + ), + ), + ).rejects.toThrow('active Goal requires an exact turn permit'); + + expect(recorder.recordNotification).not.toHaveBeenCalled(); + expect(turnMocks.run).not.toHaveBeenCalled(); + }); + + it('accepts and true-stops a supplied Notification permit as runtime work', async () => { + const { client, runtime, recorder, order } = setupGoalClient(); + + await drain( + client.sendMessageStream( + [{ text: 'dependency completed' }], + new AbortController().signal, + 'notification', + { + type: SendMessageType.Notification, + notificationDisplayText: 'Dependency completed', + goalPermit: permit, + goalTurnKey: `goal-runtime:${permit.turnId}`, + goalOrigin: 'runtime', + }, + ), + ); + + expect(runtime.permitForTurn).toHaveBeenCalledWith( + `goal-runtime:${permit.turnId}`, + ); + expect(recorder.recordNotification).toHaveBeenCalledWith( + [{ text: 'dependency completed' }], + 'Dependency completed', + undefined, + permit, + ); + expect(recorder.recordGoalRuntimeMessage).not.toHaveBeenCalled(); + expect(order).toEqual(['flush', 'finish']); + }); + + it('pauses and releases the current permit when the caller aborts', async () => { + const { client, runtime, order } = setupGoalClient(); + const caller = new AbortController(); + turnMocks.run.mockImplementationOnce(() => + (async function* () { + caller.abort(); + yield { type: GeminiEventType.UserCancelled }; + })(), + ); + + const events = await collect( + client.sendMessageStream( + [{ text: 'work until cancelled' }], + caller.signal, + 'goal-prompt', + { + type: SendMessageType.Goal, + goalPermit: permit, + goalTurnKey: `goal-runtime:${permit.turnId}`, + }, + ), + ); + + expect(runtime.dispatch).toHaveBeenCalledWith({ + action: 'pause', + expectedGoalId: permit.goalId, + expectedRevision: permit.revision, + }); + expect(order).toEqual(['pause', 'flush', 'finish']); + expect(goalStateEvents(events).map((event) => event.cause)).toEqual([ + undefined, + 'pause', + 'turn_finished', + ]); + expect( + eventIndex(events, GeminiEventType.GoalState, (event) => + event.type === GeminiEventType.GoalState + ? event.cause === 'turn_finished' + : false, + ), + ).toBeLessThan(eventIndex(events, GeminiEventType.UserCancelled)); + }); + + it('pauses and releases the current permit when model setup throws', async () => { + const { client, runtime, recorder, order } = setupGoalClient(); + const setupError = new Error('model setup exploded'); + vi.mocked(recorder.flush).mockImplementationOnce(async () => { + order.push('flush'); + throw new Error('cleanup flush exploded'); + }); + turnMocks.run.mockImplementationOnce(() => { + throw setupError; + }); + + const { events, error } = await collectOutcome( + client.sendMessageStream( + [{ text: 'start work' }], + new AbortController().signal, + 'goal-prompt', + { + type: SendMessageType.Goal, + goalPermit: permit, + goalTurnKey: `goal-runtime:${permit.turnId}`, + }, + ), + ); + + expect(error).toBe(setupError); + expect(runtime.dispatch).toHaveBeenCalledWith({ + action: 'pause', + expectedGoalId: permit.goalId, + expectedRevision: permit.revision, + }); + expect(order).toEqual(['pause', 'flush', 'finish']); + expect(goalStateEvents(events).map((event) => event.cause)).toEqual([ + undefined, + 'pause', + 'turn_finished', + ]); + }); + + it('drains a concurrent pause before a blocking Stop hook recurses', async () => { + const { client, config, runtime } = setupGoalClient(); + let stopRequestCount = 0; + const messageBus = { + request: vi.fn(async () => { + stopRequestCount += 1; + if (stopRequestCount === 1) { + await runtime.dispatch({ + action: 'pause', + expectedGoalId: permit.goalId, + expectedRevision: permit.revision, + }); + return { + output: { decision: 'block', reason: 'Run the policy check' }, + stopHookCount: 1, + }; + } + return { output: undefined, stopHookCount: 1 }; + }), + }; + vi.mocked(config.getDisableAllHooks).mockReturnValue(false); + vi.mocked(config.getMessageBus).mockReturnValue( + messageBus as unknown as ReturnType, + ); + vi.mocked(config.hasHooksForEvent).mockImplementation( + (event) => event === 'Stop', + ); + const events = await collect( + client.sendMessageStream( + [{ text: 'continue' }], + new AbortController().signal, + 'goal-prompt', + { + type: SendMessageType.Goal, + goalPermit: permit, + goalTurnKey: `goal-runtime:${permit.turnId}`, + }, + ), + ); + + expect(turnMocks.constructors.map((args) => args[2])).toEqual([ + permit, + permit, + ]); + expect(runtime.finishTurn).toHaveBeenCalledOnce(); + expect(messageBus.request).toHaveBeenCalledTimes(2); + const pauseStateIndex = eventIndex( + events, + GeminiEventType.GoalState, + (event) => + event.type === GeminiEventType.GoalState && event.cause === 'pause', + ); + const inactiveProjectionIndex = eventIndex( + events, + GeminiEventType.ActiveGoal, + (event) => + event.type === GeminiEventType.ActiveGoal && event.value === null, + ); + const loopIndex = eventIndex(events, GeminiEventType.StopHookLoop); + expect(pauseStateIndex).toBeGreaterThanOrEqual(0); + expect(inactiveProjectionIndex).toBeGreaterThan(pauseStateIndex); + expect(loopIndex).toBeGreaterThan(inactiveProjectionIndex); + }); + + it('drains a concurrent pause before a non-blocking Stop true-stops', async () => { + const { client, config, runtime } = setupGoalClient(); + const messageBus = { + request: vi.fn(async () => { + await runtime.dispatch({ + action: 'pause', + expectedGoalId: permit.goalId, + expectedRevision: permit.revision, + }); + return { output: undefined, stopHookCount: 1 }; + }), + }; + vi.mocked(config.getDisableAllHooks).mockReturnValue(false); + vi.mocked(config.getMessageBus).mockReturnValue( + messageBus as unknown as ReturnType, + ); + vi.mocked(config.hasHooksForEvent).mockImplementation( + (event) => event === 'Stop', + ); + + const events = await collect( + client.sendMessageStream( + [{ text: 'continue' }], + new AbortController().signal, + 'goal-prompt', + { + type: SendMessageType.Goal, + goalPermit: permit, + goalTurnKey: `goal-runtime:${permit.turnId}`, + }, + ), + ); + + const pauseStateIndex = eventIndex( + events, + GeminiEventType.GoalState, + (event) => + event.type === GeminiEventType.GoalState && event.cause === 'pause', + ); + const inactiveProjectionIndex = eventIndex( + events, + GeminiEventType.ActiveGoal, + (event) => + event.type === GeminiEventType.ActiveGoal && event.value === null, + ); + const finishStateIndex = eventIndex( + events, + GeminiEventType.GoalState, + (event) => + event.type === GeminiEventType.GoalState && + event.cause === 'turn_finished', + ); + expect(pauseStateIndex).toBeGreaterThanOrEqual(0); + expect(inactiveProjectionIndex).toBeGreaterThan(pauseStateIndex); + expect(finishStateIndex).toBeGreaterThan(inactiveProjectionIndex); + expect(eventIndex(events, GeminiEventType.StopHookLoop)).toBe(-1); + expect(runtime.finishTurn).toHaveBeenCalledOnce(); + }); + + it('resets the loop detector before each Goal-owned Hook continuation', async () => { + const { client, config } = setupGoalClient(); + const reset = vi.spyOn(client['loopDetector'], 'reset'); + const messageBus = { + request: vi.fn(async () => ({ + output: { decision: 'block', reason: 'continue checking' }, + stopHookCount: 1, + })), + }; + vi.mocked(config.getDisableAllHooks).mockReturnValue(false); + vi.mocked(config.getMessageBus).mockReturnValue( + messageBus as unknown as ReturnType, + ); + vi.mocked(config.hasHooksForEvent).mockImplementation( + (event) => event === 'Stop', + ); + vi.mocked(config.getStopHookBlockingCap).mockReturnValue(5); + + await drain( + client.sendMessageStream( + [{ text: 'continue' }], + new AbortController().signal, + 'goal-prompt', + { + type: SendMessageType.Hook, + goalPermit: permit, + goalTurnKey: `goal-runtime:${permit.turnId}`, + goalOrigin: 'runtime', + }, + 2, + ), + ); + + expect(turnMocks.run).toHaveBeenCalledTimes(2); + expect(messageBus.request).toHaveBeenCalledTimes(2); + expect(reset).toHaveBeenCalledTimes(2); + expect(reset).toHaveBeenCalledWith('goal-prompt'); + }); + + it('does not recurse with a stale permit when Goal preemption lands while draining steer input', async () => { + const { client, config, runtime } = setupGoalClient(); + const permitController = new AbortController(); + let permitIsCurrent = true; + vi.mocked(runtime.permitForTurn).mockImplementation(() => + permitIsCurrent ? { ...permit } : undefined, + ); + const messageBus = { + request: vi.fn(async () => ({ + output: { decision: 'block', reason: 'Run the policy check' }, + stopHookCount: 1, + })), + }; + vi.mocked(config.getDisableAllHooks).mockReturnValue(false); + vi.mocked(config.getMessageBus).mockReturnValue( + messageBus as unknown as ReturnType, + ); + vi.mocked(config.hasHooksForEvent).mockImplementation( + (event) => event === 'Stop', + ); + const getSteerInput = vi + .fn() + .mockResolvedValueOnce(undefined) + .mockImplementationOnce(async () => { + permitIsCurrent = false; + permitController.abort(); + return undefined; + }); + + await drain( + client.sendMessageStream( + [{ text: 'continue' }], + new AbortController().signal, + 'goal-prompt', + { + type: SendMessageType.Goal, + goalPermit: permit, + goalTurnKey: `goal-runtime:${permit.turnId}`, + goalSignal: permitController.signal, + getSteerInput, + }, + ), + ); + + expect(getSteerInput).toHaveBeenCalledTimes(2); + expect(turnMocks.constructors).toHaveLength(1); + expect(runtime.dispatch).not.toHaveBeenCalled(); + expect(runtime.finishTurn).not.toHaveBeenCalled(); + }); + + it('pauses Goal when a generic Stop-hook cap prevents further progress', async () => { + const { client, config, runtime, order } = setupGoalClient(); + const messageBus = { + request: vi.fn(async () => ({ + output: { decision: 'block', reason: 'still blocked' }, + stopHookCount: 1, + })), + }; + vi.mocked(config.getDisableAllHooks).mockReturnValue(false); + vi.mocked(config.getMessageBus).mockReturnValue( + messageBus as unknown as ReturnType, + ); + vi.mocked(config.hasHooksForEvent).mockImplementation( + (event) => event === 'Stop', + ); + vi.mocked(config.getStopHookBlockingCap).mockReturnValue(1); + + const events = await collect( + client.sendMessageStream( + [{ text: 'continue' }], + new AbortController().signal, + 'goal-prompt', + { + type: SendMessageType.Goal, + goalPermit: permit, + goalTurnKey: `goal-runtime:${permit.turnId}`, + }, + ), + ); + + expect(runtime.dispatch).toHaveBeenCalledWith({ + action: 'pause', + expectedGoalId: permit.goalId, + expectedRevision: permit.revision, + }); + expect(runtime.getSnapshot()).toMatchObject({ + goal: { status: 'paused' }, + }); + expect(turnMocks.run).toHaveBeenCalledOnce(); + expect(order).toEqual(['pause', 'flush', 'finish']); + expect(events).toContainEqual({ + type: GeminiEventType.HookSystemMessage, + value: + 'Stop hook blocked continuation 1 consecutive time; overriding and ending the turn.', + }); + }); + + it('does not treat permit-owned preemption as a caller cancellation', async () => { + const { client, runtime } = setupGoalClient(); + const permitController = new AbortController(); + turnMocks.run.mockImplementationOnce( + (_model, _request, signal: AbortSignal) => { + permitController.abort(); + expect(signal.aborted).toBe(true); + return emptyStream(); + }, + ); + + await drain( + client.sendMessageStream( + [{ text: 'preempt me' }], + new AbortController().signal, + 'goal-prompt', + { + type: SendMessageType.Goal, + goalPermit: permit, + goalTurnKey: `goal-runtime:${permit.turnId}`, + goalSignal: permitController.signal, + }, + ), + ); + + expect(runtime.dispatch).not.toHaveBeenCalled(); + expect(runtime.finishTurn).not.toHaveBeenCalled(); + }); + + it('runs 150 runtime-scheduled Goal turns without recursive or session budgets', async () => { + const { client, config } = setupGoalClient(); + const goalJournal: GoalJournal = { + getTranscriptCursor: () => ({ recordId: null }), + async recordGoalState( + recordUuid: string, + payload: GoalStateRecordPayloadV2, + ): Promise { + return { + uuid: recordUuid, + parentUuid: null, + sessionId: 'integration', + timestamp: new Date(0).toISOString(), + type: 'system', + subtype: 'goal_state', + provenance: 'goal_control', + cwd: '/tmp', + version: 'test', + systemPayload: structuredClone(payload), + }; + }, + }; + const runtime = createGoalRuntime({ journal: goalJournal }); + const started: GoalTurnPermit[] = []; + runtime.bindHost({ + async startGoalTurn({ permit: nextPermit }) { + started.push(structuredClone(nextPermit)); + }, + preemptGoalTurn: vi.fn(), + }); + vi.mocked(config.getGoalRuntimeReady).mockResolvedValue(runtime); + vi.mocked(config.getGoalRuntime).mockReturnValue(runtime); + await runtime.dispatch({ action: 'create', objective: 'ship' }); + + for (let turn = 0; turn < 150; turn += 1) { + const current = started[turn]!; + await drain( + client.sendMessageStream( + [{ text: 'continue' }], + new AbortController().signal, + `goal-${turn}`, + { + type: SendMessageType.Goal, + goalPermit: current, + goalTurnKey: `goal-runtime:${current.turnId}`, + }, + 0, + ), + ); + } + + expect(started).toHaveLength(151); + expect(turnMocks.run).toHaveBeenCalledTimes(150); + expect(client['sessionTurnCount']).toBe(0); + }); +}); diff --git a/packages/core/src/core/client.ts b/packages/core/src/core/client.ts index 0febece017..3a991f59c9 100644 --- a/packages/core/src/core/client.ts +++ b/packages/core/src/core/client.ts @@ -28,6 +28,16 @@ import { type MicrocompactOptions, } from '../services/microcompaction/microcompact.js'; import { slimCompactionInput } from '../services/compactionInputSlimming.js'; +import { + goalRequiresExactPermit, + PAUSED_GOAL_SYSTEM_REMINDER, + type GoalSnapshotV2, + type GoalTurnPermit, +} from '../goals/goal-protocol.js'; +import { + GoalPersistenceUnavailableError, + type GoalRuntime, +} from '../goals/goal-runtime.js'; import { activeGoalEquals, getActiveGoal, @@ -171,6 +181,8 @@ export enum SendMessageType { * recorded as a user message. */ Teammate = 'teammate', + /** Runtime-owned continuation for an active Goal. */ + Goal = 'goal', } export interface SendMessageOptions { @@ -190,6 +202,16 @@ export interface SendMessageOptions { notificationDisplayText?: string; /** Model override from skill execution. When present, overrides the session model for this turn. */ modelOverride?: string; + /** Exact runtime permit authorizing this Goal-bound turn. */ + goalPermit?: GoalTurnPermit; + /** Stable key used by the runtime to bind recursive segments to one permit. */ + goalTurnKey?: string; + /** Permit-owned cancellation signal, combined with the caller signal. */ + goalSignal?: AbortSignal; + /** Whether this permit belongs to runtime work or a real-user turn. */ + goalOrigin?: 'runtime' | 'user'; + /** Peeks a queued real-user key immediately before a Goal true Stop. */ + getQueuedGoalTurnKey?: () => string | undefined; } export interface SteerInput { @@ -211,6 +233,53 @@ function wrapIdeContext(contextText: string): string { return `\n${safeContextText}\n`; } +function sameGoalPermit( + left: GoalTurnPermit | undefined, + right: GoalTurnPermit | undefined, +): boolean { + if (!left || !right) return false; + return ( + left.goalId === right.goalId && + left.revision === right.revision && + left.turnId === right.turnId + ); +} + +type ActiveGoalEventValue = Exclude< + Extract< + ServerGeminiStreamEvent, + { type: GeminiEventType.ActiveGoal } + >['value'], + null +>; + +type GoalStateStreamEvent = Extract< + ServerGeminiStreamEvent, + { type: GeminiEventType.GoalState } +>; + +function projectActiveGoal( + snapshot: GoalSnapshotV2 | undefined, +): ActiveGoalEventValue | undefined { + const goal = snapshot?.goal; + if (goal?.status !== 'active') return undefined; + return { + condition: goal.objective, + iterations: goal.turnCount, + setAt: goal.createdAt, + tokensAtStart: 0, + hookId: `goal-v2:${goal.goalId}:${goal.revision}`, + ...(goal.lastReason === undefined ? {} : { lastReason: goal.lastReason }), + }; +} + +function sameActiveGoalProjection( + left: ActiveGoalEventValue | undefined, + right: ActiveGoalEventValue | undefined, +): boolean { + return JSON.stringify(left) === JSON.stringify(right); +} + /** * Handle for a non-blocking auto-memory recall prefetch. * @@ -1927,7 +1996,7 @@ export class GeminiClient { async *sendMessageStream( request: PartListUnion, - signal: AbortSignal, + callerSignal: AbortSignal, prompt_id: string, options?: SendMessageOptions, turns: number = MAX_TURNS, @@ -1941,6 +2010,140 @@ export class GeminiClient { ) { await this.config.assertCanStartTurn(); } + const signal = options?.goalSignal + ? AbortSignal.any([callerSignal, options.goalSignal]) + : callerSignal; + let goalPermit = options?.goalPermit + ? { ...options.goalPermit } + : undefined; + let goalTurnKey = options?.goalTurnKey; + let goalOrigin = options?.goalOrigin; + let goalRuntime: GoalRuntime | undefined; + let goalPermitReleased = false; + let unsubscribeGoalState: (() => void) | undefined; + const pendingGoalStateEvents: GoalStateStreamEvent[] = []; + let hasEmittedActiveGoalProjection = false; + let lastEmittedActiveGoal: ActiveGoalEventValue | undefined; + const closeGoalStateEvents = () => { + const unsubscribe = unsubscribeGoalState; + unsubscribeGoalState = undefined; + unsubscribe?.(); + }; + const bindGoalStateEvents = (runtime: GoalRuntime) => { + if (unsubscribeGoalState) return; + unsubscribeGoalState = runtime.subscribe((value, cause) => { + pendingGoalStateEvents.push({ + type: GeminiEventType.GoalState, + value, + ...(cause !== undefined ? { cause } : {}), + }); + }); + pendingGoalStateEvents.push({ + type: GeminiEventType.GoalState, + value: runtime.getSnapshot(), + }); + }; + const takePendingGoalEvents = (): ServerGeminiStreamEvent[] => { + const events: ServerGeminiStreamEvent[] = []; + for (const stateEvent of pendingGoalStateEvents.splice( + 0, + pendingGoalStateEvents.length, + )) { + events.push(stateEvent); + const nextActiveGoal = projectActiveGoal(stateEvent.value); + if (!hasEmittedActiveGoalProjection) { + hasEmittedActiveGoalProjection = true; + lastEmittedActiveGoal = nextActiveGoal; + if (nextActiveGoal) { + events.push({ + type: GeminiEventType.ActiveGoal, + value: nextActiveGoal, + }); + } + } else if ( + !sameActiveGoalProjection(lastEmittedActiveGoal, nextActiveGoal) + ) { + lastEmittedActiveGoal = nextActiveGoal; + events.push({ + type: GeminiEventType.ActiveGoal, + value: nextActiveGoal ?? null, + }); + } + } + return events; + }; + const loadGoalRuntime = async ( + required: boolean, + ): Promise => { + if (goalRuntime) return goalRuntime; + try { + const getReady = this.config.getGoalRuntimeReady; + if (typeof getReady === 'function') { + goalRuntime = await getReady.call(this.config); + } else { + const getRuntime = this.config.getGoalRuntime; + if (typeof getRuntime === 'function') { + goalRuntime = getRuntime.call(this.config); + } + } + } catch (error) { + if (!(error instanceof GoalPersistenceUnavailableError) || required) { + throw error; + } + } + return goalRuntime; + }; + const releaseGoalPermitOnInterruptedExit = async () => { + if ( + goalPermitReleased || + !goalPermit || + !goalTurnKey || + options?.goalSignal?.aborted + ) { + return; + } + + try { + const runtime = goalRuntime ?? (await loadGoalRuntime(true)); + if (runtime) bindGoalStateEvents(runtime); + if ( + !runtime || + !sameGoalPermit(runtime.permitForTurn(goalTurnKey), goalPermit) + ) { + return; + } + + if (runtime.getSnapshot().goal?.status === 'active') { + try { + await runtime.dispatch({ + action: 'pause', + expectedGoalId: goalPermit.goalId, + expectedRevision: goalPermit.revision, + }); + } catch (error) { + debugLogger.warn('Failed to pause interrupted Goal turn', error); + } + } + + try { + await this.config.getChatRecordingService()?.flush(); + } catch (error) { + debugLogger.warn('Failed to flush interrupted Goal turn', error); + } + + if (sameGoalPermit(runtime.permitForTurn(goalTurnKey), goalPermit)) { + await runtime.finishTurn(goalPermit); + } + goalPermitReleased = true; + } catch (error) { + debugLogger.warn('Failed to release interrupted Goal turn', error); + } + }; + const finalizeInterruptedGoalTurn = async () => { + await releaseGoalPermitOnInterruptedExit(); + closeGoalStateEvents(); + return takePendingGoalEvents(); + }; let strippedRetryEntries: Content[] = []; // Snapshot of GeminiChat's user-content push counter, taken right after the // strip. The Retry's re-submitted content is the first thing the send @@ -2014,71 +2217,163 @@ export class GeminiClient { } // Fire UserPromptSubmit hook through MessageBus (only if hooks are enabled) - const hooksEnabled = !this.config.getDisableAllHooks(); - const messageBus = this.config.getMessageBus(); - if ( - messageType !== SendMessageType.Retry && - messageType !== SendMessageType.Steer && - messageType !== SendMessageType.Cron && - messageType !== SendMessageType.Notification && - // Teammate envelopes are machine-driven re-entries like Cron / - // Notification, not user prompts: user-authored UserPromptSubmit - // hooks must not fire on (or be able to block) internal team - // coordination traffic. - messageType !== SendMessageType.Teammate && - hooksEnabled && - messageBus && - this.config.hasHooksForEvent('UserPromptSubmit') - ) { - const promptText = partToString(request); - const submittedPrompt = - messageType === SendMessageType.UserQuery && - typeof options?.submittedPrompt === 'string' && - options.submittedPrompt.trim().length > 0 - ? options.submittedPrompt - : undefined; - const response = await messageBus.request< - HookExecutionRequest, - HookExecutionResponse - >( - { - type: MessageBusType.HOOK_EXECUTION_REQUEST, - eventName: 'UserPromptSubmit', - input: { - prompt: promptText, - ...(submittedPrompt !== undefined - ? { submitted_prompt: submittedPrompt } - : {}), - }, - }, - MessageBusType.HOOK_EXECUTION_RESPONSE, - ); - const hookOutput = response.output - ? createHookOutput('UserPromptSubmit', response.output) - : undefined; - + let hooksEnabled: boolean; + let messageBus: ReturnType; + try { + hooksEnabled = !this.config.getDisableAllHooks(); + messageBus = this.config.getMessageBus(); if ( - hookOutput?.isBlockingDecision() || - hookOutput?.shouldStopExecution() + messageType !== SendMessageType.Retry && + messageType !== SendMessageType.Steer && + messageType !== SendMessageType.Cron && + messageType !== SendMessageType.Notification && + // Teammate envelopes are machine-driven re-entries like Cron / + // Notification, not user prompts: user-authored UserPromptSubmit + // hooks must not fire on (or be able to block) internal team + // coordination traffic. + messageType !== SendMessageType.Teammate && + messageType !== SendMessageType.Goal && + hooksEnabled && + messageBus && + this.config.hasHooksForEvent('UserPromptSubmit') ) { - yield { - type: GeminiEventType.UserPromptSubmitBlocked, - value: { - reason: hookOutput.getEffectiveReason(), - originalPrompt: promptText, + const promptText = partToString(request); + const submittedPrompt = + messageType === SendMessageType.UserQuery && + typeof options?.submittedPrompt === 'string' && + options.submittedPrompt.trim().length > 0 + ? options.submittedPrompt + : undefined; + const response = await messageBus.request< + HookExecutionRequest, + HookExecutionResponse + >( + { + type: MessageBusType.HOOK_EXECUTION_REQUEST, + eventName: 'UserPromptSubmit', + input: { + prompt: promptText, + ...(submittedPrompt !== undefined + ? { submitted_prompt: submittedPrompt } + : {}), + }, }, - }; - settleSteerInput(attachedSteerInput, attachedSteerPushCount); - return new Turn(this.getChat(), prompt_id); + MessageBusType.HOOK_EXECUTION_RESPONSE, + ); + const hookOutput = response.output + ? createHookOutput('UserPromptSubmit', response.output) + : undefined; + + if ( + hookOutput?.isBlockingDecision() || + hookOutput?.shouldStopExecution() + ) { + if (goalPermit) { + const runtime = await loadGoalRuntime(true); + if (!runtime || !goalTurnKey) { + throw new Error('Goal turn admission is unavailable'); + } + bindGoalStateEvents(runtime); + const admitted = runtime.permitForTurn(goalTurnKey); + if (!sameGoalPermit(admitted, goalPermit)) { + throw new Error('Goal turn permit is no longer valid'); + } + await this.config.getChatRecordingService()?.flush(); + await runtime.finishTurn(goalPermit); + goalPermitReleased = true; + closeGoalStateEvents(); + for (const goalEvent of takePendingGoalEvents()) { + yield goalEvent; + } + } + yield { + type: GeminiEventType.UserPromptSubmitBlocked, + value: { + reason: hookOutput.getEffectiveReason(), + originalPrompt: promptText, + }, + }; + settleSteerInput(attachedSteerInput, attachedSteerPushCount); + return new Turn(this.getChat(), prompt_id); + } + + // Add additional context from hooks to the request + const additionalContext = hookOutput?.getAdditionalContext(); + if (additionalContext) { + const requestArray = Array.isArray(request) ? request : [request]; + request = [...requestArray, { text: additionalContext }]; + } + } + } catch (error) { + for (const goalEvent of await finalizeInterruptedGoalTurn()) { + yield goalEvent; + } + throw error; + } + + try { + goalRuntime = await loadGoalRuntime( + messageType === SendMessageType.Goal || Boolean(goalPermit), + ); + + if (messageType === SendMessageType.Goal) { + if (!goalPermit) { + throw new Error('An automatic Goal turn requires an exact permit'); + } + goalTurnKey ??= `goal-runtime:${goalPermit.turnId}`; + goalOrigin = 'runtime'; + } else if (messageType === SendMessageType.UserQuery) { + goalOrigin = 'user'; } - // Add additional context from hooks to the request - const additionalContext = hookOutput?.getAdditionalContext(); - if (additionalContext) { - const requestArray = Array.isArray(request) ? request : [request]; - request = [...requestArray, { text: additionalContext }]; + const goalRequiresPermit = goalRuntime + ? goalRequiresExactPermit(goalRuntime.getSnapshot()) + : false; + if (goalPermit) { + if (!goalRuntime || !goalTurnKey) { + throw new Error('Goal turn admission is unavailable'); + } + const admitted = goalRuntime.permitForTurn(goalTurnKey); + if (!sameGoalPermit(admitted, goalPermit)) { + throw new Error('Goal turn permit is no longer valid'); + } + } else if ( + messageType === SendMessageType.UserQuery && + goalRuntime && + goalRequiresPermit + ) { + goalTurnKey ??= prompt_id; + goalPermit = + goalRuntime.permitForTurn(goalTurnKey) ?? + goalRuntime.beginTurn(goalTurnKey); + if (!goalPermit) { + throw new Error('Goal turn is already owned by another permit'); + } + } else if (goalRequiresPermit) { + throw new Error('An active Goal requires an exact turn permit'); } + + if (goalPermit) { + goalOrigin ??= 'runtime'; + options = { + ...(options ?? { type: messageType }), + type: messageType, + goalPermit, + goalTurnKey, + goalOrigin, + ...(messageType === SendMessageType.Goal + ? { stopHookState: undefined } + : {}), + }; + } + if (goalRuntime) bindGoalStateEvents(goalRuntime); + } catch (error) { + for (const goalEvent of await finalizeInterruptedGoalTurn()) { + yield goalEvent; + } + throw error; } + const isGoalRuntimeTurn = goalOrigin === 'runtime'; if ( messageType === SendMessageType.Notification || @@ -2091,7 +2386,12 @@ export class GeminiClient { // interaction missing from chat recording entirely. this.config .getChatRecordingService() - ?.recordNotification(request, options?.notificationDisplayText); + ?.recordNotification( + request, + options?.notificationDisplayText, + undefined, + goalPermit, + ); } // Notifications start a fresh Turn with a new prompt_id, so the loop @@ -2102,6 +2402,10 @@ export class GeminiClient { messageType === SendMessageType.Cron || messageType === SendMessageType.Notification || messageType === SendMessageType.Teammate; + if (messageType === SendMessageType.Goal) { + this.loopDetector.reset(prompt_id); + this.lastPromptId = prompt_id; + } if (isTopLevelInteraction) { this.loopDetector.reset(prompt_id); this.lastPromptId = prompt_id; @@ -2137,7 +2441,11 @@ export class GeminiClient { // right before the turn's streaming loop below. let messageDisplay: MessageDisplayDispatcher | null = null; try { - if ( + if (messageType === SendMessageType.Goal) { + this.config + .getChatRecordingService() + ?.recordGoalRuntimeMessage(request, goalPermit!); + } else if ( messageType === SendMessageType.UserQuery || messageType === SendMessageType.Cron ) { @@ -2234,9 +2542,15 @@ export class GeminiClient { if (messageType === SendMessageType.Cron) { this.config .getChatRecordingService() - ?.recordCronPrompt(request, options?.notificationDisplayText); + ?.recordCronPrompt( + request, + options?.notificationDisplayText, + goalPermit, + ); } else { - this.config.getChatRecordingService()?.recordUserMessage(request); + this.config + .getChatRecordingService() + ?.recordUserMessage(request, goalPermit); } } @@ -2254,7 +2568,7 @@ export class GeminiClient { if (messageType === SendMessageType.UserQuery || compacted) { this.lastHookMicrocompactionTimestamp = Date.now(); } - } else if (messageType === SendMessageType.Hook) { + } else if (messageType === SendMessageType.Hook && !isGoalRuntimeTurn) { this.lastHookMicrocompactionTimestamp ??= this.lastApiCompletionTimestamp ?? Date.now(); const checkpoint = this.lastHookMicrocompactionTimestamp; @@ -2263,7 +2577,7 @@ export class GeminiClient { } } - if (messageType !== SendMessageType.Retry) { + if (messageType !== SendMessageType.Retry && !isGoalRuntimeTurn) { // Attribution snapshots are recorded on every non-retry turn. File // history snapshots are created only at UserQuery boundaries; later // tool edits update that latest snapshot through trackEdit(). @@ -2311,7 +2625,10 @@ export class GeminiClient { } // Ensure turns never exceeds MAX_TURNS to prevent infinite loops - const boundedTurns = Math.min(turns, MAX_TURNS); + const boundedTurns = + messageType === SendMessageType.Goal + ? MAX_TURNS + : Math.min(turns, MAX_TURNS); if (!boundedTurns) { this.cancelPendingMemoryPrefetch('no_safe_delivery_point'); if (isTopLevelInteraction) @@ -2331,7 +2648,11 @@ export class GeminiClient { return undefined; } const maxSessionTurns = this.config.getMaxSessionTurns(); - if (maxSessionTurns > 0 && this.sessionTurnCount >= maxSessionTurns) { + if ( + !isGoalRuntimeTurn && + maxSessionTurns > 0 && + this.sessionTurnCount >= maxSessionTurns + ) { return undefined; } const steerInput = await options.getSteerInput(signal); @@ -2441,7 +2762,7 @@ export class GeminiClient { } } - const turn = new Turn(this.getChat(), prompt_id); + const turn = new Turn(this.getChat(), prompt_id, goalPermit); // Determine the model to use for this turn const model = options?.modelOverride ?? this.config.getModel(); @@ -2462,6 +2783,14 @@ export class GeminiClient { ) { const systemReminders = []; + if ( + messageType === SendMessageType.UserQuery && + !goalPermit && + goalRuntime?.getSnapshot().goal?.status === 'paused' + ) { + systemReminders.push(PAUSED_GOAL_SYSTEM_REMINDER); + } + // Inject fresh date on UserQuery turns only; Cron and ToolResult turns // reuse the same session and the startup-context date is still current. if (messageType === SendMessageType.UserQuery) { @@ -2542,7 +2871,13 @@ export class GeminiClient { }); } - const activeGoalAtTurnStart = getActiveGoal(this.config.getSessionId()); + for (const goalEvent of takePendingGoalEvents()) { + yield goalEvent; + } + + const activeGoalAtTurnStart = goalRuntime?.getSnapshot().goal + ? undefined + : getActiveGoal(this.config.getSessionId()); if (activeGoalAtTurnStart) { yield { type: GeminiEventType.ActiveGoal, @@ -2635,6 +2970,9 @@ export class GeminiClient { // the non-interactive runner) build their own list from the yielded // ToolCallRequest events and stop on LoopDetected. turn.pendingToolCalls.length = 0; + for (const goalEvent of await finalizeInterruptedGoalTurn()) { + yield goalEvent; + } const loopType = this.loopDetector.getLastLoopType(); yield { type: GeminiEventType.LoopDetected, @@ -2665,6 +3003,9 @@ export class GeminiClient { !skipLoopDetection && this.loopDetector.addAndCheckHeuristicLoops(event); if (heuristicLoop) { + for (const goalEvent of await finalizeInterruptedGoalTurn()) { + yield goalEvent; + } const loopType = this.loopDetector.getLastLoopType(); yield { type: GeminiEventType.LoopDetected, @@ -2724,6 +3065,17 @@ export class GeminiClient { }); } + for (const goalEvent of takePendingGoalEvents()) { + yield goalEvent; + } + if ( + (event.type === GeminiEventType.UserCancelled && signal.aborted) || + event.type === GeminiEventType.Error + ) { + for (const goalEvent of await finalizeInterruptedGoalTurn()) { + yield goalEvent; + } + } yield event; if (event.type === GeminiEventType.Error) { this.forceFullIdeContext = true; @@ -2758,6 +3110,11 @@ export class GeminiClient { // a no-op. await messageDisplay?.finish(); } + for (const goalEvent of signal.aborted + ? await finalizeInterruptedGoalTurn() + : takePendingGoalEvents()) { + yield goalEvent; + } // Track API completion time for thinking block idle cleanup this.lastApiCompletionTimestamp = Date.now(); @@ -2827,14 +3184,20 @@ export class GeminiClient { MessageBusType.HOOK_EXECUTION_RESPONSE, ); + for (const goalEvent of takePendingGoalEvents()) { + yield goalEvent; + } // Stop hook callbacks can mutate active goal state during request(). // Capture it before cancellation returns so clear events are not lost. - const activeGoalAfterStopHook = getActiveGoal( - this.config.getSessionId(), - ); + const activeGoalAfterStopHook = goalPermit + ? undefined + : getActiveGoal(this.config.getSessionId()); // Check if aborted after hook execution if (signal.aborted) { + for (const goalEvent of await finalizeInterruptedGoalTurn()) { + yield goalEvent; + } const activeGoalEvent = maybeEmitActiveGoalChange( activeGoalAfterStopHook, ); @@ -2859,10 +3222,100 @@ export class GeminiClient { }; } + if ( + goalPermit && + (stopOutput?.isBlockingDecision() || + stopOutput?.shouldStopExecution()) + ) { + const continueReason = stopOutput.getEffectiveReason(); + const currentIterationCount = + (options?.stopHookState?.iterationCount ?? 0) + 1; + const currentReasons = [ + ...(options?.stopHookState?.reasons ?? []), + continueReason, + ]; + const stopHookBlockingCap = this.config.getStopHookBlockingCap(); + + if (currentIterationCount >= stopHookBlockingCap) { + const warning = formatStopHookBlockingCapWarning( + 'Stop', + stopHookBlockingCap, + ); + yield { + type: GeminiEventType.HookSystemMessage, + value: warning, + }; + debugLogger.warn(warning); + for (const goalEvent of await finalizeInterruptedGoalTurn()) { + yield goalEvent; + } + if (isTopLevelInteraction) endInteractionSpan('ok'); + return turn; + } else { + for (const goalEvent of takePendingGoalEvents()) { + yield goalEvent; + } + yield { + type: GeminiEventType.StopHookLoop, + value: { + iterationCount: currentIterationCount, + reasons: currentReasons, + stopHookCount: response.stopHookCount ?? 1, + }, + }; + + this.loopDetector.reset(prompt_id); + const hookTurnBudget = boundedTurns - 1; + const pendingSteer = await takeSteerInput(hookTurnBudget); + for (const goalEvent of takePendingGoalEvents()) { + yield goalEvent; + } + if (signal.aborted) { + for (const goalEvent of await finalizeInterruptedGoalTurn()) { + yield goalEvent; + } + if (isTopLevelInteraction) endInteractionSpan('cancelled'); + return turn; + } + const continueRequest: Part[] = [{ text: continueReason }]; + if (pendingSteer) { + continueRequest.push({ text: '\n\n' }, ...pendingSteer.parts); + } + const pushCountBefore = currentPushCount(); + let hookTurn: Turn; + try { + hookTurn = yield* this.sendMessageStream( + continueRequest, + signal, + prompt_id, + { + ...options, + type: SendMessageType.Hook, + submittedPrompt: undefined, + steerInput: pendingSteer, + stopHookState: { + iterationCount: currentIterationCount, + reasons: currentReasons, + }, + }, + hookTurnBudget, + ); + } finally { + settleSteerInput(pendingSteer, pushCountBefore); + } + if (isTopLevelInteraction) { + endInteractionSpan(signal.aborted ? 'cancelled' : 'ok'); + } + normalCompletion = true; + return hookTurn; + } + } + // For Stop hooks, blocking/stop execution should force continuation if ( - stopOutput?.isBlockingDecision() || - stopOutput?.shouldStopExecution() + !goalPermit && + (stopOutput?.isBlockingDecision() || + stopOutput?.shouldStopExecution()) ) { // Check if aborted before continuing if (signal.aborted) { @@ -3035,6 +3488,29 @@ export class GeminiClient { if (activeGoalEvent) { yield activeGoalEvent; } + for (const goalEvent of takePendingGoalEvents()) { + yield goalEvent; + } + } + + if ( + goalPermit && + goalRuntime && + !turn.pendingToolCalls.length && + !signal.aborted + ) { + await this.config.getChatRecordingService()?.flush(); + const queuedGoalTurnKey = options?.getQueuedGoalTurnKey?.(); + if (queuedGoalTurnKey) { + goalRuntime.beginTurn(queuedGoalTurnKey); + } + await goalRuntime.finishTurn(goalPermit); + goalPermitReleased = true; + for (const goalEvent of takePendingGoalEvents()) { + yield goalEvent; + } + normalCompletion = true; + return turn; } if (!turn.pendingToolCalls.length && signal && !signal.aborted) { @@ -3062,7 +3538,9 @@ export class GeminiClient { } if (this.config.getSkipNextSpeakerCheck()) { - this.runManagedAutoMemoryBackgroundTasks(messageType); + if (!isGoalRuntimeTurn) { + this.runManagedAutoMemoryBackgroundTasks(messageType); + } if (arenaAgentClient) { await arenaAgentClient.reportCompleted(); } @@ -3120,7 +3598,9 @@ export class GeminiClient { return continueTurn; } - this.runManagedAutoMemoryBackgroundTasks(messageType); + if (!isGoalRuntimeTurn) { + this.runManagedAutoMemoryBackgroundTasks(messageType); + } if (arenaAgentClient) { // No continuation needed — agent completed its task @@ -3144,9 +3624,21 @@ export class GeminiClient { if (!hasToolCalls) { this.cancelPendingMemoryPrefetch('no_safe_delivery_point'); } + for (const goalEvent of takePendingGoalEvents()) { + yield goalEvent; + } normalCompletion = true; return turn; + } catch (error) { + for (const goalEvent of await finalizeInterruptedGoalTurn()) { + yield goalEvent; + } + throw error; } finally { + if (!goalPermitReleased && (callerSignal.aborted || !normalCompletion)) { + await releaseGoalPermitOnInterruptedExit(); + } + closeGoalStateEvents(); settleSteerInput(attachedSteerInput, attachedSteerPushCount); restoreStrippedRetryEntries(); // Belt-and-suspenders: close out the MessageDisplay dispatcher on any diff --git a/packages/core/src/core/coreToolScheduler.ts b/packages/core/src/core/coreToolScheduler.ts index 5036a3a3be..53823c89b1 100644 --- a/packages/core/src/core/coreToolScheduler.ts +++ b/packages/core/src/core/coreToolScheduler.ts @@ -173,6 +173,7 @@ import { getInvocationContext, runWithInvocationContext, } from '../utils/invocation-context.js'; +import { goalTurnContext } from '../goals/goal-turn-context.js'; const debugLogger = createDebugLogger('TOOL_SCHEDULER'); @@ -196,6 +197,15 @@ function dedupeRequestsByCallId( return deduped; } +function runInRequestGoalContext( + request: ToolCallRequestInfo, + callback: () => T, +): T { + return request.goalContext + ? goalTurnContext.run(request.goalContext, callback) + : goalTurnContext.exit(callback); +} + // Gap between the persistence gate and per-tool truncation thresholds. // Tools that self-truncate to ~25K add headers bringing output to ~25.4K; // the headroom ensures the gate only fires for genuinely un-truncated output @@ -1601,11 +1611,13 @@ export class CoreToolScheduler { return call; } - const invocationOrError = this.buildInvocation( - call.tool, - args as Record, - targetCallId, - call.request.prompt_id, + const invocationOrError = runInRequestGoalContext(call.request, () => + this.buildInvocation( + call.tool, + args as Record, + targetCallId, + call.request.prompt_id, + ), ); if (invocationOrError instanceof Error) { const response = createErrorResponse( @@ -2229,10 +2241,14 @@ export class CoreToolScheduler { } } - const toolInstance = await this.toolRegistry.ensureTool(canonicalName); + const toolInstance = await runInRequestGoalContext(reqInfo, () => + this.toolRegistry.ensureTool(canonicalName), + ); if (!toolInstance) { // Tool is not in registry and not excluded - likely hallucinated or typo - const errorMessage = await this.getToolNotFoundMessage(reqInfo.name); + const errorMessage = await runInRequestGoalContext(reqInfo, () => + this.getToolNotFoundMessage(reqInfo.name), + ); newToolCalls.push({ status: 'error', request: reqInfo, @@ -2272,11 +2288,13 @@ export class CoreToolScheduler { continue; } - const invocationOrError = this.buildInvocation( - toolInstance, - reqInfo.args, - reqInfo.callId, - reqInfo.prompt_id, + const invocationOrError = runInRequestGoalContext(reqInfo, () => + this.buildInvocation( + toolInstance, + reqInfo.args, + reqInfo.callId, + reqInfo.prompt_id, + ), ); if (invocationOrError instanceof Error) { const displayError = reqInfo.wasOutputTruncated @@ -2399,11 +2417,13 @@ export class CoreToolScheduler { // ---- L3→L4: Shared permission flow ---- let toolParams = invocation.params as Record; - const flowResult = await evaluatePermissionFlow( - this.config, - invocation, - canonicalName, - toolParams, + const flowResult = await runInRequestGoalContext(reqInfo, () => + evaluatePermissionFlow( + this.config, + invocation, + canonicalName, + toolParams, + ), ); const { defaultPermission, @@ -2524,15 +2544,17 @@ export class CoreToolScheduler { } const planShellDecision = isPlanShellCall - ? await evaluatePlanModeShellPolicy({ - config: this.config, - toolName: canonicalName, - requestArgs: reqInfo.args, - invocationParams: toolParams, - permissionContext: pmCtx, - ambientWorkingDirectory: planShellAmbientWorkingDirectory, - signal, - }) + ? await runInRequestGoalContext(reqInfo, () => + evaluatePlanModeShellPolicy({ + config: this.config, + toolName: canonicalName, + requestArgs: reqInfo.args, + invocationParams: toolParams, + permissionContext: pmCtx, + ambientWorkingDirectory: planShellAmbientWorkingDirectory, + signal, + }), + ) : ({ classification: 'not-applicable' } as const); const rejectPlanShell = (message: string) => { this.setStatusInternal(reqInfo.callId, 'error', { @@ -2552,13 +2574,20 @@ export class CoreToolScheduler { }; if (planShellDecision.classification !== 'not-applicable') { - const initialPlanShellError = await validatePlanModeShellContext({ - config: this.config, - decision: planShellDecision, - requestArgs: reqInfo.args, - invocationParams: invocation.params as Record, - signal, - }); + const initialPlanShellError = await runInRequestGoalContext( + reqInfo, + () => + validatePlanModeShellContext({ + config: this.config, + decision: planShellDecision, + requestArgs: reqInfo.args, + invocationParams: invocation.params as Record< + string, + unknown + >, + signal, + }), + ); if (initialPlanShellError) { rejectPlanShell(initialPlanShellError); continue; @@ -2619,17 +2648,19 @@ export class CoreToolScheduler { this.config .getGeminiClient?.() ?.getHistoryTail(MAX_TRANSCRIPT_MESSAGES, false) ?? []; - const decision = await evaluateAutoMode({ - ctx: pmCtx, - pmForcedAsk, - toolParams, - messages, - config: this.config, - signal, - skipClassifierReason: fallback.fallback - ? fallback.reason - : undefined, - }); + const decision = await runInRequestGoalContext(reqInfo, () => + evaluateAutoMode({ + ctx: pmCtx, + pmForcedAsk, + toolParams, + messages, + config: this.config, + signal, + skipClassifierReason: fallback.fallback + ? fallback.reason + : undefined, + }), + ); const outcome = applyAutoModeDecision( decision, @@ -2641,16 +2672,18 @@ export class CoreToolScheduler { shouldFirePermissionDeniedForAutoMode(decision, outcome) ) { try { - await this.config - .getHookSystem?.() - ?.firePermissionDeniedEvent( - canonicalName, - toolParams, - reqInfo.callId, - getAutoModePermissionDeniedReason(decision), - signal, - reqInfo.callId, - ); + await runInRequestGoalContext(reqInfo, () => + this.config + .getHookSystem?.() + ?.firePermissionDeniedEvent( + canonicalName, + toolParams, + reqInfo.callId, + getAutoModePermissionDeniedReason(decision), + signal, + reqInfo.callId, + ), + ); } catch (hookError) { debugLogger.warn( `PermissionDenied hook failed for tool ${reqInfo.callId}: ${hookError instanceof Error ? hookError.message : String(hookError)}`, @@ -2726,8 +2759,9 @@ export class CoreToolScheduler { ); this.setStatusInternal(reqInfo.callId, 'scheduled'); } else { - confirmationDetails = - await invocation.getConfirmationDetails(signal); + confirmationDetails = await runInRequestGoalContext(reqInfo, () => + invocation.getConfirmationDetails(signal), + ); if (autoModeFallbackMessage) { confirmationDetails = decorateClassifierUnavailableConfirmation( @@ -2737,17 +2771,20 @@ export class CoreToolScheduler { } if (planShellDecision.classification !== 'not-applicable') { - const preDisplayPlanShellError = - await validatePlanModeShellContext({ - config: this.config, - decision: planShellDecision, - requestArgs: reqInfo.args, - invocationParams: invocation.params as Record< - string, - unknown - >, - signal, - }); + const preDisplayPlanShellError = await runInRequestGoalContext( + reqInfo, + () => + validatePlanModeShellContext({ + config: this.config, + decision: planShellDecision, + requestArgs: reqInfo.args, + invocationParams: invocation.params as Record< + string, + unknown + >, + signal, + }), + ); if (preDisplayPlanShellError) { rejectPlanShell(preDisplayPlanShellError); continue; @@ -2860,6 +2897,8 @@ export class CoreToolScheduler { continue; } + const preparedConfirmationDetails = confirmationDetails; + // Fire PermissionRequest hook before showing the permission dialog. // Hooks run before the background-agent auto-deny so they can // override the denial with policy-based decisions. @@ -2870,13 +2909,15 @@ export class CoreToolScheduler { if (hooksEnabled && messageBus) { const permissionMode = String(this.config.getApprovalMode()); - const hookResult = await firePermissionRequestHook( - messageBus, - canonicalName, - (reqInfo.args as Record) || {}, - permissionMode, - undefined, - signal, + const hookResult = await runInRequestGoalContext(reqInfo, () => + firePermissionRequestHook( + messageBus, + canonicalName, + (reqInfo.args as Record) || {}, + permissionMode, + undefined, + signal, + ), ); if ( @@ -2885,24 +2926,30 @@ export class CoreToolScheduler { ) { if (hookResult.shouldAllow) { if (planShellDecision.classification !== 'not-applicable') { - const approval = await validatePlanModeShellApproval({ - config: this.config, - decision: planShellDecision, - requestArgs: reqInfo.args, - invocationParams: invocation.params as Record< - string, - unknown - >, - signal, - outcome: ToolConfirmationOutcome.ProceedOnce, - payload: hookResult.updatedInput - ? { updatedInput: hookResult.updatedInput } - : undefined, - }); + const approval = await runInRequestGoalContext( + reqInfo, + () => + validatePlanModeShellApproval({ + config: this.config, + decision: planShellDecision, + requestArgs: reqInfo.args, + invocationParams: invocation.params as Record< + string, + unknown + >, + signal, + outcome: ToolConfirmationOutcome.ProceedOnce, + payload: hookResult.updatedInput + ? { updatedInput: hookResult.updatedInput } + : undefined, + }), + ); if (approval.outcome === ToolConfirmationOutcome.Cancel) { - await confirmationDetails.onConfirm( - approval.outcome, - approval.payload, + await runInRequestGoalContext(reqInfo, () => + preparedConfirmationDetails.onConfirm( + approval.outcome, + approval.payload, + ), ); rejectPlanShell( approval.payload?.cancelMessage ?? @@ -2910,9 +2957,11 @@ export class CoreToolScheduler { ); continue; } - await confirmationDetails.onConfirm( - approval.outcome, - approval.payload, + await runInRequestGoalContext(reqInfo, () => + preparedConfirmationDetails.onConfirm( + approval.outcome, + approval.payload, + ), ); this.recordAutoModeFallbackResolution( reqInfo.callId, @@ -2932,8 +2981,10 @@ export class CoreToolScheduler { hookResult.updatedInput, ); } - await confirmationDetails.onConfirm( - ToolConfirmationOutcome.ProceedOnce, + await runInRequestGoalContext(reqInfo, () => + preparedConfirmationDetails.onConfirm( + ToolConfirmationOutcome.ProceedOnce, + ), ); this.recordAutoModeFallbackResolution( reqInfo.callId, @@ -2949,9 +3000,11 @@ export class CoreToolScheduler { const cancelPayload = hookResult.denyMessage ? { cancelMessage: hookResult.denyMessage } : undefined; - await confirmationDetails.onConfirm( - ToolConfirmationOutcome.Cancel, - cancelPayload, + await runInRequestGoalContext(reqInfo, () => + preparedConfirmationDetails.onConfirm( + ToolConfirmationOutcome.Cancel, + cancelPayload, + ), ); this.recordAutoModeFallbackResolution( reqInfo.callId, @@ -3035,16 +3088,18 @@ export class CoreToolScheduler { if (planShellDecision.classification !== 'not-applicable') { const finalPreDisplayPlanShellError = - await validatePlanModeShellContext({ - config: this.config, - decision: planShellDecision, - requestArgs: reqInfo.args, - invocationParams: invocation.params as Record< - string, - unknown - >, - signal, - }); + await runInRequestGoalContext(reqInfo, () => + validatePlanModeShellContext({ + config: this.config, + decision: planShellDecision, + requestArgs: reqInfo.args, + invocationParams: invocation.params as Record< + string, + unknown + >, + signal, + }), + ); if (finalPreDisplayPlanShellError) { rejectPlanShell(finalPreDisplayPlanShellError); continue; @@ -3079,43 +3134,43 @@ export class CoreToolScheduler { payload?: ToolConfirmationPayload, ) => runWithInvocationContext(invocationContext, async () => { - if (planShellDecision.classification !== 'not-applicable') { - if (planShellResponseClaimed) return; - planShellResponseClaimed = true; - const currentCall = this.toolCalls.find( - (call) => - call.request.callId === reqInfo.callId && - call.status === 'awaiting_approval', - ) as WaitingToolCall | undefined; - if (!currentCall) return; - const approval = await validatePlanModeShellApproval({ - config: this.config, - decision: planShellDecision, - requestArgs: currentCall.request.args, - invocationParams: currentCall.invocation.params as Record< - string, - unknown - >, - signal, - outcome, - payload, - }); + await runInRequestGoalContext(reqInfo, async () => { + if (planShellDecision.classification !== 'not-applicable') { + if (planShellResponseClaimed) return; + planShellResponseClaimed = true; + const currentCall = this.toolCalls.find( + (call) => + call.request.callId === reqInfo.callId && + call.status === 'awaiting_approval', + ) as WaitingToolCall | undefined; + if (!currentCall) return; + const approval = await validatePlanModeShellApproval({ + config: this.config, + decision: planShellDecision, + requestArgs: currentCall.request.args, + invocationParams: currentCall.invocation + .params as Record, + signal, + outcome, + payload, + }); + await this.handleConfirmationResponse( + reqInfo.callId, + originalOnConfirm, + approval.outcome, + signal, + approval.payload, + ); + return; + } await this.handleConfirmationResponse( reqInfo.callId, originalOnConfirm, - approval.outcome, + outcome, signal, - approval.payload, + payload, ); - return; - } - await this.handleConfirmationResponse( - reqInfo.callId, - originalOnConfirm, - outcome, - signal, - payload, - ); + }); }), }; this.setStatusInternal( @@ -3255,6 +3310,18 @@ export class CoreToolScheduler { // processing and potential re-execution. if (!toolCall) return; + if (goalTurnContext.getStore() !== toolCall.request.goalContext) { + return runInRequestGoalContext(toolCall.request, () => + this.handleConfirmationResponse( + callId, + originalOnConfirm, + outcome, + signal, + payload, + ), + ); + } + try { await this._handleConfirmationResponseInner( callId, @@ -3728,6 +3795,12 @@ export class CoreToolScheduler { ): Promise { if (toolCall.status !== 'scheduled') return; + if (goalTurnContext.getStore() !== toolCall.request.goalContext) { + return runInRequestGoalContext(toolCall.request, () => + this.executeSingleToolCall(toolCall, signal), + ); + } + const scheduledCall = toolCall; const { callId, name: toolName } = scheduledCall.request; const runtimeView = this.runtimeContentGeneratorViews.get(callId); @@ -5289,7 +5362,7 @@ export class CoreToolScheduler { if (!this.chatRecordingService) return; for (const call of completedCalls) { - this.chatRecordingService.recordToolResult(call.response.responseParts, { + const result = { callId: call.request.callId, status: call.status, resultDisplay: call.response.resultDisplay, @@ -5298,7 +5371,29 @@ export class CoreToolScheduler { : {}), error: call.response.error, errorType: call.response.errorType, - }); + }; + const goalContext = call.request.goalContext; + if (!goalContext) { + this.chatRecordingService.recordToolResult( + call.response.responseParts, + result, + ); + } else if ( + call.request.name === ToolNames.GET_GOAL || + call.request.name === ToolNames.UPDATE_GOAL + ) { + this.chatRecordingService.recordToolResult( + call.response.responseParts, + result, + { goalContext: { ...goalContext }, provenance: 'goal_runtime' }, + ); + } else { + this.chatRecordingService.recordToolResult( + call.response.responseParts, + result, + { goalContext: { ...goalContext } }, + ); + } } } @@ -5343,11 +5438,15 @@ export class CoreToolScheduler { string, unknown >; - const flowResult = await evaluatePermissionFlow( - this.config, - pendingTool.invocation, - pendingTool.request.name, - toolParams, + const flowResult = await runInRequestGoalContext( + pendingTool.request, + () => + evaluatePermissionFlow( + this.config, + pendingTool.invocation, + pendingTool.request.name, + toolParams, + ), ); const { finalPermission, pmForcedAsk, pmCtx, requiresUserInteraction } = flowResult; @@ -5374,17 +5473,21 @@ export class CoreToolScheduler { this.config .getGeminiClient?.() ?.getHistoryTail(MAX_TRANSCRIPT_MESSAGES, false) ?? []; - const decision = await evaluateAutoMode({ - ctx: pmCtx, - pmForcedAsk, - toolParams, - messages, - config: this.config, - signal, - skipClassifierReason: fallback.fallback - ? fallback.reason - : undefined, - }); + const decision = await runInRequestGoalContext( + pendingTool.request, + () => + evaluateAutoMode({ + ctx: pmCtx, + pmForcedAsk, + toolParams, + messages, + config: this.config, + signal, + skipClassifierReason: fallback.fallback + ? fallback.reason + : undefined, + }), + ); const outcome = applyAutoModeDecision( decision, @@ -5396,16 +5499,18 @@ export class CoreToolScheduler { shouldFirePermissionDeniedForAutoMode(decision, outcome) ) { try { - await this.config - .getHookSystem?.() - ?.firePermissionDeniedEvent( - pendingTool.request.name, - toolParams, - pendingTool.request.callId, - getAutoModePermissionDeniedReason(decision), - signal, - pendingTool.request.callId, - ); + await runInRequestGoalContext(pendingTool.request, () => + this.config + .getHookSystem?.() + ?.firePermissionDeniedEvent( + pendingTool.request.name, + toolParams, + pendingTool.request.callId, + getAutoModePermissionDeniedReason(decision), + signal, + pendingTool.request.callId, + ), + ); } catch (hookError) { debugLogger.warn( `PermissionDenied hook failed for pending tool ${pendingTool.request.callId}: ${hookError instanceof Error ? hookError.message : String(hookError)}`, diff --git a/packages/core/src/core/geminiChat.ts b/packages/core/src/core/geminiChat.ts index 5fcfd0b14f..86300a76c5 100644 --- a/packages/core/src/core/geminiChat.ts +++ b/packages/core/src/core/geminiChat.ts @@ -121,6 +121,7 @@ import { setToolCallPreparations, } from './tool-call-preparation.js'; import { InvalidStreamError } from './invalid-stream-error.js'; +import type { GoalTurnPermit } from '../goals/goal-protocol.js'; export { InvalidStreamError }; @@ -2006,7 +2007,9 @@ export class GeminiChat { model: string, params: SendMessageParameters, prompt_id: string, + goalContext?: GoalTurnPermit, ): Promise> { + const turnGoalContext = goalContext ? { ...goalContext } : undefined; const fullTurnRoute = model.endsWith('\0'); const exactRoute = fullTurnRoute ? await this.config @@ -2479,6 +2482,7 @@ export class GeminiChat { params, prompt_id, requestOverrides, + turnGoalContext, ); lastFinishReason = undefined; @@ -2887,6 +2891,7 @@ export class GeminiChat { attemptState.params, prompt_id, requestOverrides, + turnGoalContext, ); for await (const chunk of stream) { yield { type: StreamEventType.CHUNK, value: chunk }; @@ -3303,6 +3308,7 @@ export class GeminiChat { fallbackGenerator, fallbackRetryAuthType, fallbackRetryErrorCodes, + turnGoalContext, )) { const emittedUserVisibleOutput = event.type !== StreamEventType.CHUNK || @@ -3456,6 +3462,7 @@ export class GeminiChat { retryAuthType?: string; retryErrorCodes?: readonly number[]; }, + goalContext?: GoalTurnPermit, ): Promise> { const generator = overrides?.contentGenerator ?? this.config.getContentGenerator(); @@ -3532,7 +3539,7 @@ export class GeminiChat { }, }); - return this.processStreamResponse(model, streamResponse); + return this.processStreamResponse(model, streamResponse, goalContext); } private async *makeFallbackStream( @@ -3543,6 +3550,7 @@ export class GeminiChat { contentGenerator: ContentGenerator, retryAuthType?: string, retryErrorCodes?: readonly number[], + goalContext?: GoalTurnPermit, ): AsyncGenerator { const stream = await this.makeApiCallAndProcessStream( model, @@ -3550,6 +3558,7 @@ export class GeminiChat { params, prompt_id, { contentGenerator, retryAuthType, retryErrorCodes }, + goalContext, ); for await (const chunk of stream) { @@ -3995,6 +4004,7 @@ export class GeminiChat { private async *processStreamResponse( model: string, streamResponse: AsyncGenerator, + goalContext?: GoalTurnPermit, ): AsyncGenerator { // Collect ALL parts from the model response (including thoughts for recording) const allModelParts: Part[] = []; @@ -4309,6 +4319,7 @@ export class GeminiChat { ? { ...usageMetadata, ...coercedUsage } : usageMetadata, contextWindowSize, + ...(goalContext ? { goalContext: { ...goalContext } } : {}), }; if (streamError !== null) { // Stream-error + tool-use partial: defer the JSONL append until diff --git a/packages/core/src/core/goal-turn-integration.test.ts b/packages/core/src/core/goal-turn-integration.test.ts new file mode 100644 index 0000000000..a1c4122d5b --- /dev/null +++ b/packages/core/src/core/goal-turn-integration.test.ts @@ -0,0 +1,204 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { GenerateContentResponse } from '@google/genai'; +import { describe, expect, it, vi } from 'vitest'; +import type { Config } from '../config/config.js'; +import type { GoalTurnPermit } from '../goals/goal-protocol.js'; +import type { ChatRecordingService } from '../services/chatRecordingService.js'; +import { ToolNames } from '../tools/tool-names.js'; +import type { ErroredToolCall } from './coreToolScheduler.js'; +import { CoreToolScheduler } from './coreToolScheduler.js'; +import { GeminiChat, StreamEventType } from './geminiChat.js'; +import { GeminiEventType, Turn } from './turn.js'; + +const permit: GoalTurnPermit = { + goalId: 'goal-1', + revision: 1, + turnId: 'turn-1', +}; + +describe('Goal turn evidence propagation', () => { + it('forwards a defensive permit through Turn and attaches it to tool requests', async () => { + const inputPermit: GoalTurnPermit = { ...permit }; + const sendMessageStream = vi.fn().mockResolvedValue( + (async function* () { + yield { + type: StreamEventType.CHUNK, + value: { + functionCalls: [ + { id: 'goal-tool-call', name: 'read_file', args: {} }, + ], + } as unknown as GenerateContentResponse, + }; + })(), + ); + const turn = new Turn( + { sendMessageStream } as unknown as GeminiChat, + 'goal-prompt', + inputPermit, + ); + inputPermit.revision = 99; + + const events = []; + for await (const event of turn.run( + 'test-model', + [{ text: 'continue' }], + new AbortController().signal, + )) { + events.push(event); + } + + expect(sendMessageStream).toHaveBeenCalledWith( + 'test-model', + expect.any(Object), + 'goal-prompt', + permit, + ); + const toolRequest = events.find( + (event) => event.type === GeminiEventType.ToolCallRequest, + ); + expect(toolRequest?.value).toMatchObject({ + callId: 'goal-tool-call', + goalContext: permit, + }); + expect(toolRequest?.value.goalContext).not.toBe(inputPermit); + }); + + it('attaches the permit to normal and deferred assistant attempts', async () => { + const recordAssistantTurn = vi.fn(); + const chat = new GeminiChat( + { + getContentGeneratorConfig: () => ({ contextWindowSize: 4096 }), + } as unknown as Config, + {}, + [], + { recordAssistantTurn } as unknown as ChatRecordingService, + ); + const internal = chat as unknown as { + processStreamResponse: ( + model: string, + stream: AsyncGenerator, + goalContext?: GoalTurnPermit, + ) => AsyncGenerator; + pendingPartialAssistantRecord: + | Parameters[0] + | null; + }; + const normalStream = (async function* () { + yield { + candidates: [ + { + content: { role: 'model', parts: [{ text: 'normal result' }] }, + finishReason: 'STOP', + }, + ], + } as GenerateContentResponse; + })(); + + for await (const _ of internal.processStreamResponse( + 'test-model', + normalStream, + permit, + )) { + // Consume the persisted normal assistant attempt. + } + expect(recordAssistantTurn).toHaveBeenCalledWith( + expect.objectContaining({ goalContext: permit }), + ); + + const partialStream = (async function* () { + yield { + candidates: [ + { + content: { + role: 'model', + parts: [ + { + functionCall: { + id: 'partial-goal-call', + name: 'read_file', + args: {}, + }, + }, + ], + }, + }, + ], + } as GenerateContentResponse; + throw new Error('partial stream failed'); + })(); + await expect( + (async () => { + for await (const _ of internal.processStreamResponse( + 'test-model', + partialStream, + permit, + )) { + // Consume until the deferred partial attempt is staged. + } + })(), + ).rejects.toThrow('partial stream failed'); + expect(internal.pendingPartialAssistantRecord).toMatchObject({ + goalContext: permit, + message: [ + expect.objectContaining({ + functionCall: expect.objectContaining({ id: 'partial-goal-call' }), + }), + ], + }); + }); + + it('records Goal worker results as runtime evidence and other tools normally', () => { + const recordToolResult = vi.fn(); + const scheduler = Object.create(CoreToolScheduler.prototype) as { + chatRecordingService: { recordToolResult: typeof recordToolResult }; + recordToolResults(calls: ErroredToolCall[]): void; + }; + scheduler.chatRecordingService = { recordToolResult }; + const completedCall = (name: string, callId: string): ErroredToolCall => ({ + status: 'error', + request: { + callId, + name, + args: {}, + isClientInitiated: false, + prompt_id: 'recording-prompt', + goalContext: { ...permit }, + }, + response: { + callId, + responseParts: [ + { + functionResponse: { + id: callId, + name, + response: { output: 'result' }, + }, + }, + ], + resultDisplay: undefined, + error: new Error('test'), + errorType: undefined, + }, + }); + + scheduler.recordToolResults([ + completedCall('external_fact_tool', 'ordinary-call'), + ]); + scheduler.recordToolResults([ + completedCall(ToolNames.GET_GOAL, 'goal-control-call'), + ]); + + expect(recordToolResult.mock.calls[0]?.[2]).toEqual({ + goalContext: permit, + }); + expect(recordToolResult.mock.calls[1]?.[2]).toEqual({ + goalContext: permit, + provenance: 'goal_runtime', + }); + }); +}); diff --git a/packages/core/src/core/turn.test.ts b/packages/core/src/core/turn.test.ts index 82a92f5fe3..a049b4b0d0 100644 --- a/packages/core/src/core/turn.test.ts +++ b/packages/core/src/core/turn.test.ts @@ -174,6 +174,7 @@ describe('Turn', () => { config: { abortSignal: expect.any(AbortSignal) }, }, 'prompt-id-1', + undefined, ); expect(events).toEqual([ diff --git a/packages/core/src/core/turn.ts b/packages/core/src/core/turn.ts index 10c35db033..488f966206 100644 --- a/packages/core/src/core/turn.ts +++ b/packages/core/src/core/turn.ts @@ -36,6 +36,11 @@ import { } from '../utils/thoughtUtils.js'; import type { LoopType } from '../telemetry/types.js'; import type { ActiveGoal } from '../goals/activeGoalStore.js'; +import type { + GoalSnapshotV2, + GoalStateCause, + GoalTurnPermit, +} from '../goals/goal-protocol.js'; import { getProviderToolCallId } from './toolCallIdUtils.js'; const ERROR_REPORT_HISTORY_TAIL_COUNT = 8; @@ -70,6 +75,7 @@ export enum GeminiEventType { HookSystemMessage = 'hook_system_message', UserPromptSubmitBlocked = 'user_prompt_submit_blocked', StopHookLoop = 'stop_hook_loop', + GoalState = 'goal_state', ActiveGoal = 'active_goal', /** The system switched to a fallback model after the primary (or prior * fallback) exhausted retries on a capacity/availability error. */ @@ -130,6 +136,7 @@ export interface ToolCallRequestInfo { response_id?: string; /** Set to true when the LLM response was truncated due to max_tokens. */ wasOutputTruncated?: boolean; + goalContext?: GoalTurnPermit; } export interface ToolCallResponseInfo { @@ -411,8 +418,15 @@ export type ServerGeminiActiveGoalEvent = { value: ActiveGoal | null; }; +export type ServerGeminiGoalStateEvent = { + type: GeminiEventType.GoalState; + value: GoalSnapshotV2; + cause?: GoalStateCause; +}; + // The original union type, now composed of the individual types export type ServerGeminiStreamEvent = + | ServerGeminiGoalStateEvent | ServerGeminiActiveGoalEvent | ServerGeminiChatCompressedEvent | ServerGeminiCitationEvent @@ -439,11 +453,15 @@ export class Turn { private pendingCitations = new Set(); finishReason: FinishReason | undefined = undefined; private currentResponseId?: string; + private readonly goalContext?: GoalTurnPermit; constructor( private readonly chat: GeminiChat, private readonly prompt_id: string, - ) {} + goalContext?: GoalTurnPermit, + ) { + this.goalContext = goalContext ? { ...goalContext } : undefined; + } // The run method yields simpler events suitable for server logic async *run( model: string, @@ -462,6 +480,7 @@ export class Turn { }, }, this.prompt_id, + this.goalContext, ); for await (const streamEvent of responseStream) { @@ -651,6 +670,7 @@ export class Turn { isClientInitiated: false, prompt_id: this.prompt_id, response_id: this.currentResponseId, + ...(this.goalContext ? { goalContext: { ...this.goalContext } } : {}), }; this.pendingToolCalls.push(toolCallRequest); diff --git a/packages/core/src/goals/goal-persistence.test.ts b/packages/core/src/goals/goal-persistence.test.ts index 41a5858a40..9280ce6fcb 100644 --- a/packages/core/src/goals/goal-persistence.test.ts +++ b/packages/core/src/goals/goal-persistence.test.ts @@ -207,7 +207,7 @@ describe('recoverGoalFromRecords', () => { }); describe('legacy migration', () => { - it('creates a fresh active payload at the lifecycle record boundary', () => { + it('creates a fresh paused payload at the lifecycle record boundary', () => { expect( createMigratedGoalState({ objective: 'ship it', @@ -225,7 +225,7 @@ describe('legacy migration', () => { goalId: 'new-goal', revision: 1, objective: 'ship it', - status: 'active', + status: 'paused', evidenceCursor: { recordId: 'migration-record' }, turnCount: 0, activeTimeMs: 0, diff --git a/packages/core/src/goals/goal-persistence.ts b/packages/core/src/goals/goal-persistence.ts index 6f683341ac..09874deeef 100644 --- a/packages/core/src/goals/goal-persistence.ts +++ b/packages/core/src/goals/goal-persistence.ts @@ -133,7 +133,7 @@ export function createMigratedGoalState( goalId: input.goalId, revision: 1, objective, - status: 'active', + status: 'paused', evidenceCursor: { recordId: input.recordUuid }, turnCount: 0, activeTimeMs: 0, diff --git a/packages/core/src/goals/goal-runtime.test.ts b/packages/core/src/goals/goal-runtime.test.ts index 021d434ac9..b52d9d17fb 100644 --- a/packages/core/src/goals/goal-runtime.test.ts +++ b/packages/core/src/goals/goal-runtime.test.ts @@ -17,6 +17,7 @@ import { } from './goal-protocol.js'; import { createGoalRuntime, + GoalPersistenceUnavailableError, type GoalEvidenceSource, type GoalJournal, type GoalTurnHost, @@ -1393,7 +1394,7 @@ describe('goal runtime', () => { expect(newHost.preemptGoalTurn).not.toHaveBeenCalled(); }); - it('migrates a legacy active goal once before making it schedulable', async () => { + it('migrates a legacy active goal once into a paused state', async () => { const journal = fakeGoalJournal(); const host = fakeGoalTurnHost(); const runtime = createGoalRuntime({ journal }); @@ -1409,14 +1410,15 @@ describe('goal runtime', () => { goal: { objective: 'ship it', revision: 1, - status: 'active', + status: 'paused', evidenceCursor: { recordId: expect.any(String) }, }, }, }); expect(host.started).toEqual([]); runtime.bindHost(host); - await vi.waitFor(() => expect(host.started).toHaveLength(1)); + await Promise.resolve(); + expect(host.started).toEqual([]); }); it('releases a rejected host start without an unhandled rejection', async () => { @@ -2001,8 +2003,8 @@ describe('goal runtime', () => { const runtime = createGoalRuntime({ journal: fakeGoalJournal() }); runtime.bindHost(host); - await expect(runtime.restore([malformed])).rejects.toThrow( - 'malformed or uses an unsupported version', + await expect(runtime.restore([malformed])).rejects.toBeInstanceOf( + GoalPersistenceUnavailableError, ); await expect( runtime.dispatch({ action: 'create', objective: 'must not overwrite' }), @@ -2019,8 +2021,12 @@ describe('goal runtime', () => { const runtime = createGoalRuntime({ journal }); runtime.bindHost(host); - await expect(runtime.restore([legacyGoalRecord()])).rejects.toThrow( - 'migration write failed', + await expect(runtime.restore([legacyGoalRecord()])).rejects.toEqual( + expect.objectContaining({ + name: 'GoalPersistenceUnavailableError', + message: 'migration write failed', + cause: expect.objectContaining({ message: 'migration write failed' }), + }), ); await expect( runtime.dispatch({ action: 'create', objective: 'must not overwrite' }), @@ -2030,11 +2036,11 @@ describe('goal runtime', () => { await runtime.restore([legacyGoalRecord()]); expect(runtime.getSnapshot().goal).toMatchObject({ objective: 'ship it', - status: 'active', + status: 'paused', }); }); - it('commits successful legacy recovery before reentrant subscribers run', async () => { + it('commits paused legacy recovery before a reentrant resume', async () => { const journal = fakeGoalJournal({ appendErrors: [new Error('migration write failed'), undefined], }); @@ -2047,7 +2053,7 @@ describe('goal runtime', () => { let reentrantDispatch: Promise | undefined; let reentered = false; runtime.subscribe((snapshot) => { - if (reentered || snapshot.goal?.status !== 'active') return; + if (reentered || snapshot.goal?.status !== 'paused') return; reentered = true; try { runtime.bindHost(host); @@ -2055,7 +2061,7 @@ describe('goal runtime', () => { bindError = error; } reentrantDispatch = runtime.dispatch({ - action: 'pause', + action: 'resume', expectedGoalId: snapshot.goal.goalId, expectedRevision: snapshot.goal.revision, }); @@ -2066,7 +2072,7 @@ describe('goal runtime', () => { expect(bindError).toBeUndefined(); expect(host.started).toHaveLength(1); - expect(runtime.getSnapshot().goal?.status).toBe('paused'); + expect(runtime.getSnapshot().goal?.status).toBe('active'); }); it('preempts replace and clear after commit and admits only active replacements', async () => { diff --git a/packages/core/src/goals/goal-runtime.ts b/packages/core/src/goals/goal-runtime.ts index 81398585b8..06a5049b8b 100644 --- a/packages/core/src/goals/goal-runtime.ts +++ b/packages/core/src/goals/goal-runtime.ts @@ -63,6 +63,16 @@ export interface GoalEvidenceSource { readActiveTranscriptChain(): Promise; } +export class GoalPersistenceUnavailableError extends Error { + constructor( + message = 'Goal persistence is unavailable for this session', + options?: ErrorOptions, + ) { + super(message, options); + this.name = 'GoalPersistenceUnavailableError'; + } +} + export interface GoalTurnHost { startGoalTurn(input: { permit: GoalTurnPermit; @@ -571,7 +581,7 @@ export function createGoalRuntime( if (restored) return; const recovery = recoverGoalFromRecords(records); if (recovery.kind === 'unsupported') { - recoveryError = new Error(recovery.reason); + recoveryError = new GoalPersistenceUnavailableError(recovery.reason); throw recoveryError; } try { @@ -594,7 +604,14 @@ export function createGoalRuntime( recordUuid, now: Date.now(), }); - await options.journal.recordGoalState(recordUuid, payload); + try { + await options.journal.recordGoalState(recordUuid, payload); + } catch (error) { + throw new GoalPersistenceUnavailableError( + error instanceof Error ? error.message : String(error), + { cause: error }, + ); + } assertAvailable(); recoveredSnapshot = structuredClone(payload.snapshot); recoveredCause = payload.cause; diff --git a/packages/core/src/tools/agent/agent-override.test.ts b/packages/core/src/tools/agent/agent-override.test.ts index 9c884cbd64..16a338c5ff 100644 --- a/packages/core/src/tools/agent/agent-override.test.ts +++ b/packages/core/src/tools/agent/agent-override.test.ts @@ -434,17 +434,29 @@ describe('createApprovalModeOverride bound-tool isolation', () => { await parentRegistry.warmAll(); const childNames = child.getToolRegistry().getAllToolNames().sort(); + const topLevelOnlyTools = new Set([ + ToolNames.GET_GOAL, + ToolNames.UPDATE_GOAL, + ]); + const expectedChildNames = parentRegistry + .getAllToolNames() + .filter((name) => !topLevelOnlyTools.has(name)) + .sort(); - // After warmAll the core tool sets must match — the child registry - // is built from the same Config (just the override), and we copied - // any discovered tools across. So the name set should equal parent's. - expect(childNames).toEqual(parentRegistry.getAllToolNames().sort()); + // The child registry copies discovered tools and rebuilds the same core + // toolset, except for session-owned tools intentionally excluded from + // subagent contexts. + expect(childNames).toEqual(expectedChildNames); // And the parent's pre-warm names must be a subset of the post-warm // names — sanity check that warmAll didn't lose anything. - const beforeSet = new Set(beforeNames); + const beforeSet = new Set( + beforeNames.filter((name) => !topLevelOnlyTools.has(name)), + ); for (const name of beforeSet) { expect(childNames).toContain(name); } + expect(childNames).not.toContain(ToolNames.GET_GOAL); + expect(childNames).not.toContain(ToolNames.UPDATE_GOAL); // Sanity: WriteFile is registered in non-bare mode only, so bare mode // should NOT have it. diff --git a/packages/core/src/tools/read-file.test.ts b/packages/core/src/tools/read-file.test.ts index 6016774953..4cbf24969e 100644 --- a/packages/core/src/tools/read-file.test.ts +++ b/packages/core/src/tools/read-file.test.ts @@ -12,6 +12,7 @@ import path from 'node:path'; import os from 'node:os'; import fs from 'node:fs'; import fsp from 'node:fs/promises'; +import sharp from 'sharp'; import type { Config } from '../config/config.js'; import { Storage } from '../config/storage.js'; import { FileDiscoveryService } from '../services/fileDiscoveryService.js'; @@ -545,11 +546,16 @@ describe('ReadFileTool', () => { it('should handle image file and return appropriate content', async () => { const imagePath = path.join(tempRootDir, 'image.png'); - // Minimal PNG header - const pngHeader = Buffer.from([ - 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, - ]); - await fsp.writeFile(imagePath, pngHeader); + await sharp({ + create: { + width: 20, + height: 10, + channels: 3, + background: '#306090', + }, + }) + .png() + .toFile(imagePath); const params: ReadFileToolParams = { file_path: imagePath }; const invocation = tool.build(params) as ToolInvocation< ReadFileToolParams, @@ -557,13 +563,20 @@ describe('ReadFileTool', () => { >; const result = await invocation.execute(abortSignal); - expect(result.llmContent).toEqual({ - inlineData: { - data: pngHeader.toString('base64'), - mimeType: 'image/png', - displayName: 'image.png', + expect(result.llmContent).toEqual([ + { + text: expect.stringMatching( + /Image overview: 20x10; oriented source: 20x10.*tool_search.*zoom_image.*0 to 1000/, + ), }, - }); + { + inlineData: { + data: expect.any(String), + mimeType: 'image/jpeg', + displayName: 'image.png', + }, + }, + ]); expect(result.returnDisplay).toBe('Read image file: image.png'); }); @@ -1530,17 +1543,23 @@ describe('ReadFileTool', () => { it('does not return the placeholder for image files', async () => { const imagePath = path.join(tempRootDir, 'pic.png'); - const pngHeader = Buffer.from([ - 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, - ]); - await fsp.writeFile(imagePath, pngHeader); + await sharp({ + create: { + width: 8, + height: 8, + channels: 3, + background: '#306090', + }, + }) + .png() + .toFile(imagePath); const first = await read({ file_path: imagePath }); - // Image returns a Part, not a string. + // Image returns Parts, not a string. expect(typeof first.llmContent).not.toBe('string'); const second = await read({ file_path: imagePath }); - // Must remain a Part — never collapsed to a string placeholder. + // Must remain Parts — never collapsed to a string placeholder. expect(typeof second.llmContent).not.toBe('string'); }); diff --git a/packages/core/src/tools/zoom-image.ts b/packages/core/src/tools/zoom-image.ts index 619615af26..34fc314ebd 100644 --- a/packages/core/src/tools/zoom-image.ts +++ b/packages/core/src/tools/zoom-image.ts @@ -5,15 +5,17 @@ */ import path from 'node:path'; -import fs from 'node:fs/promises'; import type { Part } from '@google/genai'; -import type { Metadata } from 'sharp'; import type { Config } from '../config/config.js'; import type { PermissionDecision } from '../permissions/types.js'; import { logFileOperation } from '../telemetry/loggers.js'; import { FileOperation } from '../telemetry/metrics.js'; import { FileOperationEvent } from '../telemetry/types.js'; import { getSpecificMimeType } from '../utils/fileUtils.js'; +import { + ImageViewError, + renderNormalizedImageCrop, +} from '../utils/image-view.js'; import { makeRelative, shortenPath, unescapePath } from '../utils/paths.js'; import { getFileReadDefaultPermission } from './file-read-permission.js'; import { ToolErrorType } from './tool-error.js'; @@ -21,15 +23,6 @@ import { ToolDisplayNames, ToolNames } from './tool-names.js'; import type { ToolInvocation, ToolLocation, ToolResult } from './tools.js'; import { BaseDeclarativeTool, BaseToolInvocation, Kind } from './tools.js'; -const IMAGE_VIEW_MAX_EDGE = 1568; -const IMAGE_VIEW_MAX_PATCHES = 1568; -const IMAGE_PATCH_SIZE = 28; -const IMAGE_MAX_UPSCALE = 8; -const IMAGE_JPEG_QUALITY = 92; -const IMAGE_MAX_SOURCE_BYTES = 100 * 1024 * 1024; -const IMAGE_MAX_OUTPUT_BYTES = 9 * 1024 * 1024; -const SUPPORTED_IMAGE_FORMATS = new Set(['jpeg', 'png', 'webp']); - export interface ZoomImageParams { file_path: string; x1: number; @@ -38,11 +31,6 @@ export interface ZoomImageParams { y2: number; } -interface ImageSize { - width: number; - height: number; -} - function failureResult(message: string, type: ToolErrorType): ToolResult { return { llmContent: message, @@ -51,48 +39,6 @@ function failureResult(message: string, type: ToolErrorType): ToolResult { }; } -function fitsVisualBudget({ width, height }: ImageSize): boolean { - return ( - width <= IMAGE_VIEW_MAX_EDGE && - height <= IMAGE_VIEW_MAX_EDGE && - Math.ceil(width / IMAGE_PATCH_SIZE) * - Math.ceil(height / IMAGE_PATCH_SIZE) <= - IMAGE_VIEW_MAX_PATCHES - ); -} - -function magnifiedSize(width: number, height: number): ImageSize { - const widthIsLongEdge = width >= height; - const maxLongEdge = Math.min( - IMAGE_VIEW_MAX_EDGE, - Math.max(width, height) * IMAGE_MAX_UPSCALE, - ); - let low = 1; - let high = maxLongEdge; - let best: ImageSize = { width: 1, height: 1 }; - - while (low <= high) { - const longEdge = Math.floor((low + high) / 2); - const candidate = widthIsLongEdge - ? { - width: longEdge, - height: Math.max(1, Math.round((height / width) * longEdge)), - } - : { - width: Math.max(1, Math.round((width / height) * longEdge)), - height: longEdge, - }; - if (fitsVisualBudget(candidate)) { - best = candidate; - low = longEdge + 1; - } else { - high = longEdge - 1; - } - } - - return best; -} - class ZoomImageInvocation extends BaseToolInvocation< ZoomImageParams, ToolResult @@ -130,145 +76,47 @@ class ZoomImageInvocation extends BaseToolInvocation< ToolErrorType.READ_CONTENT_FAILURE, ); } - let sharp: typeof import('sharp'); + let view: Awaited>; try { - // sharp is a CJS `export =` module: at runtime the dynamic-import - // namespace carries the callable on `.default`, which the NodeNext types - // collapse away, so unwrap it explicitly (cf. utils/iconvHelper.ts). - sharp = ( - (await import('sharp')) as unknown as { - default: typeof import('sharp'); - } - ).default; - } catch { - return failureResult( - 'zoom_image is unavailable because the "sharp" image module could not be loaded.', - ToolErrorType.READ_CONTENT_FAILURE, + view = await renderNormalizedImageCrop( + this.params.file_path, + this.params, + signal, ); - } - let stats: Awaited>; - try { - stats = await fs.stat(this.params.file_path); } catch (error) { - if ((error as NodeJS.ErrnoException).code === 'ENOENT') { - return failureResult( - `Image file not found: ${this.params.file_path}`, - ToolErrorType.FILE_NOT_FOUND, - ); - } - throw error; - } - if (stats.isDirectory()) { - return failureResult( - `Image path is a directory: ${this.params.file_path}`, - ToolErrorType.TARGET_IS_DIRECTORY, - ); - } - if (!stats.isFile()) { - return failureResult( - `Image path is not a regular file: ${this.params.file_path}`, - ToolErrorType.TARGET_NOT_REGULAR_FILE, - ); - } - if (stats.size > IMAGE_MAX_SOURCE_BYTES) { - return failureResult( - `Image file exceeds the 100 MB source limit: ${this.params.file_path}`, - ToolErrorType.FILE_TOO_LARGE, - ); - } - let metadata: Metadata; - try { - metadata = await sharp(this.params.file_path, { - failOn: 'error', - limitInputPixels: true, - }).metadata(); - } catch { - return failureResult( - `Unsupported image. zoom_image accepts static PNG, JPEG, or WebP files: ${this.params.file_path}`, - ToolErrorType.READ_CONTENT_FAILURE, - ); - } - signal.throwIfAborted(); - if (!SUPPORTED_IMAGE_FORMATS.has(metadata.format)) { - return failureResult( - `Unsupported image. zoom_image accepts static PNG, JPEG, or WebP files: ${this.params.file_path}`, - ToolErrorType.READ_CONTENT_FAILURE, - ); - } - if ((metadata.pages ?? 1) > 1) { - return failureResult( - `zoom_image accepts static images only: ${this.params.file_path}`, - ToolErrorType.READ_CONTENT_FAILURE, - ); - } - - const sourceWidth = metadata.autoOrient.width; - const sourceHeight = metadata.autoOrient.height; - const left = Math.min( - sourceWidth - 1, - Math.max(0, Math.floor((this.params.x1 / 1000) * sourceWidth)), - ); - const top = Math.min( - sourceHeight - 1, - Math.max(0, Math.floor((this.params.y1 / 1000) * sourceHeight)), - ); - const right = Math.min( - sourceWidth, - Math.max(left + 1, Math.ceil((this.params.x2 / 1000) * sourceWidth)), - ); - const bottom = Math.min( - sourceHeight, - Math.max(top + 1, Math.ceil((this.params.y2 / 1000) * sourceHeight)), - ); - const cropWidth = right - left; - const cropHeight = bottom - top; - const outputSize = magnifiedSize(cropWidth, cropHeight); - - let output: Buffer; - try { - output = await sharp(this.params.file_path, { - autoOrient: true, - failOn: 'error', - limitInputPixels: true, - }) - .extract({ left, top, width: cropWidth, height: cropHeight }) - .resize(outputSize.width, outputSize.height, { - fit: 'fill', - kernel: sharp.kernel.lanczos3, - }) - .flatten({ background: '#ffffff' }) - .jpeg({ - quality: IMAGE_JPEG_QUALITY, - chromaSubsampling: '4:4:4', - }) - .toBuffer(); - } catch { signal.throwIfAborted(); - return failureResult( - `Failed to decode image: ${this.params.file_path}`, - ToolErrorType.READ_CONTENT_FAILURE, - ); - } - signal.throwIfAborted(); - if (output.length > IMAGE_MAX_OUTPUT_BYTES) { - return failureResult( - `Zoomed image exceeds the 9 MB output limit: ${this.params.file_path}`, - ToolErrorType.FILE_TOO_LARGE, - ); + const message = + error instanceof Error ? error.message : 'Failed to decode image.'; + let errorType = ToolErrorType.READ_CONTENT_FAILURE; + if (error instanceof ImageViewError) { + if (error.code === 'file_not_found') { + errorType = ToolErrorType.FILE_NOT_FOUND; + } else if (error.code === 'target_is_directory') { + errorType = ToolErrorType.TARGET_IS_DIRECTORY; + } else if (error.code === 'target_not_regular_file') { + errorType = ToolErrorType.TARGET_NOT_REGULAR_FILE; + } else if ( + error.code === 'source_too_large' || + error.code === 'output_too_large' + ) { + errorType = ToolErrorType.FILE_TOO_LARGE; + } + } + return failureResult(message, errorType); } const text = `Zoomed normalized region (${this.params.x1},${this.params.y1})-` + `(${this.params.x2},${this.params.y2}) from ${this.params.file_path}. ` + - `Oriented source: ${sourceWidth}x${sourceHeight}; source crop: ` + - `${cropWidth}x${cropHeight}; returned view: ` + - `${outputSize.width}x${outputSize.height}.`; + `Oriented source: ${view.sourceWidth}x${view.sourceHeight}; source crop: ` + + `${view.selectedWidth}x${view.selectedHeight}; returned view: ` + + `${view.outputWidth}x${view.outputHeight}.`; const llmContent: Part[] = [ { text }, { inlineData: { - mimeType: 'image/jpeg', - data: output.toString('base64'), + mimeType: view.mimeType, + data: view.bytes.toString('base64'), }, }, ]; diff --git a/packages/core/src/utils/fileUtils.test.ts b/packages/core/src/utils/fileUtils.test.ts index 865bc46340..286e68ee48 100644 --- a/packages/core/src/utils/fileUtils.test.ts +++ b/packages/core/src/utils/fileUtils.test.ts @@ -22,6 +22,7 @@ import path from 'node:path'; import os from 'node:os'; import mime from 'mime/lite'; import type { Part } from '@google/genai'; +import sharp from 'sharp'; import { isWithinRoot, @@ -1179,30 +1180,234 @@ describe('fileUtils', () => { }); it('should process an image file', async () => { - const fakePngData = Buffer.from('fake png data'); - actualNodeFs.writeFileSync(testImageFilePath, fakePngData); + await sharp({ + create: { + width: 20, + height: 10, + channels: 3, + background: '#306090', + }, + }) + .png() + .toFile(testImageFilePath); mockMimeGetType.mockReturnValue('image/png'); const result = await processSingleFileContent( testImageFilePath, mockConfig, ); - expect( - (result.llmContent as { inlineData: unknown }).inlineData, - ).toBeDefined(); - expect( - (result.llmContent as { inlineData: { mimeType: string } }).inlineData - .mimeType, - ).toBe('image/png'); - expect( - (result.llmContent as { inlineData: { data: string } }).inlineData.data, - ).toBe(fakePngData.toString('base64')); - expect( - (result.llmContent as { inlineData: { displayName?: string } }) - .inlineData.displayName, - ).toBe('image.png'); + const parts = result.llmContent as Part[]; + expect(parts[0]).toEqual({ + text: + 'Image overview: 20x10; oriented source: 20x10. ' + + 'If details are too small, use tool_search for "zoom image", then ' + + 'call zoom_image with coordinates normalized from 0 to 1000.', + }); + expect(parts[1]).toEqual({ + inlineData: { + mimeType: 'image/jpeg', + data: expect.any(String), + displayName: 'image.png', + }, + }); + const metadata = await sharp( + Buffer.from(parts[1]!.inlineData!.data!, 'base64'), + ).metadata(); + expect(metadata).toMatchObject({ width: 20, height: 10 }); expect(result.returnDisplay).toContain('Read image file: image.png'); }); + it('returns a bounded overview when a PNG exceeds the old data URI limit', async () => { + const largeImagePath = path.join(tempRootDir, 'large.png'); + await sharp({ + create: { + width: 2200, + height: 1800, + channels: 3, + background: '#306090', + }, + }) + .png({ compressionLevel: 0 }) + .toFile(largeImagePath); + expect(actualNodeFs.statSync(largeImagePath).size).toBeGreaterThan( + 9.9 * 1024 * 1024, + ); + mockMimeGetType.mockReturnValue('image/png'); + + const result = await processSingleFileContent(largeImagePath, mockConfig); + + expect(result.error).toBeUndefined(); + const parts = result.llmContent as Part[]; + const overview = parts[1]!.inlineData!; + const metadata = await sharp( + Buffer.from(overview.data!, 'base64'), + ).metadata(); + expect(overview.mimeType).toBe('image/jpeg'); + expect(Buffer.from(overview.data!, 'base64').length).toBeLessThanOrEqual( + 9 * 1024 * 1024, + ); + expect(Math.max(metadata.width!, metadata.height!)).toBeLessThanOrEqual( + 1568, + ); + expect( + Math.ceil(metadata.width! / 28) * Math.ceil(metadata.height! / 28), + ).toBeLessThanOrEqual(1568); + }); + + it('rejects canonical image sources above 100 MB before decoding', async () => { + const oversizedPath = path.join(tempRootDir, 'oversized.png'); + const handle = await fsPromises.open(oversizedPath, 'w'); + await handle.truncate(100 * 1024 * 1024 + 1); + await handle.close(); + mockMimeGetType.mockReturnValue('image/png'); + + const result = await processSingleFileContent(oversizedPath, mockConfig); + + expect(result.errorType).toBe(ToolErrorType.FILE_TOO_LARGE); + expect(result.llmContent).toContain('100 MB source limit'); + }); + + it('forwards a corrupt canonical image verbatim instead of failing the read', async () => { + const corruptPath = path.join(tempRootDir, 'corrupt.png'); + const bytes = Buffer.from('not a real png'); + await fsPromises.writeFile(corruptPath, bytes); + mockMimeGetType.mockReturnValue('image/png'); + + const result = await processSingleFileContent(corruptPath, mockConfig); + + expect(result.error).toBeUndefined(); + expect(result.llmContent).toEqual({ + inlineData: { + data: bytes.toString('base64'), + mimeType: 'image/png', + displayName: 'corrupt.png', + }, + }); + expect(result.returnDisplay).toContain('Read image file: corrupt.png'); + }); + + it('forwards an animated canonical image verbatim instead of failing the read', async () => { + const twoFrameGif = Buffer.from( + '47494638396101000100800000000000ffffff21f90400010000002c000000000100010000020244010021f90400010000002c00000000010001000002024c01003b', + 'hex', + ); + const animatedPath = path.join(tempRootDir, 'animated.webp'); + await sharp(twoFrameGif, { animated: true }).webp().toFile(animatedPath); + mockMimeGetType.mockReturnValue('image/webp'); + + const result = await processSingleFileContent(animatedPath, mockConfig); + + const onDisk = await fsPromises.readFile(animatedPath); + expect(result.error).toBeUndefined(); + expect(result.llmContent).toEqual({ + inlineData: { + data: onDisk.toString('base64'), + mimeType: 'image/webp', + displayName: 'animated.webp', + }, + }); + expect(result.returnDisplay).toContain('Read image file: animated.webp'); + }); + + it('forwards non-canonical content behind a canonical extension verbatim', async () => { + const gifBytes = Buffer.from( + '47494638396101000100800000000000ffffff21f90400010000002c000000000100010000020244010021f90400010000002c00000000010001000002024c01003b', + 'hex', + ); + const mismatchPath = path.join(tempRootDir, 'mismatch.png'); + await fsPromises.writeFile(mismatchPath, gifBytes); + mockMimeGetType.mockReturnValue('image/png'); + + const result = await processSingleFileContent(mismatchPath, mockConfig); + + expect(result.error).toBeUndefined(); + expect(result.llmContent).toEqual({ + inlineData: { + data: gifBytes.toString('base64'), + mimeType: 'image/png', + displayName: 'mismatch.png', + }, + }); + }); + + it('applies EXIF orientation before describing and rendering an overview', async () => { + const orientedPath = path.join(tempRootDir, 'oriented.jpg'); + await sharp({ + create: { + width: 60, + height: 40, + channels: 3, + background: '#306090', + }, + }) + .jpeg() + .withMetadata({ orientation: 6 }) + .toFile(orientedPath); + mockMimeGetType.mockReturnValue('image/jpeg'); + + const result = await processSingleFileContent(orientedPath, mockConfig); + const parts = result.llmContent as Part[]; + const metadata = await sharp( + Buffer.from(parts[1]!.inlineData!.data!, 'base64'), + ).metadata(); + + expect(parts[0]?.text).toContain('oriented source: 40x60'); + expect(metadata).toMatchObject({ width: 40, height: 60 }); + }); + + it('flattens transparent overview pixels onto white', async () => { + const transparentPath = path.join(tempRootDir, 'transparent.webp'); + await sharp({ + create: { + width: 20, + height: 20, + channels: 4, + background: { r: 0, g: 0, b: 0, alpha: 0 }, + }, + }) + .webp() + .toFile(transparentPath); + mockMimeGetType.mockReturnValue('image/webp'); + + const result = await processSingleFileContent( + transparentPath, + mockConfig, + ); + const parts = result.llmContent as Part[]; + const { data, info } = await sharp( + Buffer.from(parts[1]!.inlineData!.data!, 'base64'), + ) + .raw() + .toBuffer({ resolveWithObject: true }); + const center = + (Math.floor(info.height / 2) * info.width + + Math.floor(info.width / 2)) * + info.channels; + + expect(Array.from(data.subarray(center, center + 3))).toEqual([ + 255, 255, 255, + ]); + }); + + it.each([ + ['animated GIF', 'animation.gif', 'image/gif'], + ['BMP', 'bitmap.bmp', 'image/bmp'], + ])('keeps %s image bytes unchanged', async (_label, name, mimeType) => { + const filePath = path.join(tempRootDir, name); + const bytes = Buffer.from('unchanged image bytes'); + actualNodeFs.writeFileSync(filePath, bytes); + mockMimeGetType.mockReturnValue(mimeType); + + const result = await processSingleFileContent(filePath, mockConfig); + + expect(result.llmContent).toEqual({ + inlineData: { + data: bytes.toString('base64'), + mimeType, + displayName: name, + }, + }); + }); + it('should reject image files when model does not support image', async () => { const fakePngData = Buffer.from('fake png data'); actualNodeFs.writeFileSync(testImageFilePath, fakePngData); @@ -1224,8 +1429,16 @@ describe('fileUtils', () => { }); it('keeps image inline when preserveUnsupportedImage is true', async () => { - const fakePngData = Buffer.from('fake png data'); - actualNodeFs.writeFileSync(testImageFilePath, fakePngData); + await sharp({ + create: { + width: 8, + height: 8, + channels: 3, + background: '#306090', + }, + }) + .png() + .toFile(testImageFilePath); mockMimeGetType.mockReturnValue('image/png'); const mockConfigNoImage = { @@ -1239,10 +1452,13 @@ describe('fileUtils', () => { { preserveUnsupportedImage: true }, ); expect(typeof result.llmContent).toBe('object'); - expect( - (result.llmContent as { inlineData: { mimeType: string } }).inlineData - .mimeType, - ).toBe('image/png'); + const parts = result.llmContent as Part[]; + expect(parts[0]?.text).toContain('Image overview'); + expect(parts[1]?.inlineData).toMatchObject({ + mimeType: 'image/jpeg', + data: expect.any(String), + displayName: 'image.png', + }); expect(result.returnDisplay).toContain('Read image file'); }); @@ -1285,6 +1501,29 @@ describe('fileUtils', () => { expect(result.llmContent).toContain('does not support audio input'); }); + it('keeps supported audio bytes unchanged', async () => { + const audioPath = path.join(tempRootDir, 'clip.mp3'); + const audioBytes = Buffer.from('fake audio data'); + actualNodeFs.writeFileSync(audioPath, audioBytes); + mockMimeGetType.mockReturnValue('audio/mpeg'); + const audioConfig = { + ...mockConfig, + getContentGeneratorConfig: () => ({ + modalities: { image: true, audio: true, video: true }, + }), + } as unknown as Config; + + const result = await processSingleFileContent(audioPath, audioConfig); + + expect(result.llmContent).toEqual({ + inlineData: { + data: audioBytes.toString('base64'), + mimeType: 'audio/mpeg', + displayName: 'clip.mp3', + }, + }); + }); + it('processes an .m4v video as inline data despite the mime/lite gap', async () => { // Regression guard for the /learn local-video path: mime/lite returns // null for .m4v, so without the detectFileType override the file fell @@ -3088,16 +3327,11 @@ describe('fileUtils', () => { }); it('should still return an error if an inline media file exceeds 10MB', async () => { - mockMimeGetType.mockReturnValue('image/png'); - actualNodeFs.writeFileSync( - testImageFilePath, - Buffer.alloc(11 * 1024 * 1024), - ); + const largeGifPath = path.join(tempRootDir, 'large.gif'); + mockMimeGetType.mockReturnValue('image/gif'); + actualNodeFs.writeFileSync(largeGifPath, Buffer.alloc(11 * 1024 * 1024)); - const result = await processSingleFileContent( - testImageFilePath, - mockConfig, - ); + const result = await processSingleFileContent(largeGifPath, mockConfig); expect(result.error).toContain('File size exceeds the 10MB limit'); expect(result.returnDisplay).toContain( diff --git a/packages/core/src/utils/fileUtils.ts b/packages/core/src/utils/fileUtils.ts index 70a167fce7..4ca6f020bc 100644 --- a/packages/core/src/utils/fileUtils.ts +++ b/packages/core/src/utils/fileUtils.ts @@ -41,8 +41,18 @@ import { DEFAULT_RANGE_READ_BYTES, TEXT_RANGE_FAST_PATH_MAX_SIZE, } from './text-range-constants.js'; +import { + IMAGE_MAX_SOURCE_BYTES, + ImageViewError, + renderImageOverview, +} from './image-view.js'; const debugLogger = createDebugLogger('FILE_UTILS'); +const CANONICAL_IMAGE_MIME_TYPES = new Set([ + 'image/jpeg', + 'image/png', + 'image/webp', +]); // Default values for encoding and separator format export const DEFAULT_ENCODING: BufferEncoding = 'utf-8'; @@ -1093,6 +1103,12 @@ export async function processSingleFileContent( } const fileType = await detectFileType(filePath); + const mediaMimeType = + mime.getType(filePath) ?? + MIME_LITE_MISSING_VIDEO_TYPES.get(path.extname(filePath).toLowerCase()) ?? + 'application/octet-stream'; + const shouldRenderImageOverview = + fileType === 'image' && CANONICAL_IMAGE_MIME_TYPES.has(mediaMimeType); const relativePathForDisplay = path .relative(rootDirectory, filePath) .replace(/\\/g, '/'); @@ -1229,7 +1245,20 @@ export async function processSingleFileContent( }; } } - if (fileSizeInMB > 9.9 && !willExtractPdfText && fileType !== 'text') { + if (shouldRenderImageOverview && stats.size > IMAGE_MAX_SOURCE_BYTES) { + return { + llmContent: 'Image file exceeds the 100 MB source limit.', + returnDisplay: 'Image file exceeds the 100 MB source limit.', + error: `Image file exceeds the 100 MB source limit: ${filePath}`, + errorType: ToolErrorType.FILE_TOO_LARGE, + }; + } + if ( + fileSizeInMB > 9.9 && + !willExtractPdfText && + fileType !== 'text' && + !shouldRenderImageOverview + ) { return { llmContent: 'File size exceeds the 10MB limit.', returnDisplay: 'File size exceeds the 10MB limit.', @@ -1416,7 +1445,79 @@ export async function processSingleFileContent( stats, }; } - case 'image': + case 'image': { + if (shouldRenderImageOverview) { + try { + const view = await renderImageOverview( + filePath, + signal ?? new AbortController().signal, + ); + return { + llmContent: [ + { + text: + `Image overview: ${view.outputWidth}x${view.outputHeight}; ` + + `oriented source: ${view.sourceWidth}x${view.sourceHeight}. ` + + `If details are too small, use tool_search for "zoom image", then ` + + `call zoom_image with coordinates normalized from 0 to 1000.`, + }, + { + inlineData: { + data: view.bytes.toString('base64'), + mimeType: view.mimeType, + displayName, + }, + }, + ], + returnDisplay: `Read image file: ${relativePathForDisplay}`, + }; + } catch (error) { + signal?.throwIfAborted(); + if (error instanceof ImageViewError) { + // Non-size render failures (sharp missing, animated, + // unsupported, or corrupt input) fall through to the legacy + // inline-bytes branch below rather than hard-failing the read, + // matching main's forward-verbatim behaviour. The size codes + // stay a hard error because that branch cannot shrink them. + if ( + error.code === 'source_too_large' || + error.code === 'output_too_large' + ) { + const userMessage = error.message.replace(`: ${filePath}`, ''); + return { + llmContent: userMessage, + returnDisplay: userMessage, + error: error.message, + errorType: ToolErrorType.FILE_TOO_LARGE, + }; + } + } else { + throw error; + } + } + } + const contentBuffer = await fs.promises.readFile(filePath); + const base64Data = contentBuffer.toString('base64'); + const base64SizeInMB = base64Data.length / (1024 * 1024); + if (base64SizeInMB > 9.9) { + return { + llmContent: `File exceeds the 10MB data URI limit after base64 encoding (${base64SizeInMB.toFixed(2)}MB encoded).`, + returnDisplay: `File exceeds the 10MB data URI limit after base64 encoding.`, + error: `File exceeds the 10MB data URI limit after base64 encoding: ${filePath} (${base64SizeInMB.toFixed(2)}MB encoded)`, + errorType: ToolErrorType.FILE_TOO_LARGE, + }; + } + return { + llmContent: { + inlineData: { + data: base64Data, + mimeType: mediaMimeType, + displayName, + }, + }, + returnDisplay: `Read image file: ${relativePathForDisplay}`, + }; + } case 'audio': case 'video': { const contentBuffer = await fs.promises.readFile(filePath); @@ -1435,12 +1536,7 @@ export async function processSingleFileContent( llmContent: { inlineData: { data: base64Data, - mimeType: - mime.getType(filePath) ?? - MIME_LITE_MISSING_VIDEO_TYPES.get( - path.extname(filePath).toLowerCase(), - ) ?? - 'application/octet-stream', + mimeType: mediaMimeType, displayName, }, }, diff --git a/packages/core/src/utils/image-view.test.ts b/packages/core/src/utils/image-view.test.ts new file mode 100644 index 0000000000..fa1aa32ac6 --- /dev/null +++ b/packages/core/src/utils/image-view.test.ts @@ -0,0 +1,122 @@ +/** + * @license + * Copyright 2026 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import sharp from 'sharp'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { + renderImageOverview, + renderNormalizedImageCrop, +} from './image-view.js'; + +describe('image views', () => { + let root: string; + const signal = new AbortController().signal; + + beforeEach(async () => { + root = await fs.mkdtemp(path.join(os.tmpdir(), 'image-view-')); + }); + + afterEach(async () => { + await fs.rm(root, { recursive: true, force: true }); + }); + + it('keeps a small overview at its oriented source size', async () => { + const filePath = path.join(root, 'small.png'); + await sharp({ + create: { + width: 20, + height: 10, + channels: 3, + background: '#306090', + }, + }) + .png() + .toFile(filePath); + + const view = await renderImageOverview(filePath, signal); + const metadata = await sharp(view.bytes).metadata(); + + expect(view).toMatchObject({ + mimeType: 'image/jpeg', + sourceWidth: 20, + sourceHeight: 10, + selectedWidth: 20, + selectedHeight: 10, + outputWidth: 20, + outputHeight: 10, + }); + expect(metadata).toMatchObject({ width: 20, height: 10, format: 'jpeg' }); + }); + + it('bounds a large overview by the shared edge and patch budget', async () => { + const filePath = path.join(root, 'large.png'); + await sharp({ + create: { + width: 4000, + height: 2000, + channels: 3, + background: '#804020', + }, + }) + .png() + .toFile(filePath); + + const view = await renderImageOverview(filePath, signal); + + expect(Math.max(view.outputWidth, view.outputHeight)).toBeLessThanOrEqual( + 1568, + ); + expect( + Math.ceil(view.outputWidth / 28) * Math.ceil(view.outputHeight / 28), + ).toBeLessThanOrEqual(1568); + expect(view.bytes.length).toBeLessThanOrEqual(9 * 1024 * 1024); + }); + + it('may magnify a normalized crop while preserving its source dimensions', async () => { + const filePath = path.join(root, 'crop.png'); + await sharp({ + create: { + width: 400, + height: 400, + channels: 3, + background: '#306090', + }, + }) + .png() + .toFile(filePath); + + const view = await renderNormalizedImageCrop( + filePath, + { x1: 0, y1: 0, x2: 25, y2: 25 }, + signal, + ); + const metadata = await sharp(view.bytes).metadata(); + + expect(view).toMatchObject({ + mimeType: 'image/jpeg', + sourceWidth: 400, + sourceHeight: 400, + selectedWidth: 10, + selectedHeight: 10, + outputWidth: 80, + outputHeight: 80, + }); + expect(metadata).toMatchObject({ width: 80, height: 80, format: 'jpeg' }); + expect(view.bytes.length).toBeLessThanOrEqual(9 * 1024 * 1024); + }); + + it('reports decode_failed for a corrupt canonical image', async () => { + const filePath = path.join(root, 'corrupt.png'); + await fs.writeFile(filePath, 'not a real png'); + + await expect(renderImageOverview(filePath, signal)).rejects.toMatchObject({ + code: 'decode_failed', + }); + }); +}); diff --git a/packages/core/src/utils/image-view.ts b/packages/core/src/utils/image-view.ts new file mode 100644 index 0000000000..339339b33c --- /dev/null +++ b/packages/core/src/utils/image-view.ts @@ -0,0 +1,314 @@ +/** + * @license + * Copyright 2026 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import fs from 'node:fs/promises'; +import type { Metadata } from 'sharp'; + +const IMAGE_VIEW_MAX_EDGE = 1568; +const IMAGE_VIEW_MAX_PATCHES = 1568; +const IMAGE_PATCH_SIZE = 28; +const IMAGE_MAX_UPSCALE = 8; +const IMAGE_JPEG_QUALITY = 92; +export const IMAGE_MAX_SOURCE_BYTES = 100 * 1024 * 1024; +const IMAGE_MAX_OUTPUT_BYTES = 9 * 1024 * 1024; +const SUPPORTED_IMAGE_FORMATS = new Set(['jpeg', 'png', 'webp']); + +export interface NormalizedRegion { + x1: number; + y1: number; + x2: number; + y2: number; +} + +export interface ImageView { + bytes: Buffer; + mimeType: 'image/jpeg'; + sourceWidth: number; + sourceHeight: number; + selectedWidth: number; + selectedHeight: number; + outputWidth: number; + outputHeight: number; +} + +interface ImageSize { + width: number; + height: number; +} + +interface PreparedImage { + bytes: Buffer; + metadata: Metadata; + sharp: typeof import('sharp'); +} + +export type ImageViewErrorCode = + | 'renderer_unavailable' + | 'file_not_found' + | 'target_is_directory' + | 'target_not_regular_file' + | 'source_too_large' + | 'unsupported_image' + | 'animated_image' + | 'decode_failed' + | 'output_too_large'; + +export class ImageViewError extends Error { + constructor( + readonly code: ImageViewErrorCode, + message: string, + ) { + super(message); + } +} + +function fitsVisualBudget({ width, height }: ImageSize): boolean { + return ( + width <= IMAGE_VIEW_MAX_EDGE && + height <= IMAGE_VIEW_MAX_EDGE && + Math.ceil(width / IMAGE_PATCH_SIZE) * + Math.ceil(height / IMAGE_PATCH_SIZE) <= + IMAGE_VIEW_MAX_PATCHES + ); +} + +function boundedSize( + width: number, + height: number, + maxUpscale: number, +): ImageSize { + const widthIsLongEdge = width >= height; + const maxLongEdge = Math.min( + IMAGE_VIEW_MAX_EDGE, + Math.max(width, height) * maxUpscale, + ); + let low = 1; + let high = maxLongEdge; + let best: ImageSize = { width: 1, height: 1 }; + + while (low <= high) { + const longEdge = Math.floor((low + high) / 2); + const candidate = widthIsLongEdge + ? { + width: longEdge, + height: Math.max(1, Math.round((height / width) * longEdge)), + } + : { + width: Math.max(1, Math.round((width / height) * longEdge)), + height: longEdge, + }; + if (fitsVisualBudget(candidate)) { + best = candidate; + low = longEdge + 1; + } else { + high = longEdge - 1; + } + } + + return best; +} + +async function prepareImage( + filePath: string, + signal: AbortSignal, +): Promise { + signal.throwIfAborted(); + let sharp: typeof import('sharp'); + try { + // sharp is a CJS `export =` module, so the callable is on `.default` + // at runtime even though NodeNext types collapse that namespace away. + sharp = ( + (await import('sharp')) as unknown as { + default: typeof import('sharp'); + } + ).default; + } catch { + throw new ImageViewError( + 'renderer_unavailable', + 'Image rendering is unavailable because the "sharp" image module could not be loaded.', + ); + } + + let stats: Awaited>; + try { + stats = await fs.stat(filePath); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + throw new ImageViewError( + 'file_not_found', + `Image file not found: ${filePath}`, + ); + } + throw error; + } + if (stats.isDirectory()) { + throw new ImageViewError( + 'target_is_directory', + `Image path is a directory: ${filePath}`, + ); + } + if (!stats.isFile()) { + throw new ImageViewError( + 'target_not_regular_file', + `Image path is not a regular file: ${filePath}`, + ); + } + if (stats.size > IMAGE_MAX_SOURCE_BYTES) { + throw new ImageViewError( + 'source_too_large', + `Image file exceeds the 100 MB source limit: ${filePath}`, + ); + } + + const bytes = await fs.readFile(filePath, { signal }); + if (bytes.length > IMAGE_MAX_SOURCE_BYTES) { + throw new ImageViewError( + 'source_too_large', + `Image file exceeds the 100 MB source limit: ${filePath}`, + ); + } + + let metadata: Metadata; + try { + metadata = await sharp(bytes, { + failOn: 'error', + limitInputPixels: true, + }).metadata(); + } catch { + signal.throwIfAborted(); + throw new ImageViewError( + 'decode_failed', + `Failed to decode image (file may be corrupt or not a static PNG, JPEG, or WebP): ${filePath}`, + ); + } + signal.throwIfAborted(); + + if (!SUPPORTED_IMAGE_FORMATS.has(metadata.format)) { + throw new ImageViewError( + 'unsupported_image', + `Unsupported image. Expected a static PNG, JPEG, or WebP file: ${filePath}`, + ); + } + if ((metadata.pages ?? 1) > 1) { + throw new ImageViewError( + 'animated_image', + `Only static images are supported: ${filePath}`, + ); + } + + return { bytes, metadata, sharp }; +} + +async function renderImageView( + filePath: string, + prepared: PreparedImage, + selection: { left: number; top: number; width: number; height: number }, + outputSize: ImageSize, + signal: AbortSignal, +): Promise { + const { bytes, metadata, sharp } = prepared; + let output: Buffer; + try { + output = await sharp(bytes, { + autoOrient: true, + failOn: 'error', + limitInputPixels: true, + }) + .extract(selection) + .resize(outputSize.width, outputSize.height, { + fit: 'fill', + kernel: sharp.kernel.lanczos3, + }) + .flatten({ background: '#ffffff' }) + .jpeg({ + quality: IMAGE_JPEG_QUALITY, + chromaSubsampling: '4:4:4', + }) + .toBuffer(); + } catch { + signal.throwIfAborted(); + throw new ImageViewError( + 'decode_failed', + `Failed to render image overview: ${filePath}`, + ); + } + signal.throwIfAborted(); + if (output.length > IMAGE_MAX_OUTPUT_BYTES) { + throw new ImageViewError( + 'output_too_large', + `Rendered image exceeds the 9 MB output limit: ${filePath}`, + ); + } + + return { + bytes: output, + mimeType: 'image/jpeg', + sourceWidth: metadata.autoOrient.width, + sourceHeight: metadata.autoOrient.height, + selectedWidth: selection.width, + selectedHeight: selection.height, + outputWidth: outputSize.width, + outputHeight: outputSize.height, + }; +} + +export async function renderImageOverview( + filePath: string, + signal: AbortSignal, +): Promise { + const prepared = await prepareImage(filePath, signal); + const sourceWidth = prepared.metadata.autoOrient.width; + const sourceHeight = prepared.metadata.autoOrient.height; + const outputSize = boundedSize(sourceWidth, sourceHeight, 1); + return renderImageView( + filePath, + prepared, + { left: 0, top: 0, width: sourceWidth, height: sourceHeight }, + outputSize, + signal, + ); +} + +export async function renderNormalizedImageCrop( + filePath: string, + region: NormalizedRegion, + signal: AbortSignal, +): Promise { + const prepared = await prepareImage(filePath, signal); + const sourceWidth = prepared.metadata.autoOrient.width; + const sourceHeight = prepared.metadata.autoOrient.height; + const left = Math.min( + sourceWidth - 1, + Math.max(0, Math.floor((region.x1 / 1000) * sourceWidth)), + ); + const top = Math.min( + sourceHeight - 1, + Math.max(0, Math.floor((region.y1 / 1000) * sourceHeight)), + ); + const right = Math.min( + sourceWidth, + Math.max(left + 1, Math.ceil((region.x2 / 1000) * sourceWidth)), + ); + const bottom = Math.min( + sourceHeight, + Math.max(top + 1, Math.ceil((region.y2 / 1000) * sourceHeight)), + ); + const selectedWidth = right - left; + const selectedHeight = bottom - top; + const outputSize = boundedSize( + selectedWidth, + selectedHeight, + IMAGE_MAX_UPSCALE, + ); + + return renderImageView( + filePath, + prepared, + { left, top, width: selectedWidth, height: selectedHeight }, + outputSize, + signal, + ); +} diff --git a/packages/core/src/utils/pathReader.test.ts b/packages/core/src/utils/pathReader.test.ts index 5b5e951acb..1db43d198a 100644 --- a/packages/core/src/utils/pathReader.test.ts +++ b/packages/core/src/utils/pathReader.test.ts @@ -7,6 +7,7 @@ import { describe, it, expect, afterEach, vi } from 'vitest'; import mock from 'mock-fs'; import * as path from 'node:path'; +import sharp from 'sharp'; import { WorkspaceContext } from './workspaceContext.js'; import { readPathFromWorkspace } from './pathReader.js'; import type { Config } from '../config/config.js'; @@ -104,11 +105,17 @@ describe('readPathFromWorkspace', () => { expect(result).toEqual(['hello from cwd']); }); - it('should read an image file and return it as inlineData (Part object)', async () => { - // Use a real PNG header for robustness - const imageData = Buffer.from([ - 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, - ]); + it('should read an image file as overview text and inlineData', async () => { + const imageData = await sharp({ + create: { + width: 20, + height: 10, + channels: 3, + background: '#306090', + }, + }) + .png() + .toBuffer(); mock({ [CWD]: { 'image.png': imageData, @@ -119,12 +126,17 @@ describe('readPathFromWorkspace', () => { } as unknown as FileDiscoveryService; const config = createMockConfig(CWD, [], mockFileService); const result = await readPathFromWorkspace('image.png', config); - // Expect [Part] for image content + // Expect overview text immediately followed by the bounded image. expect(result).toEqual([ + { + text: expect.stringContaining( + 'Image overview: 20x10; oriented source: 20x10', + ), + }, { inlineData: { - mimeType: 'image/png', - data: imageData.toString('base64'), + mimeType: 'image/jpeg', + data: expect.any(String), displayName: 'image.png', }, }, @@ -236,9 +248,16 @@ describe('readPathFromWorkspace', () => { }); it('should handle mixed content and include files from subdirectories', async () => { - const imageData = Buffer.from([ - 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, - ]); + const imageData = await sharp({ + create: { + width: 8, + height: 8, + channels: 3, + background: '#306090', + }, + }) + .png() + .toBuffer(); mock({ [CWD]: { 'mixed-dir': { @@ -274,8 +293,8 @@ describe('readPathFromWorkspace', () => { ); expect(imagePart).toEqual({ inlineData: { - mimeType: 'image/png', - data: imageData.toString('base64'), + mimeType: 'image/jpeg', + data: expect.any(String), displayName: 'photo.png', }, }); diff --git a/packages/core/src/utils/readManyFiles.test.ts b/packages/core/src/utils/readManyFiles.test.ts index 4351432c80..e87d1e89cf 100644 --- a/packages/core/src/utils/readManyFiles.test.ts +++ b/packages/core/src/utils/readManyFiles.test.ts @@ -9,6 +9,7 @@ import fs from 'node:fs/promises'; import * as nodeFs from 'node:fs'; import path from 'node:path'; import os from 'node:os'; +import sharp from 'sharp'; import type { Part, PartListUnion } from '@google/genai'; import { readManyFiles } from './readManyFiles.js'; import { FileDiscoveryService } from '../services/fileDiscoveryService.js'; @@ -192,11 +193,19 @@ describe('readManyFiles', () => { expect(result.files[0]!.filePath).toBe(absolutePath); }); - it('preserves unsupported images when the bridge handoff flag is set', async () => { + it('renders canonical images through the overview pipeline even when the bridge handoff flag is set', async () => { const relativePath = 'screenshot.png'; const absolutePath = path.join(tempRootDir, relativePath); - const imageBytes = Buffer.from('fake png data'); - await fs.writeFile(absolutePath, imageBytes); + await sharp({ + create: { + width: 20, + height: 10, + channels: 3, + background: '#306090', + }, + }) + .png() + .toFile(absolutePath); const mockConfig = createMockConfig(tempRootDir); const result = await readManyFiles(mockConfig, { @@ -209,13 +218,24 @@ describe('readManyFiles', () => { expect( (imagePart as { inlineData: { mimeType: string; data: string } }) .inlineData, - ).toEqual({ - mimeType: 'image/png', - data: imageBytes.toString('base64'), + ).toMatchObject({ + mimeType: 'image/jpeg', + data: expect.any(String), displayName: 'screenshot.png', }); + const parts = result.contentParts as Part[]; + const imageIndex = parts.indexOf(imagePart!); + expect(parts[imageIndex - 2]?.text).toBe( + `\nContent from ${absolutePath}:\n`, + ); + expect(parts[imageIndex - 1]?.text).toContain( + 'Image overview: 20x10; oriented source: 20x10', + ); expect(result.files).toHaveLength(1); - expect(result.files[0]!.content).toEqual(imagePart); + expect(result.files[0]!.content).toEqual([ + parts[imageIndex - 1], + imagePart, + ]); }); it('skips unsupported images when the bridge handoff flag is absent', async () => { diff --git a/packages/vscode-ide-companion/package.json b/packages/vscode-ide-companion/package.json index 102e43fe83..cee75eb5e0 100644 --- a/packages/vscode-ide-companion/package.json +++ b/packages/vscode-ide-companion/package.json @@ -2,7 +2,7 @@ "name": "qwen-code-vscode-ide-companion", "displayName": "Qwen Code Companion", "description": "Enable Qwen Code with direct access to your VS Code workspace.", - "version": "0.21.0", + "version": "0.21.1", "publisher": "qwenlm", "icon": "assets/icon.png", "repository": { diff --git a/packages/web-shell/package.json b/packages/web-shell/package.json index bf683f9f8a..37a81da534 100644 --- a/packages/web-shell/package.json +++ b/packages/web-shell/package.json @@ -1,6 +1,6 @@ { "name": "@qwen-code/web-shell", - "version": "0.21.0", + "version": "0.21.1", "type": "module", "main": "./dist/index.js", "module": "./dist/index.js", diff --git a/packages/web-templates/package.json b/packages/web-templates/package.json index 98ebc3dbc1..0477c44e50 100644 --- a/packages/web-templates/package.json +++ b/packages/web-templates/package.json @@ -1,6 +1,6 @@ { "name": "@qwen-code/web-templates", - "version": "0.21.0", + "version": "0.21.1", "description": "Web templates bundled as embeddable JS/CSS strings", "repository": { "type": "git", diff --git a/packages/webui/package.json b/packages/webui/package.json index 621dccd0a9..2182bdbbb8 100644 --- a/packages/webui/package.json +++ b/packages/webui/package.json @@ -1,6 +1,6 @@ { "name": "@qwen-code/webui", - "version": "0.21.0", + "version": "0.21.1", "description": "Shared UI components for Qwen Code packages", "type": "module", "main": "./dist/index.cjs",