Fix completed prompt lifecycle race#2
Closed
ytahdn wants to merge 1 commit into
Closed
Conversation
ytahdn
pushed a commit
that referenced
this pull request
Jun 18, 2026
QwenLM#5231) * feat(core,cli): workflow tool token budget + per-run UI surfacing (P5) P5 of the Dynamic Workflows port (QwenLM#4721): per-run output-token budget for the Workflow tool, wired through the orchestrator dispatch gate, WorkflowRunRegistry, BackgroundTasksDialog phase tree, and the /workflows slash command. Also introduces a one-time usage banner the first time a workflow runs in a session, gated by the skipWorkflowUsageWarning setting. Knobs: QWEN_CODE_MAX_TOKENS_PER_WORKFLOW=<int> env, per-run cap skipWorkflowUsageWarning: true setting, suppress banner Budget gate semantics: SOFT cap, not pre-commit reservation. Gate is checked at dispatch entry, so concurrent fan-out (parallel / pipeline) can overshoot by up to (concurrency_window - 1) x per_dispatch_tokens before the first overshoot dispatch throws WorkflowBudgetExceededError. Matches upstream Claude Code 2.1.168 semantics. Operators sizing the cap should subtract the overshoot margin. Implementation: - WorkflowBudgetImpl (workflow-budget.ts) + env resolver with HARD_MAX_TOKENS_CEILING=100M ceiling on the env override. - WorkflowBudgetExceededError carries runId / budgetTotal / spent. - countedDispatch budget gate + onTokens callback feeding budget.recordSpent from getExecutionSummary().outputTokens. - WorkflowOrchestratorEmitter.budgetUpdated event; fires after each successful dispatch, skipped on rejection and when budget is null. - WorkflowTask gains tokensSpent / tokenBudgetTotal / perPhaseTokens fields; WorkflowRunRegistry.onBudgetUpdated attributes deltas to currentPhase at fire time and re-emits statusChange. - WorkflowRunRegistry.shouldShowUsageWarning latch fires once per registry instance; survives reset(). - WorkflowTool wires WorkflowBudgetImpl.fromEnv, threads onTokens into createProductionDispatch, mirrors budget into the registry via the emitter, and prepends the usage banner on the SUCCESS path only. - WorkflowDetailBody + /workflows listing + live phase-tree render budget chip (tokens / cap) and per-phase token totals. Verification (270 + 4 + 4 = 272 core + 42 cli): - workflow-budget.test.ts (18) + workflow-orchestrator.test.ts (+8 P5 + budget-gate + budgetUpdated emitter) - workflow-run-registry.test.ts (+10 P5: budget fields, latch, per-phase attribution, no-op on terminal entries) - workflow.test.ts (+4 P5: banner appears once, suppressed by setting, failure-path latch unchanged, fail-then-success re-emits banner) - workflowsCommand.test.ts (+4 P5: row chip capped/uncapped, detail tokens/cap/per-phase chips) - BackgroundTasksDialog.test.tsx unchanged (32 still pass) - Real-LLM E2E (DashScope qwen3.7-plus): tmux session driving Workflow tool, banner verified in returnDisplay, /workflows shows tokens 0 / cap (no cap) on uncapped run, banner suppression on 2nd run confirmed (latch consumed exactly once). Self-review round 1 fixes: - "hard ceiling" docstring softened to "soft cap" with per_dispatch x concurrency_window overshoot bound documented; banner copy aligned ("soft cap" instead of "hard ceiling"). - Attempted failure-path banner reverted after coreToolScheduler inspection: createErrorResponse hard-codes resultDisplay = error.message whenever result.error is set, so a failure-path banner would have been invisible AND would have silently flipped the registry latch, causing the next successful run to skip the banner too. Failure path now does not touch the latch; failure-path test asserts the fail-then-success run still gets the banner. - skipWorkflowUsageWarning setting placement aligned with skipNextSpeakerCheck sibling under settings.model.*. - QWEN_CODE_MAX_TOKENS_PER_WORKFLOW=0 documented as "treated as unset" with explicit pointer to QWEN_CODE_DISABLE_WORKFLOWS=1 for the "no workflows at all" intent. Refs QwenLM#4721. * fix(core,cli): close P5 review round 1 — token tracking gaps + UI polish (PR QwenLM#5231) Addresses 4 Critical + 7 Suggestions from qwen-code-ci-bot's multi-agent review: Critical fixes (orchestrator core): - #1 (workflow-orchestrator.ts): schema-mode success path was missing the onTokens call entirely, so structured-output agents never recorded against the budget. Lifted the token report to a single `reportTokens` helper invoked once after `subagent.execute()` returns, BEFORE the schema/non-schema branch. Both fast-path and override-path dispatch now hit the same reporting site regardless of terminate mode. - #2 (workflow-orchestrator.ts): the entry budget gate in countedDispatch was bypassed by `parallel()` batches — all N thunks fire-check-queue in a single microtask burst with spent=0, so every queued dispatch passed the gate before any could record tokens. Added a SECOND gate inside the limiter.run callback so queued thunks observe budget mutations from already-completed in-flight dispatches at slot-acquire time, restoring the documented overshoot bound of (concurrency_window - 1) × per_dispatch_tokens (previously up to N × per_dispatch_tokens for a single `parallel()` of N items). - QwenLM#3 (workflow-orchestrator.ts): CANCELLED / TIMEOUT / MAX_TURNS / ERROR terminations threw without recording tokens, so failed dispatches burned budget silently. Same `reportTokens` lift fixes this — tokens are now read before the terminate-mode check on both paths. - QwenLM#4 (workflow-orchestrator.ts): added debugLogger.warn at both gate sites (entry + intra-limiter) for budget-rejected dispatches. Suggestion fixes: - QwenLM#5 (workflow.ts): `resolveUsageBanner` JSDoc still said "Called from BOTH the success and failure paths" after the earlier failure-path revert. Corrected to "SUCCESS path only" with the scheduler-override rationale moved into the docstring. - QwenLM#6 (workflowsCommand.ts, BackgroundTasksDialog.tsx): null-sentinel perPhaseTokens (tokens spent before the first phase() call) was attributed by the registry but never rendered. Detail view + phase tree now surface a "(no phase)" row when the null-key bucket has spend. - QwenLM#7 (workflowsCommand.ts, BackgroundTasksDialog.tsx): use the existing `formatTokenCount` helper from `cli/ui/utils/formatters.ts` (the same surface statusLinePresets and TurnCard use) so token counts render as `1.5k / 10k` instead of raw integers. - QwenLM#8 (workflow-run-registry.ts): `onBudgetUpdated` no longer fires `emitStatusChange` when neither tokensSpent nor tokenBudgetTotal changed. Production code fires `budgetUpdated` after every successful dispatch including zero-output-token ones; gating the emit avoids a no-op UI re-render burst on those. - QwenLM#11 (workflow.ts): final returnDisplay JSON now includes the `tokens` block whenever any usage is reported OR a cap is set, aligned with `buildLivePhaseTreeDisplay` (was only included when spend > 0, inconsistent with the live render). Test additions: - workflow-budget.test.ts: unchanged (18). - workflow-orchestrator.test.ts: +6 R1 tests (parallel-batch overshoot regression for #2, GOAL+CANCELLED/MAX_TURNS/TIMEOUT/ERROR token recording for QwenLM#3 via createProductionDispatch, schema-mode success token recording for #1, no-onTokens crash safety). Mock subagent extended with getExecutionSummary + nextOutputTokens to drive these. - workflow-run-registry.test.ts: +1 R1 test for QwenLM#8 emit gating; rewrote the backwards/zero-delta test to the new monotonic-spent contract. - workflow.test.ts: +1 R1 test for QwenLM#10 (capped banner shape — was untested; only the uncapped shape had coverage). - workflowsCommand.test.ts: +1 R1 test for QwenLM#6 null-sentinel surfacing. Updated assertions for QwenLM#7 formatTokenCount output (`1.5k/10kt`). Total: 282 core tests passing (+10 R1), 43 CLI tests passing (+1 R1), 0 lint, 0 typecheck for workflow-touching files. Real-LLM tmux + JSON E2E reconfirmed end-to-end (banner now says "soft cap", display payload shape unchanged, run registers + completes cleanly). QwenLM#9 fold: the parallel-batch overshoot test serves as the regression guard for the intra-limiter gate fix in #2. QwenLM#7 partial: workflowsCommand.ts and BackgroundTasksDialog.tsx are the only two `tokens` render sites in P5; both updated. Other token-bearing surfaces (statusLinePresets, TurnCard) already use the helper. PR: QwenLM#5231 * fix(core,cli): close P5 review round 2 — UI emit dedup + error tail + dialog coverage (PR QwenLM#5231) 3 real findings from qwen-code-ci-bot's round 2 review (the other 11 findings on the same review were already addressed by R1 commit 6c5de81 — the bot used a stale snapshot that did not include R1). Fixes: - QwenLM#12 (workflow.ts): every dispatch completion produced TWO `safeEmitUpdate` calls — once in the `agentCompleted` handler, once in the `budgetUpdated` handler that fires right after. Over a 1000-agent workflow that's 2000 TUI redraws when 1000 suffices. Dropped the `safeEmitUpdate` call from the `agentCompleted` handler and kept it in `budgetUpdated`; the orchestrator fires the two events back-to-back, so the deferred render shows both updates atomically. Production `WorkflowTool.execute()` always wires `WorkflowBudgetImpl.fromEnv()`, so `budgetUpdated` always fires — test paths that omit budget use the injected dispatch shape and don't exercise this emitter wiring. - QwenLM#14 (workflow-budget.ts): the WorkflowBudgetExceededError message carried an advisory tail — "Increase QWEN_CODE_MAX_TOKENS_PER_WORKFLOW or unset it to remove the cap" — that reaches the LLM via `tool_result`. The model could surface this to the user and effectively coach them to remove the operator-set budget policy. Trimmed to the factual portion only. Operators can still find the env knob via the `debugLogger.warn` at both gate sites that names `MAX_TOKENS_PER_WORKFLOW_ENV` verbatim. - QwenLM#15 (BackgroundTasksDialog.test.tsx): WorkflowDetailBody had no rendering test coverage. Added 4 cases under a new R2 QwenLM#15 describe: capped M/N chip with per-phase tally, uncapped plain-spent + zero- chip suppression, hidden chip when both spend and cap are zero/null, and null-sentinel `(no phase)` row. Declined / declined-with-counter-evidence: - QwenLM#13 (workflow-budget.ts threat-model docstring): bot claimed the overshoot bound is off-by-one — `concurrency_window × per_dispatch` rather than `(concurrency_window - 1) × per_dispatch`. The latter is the correct tighter upper bound: when the gate first tips, the tipping dispatch's own tokens are already counted in `spent`, and only `concurrency_window - 1` other in-flight dispatches remain to add overshoot. The looser bound the bot suggests would mislead operators into oversized safety margins. Test count: 282 → 283 core (+1 R2 QwenLM#14 negative assertion), 42 → 47 CLI (+5 R2 QwenLM#15 + null-sentinel coverage). 0 lint, 0 typecheck for workflow-touching files. PR: QwenLM#5231 * fix(core): close P5 review round 3 — finally-bracket execute(), error-arm budgetUpdated, gate-before-count (PR QwenLM#5231) 3 fixes for round 3 review. wenshao (human maintainer) caught a real production-path token leak that R1's `R1 QwenLM#3` test missed; bot also found that R2 QwenLM#12 (UI emit dedup) left the error arm with zero re-renders. Critical fixes (orchestrator core): - QwenLM#6 (wenshao): `reportTokens` was on the line AFTER `await subagent.execute(...)` at both dispatch sites (fast path :353 + override path :642) — NOT in a `finally`. `AgentHeadless.execute()` re-throws on real reasoning-loop failure (`agent-headless.ts:287-294`), so the production ERROR path skipped `reportTokens` entirely and the dispatch's burned tokens leaked. Wrapped `await subagent.execute()` in `try { ... } finally { reportTokens(...) }` at both sites. `getExecutionSummary()` is safe to read inside the throw path because `AgentHeadless.execute()`'s own outer `finally` finalizes stats before the throw propagates. R1's `R1 QwenLM#3` test passed only because the mock execute() RETURNED with ERROR mode (the rare `createChat` early-return); the production reasoning-loop throw was untested. Test pattern: R3 QwenLM#6 tests now mock `execute()` to THROW directly, asserting `onTokens` still fires for both fast path and override path (sibling-drift coverage). - #1 (bot): with R2 QwenLM#12's UI-emit dedup, the error arm of `countedDispatch` fired `agentCompleted` (no `safeEmitUpdate`) and NEVER fired `budgetUpdated` — producing ZERO UI re-renders per failed dispatch. The registry's `tokensSpent` / `perPhaseTokens` also diverged from `budget.spent()` because the host counter advanced (via the reportTokens-in-finally above) while the registry never saw it. Error arm now also fires `emitter?.budgetUpdated?.()` with the post-throw spent + total. Updated R1's "does NOT fire on dispatch rejection" test to assert the new contract (DOES fire, with the cumulative spent) — that test only passed before because the mock dispatch threw without ever calling `budget.recordSpent`, masking the production behavior. - QwenLM#7 (wenshao, suggestion → accepted): `agentCount += 1` ran BEFORE the budget gate. After budget exhaustion, every subsequent `agent()` call still incremented `agentCount`, eventually tripping the agent-cap and surfacing the WRONG terminal error (`Workflow exceeded the maximum of N agent() calls per run`) when the real cause was budget exhaustion. Moved the budget gate above the `agentCount += 1`. Also keeps `agentCount` and `agentsDispatched` (registry counter) counting the same set of calls. New test loops 1100 budget-rejected dispatches and asserts the script completes with budget errors only, never agent-cap errors. Declined (round 5 Suggestion bar): - bot #2 (rename `shouldShowUsageWarning` → `tryConsumeUsageWarning`): naming style, R5 → overthinking. - bot QwenLM#3 (debugLogger on NaN drop in recordSpent): hostile-provider defensive hardening, R5 → overthinking. - bot QwenLM#4 (debugLogger on negative delta in onBudgetUpdated): same. - bot QwenLM#5 (triple emitStatusChange per dispatch): efficiency, R5 → overthinking; #1 fix kept the dispatched / completed / budget callback shape, and TUI emits are still 2 per dispatch (the middle one no longer fires safeEmitUpdate per R2 QwenLM#12). Declined with counter-evidence: - (None this round.) Test count: 283 → 287 core (+4 R3 tests: throw-path fast/override, budgetUpdated on error, agentCount/gate ordering), 47 CLI unchanged. 0 lint, 0 typecheck for workflow-touching files. CI lint failure on this PR is pre-existing main breakage (shellcheck SC2295 in `.github/workflows/qwen-autofix.yml:598` introduced by commit a335f9c, unrelated to this PR's diff) — leaving alone. PR: QwenLM#5231
ytahdn
pushed a commit
that referenced
this pull request
Jun 19, 2026
* feat(cli): show follow-up suggestion in input placeholder When enableFollowupSuggestions is true, display the generated follow-up suggestion as the input placeholder text (replacing the default "Type your message..."). Tab/Enter/Right arrow accepts the suggestion; typing dismisses it. Also change the default of enableFollowupSuggestions from false to true so the feature is on by default. Key changes: - AppContainer: dismissPromptSuggestion no longer clears promptSuggestion state, preserving it for placeholder restore after user types then deletes - InputPrompt: Tab/Enter/Right arrow/typing handlers check promptSuggestion prop as fallback when followup.state is not visible (e.g. after 300ms delay or user dismissed) - Composer: placeholder shows suggestion text when available - hasTabConsumer: include promptSuggestion to prevent Windows bare Tab from cycling approval mode * chore: update settings.schema.json (enableFollowupSuggestions default: false → true) * test(cli): add tests for promptSuggestion prop fallback paths (QwenLM#5145) - Add unit tests for Tab/Right arrow/Enter accepting promptSuggestion when followup.state.suggestion is null (type-then-delete path). - Add unit test for hasTabConsumer reporting true immediately when promptSuggestion prop is set (no followup debounce needed). - Update stale comment on speculation abort useEffect in AppContainer. Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * fix(cli): address PR QwenLM#5145 review feedback for promptSuggestion - Fix Enter key to fill buffer instead of submitting suggestion (matches Tab/Right-arrow behavior and Claude Code design) - Add suggestionDismissed state to hasTabConsumer for Windows Tab cycling - Fix suggestionDismissed to be set to true on user input (paste/typing) - Add speculation abort to dismissPromptSuggestion callback - Remove dead placeholder branch from Composer.tsx - Update tests to reflect Enter no longer auto-submits suggestion * fix(cli): address PR QwenLM#5145 review from wenshao + telemetry gap wenshao's review (posted after the previous fixes) flagged two issues, both still valid against the current code; doudouOUC's telemetry gap is addressed too. - settings description: replace stale "Enter to accept and submit" with "Press Tab, Right Arrow, or Enter to accept into the input buffer" in both settingsSchema.ts and settings.schema.json (Enter now only fills the buffer, and the feature defaults to enabled). - hasTabConsumer / handler consistency: drop the redundant `suggestionDismissed` state and gate hasTabConsumer on `buffer.text.length === 0` — the exact condition the Tab/Right/Enter handlers already use. Fixes the type-then-delete desync where Windows bare Tab would both insert the suggestion and cycle approval mode (regression of QwenLM#4171). - fallback telemetry: add a `fallbackText` option to the followup controller's accept() so the prop-fallback path (no live suggestion, e.g. within the show delay or after type-then-delete) routes through accept() and logs onOutcome instead of silently bypassing telemetry. Tab/Right/Enter handlers now call accept(method, { fallbackText }). - tests: add core-level coverage for accept() with/without fallbackText, and fix the InputPrompt "fallback" tests that advanced 700ms (which silently exercised the normal visible-suggestion path) to advance only 100ms so followup.state.suggestion truly stays null. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(cli): add accept_source telemetry + tests for promptSuggestion fallback Follow-up to wenshao's second review pass on QwenLM#5145. - accept_source telemetry: fallback accepts report time_to_accept_ms: 0 (the suggestion was never shown via the timer), which is indistinguishable from an instant accept. Add an `accept_source: 'live' | 'fallback'` field to the followup controller's onOutcome and PromptSuggestionEvent so analytics can tell the two apart. The controller derives it from whether a live `currentState.suggestion` was present before applying `fallbackText`. - tests: assert accept_source on the fallback accept; add a test that a live suggestion takes priority over fallbackText (guards the `?? fallbackText` ordering); add an InputPrompt test pinning the new buffer.text.length === 0 gate — hasTabConsumer reports false when a promptSuggestion is set but the buffer is non-empty (the old Boolean(promptSuggestion) gate wrongly reported true). The empty-buffer → true direction stays covered by the existing test. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * refactor(cli): address doudouOUC review on QwenLM#5145 (dedupe, rename, telemetry) Three [Suggestion]-level items from the latest review pass. - Extract `availableSuggestion`: the compound condition `(followup.state.isVisible || promptSuggestion) && (followup.state.suggestion ?? promptSuggestion)` was copy-pasted across the Tab/Right/Enter accept guards, both typing-dismiss guards, and the placeholder prop. Collapse them into one derived value so the sites can't drift apart. Behavior is unchanged (the controller keeps `isVisible` and `suggestion` in lockstep). - Rename `dismissPromptSuggestion` -> `abortPromptSuggestion` across the UIState context, AppContainer, Composer, and the MainContent mock. The function only aborts in-flight generation/speculation and deliberately does NOT clear `promptSuggestion` (so the placeholder can restore it); the "dismiss" name implied the suggestion was gone. - Omit `time_to_first_keystroke_ms` for fallback accepts. With `accept_source: 'fallback'` the suggestion was never shown via the timer (shownAt stayed 0), so `prevShownAtRef` still holds a previous suggestion's timestamp and the delta would be meaningless. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(cli): actually enable followup suggestions by default PR QwenLM#5145 changed the schema default to `true`, but `mergeSettings` never applies SETTINGS_SCHEMA defaults, so the runtime `=== true` gates left the feature off while the settings panel read it as on (verified by wenshao). - Flip both runtime gates to treat an unset value as enabled — only an explicit `false` opts out: `AppContainer.tsx` and the ACP `Session.ts` (`#maybeEmitFollowupSuggestion`). - Add a Session test for the unset/default-on path. - Fix the stale `UIStateContext` JSDoc left over from the dismiss→abort rename (it no longer clears state). - Docs: mark the feature on-by-default, correct Enter (fills the input, does not submit), ghost-text → placeholder text, and add a cost note that `fastModel` forks to a separate cache and can cost more than the default main-model + shared-cache path on long conversations. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(core): reject control chars and ANSI escapes in prompt suggestions The follow-up suggestion is influenceable through conversation history (tool/file/web output) and is rendered verbatim in the input placeholder now that enableFollowupSuggestions defaults to on. Raw control bytes (CR, ESC/CSI, C1) reached the terminal because getFilterReason only rejected newlines and asterisks. Reject them at the source so the displayed and inserted text always match. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(cli): clear promptSuggestion on submit and accept paths Addresses doudouOUC review on QwenLM#5145. Since abortPromptSuggestion was changed to preserve `promptSuggestion` for type-then-delete restore, the submit and accept paths leaked stale suggestion text: - handleSubmitAndClear only called followup.dismiss(); after a synchronous command (/clear, /help) that never triggers AppContainer's streaming transition, the placeholder kept showing the old suggestion. - Tab/Right/Enter accept never cleared the prop, so clearing the buffer without submitting (Ctrl+U) made the accepted suggestion reappear as a ghost placeholder. Both now call onPromptSuggestionDismiss?.() after the followup action. Also reuse the availableSuggestion single-source-of-truth in hasTabConsumer instead of an inlined parallel expression, and add useFollowupSuggestions tests asserting the accept_source guard suppresses time_to_first_keystroke_ms on fallback accepts. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * test(cli): assert promptSuggestion is cleared on accept and submit Regression coverage for the state-leak fixed in 04fcffd (doudouOUC Critical #1/#2, confirmed by wenshao's maintainer re-verification): Tab, Right-arrow and Enter accepts plus message submit must each call onPromptSuggestionDismiss, so the persisted promptSuggestion can't reappear as a ghost placeholder when the buffer is next cleared. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
ytahdn
added a commit
that referenced
this pull request
Jun 21, 2026
…ame field (QwenLM#5002) * 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. Sidec…
chiga0
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 & QwenLM#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 QwenLM#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>
chiga0
pushed a commit
that referenced
this pull request
Jun 26, 2026
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 QwenLM#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 QwenLM#4). - Scope by isCollapsibleTool incl. glob, not a hardcoded read/ls/grep list (audit QwenLM#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 QwenLM#3). Generated with AI Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
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.
What this PR does
This PR fixes a daemon session lifecycle race where a fast prompt can complete over SSE before the
/promptHTTP acceptance response returns.When a matching
turn_completeorturn_errorarrives before the prompt action has bound its resolver, the provider now releases the active prompt slot immediately and stores the settled result by session and prompt id. When the HTTP acceptance response arrives later, the prompt promise resolves from that settled cache instead of keeping the session marked busy.Why it's needed
Fast slash commands such as
/directorycan emituser_message_chunk,agent_message_chunk, andturn_completealmost immediately. In that ordering, the UI can already show the turn as complete while the provider still keeps the session inactivePromptsRef.That stale active prompt slot makes the next prompt fail with
Prompt failed: A prompt is already in progress, even though the daemon has already sentturn_complete.The fix keeps the lifecycle consistent: a terminal turn event releases the busy slot immediately, while the original prompt promise still resolves correctly once the delayed acceptance response is observed.
Reviewer Test Plan
How to verify
Run web-shell and submit a fast slash command such as
/directorythat returns usage text immediately. After the usage output appears and the turn completes, submit another prompt. The second prompt should send normally instead of showingPrompt failed: A prompt is already in progress.Evidence (Before & After)
Before: A fast slash command could receive
turn_completeover SSE, render as complete, and still leave the provider's active prompt slot occupied until the HTTP acceptance response returned. A following prompt during that window failed as already in progress.After: The terminal SSE event releases the active slot immediately, and a following prompt can be sent while the original prompt promise is later resolved from the settled result cache.
Tested on
Environment (optional)
cd packages/webui && npx vitest run src/daemon/session/DaemonSessionProvider.test.tsxnpx eslint packages/webui packages/web-shell --ext .ts,.tsx --max-warnings 0npm run build --workspace=@qwen-code/webuinpm run build --workspace=@qwen-code/web-shellRisk & Scope
Linked Issues
Stacked on QwenLM#5190
中文说明
What this PR does
这个 PR 修复 daemon session 生命周期里的一个时序问题:某些很快完成的 prompt 会先通过 SSE 收到完成事件,随后
/promptHTTP accepted 响应才返回。当匹配的
turn_complete或turn_error早于 prompt action 绑定 resolver 到达时,provider 现在会立即释放 active prompt slot,并按 session 和 prompt id 暂存已完成结果。稍后 HTTP accepted 响应返回时,prompt promise 会从这个 settled cache 中 resolve,而不是继续让 session 保持 busy。Why it's needed
像
/directory这样的快速斜杠命令可能几乎立刻发出user_message_chunk、agent_message_chunk和turn_complete。在这个顺序下,界面已经能展示 turn 完成,但 provider 里仍然把 session 保留在activePromptsRef。这个过期的 active prompt slot 会导致下一条 prompt 报错:
Prompt failed: A prompt is already in progress,即使 daemon 已经发送了turn_complete。修复后生命周期保持一致:终止性的 turn 事件会立即释放 busy slot,同时原来的 prompt promise 仍会在稍后观察到 delayed acceptance response 时正确 resolve。
Reviewer Test Plan
How to verify
运行 web-shell,提交一个会立即返回 usage 文本的快速斜杠命令,例如
/directory。usage 输出出现并且 turn 完成后,再提交另一条 prompt。第二条 prompt 应正常发送,而不是显示Prompt failed: A prompt is already in progress。Evidence (Before & After)
Before:快速斜杠命令可以先通过 SSE 收到
turn_complete并在界面上显示完成,但 provider 的 active prompt slot 会继续被占用直到 HTTP accepted 响应返回。在这个窗口内发送下一条 prompt 会报 already in progress。After:终止性的 SSE 事件会立即释放 active slot;下一条 prompt 可以发送;原 prompt promise 会稍后从 settled result cache 中完成。
Tested on
Environment (optional)
cd packages/webui && npx vitest run src/daemon/session/DaemonSessionProvider.test.tsxnpx eslint packages/webui packages/web-shell --ext .ts,.tsx --max-warnings 0npm run build --workspace=@qwen-code/webuinpm run build --workspace=@qwen-code/web-shellRisk & Scope
Linked Issues
基于 QwenLM#5190