* docs(design): define default background subagents
* feat(core): improve subagent delegation defaults
* docs(core): cross-reference the three background-classification sites
Add pointer comments linking the core dispatch source of truth
(AgentTool.execute) and its two UI mirrors (web-shell
isBackgroundSubAgentToolCall, desktop detectBackgroundEvents) so the
replicated top-level-agent background heuristic is not changed in
isolation. Addresses PR review feedback.
* fix(core): align background classification for fork and named-teammate launches
Address review feedback on the background-classification rule so core dispatch
and the two UI classifiers stay consistent:
- core: exclude a name-without-active-team launch from the default-background
path so it stays foreground, matching both UI classifiers (which exclude
name). Previously such a launch was backgrounded by core but tracked as
foreground by the UIs.
- web-shell and desktop classifiers: exclude subagent_type "fork" from the
default-background heuristic, mirroring core's !isForkRequested guard. A
top-level fork request with an omitted flag runs foreground in core but was
classified as background by the UIs.
- add a core dispatch test asserting a working_dir launch with an omitted
run_in_background flag stays in the foreground.
* test: cover fork/background classification and precedence per review feedback
Address unresolved review threads on PR #7048:
- Add web-shell and desktop UI classifier tests asserting an omitted-flag
`subagent_type: "fork"` launch stays in the foreground, verifying the
documented `!isForkRequested` parity with core dispatch.
- Add a core AgentTool test asserting an explicit `run_in_background: false`
overrides a subagent config with `background: true`, locking in the
`run_in_background ?? config` precedence against a `||` regression.
- Harden the Explore read-only prompt: pipelines must not send data to a
network endpoint (no curl/wget/nc), closing the `cat file | curl`
exfiltration gap.
* fix(core): restore general no-unnecessary-files guard in general-purpose prompt
Address review feedback: the rewritten general-purpose prompt dropped the
broad guard against creating unrequested files, keeping only the
documentation-specific line. Restore a general 'do not create files unless
necessary' guard so speculative utility/config files are not created.
* test(desktop): cover named-teammate foreground guard in detectBackgroundEvents
Add a desktop tool-matching test asserting a top-level Agent with a
`name` set (named teammate) stays foreground and emits no
task_backgrounded event, mirroring the web-shell classifier's
named-teammate coverage and the existing fork-exclusion test.
* test(core): cover named-teammate foreground dispatch when flag omitted
Add a core-dispatch test asserting a top-level Agent launch with `name`
set and `run_in_background` omitted stays foreground when no team is
active, guarding the `this.params.name === undefined` exclusion in
backgroundRequested directly (previously only covered by the UI
classifiers).
---------
Co-authored-by: Claude <noreply@anthropic.com>
* fix(core): make the per-turn tool-call cap adaptive
The per-turn tool-call cap (model.maxToolCallsPerTurn, default 100) was a
blunt circuit breaker: it halted any turn on the 101st tool call regardless
of whether the model was stuck or doing productive work. Large multi-package
implementation turns legitimately exceed 100 calls, so the cap killed
productive turns — a false positive.
Make the cap adaptive. The configured value is now a soft cap: once a turn
exceeds it, the cap halts only when a stuck-repetition signal is present (the
same (tool, args) call repeated 6+ times); a productive turn (diverse calls,
no repetition) continues up to a hard backstop of 3x the soft cap, which
always halts to bound an argument-varying runaway.
Validated against a real session whose 100-call turn was halted mid-build
with no repetition (max key repeat 2): that turn now continues, while genuine
stuck loops still halt at the soft cap. The always-on cap keeps its own
per-(tool,args) repeat tracker so it stays independent of skipLoopDetection.
The ACP/daemon path has a separate blunt cap that is not aligned here; noted
as a follow-up in the design doc.
* chore: regenerate settings schema for adaptive cap
* fix(core): clarify adaptive cap is interactive-only; strengthen cap tests
Address review feedback:
- The setting description now notes the daemon/ACP path still halts at the
configured value regardless of repetition, so it no longer overclaims the
adaptive behavior for non-interactive paths.
- Rename the misleading "fires at the built-in default soft cap value" test
(it only asserts diverse calls are allowed past the cap).
- Add a retry test that builds a stuck-repetition signal before the retry and
verifies it is cleared, so removing the capMaxKeyRepeat reset would fail.
* test(core): cover productive-then-stuck cap; clarify cap halt hint
Address review round 2:
- Add a test for a turn that crosses the soft cap with diverse calls and then
becomes stuck mid-range, so the stuck check is verified across the whole
(softCap, hardCap] range, not just at the boundary.
- Make the headless cap-halt message accurate for both triggers: it no longer
only suggests raising maxToolCallsPerTurn (correct for the hard backstop but
misleading for a stuck repeat) and now also points at the repetition.
* refactor(core): hash tool-call key once in always-on cap path; tighten cap tests
Address review round 3:
- Compute the (tool,args) key once in checkAlwaysOnSafeties and share it
between the consecutive-identical guard and the cap stuck tracker (was
hashed twice per call; args can be large). Skip hashing entirely when loop
detection is disabled for the session (no consumer).
- Add a test that the stuck signal accumulates across Finished round-trip
boundaries within a turn.
- Tighten the Stop-hook continuation budget test (cap 4 -> 1) so it still
guards the reset under the adaptive cap, where diverse calls no longer trip
the soft cap.
- Document the monotone stuck-signal non-goal and the telemetry-differentiation
follow-up in the design doc.
* fix(core): canonicalize tool-call key fields; clarify adaptive cap scope
Address Codex review (2 Critical):
- getToolCallKey now canonicalizes object keys recursively (preserving array
order) before hashing, so a stuck model cannot evade the repeat guards by
reordering argument fields. Adds a reordered-arguments regression test.
- Correct the maxToolCallsPerTurn description: the adaptive behavior applies to
both the interactive TUI and non-interactive (-p / JSON / stream-JSON)
core-client runs; only the daemon/ACP path is strict. Updated in
settingsSchema.ts, settings.md, and the regenerated settings.schema.json.
* test(core): cover nested key reordering and the consecutive guard's canonicalization
Address review:
- Extend the reordered-args stuck-signal test to include nested objects, so a
regression that breaks canonicalizeForHash's recursion is caught.
- Add a reordered-args test for the consecutive-identical guard, pinning the
canonicalization contract for that always-on detector (not just the cap).
* fix(core): treat explicit maxToolCallsPerTurn as a hard cap; keep default adaptive
Address yiliang114's Critical: v0.19.10 shipped maxToolCallsPerTurn as a hard
cap, but the adaptive change multiplied every configured value by 3, turning an
explicit N into a 3N budget — a breaking change for users who set it to bound
unattended cost.
Behavior now depends on whether the value was explicitly configured
(Config.isMaxToolCallsPerTurnExplicit):
- Explicit N -> hard cap (halt at N+1), preserving the released contract.
- Default (unset) -> adaptive: soft cap 100, halt only on a stuck-repetition
signal, with the hard backstop raised to 1000 (10x) so modern models making
hundreds of legitimate calls per task are not false-positived.
Adds the explicit-hard-cap regression (cap of 2 halts call 3) and a contrast
test proving the explicit flag drives the behavior. Updates the setting
description, headless hint/label, and design doc accordingly.
* feat(channels): stamp daemon sourceId with channel instance name on created sessions
Channel workers tagged created sessions with sourceType 'channel' (#6991,
refs #6962) but no sourceId, leaving sessions from different channel
instances (e.g. dingtalk-main vs feishu-main) indistinguishable on the
daemon data plane (session lists, transcripts, ?sourceType= filters).
The session-source mechanism already supports a paired sourceId —
scheduled tasks use it for the task id.
Plumb the channel instance name through the creation path:
SessionRouter.createLiveSession → bridge.newSession options
(ChannelAgentBridgeSessionOptions.sourceId) → session-factory request →
createOrAttach. loadSession deliberately never forwards it: loading an
existing session never re-stamps its creation attribution, matching the
issue's 'only newly created channel sessions should be tagged' rule and
the daemon's single-attach source rule.
Tests: channels/base suite (764) and cli daemon-worker suite (66) green.
New cases cover router stamping, bridge new-vs-load forwarding, and the
worker factory create-vs-load behavior.
* fix(channels): declare sourceId in createOrAttach DI type and cover replacement-path stamping
Review feedback on #7078:
- DaemonSessionClientStaticLike.createOrAttach's request type declared
sourceType but not sourceId; the field type-checked only via object-spread
excess-property exemption. Declare sourceId?: string so the contract
documents the request and direct-property edits keep compiling.
- Add a sourceId assertion to the load-failure replacement test so the
recovery path's per-instance attribution is regression-guarded.
---------
Co-authored-by: 欢伯 <ri.xur@alibaba-inc.com>
* docs(cli): design for VP mode mouse text selection and copy
First step of the VP mouse-selection feature: the design doc, submitted
ahead of implementation so the approach can be reviewed before code lands.
Proposes an application-level selection and copy system for VP mode, where
SGR mouse tracking currently suppresses native terminal selection. Evaluates
two routes and commits to exposing the Ink renderer cell grid via the
existing Ink patch, which makes coordinate-to-text mapping uniform across all
content types. Implementation follows as staged milestones on this branch.
* docs(cli): revise VP selection design per feasibility audit
Rework the design after the implementation-feasibility audit against the
Ink 7 renderer source. Key corrections and scope changes:
- Two-PR plan. PR 1 (this branch) delivers the Ink frame-buffer foundation
and a visible-region visual-selection MVP (visual-cell copy, clear on
scroll). PR 2 adds cross-screen selection and semantic copy fidelity
(soft-wrap rejoin, gutter exclusion), which need renderer semantic metadata.
- Highlight goes through a bidirectional frame controller with a
pre-serialization transform and throttled repaint; the earlier read-only
accessor plus post-commit callback could not highlight the current frame.
- Immutable cell/style allocation to avoid leaking the highlight onto other
on-screen occurrences of the same text via shared cached styled chars.
- Corrected coordinate formula (frame is not unconditionally top-anchored),
OSC 52 behavior (over-cap skips and returns false; surface a copy failure),
and the note that release already has non-selection consumers.
- Merge gate and milestones aligned to the two-PR split.
* feat(cli): M0 Ink frame-buffer controller for VP text selection
First implementation milestone of VP-mode mouse text selection: the Ink
frame-buffer foundation and its read-side wrapper, with the design's M0
go/no-go criteria proven by tests.
Extend the Ink patch into a small bidirectional frame controller. The
renderer now retains the composited cell grid, applies a selection-range
background highlight before serialization (allocating new cells so the
shared cached styled chars are never mutated), and publishes an immutable
frame each render. setSelection deduplicates and schedules exactly one
repaint through Ink's own throttle; getFrameController(stdout) exposes the
bridge to the application.
Add ScreenBuffer, the read-side wrapper the selection state machine (M1)
will build on: getCellAt / lineText / dimensions plus setSelection and
subscribe. Tests cover addressable cells, wide-character spacer handling,
pre-serialization highlight and clear, no highlight leakage onto identical
on-screen text, and single-frame-per-change dedup (no render loop).
The package.json export additions from the existing patch are preserved.
* feat(cli): M1 mouse text selection with live highlight in VP mode
Turn mouse press/drag/release in the VP history viewport into a text
selection, highlighted live and copied on release.
- SelectionState: anchor/focus model in composited-frame coordinates,
normalized to reading order, with collapsed/empty queries.
- selection-text: extract the visual text of a range, emitting a wide glyph
once (skipping its spacer) and trimming per-line trailing padding. B1
fidelity — visual cells as shown; soft-wrap rejoin and gutter exclusion
are PR 2.
- selection-coords: map a terminal cell to frame grid coordinates via the
frame anchor (bottom-pinned on overflow), plus viewport bounds/clamp.
- TextSelectionController: headless controller that subscribes button-level
mouse events, drives the state machine, highlights the range through the
frame controller (setSelection), and copies on release. Ignores the
scrollbar column and presses outside the viewport; clears on any scroll
(visible-region only in B1).
- VirtualizedList/ScrollableList expose getViewportRect(); MainContent mounts
the controller in the VP branch.
Copy-failure feedback, key preemption (Esc/Ctrl+Shift+C), streaming/resize
invalidation, and word/line selection follow in M3/M4.
* feat(cli): gate VP text selection behind ui.textSelection settings
Add ui.textSelection.enabled (default true) and ui.textSelection.copyOnSelect
(default true) and wire them into the selection controller: selection is only
active when enabled, and release copies only when copyOnSelect is on. Disabling
enabled leaves the mouse to the terminal for those who prefer it.
* feat(cli): M4 word/line select, selection invalidation, and docs
- Double-click selects a word, triple-click selects a line, driven by a
multi-click detector in the controller; word/line spans come from the
composited frame (non-whitespace run / first-to-last-content cell) and are
copied on select.
- Invalidate the selection when the content scrolls, streams (scrollHeight
changes), or the terminal resizes (frame height changes / resize event),
detected via a frame subscription with a baseline captured at selection
start. Our own highlight renders leave those unchanged, so there is no
render loop.
- Document mouse selection in the keyboard-shortcuts reference and the
ui.textSelection settings in the settings reference; refresh the
useTerminalBuffer description and regenerate the IDE settings schema.
A single click already clears a selection, so Esc-to-clear, the manual
copy keybinding, the footer "copied" toast, and a drag discoverability hint
are deferred follow-ups within this PR.
* fix(cli): drop redundant resize listener in selection controller
Real-terminal testing surfaced a MaxListenersExceededWarning (11 resize
listeners) on startup: the selection controller added a stdout 'resize'
listener on top of the existing ones. A resize reflows content, which changes
the frame/scroll height already watched by the frame subscription, so the
explicit listener was redundant. Remove it to stay under the max-listeners
cap; invalidation on resize still works via the subscription.
* fix(cli): close VP selection review gaps
* refactor(cli): make VP text selection unconditional
* fix(cli): make VP mouse selection usable
* fix(cli): preserve selection in thought blocks
---------
Co-authored-by: 秦奇 <gary.gq@alibaba-inc.com>
Co-authored-by: Shaojin Wen <shaojin.wensj@alibaba-inc.com>
Expose persisted active/archived/total (plus live) via a dedicated aggregate
endpoint so clients do not need to page the full session list. Counts reuse the
existing chats-dir disk scan pattern from session title search; responses mark
expensive/disk_scan so callers know not to poll.
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
* fix(web-shell): batch transcript dispatch to avoid tab-return freeze
Dispatching each buffered SSE event individually makes a tab-return burst O(events x blocks) on the main thread (per-dispatch block-array copy + freeze), freezing very long sessions for minutes. Coalesce the live stream into one dispatch per macrotask, cap the client's in-memory transcript window, and skip the dev-only block freeze in production.
* fix(web-shell): flush transcript buffer on teardown, guard freeze for browser
Address review feedback: teardown now flushes buffered transcript events instead of dropping them (the SSE client advances lastSeenEventId as events are yielded, so a dropped buffer would be skipped by a same-session incremental resume). Guard FREEZE_TRANSCRIPT_BLOCKS with typeof process so an unbundled browser consumer of the daemon/ui surface does not throw a ReferenceError. Add a dispatch-count assertion to the burst test and an unmount-flush regression test, and align the design doc (setTimeout-only flush, verification plan).
* fix(web-shell): flush before observer debug guard to keep assistant bursts in one block
Address ytahdn's PR #7012 review: the batched-dispatch debug guard read the committed store's activeAssistantBlockId, which lags the pending buffer within a burst, so a debug event interleaved in an observer assistant burst was not filtered and split the block. Flush the buffer before the guard, scoped to observer-mode debug events (rare) so steady streaming keeps batching. Add a focused burst regression test, make the unmount-flush test deterministic with fake timers (it was timing-racy), and update the design doc.
* fix(web-shell): flush buffered transcript on SSE loop error
The catch block at the end of the connection loop skipped the post-loop
flush, leaving buffered transcript events on a scheduled timer. The
retriable path resumes via Last-Event-ID without resetting the store,
and lastSeenEventId has already advanced past those events, so clearing
the buffer would drop them on the incremental delta-resume. Flush
instead.
Also route the restored-prompt settle and replay_complete control
dispatches through dispatchTranscriptNow so each is self-contained
(flush + dispatch) rather than relying on an earlier flush by timing,
and tighten the burst regression test from toContain(CHUNK_COUNT) to
toEqual([CHUNK_COUNT]) so a regression emitting redundant per-event
dispatches also fails.
Addresses the ci-bot review.
* fix(web-shell): keep a batched transcript dispatch throw from cascading
A reducer throw inside runTranscriptFlush escaped as an uncaught
setTimeout error on the macrotask path and, via flushTranscriptSync,
propagated out of the catch block (aborting lastSeenEventId bookkeeping,
reconnect, auth branching, terminal cleanup, and pendingSessionLoad
rejection) and out of the useEffect cleanup (leaving half-torn-down
state). Wrap the dispatch in try/catch and log it with the batch size so
the throw is surfaced without crashing the session or skipping teardown;
one guard fixes all three paths.
Also document the flush precondition on settleActivePromptFromTurnEvent,
which dispatches assistant.done directly and previously carried that
contract only as an inline comment at the call site.
Addresses the ci-bot review.
* feat(cli): change default approval mode from default to auto
The default approval mode required manual confirmation for every tool
call, producing dozens of confirmation prompts per task. Auto mode uses
a three-layer filter (workspace edits, read-only allowlist, LLM
classifier) to auto-approve safe operations while still guarding risky
ones.
Untrusted folders are still forced to default mode for safety.
Closes#6898
* fix(cli): keep manual approval in safe and bare modes
Restricted modes (safe/bare) strip permissions, allowlists, MCP servers
and hooks to provide a maximally restrictive session. The new AUTO
default fallback was silently downgrading them to the LLM classifier,
contradicting their lockdown intent. Restore DEFAULT (manual approval)
for these modes while keeping AUTO as the default for normal sessions.
Explicit --approval-mode and --yolo flags still take effect, since they
are resolved before the fallback.
* chore(cli): regenerate settings schema for auto default
Regenerate the VS Code settings schema so the tools.approvalMode default
matches the new auto value (fixes the "settings schema is up-to-date" CI
check). Also add coverage for the serve-mode approval fallback when no
approval mode is configured.
* test(cli): update SettingsDialog snapshots for auto approval default
The settings schema now defaults tools.approvalMode to auto, so the
SettingsDialog renders "Auto" instead of "Ask permissions" for the Tool
Approval Mode field. Regenerate the affected snapshots (10 updated).
* test(core): pin DEFAULT baseline in agent-override tests
These tests exercise createApprovalModeOverride isolation and the
DEFAULT→AUTO rule strip/restore transitions, so they implicitly relied
on the Config constructor defaulting to DEFAULT. Now that the default is
AUTO, pin the baseline explicitly so the tests no longer depend on the
constructor default.
---------
Co-authored-by: pomelo.lcw <pomelo.lcw@alibaba-inc.com>
* feat(acp): expose tool-call preparation lifecycle
Why:
ACP clients receive no signal while providers stream tool arguments, making long calls appear stalled and delaying tool-identity policy decisions.
What:
- attach transient preparation metadata for Anthropic and OpenAI-compatible streams
- emit correlated ACP pending, execution, and discarded lifecycle updates
- preserve normalized call IDs across partial chunks and provider ID reuse
- clear abandoned retry calls and keep cleanup failures from terminating healthy retry/fallback streams
- deduplicate suppressed preparations and protect completed remapped parser buffers
- cover multi-tool Anthropic streams, ID reservation, TodoWrite suppression, retry cleanup, cancellation, and stream failure
Impact:
The metadata is additive and consumed only by ACP. It exposes no partial arguments, is not persisted to conversation history, and does not move permissions, hooks, scheduling, or execution ahead of complete function calls.
Tests:
- Core provider and stream suites: 649 passed
- ACP lifecycle suites: 316 passed
- npm run build
- npm run typecheck
- npm run lint:ci
- changed-file Prettier and git diff checks
Refs: #6775
* fix(acp): stabilize tool preparation lifecycle updates
Why:
- ACP cleanup failures must not convert a successful model stream into a failed prompt.
- A prepared tool call must be updated in place when execution starts instead of creating a second card.
What:
- Preserve the primary stream outcome when preparation cleanup fails and remove duplicate message display finalization.
- Track prepared call IDs so execution starts use tool_call_update, guard empty preparation metadata, and cover late stable IDs.
Impact:
- Ordinary tool calls keep their existing tool_call start frame.
- Streaming parser production behavior is unchanged.
* Update packages/cli/src/acp-integration/session/Session.test.ts
overrides参数在createPreparationResponse中被声明但从未使用——所有11个调用点仅传递callId且toolName. 该as GenerateContentResponse强制类型转换会绕过对始终为空对象的结构化类型检查。
Co-authored-by: qwen-code-ci-bot <qwen-code-ci@service.alibaba.com>
* fix(acp): harden preparation lifecycle handling
Why:
- A malformed test helper prevented the preparation lifecycle suite from compiling.
- Duplicate preparing frames and state cleanup need direct regression coverage.
What:
- Repair the preparation response helper and isolate cleanup warning assertions.
- Suppress duplicate preparing frames and cover terminal cleanup plus missing tool call IDs.
Impact:
- Normal preparation and execution transitions remain unchanged.
- Repeated preparation frames for the same call ID are now ignored.
---------
Co-authored-by: qwen-code-ci-bot <qwen-code-ci@service.alibaba.com>
* feat(core): emit liveness heartbeats for silent foreground shell commands
Silent foreground commands previously produced no events between spawn
and settle, so ACP gateways and stream-json consumers could not tell a
long-running command from a dead session. The shell tool now emits a
structured ShellProgressData through the existing updateOutput channel
whenever no display update has fired for tools.shell.heartbeatIntervalMs
(default 10s, 0 disables). Heartbeats carry liveness stats only - never
command output - and never enter model context.
Consumers: the ACP session forwards heartbeats as meta-only
tool_call_update frames (gated so a tick racing the settle path cannot
regress status after completion) and records heartbeat span attributes;
stream-json forwards them as tool_progress events behind
includePartialMessages; the TUI scheduler, React hook, and subagent
runtime ignore them so live output views are not replaced by stats
objects.
* docs(design): add silent command heartbeat design doc
* fix(acp): keep tool_call_update heartbeats from breaking in-repo consumers
Codex review of the heartbeat change found that in-repo ACP consumers
did not tolerate the new meta-only in_progress frames. A full sweep of
tool_call_update consumers found three that mishandled them, each now
guarded with a regression test:
- The desktop agent converted every tool_call_update into a terminal
tool_result, so the first heartbeat would prematurely complete the
command with an empty result. It now skips in_progress updates.
- DaemonChannelBridge requires kind on tool_call_update and flagged the
kind-less heartbeat as a malformed-protocol error every interval. It
now drops kind-less in_progress frames silently.
- The web-shell daemon UI normalizer derived the tool block title from
_meta.toolName, overwriting the human-readable title on every
heartbeat. It now drops heartbeat frames outright.
The remaining consumers (VS Code companion, acp-bridge compaction,
session export, daemon TUI adapter) merge updates conditionally and are
heartbeat-safe without changes.
* fix(core): address PR review — heartbeat monotonic gate, guard scope, telemetry
Review round 1 on #6876 (yiliang114, wenshao, chiga0, qwen3.7-max):
- shell.ts: the silent-idle gate now uses the monotonic performance.now()
clock (via lastOutputPerfTime, falling back to spawn time) instead of the
Date.now()-based lastUpdateTime, so an NTP step can neither skew the
payload nor misfire a heartbeat — matching the design doc's monotonic
commitment. It also keys off actual output arrival rather than the
throttled display update.
- session-tracing.ts: endToolExecutionSpan now applies caller-supplied
attributes BEFORE the canonical keys (duration_ms, success, error) so a
passthrough attribute can never mask the span's own outcome fields.
- desktop qwen-agent.ts: the in_progress drop guard is now scoped to frames
carrying _meta.shellProgress, matching the daemon bridge and web-shell
normalizer guards, so a future non-heartbeat in_progress frame is not
silently swallowed.
- Tests: the desktop regression test now pins result==='done' (previously
it stayed green even with the guard removed); added a Session.test
assertion that heartbeat counts reach the tool-execution span attributes.
* fix(acp): align desktop heartbeat guard with normalizer; test kind pass-through
Review round 2 on #6876 (qwen3.7-max via ci-bot):
- The desktop qwen-agent in_progress drop guard was broader than the
web-shell normalizer's: it dropped any in_progress + shellProgress frame
regardless of kind, while the normalizer only drops kind-less ones. The
comment claimed they matched. Added the kind-absent check so the desktop
guard matches the normalizer exactly — a kind-bearing frame now passes
through on both platforms (heartbeats emitted by the ACP session never
carry a kind, so real behavior is unchanged).
- Added pass-through tests on both sides (daemonUi + desktop) asserting an
in_progress frame WITH a kind normalizes to a tool.update / tool_result
rather than being dropped, so the load-bearing kind-absent condition is
no longer only exercised on the drop path.
* fix(channels): scope daemon bridge heartbeat drop to shellProgress frames
Review round 3 on #6876 (qwen3.7-max via ci-bot): the DaemonChannelBridge
heartbeat guard lived in the shared tool_call / tool_call_update case and
dropped ANY kind-less in_progress frame, so a genuinely malformed kind-less
tool_call (status in_progress, no shellProgress) was silently swallowed
instead of reaching emitProtocolError. Gate the drop on _meta.shellProgress
— matching the qwen-agent and web-shell normalizer guards — so real
heartbeats are still dropped while malformed frames are flagged. Added a
regression test for the malformed path.