mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-08-14 11:15:25 +00:00
Merge branch 'main' into agent/agent-view-supervisor-runtime
This commit is contained in:
commit
b3caf9b603
47 changed files with 3700 additions and 614 deletions
14
.github/workflows/ci.yml
vendored
14
.github/workflows/ci.yml
vendored
|
|
@ -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' }}"
|
||||
|
|
|
|||
237
CHANGELOG.md
237
CHANGELOG.md
|
|
@ -12,6 +12,221 @@ are listed; nightly and preview pre-releases are intentionally omitted.
|
|||
> [GitHub Releases](https://github.com/QwenLM/qwen-code/releases). Do not edit it
|
||||
> by hand — run `npm run changelog` to regenerate.
|
||||
|
||||
## [0.21.1](https://github.com/QwenLM/qwen-code/releases/tag/v0.21.1) - 2026-07-28
|
||||
|
||||
### Highlights
|
||||
|
||||
_See the complete change list below._
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
No known breaking changes.
|
||||
|
||||
### Complete Change List
|
||||
|
||||
#### Features
|
||||
|
||||
- feat(core): Align GenAI content telemetry fields ([#7667](https://github.com/QwenLM/qwen-code/pull/7667)) by @doudouOUC
|
||||
- feat(core): add Goal v3 runtime orchestration ([#7664](https://github.com/QwenLM/qwen-code/pull/7664)) by @qqqys
|
||||
- feat(triage): stop in-agent CI polling, finalize evidence and approval after CI completes ([#7693](https://github.com/QwenLM/qwen-code/pull/7693)) by @wenshao
|
||||
- feat(serve): expose workspace Channel management API ([#7637](https://github.com/QwenLM/qwen-code/pull/7637)) by @qqqys
|
||||
- feat(web-shell): add read-only GitHub pull requests panel ([#7683](https://github.com/QwenLM/qwen-code/pull/7683)) by @wenshao
|
||||
- Adds a retrieval-only context_search tool for external corpora, configured by administrators without automatic recall or write capabilities. ([#7586](https://github.com/QwenLM/qwen-code/pull/7586)) by @doudouOUC
|
||||
- Introduces the qwen review comment-status subcommand to quickly triage existing inline comments and reduce API calls during review runs. ([#7690](https://github.com/QwenLM/qwen-code/pull/7690)) by @wenshao
|
||||
- Enforces a strict write contract for review submissions and adds a tripwire to detect and flag any bypass attempts via terminal commands. ([#7691](https://github.com/QwenLM/qwen-code/pull/7691)) by @wenshao
|
||||
- Enables hot-reloading of workspace trust changes in the running daemon, applying new policies immediately without requiring a process restart. ([#7268](https://github.com/QwenLM/qwen-code/pull/7268)) by @doudouOUC
|
||||
- Adds retryInitialDelayMs and retryMaxDelayMs settings to configure stream-side rate-limit retry delays for better provider compatibility. ([#7674](https://github.com/QwenLM/qwen-code/pull/7674)) by @jay666mnj
|
||||
- Updates the /stats command and Session tab to display generation timing metrics including TTFT, duration, output tokens, and TPS. ([#7677](https://github.com/QwenLM/qwen-code/pull/7677)) by @destire-mio
|
||||
- Adds a GitHub channel adapter that polls notifications and responds to mentions by posting comments using a signal-based wakeup architecture. ([#7632](https://github.com/QwenLM/qwen-code/pull/7632)) by @OrbitZore
|
||||
- Adds optional retryInitialDelayMs and retryMaxDelayMs settings to configure SSE stream rate-limit retry delays for specific provider quota windows. ([#7666](https://github.com/QwenLM/qwen-code/pull/7666)) by @hogeheer499-commits
|
||||
- Introduces an overridable default-disabled state for skills, allowing soft defaults that yield to explicit enablement while hard disables remain absolute. ([#7357](https://github.com/QwenLM/qwen-code/pull/7357)) by @samuelhsin
|
||||
- feat(webui): add workspace Channel management hook ([#7728](https://github.com/QwenLM/qwen-code/pull/7728)) by @qqqys
|
||||
- feat(review): redefine medium effort as a balanced verified pass ([#7733](https://github.com/QwenLM/qwen-code/pull/7733)) by @wenshao
|
||||
- feat(review): mutation-test the tests in the test-coverage pass (Agent 5) ([#7735](https://github.com/QwenLM/qwen-code/pull/7735)) by @wenshao
|
||||
- feat(review): borrow maintainer review lenses into the agent briefs ([#7736](https://github.com/QwenLM/qwen-code/pull/7736)) by @wenshao
|
||||
- feat(web-shell): persist terminal history pagination errors ([#7709](https://github.com/QwenLM/qwen-code/pull/7709)) by @PratikWayase
|
||||
- feat(triage): add sandboxed /verify deep-verification lane ([#7710](https://github.com/QwenLM/qwen-code/pull/7710)) by @wenshao
|
||||
- feat(review): give the verifier a probe capability — run a runnable claim, don't just read it ([#7756](https://github.com/QwenLM/qwen-code/pull/7756)) by @wenshao
|
||||
- feat(core): add model grade selection for subagent spawn (#7685) ([#7702](https://github.com/QwenLM/qwen-code/pull/7702)) by @yiliang114
|
||||
- feat(dingtalk): support outbound image delivery ([#7698](https://github.com/QwenLM/qwen-code/pull/7698)) by @qqqys
|
||||
- feat(web-shell): allow widening sidebar up to half the window width ([#7778](https://github.com/QwenLM/qwen-code/pull/7778)) by @wenshao
|
||||
- feat(core): add Goal v3 worker tools ([#7729](https://github.com/QwenLM/qwen-code/pull/7729)) by @qqqys
|
||||
- feat(review): script-lint as a deterministic gate — compose-review reads the report, no agent ([#7751](https://github.com/QwenLM/qwen-code/pull/7751)) by @wenshao
|
||||
- feat(web-shell): add monitor task details ([#7817](https://github.com/QwenLM/qwen-code/pull/7817)) by @ytahdn
|
||||
- feat(web-shell): Scope voice to composer workspace ([#7754](https://github.com/QwenLM/qwen-code/pull/7754)) by @doudouOUC
|
||||
- feat(hooks): Add submitted prompt provenance ([#7762](https://github.com/QwenLM/qwen-code/pull/7762)) by @doudouOUC
|
||||
- feat(autofix): retry deterministic rejection once ([#7796](https://github.com/QwenLM/qwen-code/pull/7796)) by @qqqys
|
||||
- feat(web-shell): add Channel management page ([#7793](https://github.com/QwenLM/qwen-code/pull/7793)) by @qqqys
|
||||
- feat(acp): add session-scoped runtime MCP ([#7847](https://github.com/QwenLM/qwen-code/pull/7847)) by @qqqys
|
||||
- feat(core): persist and replay Goal v3 state ([#7815](https://github.com/QwenLM/qwen-code/pull/7815)) by @qqqys
|
||||
- feat(core): add full-resolution image zoom tool ([#7809](https://github.com/QwenLM/qwen-code/pull/7809)) by @qqqys
|
||||
- feat(web-shell): add composer footer renderer ([#7856](https://github.com/QwenLM/qwen-code/pull/7856)) by @dreamWB
|
||||
- feat: Gate session writer lease behind opt-in ([#7894](https://github.com/QwenLM/qwen-code/pull/7894)) by @doudouOUC
|
||||
- feat(ci): Deduplicate E2E failure issues by commenting on existing issue ([#7792](https://github.com/QwenLM/qwen-code/pull/7792)) by @yiliang114
|
||||
- feat(triage): add revert-pattern high-risk path detection ([#7414](https://github.com/QwenLM/qwen-code/pull/7414)) by @yiliang114
|
||||
- feat(web-shell): add native workspace folder picker ([#7849](https://github.com/QwenLM/qwen-code/pull/7849)) by @qqqys
|
||||
- feat(web-shell): add Channel configuration flows ([#7893](https://github.com/QwenLM/qwen-code/pull/7893)) by @qqqys
|
||||
- feat(core): Add ARMS session user ID ([#7921](https://github.com/QwenLM/qwen-code/pull/7921)) by @doudouOUC
|
||||
- feat(channels): expose loop tools in daemon sessions ([#7891](https://github.com/QwenLM/qwen-code/pull/7891)) by @qqqys
|
||||
- feat(web-shell): honor voice hold mode ([#7839](https://github.com/QwenLM/qwen-code/pull/7839)) by @callmeYe
|
||||
- feat(web-shell): manage Channel pairing requests ([#7909](https://github.com/QwenLM/qwen-code/pull/7909)) by @qqqys
|
||||
- feat(channels): dispatch GitHub notifications by reason ([#7826](https://github.com/QwenLM/qwen-code/pull/7826)) by @yiliang114
|
||||
- feat(web-shell): add git branch picker, commit dialog, and create PR flow ([#7731](https://github.com/QwenLM/qwen-code/pull/7731)) by @wenshao
|
||||
- feat(triage): surface the sandboxed lanes on the CI path ([#7917](https://github.com/QwenLM/qwen-code/pull/7917)) by @wenshao
|
||||
- feat(web-shell): suggest BTW for side questions ([#7935](https://github.com/QwenLM/qwen-code/pull/7935)) by @carffuca
|
||||
- feat(triage): make the verify report readable in Chinese ([#7918](https://github.com/QwenLM/qwen-code/pull/7918)) by @wenshao
|
||||
|
||||
#### Bug Fixes
|
||||
|
||||
- fix(cli): measure insight days and hours in local time everywhere ([#7670](https://github.com/QwenLM/qwen-code/pull/7670)) by @ComplexSimply
|
||||
- fix(ci): don't fail triage cleanup when there is nothing to clean ([#7688](https://github.com/QwenLM/qwen-code/pull/7688)) by @wenshao
|
||||
- fix(ci): update qwen in the runner's active npm prefix ([#7689](https://github.com/QwenLM/qwen-code/pull/7689)) by @yiliang114
|
||||
- fix(acp): sweep review worktree leases at the end of each prompt turn ([#7694](https://github.com/QwenLM/qwen-code/pull/7694)) by @wenshao
|
||||
- fix(core): write a status sidecar so models stop misreading quiet background shells ([#7669](https://github.com/QwenLM/qwen-code/pull/7669)) by @ComplexSimply
|
||||
- fix(core): exit_plan_mode returns guidance error from execute() instead of permission deny ([#7673](https://github.com/QwenLM/qwen-code/pull/7673)) by @qwen-code-dev-bot
|
||||
- fix(core): tell the model when the user manually exits plan mode ([#7682](https://github.com/QwenLM/qwen-code/pull/7682)) by @zjunothing
|
||||
- fix(core): give plugins from the same repository distinct extension ids ([#7676](https://github.com/QwenLM/qwen-code/pull/7676)) by @zjunothing
|
||||
- fix(core): allow reading saved plan files without a confirmation prompt ([#7678](https://github.com/QwenLM/qwen-code/pull/7678)) by @zjunothing
|
||||
- fix(cli): clear stale retry error when agent auto-recovers mid-turn ([#7681](https://github.com/QwenLM/qwen-code/pull/7681)) by @qwen-code-dev-bot
|
||||
- fix(mcp): harden OAuth callback handling ([#7510](https://github.com/QwenLM/qwen-code/pull/7510)) by @gauravyad86
|
||||
- fix(web-shell): add :focus-visible outline to GitHub PR list rows ([#7704](https://github.com/QwenLM/qwen-code/pull/7704)) by @wenshao
|
||||
- fix(triage): resolve stage comment ids by marker at patch time, harden model injection ([#7703](https://github.com/QwenLM/qwen-code/pull/7703)) by @wenshao
|
||||
- fix(triage): resolve finalize PRs from the open-PR list, not the commit association ([#7706](https://github.com/QwenLM/qwen-code/pull/7706)) by @wenshao
|
||||
- Fixes inline math recognition to correctly handle single-character expressions, escaped dollars, and code spans across prose and tables. ([#7701](https://github.com/QwenLM/qwen-code/pull/7701)) by @CubeLander
|
||||
- Fixes desktop file size display to correctly render terabyte and larger values instead of showing undefined for huge files. ([#7623](https://github.com/QwenLM/qwen-code/pull/7623)) by @chinesepowered
|
||||
- Ensures const-derived enums are correctly stringified during OpenAPI 3.0 conversion to meet strict schema requirements. ([#7547](https://github.com/QwenLM/qwen-code/pull/7547)) by @chinesepowered
|
||||
- Fixes schema conversion to properly handle nested objects even when property names match JSON Schema constraint keywords like maximum. ([#7546](https://github.com/QwenLM/qwen-code/pull/7546)) by @chinesepowered
|
||||
- Silences xterm.js parser diagnostic logs in headless shell terminals to prevent error messages from leaking when commands emit invalid ANSI escape sequences. ([#7663](https://github.com/QwenLM/qwen-code/pull/7663)) by @mvanhorn
|
||||
- Hardens the review skill by declaring the optional host argument in CommentStatusArgs and improving error handling for authentication or network failures. ([#7708](https://github.com/QwenLM/qwen-code/pull/7708)) by @wenshao
|
||||
- Ensures StopFailure hooks fire correctly when loop detection triggers an early return during the streaming loop instead of only on API errors. ([#7592](https://github.com/QwenLM/qwen-code/pull/7592)) by @qwen-code-dev-bot
|
||||
- Enables the Changes and History dialogs for worktree sessions in the Web Shell by threading the correct git working directory through the entire stack. ([#7695](https://github.com/QwenLM/qwen-code/pull/7695)) by @wenshao
|
||||
- Strictly parses the heatmapDays query parameter to reject malformed values like negative numbers or decimals, falling back to the default instead of clamping invalid input. ([#7218](https://github.com/QwenLM/qwen-code/pull/7218)) by @VectorPeak
|
||||
- Raises default live journal caps to 10,000 events and 8 MiB while exposing --max-journal-events and --max-journal-bytes flags for configuration. ([#7715](https://github.com/QwenLM/qwen-code/pull/7715)) by @wenshao
|
||||
- Allows pinning and grouping secondary workspace sessions in the Web Shell sidebar without requiring the workspace to be locked first. ([#7716](https://github.com/QwenLM/qwen-code/pull/7716)) by @wenshao
|
||||
- Corrects the review submit-gate audit to avoid falsely claiming a bypass when detecting valid same-account writes from other sources like triage bots. ([#7718](https://github.com/QwenLM/qwen-code/pull/7718)) by @wenshao
|
||||
- Fixes QQ Bot session restoration by returning the input session ID from AcpBridge and ensuring session patching runs even when validation fails. ([#7722](https://github.com/QwenLM/qwen-code/pull/7722)) by @Eric-GoodBoy-Tech
|
||||
- fix(core): prevent updates to extension-provided agents ([#7245](https://github.com/QwenLM/qwen-code/pull/7245)) by @destire-mio
|
||||
- fix(core): fall back to system rg when bundled ripgrep cannot run ([#7203](https://github.com/QwenLM/qwen-code/pull/7203)) by @harjothkhara
|
||||
- fix(core): avoid required tools in DashScope thinking ([#7661](https://github.com/QwenLM/qwen-code/pull/7661)) by @hogeheer499-commits
|
||||
- fix(channels): use username as senderId in GitHub adapter to fix allowlist gate ([#7727](https://github.com/QwenLM/qwen-code/pull/7727)) by @OrbitZore
|
||||
- fix(web-shell): parse 256-color and truecolor SGR sequences in parseAnsi ([#7620](https://github.com/QwenLM/qwen-code/pull/7620)) by @chinesepowered
|
||||
- fix(triage): only the bot's own approval counts as already approved ([#7737](https://github.com/QwenLM/qwen-code/pull/7737)) by @wenshao
|
||||
- fix(core): stop humanReadableCron naming intervals that never happen ([#7529](https://github.com/QwenLM/qwen-code/pull/7529)) by @chinesepowered
|
||||
- fix(cli): keep IME cursor aligned after footer updates ([#7711](https://github.com/QwenLM/qwen-code/pull/7711)) by @water-in-stone
|
||||
- fix(core): redact the plan argument from history after an approved exit_plan_mode ([#7197](https://github.com/QwenLM/qwen-code/pull/7197)) by @zjunothing
|
||||
- fix(review): correct the borrowed lenses and vacuous-test severity (follow-up to #7735/#7736) ([#7746](https://github.com/QwenLM/qwen-code/pull/7746)) by @wenshao
|
||||
- fix(core): reliably deliver manual plan-exit notices ([#7744](https://github.com/QwenLM/qwen-code/pull/7744)) by @doudouOUC
|
||||
- fix(review): recover bilingual register from the live PR when the plan omits the Han flag ([#7739](https://github.com/QwenLM/qwen-code/pull/7739)) by @wenshao
|
||||
- fix(cli): complete repeated skill slash commands ([#7720](https://github.com/QwenLM/qwen-code/pull/7720)) by @Sparkle6979
|
||||
- fix(cli): handle escaped dollars around inline math ([#7741](https://github.com/QwenLM/qwen-code/pull/7741)) by @CubeLander
|
||||
- fix(cli): show tool descriptions in multi-tool compact summaries ([#7589](https://github.com/QwenLM/qwen-code/pull/7589)) by @ovochouovo
|
||||
- fix(ci): rename triage status marker to avoid duplicate-guard collision ([#7723](https://github.com/QwenLM/qwen-code/pull/7723)) by @yiliang114
|
||||
- fix(core): route id-less continuation chunks to a colliding tool-call opener's slot ([#6981](https://github.com/QwenLM/qwen-code/pull/6981)) by @he-yufeng
|
||||
- fix(autofix): answer every review thread, resolve the ones actually fixed ([#7758](https://github.com/QwenLM/qwen-code/pull/7758)) by @wenshao
|
||||
- fix(core): treat properties as a name map in toOpenAPI30 ([#7760](https://github.com/QwenLM/qwen-code/pull/7760)) by @chinesepowered
|
||||
- fix(core): keep leading whitespace in gitignore patterns ([#7763](https://github.com/QwenLM/qwen-code/pull/7763)) by @chinesepowered
|
||||
- fix(core): stop trailing slash from anchoring nested gitignore patterns ([#7764](https://github.com/QwenLM/qwen-code/pull/7764)) by @chinesepowered
|
||||
- fix(core): keep the model name when a model id carries a variant tag ([#7766](https://github.com/QwenLM/qwen-code/pull/7766)) by @chinesepowered
|
||||
- fix(weixin): create the account credential file already private ([#7726](https://github.com/QwenLM/qwen-code/pull/7726)) by @chinesepowered
|
||||
- fix(core): stop rewriting backslash escapes in gitignore patterns ([#7765](https://github.com/QwenLM/qwen-code/pull/7765)) by @chinesepowered
|
||||
- fix(core): scope the timeout veto to the fragment it appears in ([#7776](https://github.com/QwenLM/qwen-code/pull/7776)) by @chinesepowered
|
||||
- fix(core): decline sed patterns whose bracket expression starts with ] ([#7775](https://github.com/QwenLM/qwen-code/pull/7775)) by @chinesepowered
|
||||
- fix(web-shell): stabilize mobile voice input ([#7806](https://github.com/QwenLM/qwen-code/pull/7806)) by @ytahdn
|
||||
- fix(test): give every E2E case a clean directory again ([#7811](https://github.com/QwenLM/qwen-code/pull/7811)) by @wenshao
|
||||
- fix(web-shell): allow shell commands in new tasks without a session ([#7724](https://github.com/QwenLM/qwen-code/pull/7724)) by @wenshao
|
||||
- fix(triage): carry the /verify lane's hardening across to /tmux ([#7753](https://github.com/QwenLM/qwen-code/pull/7753)) by @wenshao
|
||||
- fix(web-shell): render task notifications as system messages ([#7822](https://github.com/QwenLM/qwen-code/pull/7822)) by @ytahdn
|
||||
- fix(ci): add default bash shell to container jobs in qwen-triage ([#7838](https://github.com/QwenLM/qwen-code/pull/7838)) by @qwen-code-dev-bot
|
||||
- fix(web-shell): preserve pasted text in composer ([#7824](https://github.com/QwenLM/qwen-code/pull/7824)) by @ytahdn
|
||||
- fix(ci): add git safe.directory for container jobs ([#7843](https://github.com/QwenLM/qwen-code/pull/7843)) by @qwen-code-dev-bot
|
||||
- fix(ci): add --init to container jobs to reap zombie processes ([#7848](https://github.com/QwenLM/qwen-code/pull/7848)) by @qwen-code-dev-bot
|
||||
- fix(core): wait for output stream flush before settling background shells ([#7833](https://github.com/QwenLM/qwen-code/pull/7833)) by @ComplexSimply
|
||||
- fix(scripts): retry model calls and surface degraded release notes ([#7535](https://github.com/QwenLM/qwen-code/pull/7535)) by @he-yufeng
|
||||
- fix(ci): prevent worktree cleanup from failing the verify job ([#7857](https://github.com/QwenLM/qwen-code/pull/7857)) by @qwen-code-dev-bot
|
||||
- fix(release): publish all channel packages, not just channel-base ([#7845](https://github.com/QwenLM/qwen-code/pull/7845)) by @yiliang114
|
||||
- fix(scripts): harden retry classification and preserve deadline error context ([#7854](https://github.com/QwenLM/qwen-code/pull/7854)) by @yiliang114
|
||||
- fix(core): fast-fail permanent quota-exhaustion 429s instead of silent retry ([#7842](https://github.com/QwenLM/qwen-code/pull/7842)) by @yiliang114
|
||||
- fix(web-shell): isolate history and session drafts ([#7810](https://github.com/QwenLM/qwen-code/pull/7810)) by @ytahdn
|
||||
- fix(triage): retry a transient npm ci before blaming the PR for it ([#7884](https://github.com/QwenLM/qwen-code/pull/7884)) by @wenshao
|
||||
- fix(webui): fall back to history_truncated marker recordId for transcript pagination anchor ([#7829](https://github.com/QwenLM/qwen-code/pull/7829)) by @wenshao
|
||||
- fix(review): give the review retry the remaining time budget ([#7852](https://github.com/QwenLM/qwen-code/pull/7852)) by @wenshao
|
||||
- fix(integration): configure Docker sandbox networking for submitted-prompt provenance test (#7879) ([#7881](https://github.com/QwenLM/qwen-code/pull/7881)) by @qwen-code-dev-bot
|
||||
- fix(cli): report a genuine $0.00 cost instead of N/A ([#7784](https://github.com/QwenLM/qwen-code/pull/7784)) by @chinesepowered
|
||||
- fix(core): reject socks5h and socks4a proxy URLs ([#7786](https://github.com/QwenLM/qwen-code/pull/7786)) by @chinesepowered
|
||||
- fix(core): correct the character classes in checkContentLoop ([#7788](https://github.com/QwenLM/qwen-code/pull/7788)) by @chinesepowered
|
||||
- fix(web-shell): make /copy with a bare index work ([#7789](https://github.com/QwenLM/qwen-code/pull/7789)) by @chinesepowered
|
||||
- fix(core): decline combined sed flags where -i is not last ([#7790](https://github.com/QwenLM/qwen-code/pull/7790)) by @chinesepowered
|
||||
- fix(core): pass the Grep pattern behind -e so a leading dash is not an option ([#7863](https://github.com/QwenLM/qwen-code/pull/7863)) by @chinesepowered
|
||||
- fix(core): cap a bare Error's message like every other getErrorMessage path ([#7865](https://github.com/QwenLM/qwen-code/pull/7865)) by @chinesepowered
|
||||
- fix(cli): make wrapToVisualLines count zero-width characters like its sibling ([#7873](https://github.com/QwenLM/qwen-code/pull/7873)) by @chinesepowered
|
||||
- fix(core): charge the separator and ellipsis to the preview budget ([#7874](https://github.com/QwenLM/qwen-code/pull/7874)) by @chinesepowered
|
||||
- fix(cli): do not count a partial trailing line when re-opening a split fence ([#7875](https://github.com/QwenLM/qwen-code/pull/7875)) by @chinesepowered
|
||||
- fix(cli): make /copy <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
|
||||
|
||||
|
|
|
|||
|
|
@ -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, {
|
||||
|
|
|
|||
|
|
@ -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
56
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
]);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
1036
packages/core/src/core/client-goal.test.ts
Normal file
1036
packages/core/src/core/client-goal.test.ts
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)}`,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
204
packages/core/src/core/goal-turn-integration.test.ts
Normal file
204
packages/core/src/core/goal-turn-integration.test.ts
Normal 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',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -174,6 +174,7 @@ describe('Turn', () => {
|
|||
config: { abortSignal: expect.any(AbortSignal) },
|
||||
},
|
||||
'prompt-id-1',
|
||||
undefined,
|
||||
);
|
||||
|
||||
expect(events).toEqual([
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
},
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
},
|
||||
|
|
|
|||
122
packages/core/src/utils/image-view.test.ts
Normal file
122
packages/core/src/utils/image-view.test.ts
Normal 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',
|
||||
});
|
||||
});
|
||||
});
|
||||
314
packages/core/src/utils/image-view.ts
Normal file
314
packages/core/src/utils/image-view.ts
Normal 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,
|
||||
);
|
||||
}
|
||||
|
|
@ -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',
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue