Merge branch 'main' into agent/agent-view-supervisor-runtime

This commit is contained in:
Zqc 2026-07-29 11:37:32 +08:00 committed by GitHub
commit b3caf9b603
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
47 changed files with 3700 additions and 614 deletions

View file

@ -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' }}"

View file

@ -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 <message> <index> 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

View file

@ -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, {

View file

@ -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();
}

56
package-lock.json generated
View file

@ -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",

View file

@ -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",

View file

@ -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",

View file

@ -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",

View file

@ -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",

View file

@ -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": {

View file

@ -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": {

View file

@ -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"
},

View file

@ -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",

View file

@ -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"
},

View file

@ -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"

View file

@ -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": {

View file

@ -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"

View file

@ -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": {

View file

@ -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",

View file

@ -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;

View file

@ -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",

View file

@ -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,
]);
});

View file

@ -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<GoalRuntime> | undefined;
private goalTurnHost: GoalTurnHost | undefined;
private goalTurnHostUnbind: (() => void) | undefined;
private goalTurnHostGeneration = 0;
private readonly chatRecordingFailureListeners =
new Set<ChatRecordingFailureListener>();
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<GoalRuntime> {
const runtime = this.getGoalRuntime();
if (!Object.hasOwn(this, 'goalRuntimeReady') || !this.goalRuntimeReady) {
return Promise.reject(new GoalPersistenceUnavailableError());
}
return this.goalRuntimeReady.then(() => runtime);
}
async rebaseGoalRuntimeFromActiveTranscript(): Promise<void> {
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<void> => {
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);

File diff suppressed because it is too large Load diff

View file

@ -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 `<system-reminder>\n${safeContextText}\n</system-reminder>`;
}
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<GoalRuntime | undefined> => {
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<Config['getMessageBus']>;
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

View file

@ -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<T>(
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<string, unknown>,
targetCallId,
call.request.prompt_id,
const invocationOrError = runInRequestGoalContext(call.request, () =>
this.buildInvocation(
call.tool,
args as Record<string, unknown>,
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<string, unknown>;
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<string, unknown>,
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<string, unknown>) || {},
permissionMode,
undefined,
signal,
const hookResult = await runInRequestGoalContext(reqInfo, () =>
firePermissionRequestHook(
messageBus,
canonicalName,
(reqInfo.args as Record<string, unknown>) || {},
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<string, unknown>,
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<void> {
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)}`,

View file

@ -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<AsyncGenerator<StreamEvent>> {
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<AsyncGenerator<GenerateContentResponse>> {
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<StreamEvent> {
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<GenerateContentResponse>,
goalContext?: GoalTurnPermit,
): AsyncGenerator<GenerateContentResponse> {
// 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

View file

@ -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<GenerateContentResponse>,
goalContext?: GoalTurnPermit,
) => AsyncGenerator<GenerateContentResponse>;
pendingPartialAssistantRecord:
| Parameters<ChatRecordingService['recordAssistantTurn']>[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',
});
});
});

View file

@ -174,6 +174,7 @@ describe('Turn', () => {
config: { abortSignal: expect.any(AbortSignal) },
},
'prompt-id-1',
undefined,
);
expect(events).toEqual([

View file

@ -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<string>();
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);

View file

@ -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,

View file

@ -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,

View file

@ -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<unknown> | 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 () => {

View file

@ -63,6 +63,16 @@ export interface GoalEvidenceSource {
readActiveTranscriptChain(): Promise<readonly GoalEvidenceRecord[]>;
}
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;

View file

@ -434,17 +434,29 @@ describe('createApprovalModeOverride bound-tool isolation', () => {
await parentRegistry.warmAll();
const childNames = child.getToolRegistry().getAllToolNames().sort();
const topLevelOnlyTools = new Set<string>([
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.

View file

@ -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');
});

View file

@ -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<ReturnType<typeof renderNormalizedImageCrop>>;
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<ReturnType<typeof fs.stat>>;
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'),
},
},
];

View file

@ -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(

View file

@ -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,
},
},

View file

@ -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',
});
});
});

View file

@ -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<PreparedImage> {
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<ReturnType<typeof fs.stat>>;
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<ImageView> {
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<ImageView> {
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<ImageView> {
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,
);
}

View file

@ -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',
},
});

View file

@ -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 () => {

View file

@ -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": {

View file

@ -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",

View file

@ -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",

View file

@ -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",