feat(daemon): server-pushed followup_suggestion event for the webui#4507
Conversation
📋 Review SummaryThis PR implements a daemon-assisted follow-up suggestion feature that pushes server-generated "ghost-text" suggestions to attached clients after each clean assistant turn. The implementation spans the SDK schema layer, ACP bridge translation, CLI session logic, and webui hook integration. The code demonstrates strong architectural consistency with existing patterns (PR 14b MCP budget events, auth device-flow events) and includes comprehensive test coverage. 🔍 General Feedback
🎯 Specific Feedback🟡 High
🟢 Medium
🔵 Low
✅ Highlights
|
|
Thanks — declining all eight, brief reasoning per: High
Medium
Low
|
There was a problem hiding this comment.
Pull request overview
This PR adds a new daemon→client SSE event (followup_suggestion) so ACP children can push server-generated “ask next” ghost-text suggestions to daemon-attached UIs (notably the webui), without giving those UIs direct LLM access.
Changes:
- SDK: introduces
followup_suggestionas a known daemon event, normalizes it into afollowup.suggestionUI event, and stores it in transcript sidechannel state with a newclearFollowupSuggestion()store action. - ACP bridge + CLI: emits
followup_suggestionSSE frames viaextNotification('qwen/notify/session/prompt-suggestion', …)after cleanend_turncompletion; adds cancellation to avoid stale suggestions. - Webui: adds
useDaemonFollowupSuggestion()to wire the daemon sidechannel into the existinguseFollowupSuggestionscontroller for<InputForm>.
Reviewed changes
Copilot reviewed 17 out of 17 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| packages/webui/src/daemon/useDaemonFollowupSuggestion.ts | New hook bridging daemon transcript sidechannel into webui followup suggestion controller. |
| packages/webui/src/daemon/index.ts | Exports the new webui daemon hook. |
| packages/sdk-typescript/src/daemon/events.ts | Adds followup_suggestion schema + predicates + reducer state (lastFollowupSuggestion). |
| packages/sdk-typescript/src/daemon/ui/normalizer.ts | Normalizes daemon followup_suggestion into UI followup.suggestion. |
| packages/sdk-typescript/src/daemon/ui/types.ts | Adds UI event type + transcript sidechannel field + store action type surface. |
| packages/sdk-typescript/src/daemon/ui/transcript.ts | Stores latest followup suggestion as sidechannel only; adds selector. |
| packages/sdk-typescript/src/daemon/ui/store.ts | Implements clearFollowupSuggestion() and seeds/clones sidechannel state. |
| packages/sdk-typescript/src/daemon/ui/terminal.ts | Renders followup suggestion as a debug-style terminal line. |
| packages/sdk-typescript/src/daemon/ui/index.ts | Re-exports new selector and UI event type. |
| packages/sdk-typescript/src/daemon/index.ts | Re-exports selector + types at daemon package boundary. |
| packages/sdk-typescript/src/index.ts | Re-exports new assist-push types at SDK root. |
| packages/sdk-typescript/test/unit/daemonEvents.test.ts | Unit coverage for event recognition, predicate rejection, and reducer propagation. |
| packages/sdk-typescript/test/unit/daemonUi.test.ts | Unit coverage for UI normalization + transcript sidechannel behavior + terminal renderer. |
| packages/acp-bridge/src/bridgeClient.ts | Translates extNotification prompt-suggestion into followup_suggestion SSE frames. |
| packages/acp-bridge/src/bridge.test.ts | Tests bridge emission + malformed payload dropping + post-close drop behavior. |
| packages/cli/src/acp-integration/session/Session.ts | Fire-and-forget followup suggestion generation after end_turn, with abort handling. |
| packages/cli/src/acp-integration/session/Session.test.ts | Adds tests for emitting, suppression logging, and abort semantics. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
|
This PR is part of a conflict group with PR #4476, PR #4520, PR #4545, and PR #4546. These PRs share modifications to core daemon and event handling code. Key shared functions:
Recommendation: This PR modifies daemon event handling which overlaps with 4 other PRs. Consider merging after the group has been reviewed together. This conflict was detected automatically by CodeScope skills — structural analysis of call graphs and file modifications. |
|
This PR is part of a conflict group with PR #4476, PR #4520, PR #4545, and PR #4546. These PRs share modifications to core daemon and event handling code. Key shared functions:
Recommendation: This PR modifies daemon event handling which overlaps with 4 other PRs. Consider merging after the group has been reviewed together. This conflict was detected automatically by CodeScope skills — structural analysis of call graphs and file modifications. |
wenshao
left a comment
There was a problem hiding this comment.
Test failure root cause refinement (Session.test.ts:3131)
The previously reported test failure (capturedSignal.aborted is false) is a test-authoring bug, not an implementation defect. The abort mechanism in Session.ts works correctly — verified by debug tracing (533/534 tests pass). The test's generateMock.mockImplementation (not mockImplementationOnce) runs on every call, so the second prompt's #maybeEmitFollowupSuggestion overwrites capturedSignal with the new (non-aborted) signal before the assertion runs.
Fix: pin the first signal before sending the second prompt:
const firstSignal = capturedSignal!;
await session.prompt({ sessionId: 'test-session-id', prompt: [{ type: 'text', text: 'second' }] });
expect(firstSignal.aborted).toBe(true);Test coverage gaps (Suggestions)
- Session.ts:610 — No test for
stopReason !== 'end_turn'early return. All tests produceend_turn; a regression removing the guard would go undetected. - Session.ts:658 — No test for the error-swallowing
catchblock. Critical invariant ("failed suggestion is invisible to the user") is unverified. - bridgeClient.ts:528 —
originatorClientIdconditional spread never tested in the present case. - useDaemonFollowupSuggestion.ts — Entire 140-line hook (new in this PR) has zero test coverage. Dedup ref logic and store invalidation are regression-prone.
Needs Human Review (low-confidence)
- Session.ts:636 — TOCTOU window between abort check and
await extNotification. Client-sideclear()mitigates stale ghost-text. - Session.ts:623 — No wall-clock timeout on suggestion generation. If user walks away, IIFE runs until LLM provider timeout.
- events.ts:1605 —
lastFollowupSuggestionnot cleared on prompt-boundary events during SSE replay/reconnect. Stale suggestions may reappear after page reload.
Deterministic analysis: tsc 0 findings, eslint 0 findings. CI: 11/11 passing. Tests: 269 SDK + 186 bridge + 78/79 CLI Session.
— qwen3.7-max via Qwen Code /review
72422b6 to
479158b
Compare
Review fixes pushed (f059338)
Ready for re-review. |
wenshao
left a comment
There was a problem hiding this comment.
R3 Review (qwen3.7-max) — All R2 findings addressed. 548 tests pass, tsc/eslint clean.
Reverse audit finding: The originatorClientId conditional on followup_suggestion events in bridgeClient.ts is dead code. bridge.ts:sendPrompt clears activePromptOriginatorClientId in .finally() when the prompt promise settles — before #maybeEmitFollowupSuggestion runs (it fires after #executePrompt returns, plus an additional LLM round-trip for suggestion generation). So entry.activePromptOriginatorClientId is always undefined when the extNotification arrives. Either drop the conditional or capture the originator at prompt time.
Needs Human Review:
- SSE replay ring may resurrect dismissed suggestions on client reconnect (
followup_suggestionevents get ring IDs;lastPushedPromptIdRefresets on remount) - No upper-bound on suggestion text length in bridgeClient.ts (defense-in-depth)
- Suggestion generation failures (
debugLogger.warn) not visible in daemon stderr writerIdleTimeoutMsmissingassertTimerDelayInRangevalidation (out of PR scope)
— qwen3.7-max via Qwen Code /review
| this.followupAbort = ac; | ||
| const promptId = | ||
| this.config.getSessionId() + '########' + String(this.turn); | ||
| const fullHistory = chat.getHistory(true); |
There was a problem hiding this comment.
[Suggestion] chat.getHistory(true) deep-clones the entire conversation history via structuredClone(), then .slice(-40) discards everything beyond the tail. For long-lived daemon sessions (200+ turns with tool output blobs), this clones megabytes on every turn boundary.
The codebase already has getHistoryTail(count, curated) (at geminiChat.ts:2098) which does structuredClone(history.slice(-count)) — slicing first, then cloning only the 40 entries actually needed. This bounds the clone cost to O(40) regardless of session length.
| const fullHistory = chat.getHistory(true); | |
| const conversationHistory = chat.getHistoryTail(40, true); |
— qwen3.7-max via Qwen Code /review
1284383 to
1ca0572
Compare
Schema-only addition that lets the daemon push server-generated
follow-up suggestions ("what you might want to ask next") through the
per-session SSE bus. Zero runtime effect on its own — old daemons
just don't emit the event, and this commit doesn't change any
publisher; the bridge handler + ACP-child generator land in follow-up
commits.
Adds the new event taxonomy across the three layers:
- `events.ts`: `followup_suggestion` in `DAEMON_KNOWN_EVENT_TYPE_VALUES`,
`DaemonFollowupSuggestionData` interface, `DaemonFollowupSuggestionEvent`
envelope, `DaemonAssistEvent` union (new — reserved for future assist
hints like server-side speculation), `KnownDaemonEvent` extension,
`lastFollowupSuggestion` on `DaemonSessionViewState`,
`asKnownDaemonEvent` + `reduceDaemonSessionEvent` cases, and an
`isFollowupSuggestionData` predicate rejecting empty / malformed
payloads.
- `ui/normalizer.ts` + `ui/types.ts`: maps the daemon event to a
typed `DaemonUiFollowupSuggestionEvent` (`type: 'followup.suggestion'`).
- `ui/transcript.ts` + `ui/store.ts`: stores `lastFollowupSuggestion` on
`DaemonTranscriptSidechannelState` (no chat-stream block), exposes a
`selectLastFollowupSuggestion` selector, and adds a
`clearFollowupSuggestion()` store action mirroring `clearAwaitingResync`
so adapters can invalidate the suggestion on sendPrompt without a
wire round-trip.
- `ui/terminal.ts`: adds the new variant to the exhaustive switch so the
terminal renderer stays exhaustive.
- Public surface re-exports in `daemon/index.ts`, `daemon/ui/index.ts`,
and top-level `src/index.ts`.
Tests:
- `daemonEvents.test.ts` covers schema narrowing, malformed/empty-string
rejection via `unrecognizedKnownEventCount`, and reducer overwrite
semantics.
- `daemonUi.test.ts` covers normalizer happy path + malformed fallback,
transcript sidechannel storage (no block append), the
`clearFollowupSuggestion` store action, and the terminal renderer
line.
Wire contract is additive: old SDK consumers ignore unknown
`followup_suggestion` events via `asKnownDaemonEvent → undefined`.
🤖 Generated with [Qwen Code](https://github.com/QwenLM/qwen-code)
Recognize a new ACP child→bridge notification method `qwen/notify/session/prompt-suggestion` and translate it into a `followup_suggestion` SSE frame on the per-session bus. Mirrors the existing `qwen/notify/session/mcp-budget-event` precedent in the same handler. Differences from `mcp-budget-event`: - No early-event buffering: the new method only fires *after* a prompt completes, never inside `newSession`. A missing entry means the session has already closed, in which case we drop the suggestion silently (best-effort UX). - The wire `data` is the same shape as the inbound `params` minus `v`; no `kind` discriminator (the method name is the discriminator), so the routing logic is straight-line. Empty or malformed payloads (missing sessionId / suggestion / promptId, non-string fields, empty suggestion) are dropped at the handler boundary — the daemon filters rejected suggestions server-side via `getFilterReason()` and only emits when accepted, so empty strings on the wire are protocol garbage and not worth a debug fallback. The frame stamps `originatorClientId` from `activePromptOriginatorClientId` when one is set (same pattern as `mcp-budget-event`). Tests: - Happy path: notification arrives, SSE frame fires with full payload and monotonic id. - Malformed-payload drops (missing fields / empty suggestion / wrong types) produce no SSE frame. - Post-close notification drops silently without throwing (no early buffering means no resurrection of dead sessions). 🤖 Generated with [Qwen Code](https://github.com/QwenLM/qwen-code)
The activating change for the daemon follow-up suggestion pipeline.
Wires together the SDK schema (Commit 1) and the bridge handler
(Commit 2) so the daemon actually generates and pushes a server-side
suggestion after every clean assistant turn, and provides the webui
hook that consumes it.
## ACP child (Session.ts)
Adds a fire-and-forget IIFE at the end of `prompt()` (after
`#executePrompt` resolves with `stopReason === 'end_turn'`) that:
- Calls the existing `generatePromptSuggestion` from core with the
curated, 40-entry-tail conversation history (same shape as the
CLI's `AppContainer.tsx` integration).
- Forwards the result through the new
`qwen/notify/session/prompt-suggestion` extNotification when a
non-empty post-filter suggestion is produced.
- Logs filter-reason suppressions via the existing
`PromptSuggestionEvent` telemetry — keeps generator analytics
observable in the same stream regardless of in-process vs daemon
execution.
Guards mirror the CLI's path: only on `end_turn`, only when
`settings.merged.ui.enableFollowupSuggestions === true`, and never in
`ApprovalMode.PLAN`. The IIFE swallows its own errors — a failed
suggestion is invisible UX, and a throw here would propagate up
through `prompt()` and break the primary response path.
A new `followupAbort: AbortController | null` field is aborted at
the top of the next `prompt()` and inside `cancelPendingPrompt()`, so
a stale suggestion never lands after the user has moved on.
Tests cover: happy path (extNotification fires with the right
payload), feature disabled (no call), PLAN mode (no call),
suppressed result logs PromptSuggestionEvent, new prompt aborts
in-flight gen, cancelPendingPrompt aborts in-flight gen. The tests
use a partial `vi.mock` of `@qwen-code/qwen-code-core` to spy on
`generatePromptSuggestion` / `logPromptSuggestion` while preserving
the rest of the core surface for existing tests.
## Webui hook (useDaemonFollowupSuggestion)
A small hook that subscribes to the SDK store's
`lastFollowupSuggestion` sidechannel and drives the existing
`useFollowupSuggestions` controller. Returns `{ followupState,
onAcceptFollowup, onDismissFollowup, clear }` ready to wire into
`<InputForm followupState={...} ... />`.
Promo `lastPushedPromptIdRef` is what prevents the effect from
re-showing a suggestion after the user dismisses it locally — without
the gate, the React effect would see the still-present store value on
the next render and replay it.
Both accept and dismiss callbacks also clear the store via
`store.clearFollowupSuggestion()`, and `clear()` is exposed for
adapters to call just before `actions.sendPrompt(...)` so the prior
turn's ghost-text disappears immediately (no wire round-trip — the
daemon does not emit a "cleared" event on prompt boundaries; clients
self-invalidate).
## Sidechannel perf tweak (transcript.ts)
`cloneTranscriptState` now shares the `lastFollowupSuggestion`
reference between snapshots (the reducer assigns a new object when
updating, never mutates in-place). Reference stability across unrelated
dispatches lets `useSyncExternalStore` subscribers skip re-renders for
events that don't touch the suggestion — without this, the hook would
re-render once per assistant text delta in a streaming turn.
## Notes
- The webui package lacks an automated test runner in this repo
(no `test` script in `package.json`, not in root `vitest.config.ts`
`projects`). The hook is exercised end-to-end via the daemon
integration but has no dedicated unit-test file in this PR; that's
separate scaffolding work.
🤖 Generated with [Qwen Code](https://github.com/QwenLM/qwen-code)
…ock + warn log - Move followupAbort cleanup before the hadPrompt/hadCron guard in cancelPendingPrompt() so it runs unconditionally (fixes window where cancel during suggestion-only state would skip cleanup) - Change generateMock from mockImplementation to mockImplementationOnce chain so second prompt's suggestion call doesn't hang - Split catch log: debug for aborted, warn for real errors
093fecd to
f059338
Compare
wenshao
left a comment
There was a problem hiding this comment.
R6 Critical (originatorClientId timing race) resolved — dead conditional spread and its false-confidence test removed cleanly. No consumer reads originatorClientId from followup_suggestion events (DaemonFollowupSuggestionData type never declared it). Build clean, 470 tests pass (196 bridge + 274 SDK), tsc/eslint 0 findings. No new issues. LGTM. ✅ — qwen3.7-max via Qwen Code /review
本地真实测试报告(追加,针对最新 commit
|
| 项 | 值 |
|---|---|
| 测试 commit | 0166d432c (fix(daemon): remove dead originatorClientId spread from followup_suggestion) |
| Base branch | daemon_mode_b_main |
| 平台 | Linux x86_64 (Debian 13) |
| Node | v22.22.2 |
| Worktree | /root/git/qwen-code-x1/.qwen/tmp/pr-4507(基于 pull/4507/head) |
1. 静态检查
| 命令 | 结果 |
|---|---|
npx tsc --noEmit -p packages/sdk-typescript |
SDK=0 |
npx tsc --noEmit -p packages/acp-bridge |
BRIDGE=0 |
npx tsc --noEmit -p packages/webui |
WEBUI=0 |
npx tsc --noEmit -p packages/cli |
CLI=0 |
npx eslint <17 个 touched 文件> |
0 problems |
npm run build |
OK,packages/cli/dist/index.js 生成成功 |
2. 单测
| Suite | 结果 | 备注 |
|---|---|---|
sdk-typescript: daemonEvents.test.ts + daemonUi.test.ts |
274/274 | 与上轮一致(schema/store 部分本轮无变化) |
acp-bridge: bridge.test.ts |
196/196 | 196 = 197 - 1:0166d432c 删了 originatorClientId stamping 那条 false-confidence 测试,剩余 originatorClientId stamping 在 model_switched handler 上的测试保留 |
cli: Session.test.ts |
81/81 | PR body 第 3 项打勾 |
3. tmux 真实 daemon end-to-end
启动方式(PR body 第 1-3 步):
node /root/git/qwen-code-x1/.qwen/tmp/pr-4507/packages/cli/dist/index.js \
serve --port 18507 --workspace /tmp/pr4507-ws.qwen/settings.json 仅切换 ui.enableFollowupSuggestions 一个开关,其它走我自己已有的 user-level settings。
Positive:enableFollowupSuggestions: true
发两条 prompt,订 GET /session/<id>/events:
=== Prompt 1: 用一句话告诉我什么是CAP定理 ===
{"stopReason":"end_turn"}, 9.3s
=== Prompt 2: 再用一句话告诉我什么是Paxos协议 ===
{"stopReason":"end_turn"}, 7.3s
SSE 总计 504 行、125 个事件,分类:
| type | 计数 |
|---|---|
session_update |
124 |
followup_suggestion |
1 |
关键证据:Wire 帧(实测原文)
{
"id": 125,
"v": 1,
"type": "followup_suggestion",
"data": {
"sessionId": "62411896-0812-4967-8480-bb7c4c621cb4",
"suggestion": "what about Raft protocol",
"promptId": "62411896-0812-4967-8480-bb7c4c621cb4########2"
},
"_meta": {
"serverTimestamp": 1779862761764
}
}| 字段 | 期望 | 实测 |
|---|---|---|
id |
单调递增 SSE id | ✅ 125 |
v |
1(schema 版本) | ✅ |
type |
"followup_suggestion" |
✅ |
data.sessionId |
session id | ✅ |
data.suggestion |
上下文相关跟问 | ✅ "what about Raft protocol"(CAP+Paxos→Raft 是合理跟问,过了 server-side getFilterReason()) |
data.promptId |
<sessionId>########<turn> |
✅ ########2 |
_meta.serverTimestamp |
unix ms | ✅ 1779862761764 |
关键验证:originatorClientId 字段不再出现(0166d432c 验证点)
$ grep '"type":"followup_suggestion"' /tmp/pr4507-sse.log | sed 's/^data: //' | jq 'keys'
[
"_meta",
"data",
"id",
"type",
"v"
]
$ grep '"type":"followup_suggestion"' /tmp/pr4507-sse.log | grep -c originatorClientId
0✅ Frame 的 root 层只有 _meta, data, id, type, v 5 个 key,没有 originatorClientId——这与 0166d432c 删除的 dead 条件 spread 行为完全一致:activePromptOriginatorClientId 在 prompt 的 .finally() 里被清空,suggestion 在 prompt 完成后 fire,spread 永远拿到 undefined → 删除后 wire 上更干净,少传 1 个 undefined 字段。
Per-turn 触发模式:MIN_ASSISTANT_TURNS=2 守卫生效
只有 prompt 2 之后才有 followup_suggestion——这与 packages/core/src/followup/suggestionGenerator.ts:103 的 if (modelTurns < MIN_ASSISTANT_TURNS) return { suggestion: null, filterReason: 'early_conversation' } 一致:prompt 1 后 model turn = 1,被 early_conversation 抑制(走 PromptSuggestionEvent 上 telemetry,不上 wire);prompt 2 后 model turn = 2,开始放行。
Negative:enableFollowupSuggestions: false
通过 jq 把 .ui.enableFollowupSuggestions 改成 false 后重启 daemon(设置在 boot 时读),发同样两条 prompt:
=== SSE size ===
556 lines
=== followup count (must be 0) ===
0
=== types seen ===
138 session_update
✅ 0 个 followup_suggestion 帧——Session.ts:609 的 if (this.settings.merged.ui?.enableFollowupSuggestions !== true) return; 守卫工作正常。
4. R5 改动逐项对账
0166d432c:删除 dead originatorClientId spread
| 验证维度 | 结果 |
|---|---|
实际 wire 帧无 originatorClientId 字段 |
✅ 见上方 jq 'keys' 输出 |
model_switched handler 仍然保留 originatorClientId 标注(只删 followup_suggestion 那条) |
✅ bridgeClient.ts:539-548 的 mcp-budget-event spread 仍在 |
bridge.test.ts 总数 197 → 196(减掉 false-confidence 测试) |
✅ 实测 196/196 |
579347762:[demux] 日志格式对齐
| 验证维度 | 结果 |
|---|---|
bridgeClient.ts:510 的 malformed-drop 日志格式:session=X type=prompt_suggestion action=dropped reason=malformed |
✅ 实际源码与既有 current_model_update 路径(510/581/588/601 行)四条 [demux] 日志同款 key 顺序 |
bridge.test.ts:6397 drops malformed prompt-suggestion payloads 4 个 malformed 子用例(empty suggestion / 缺 promptId / 缺 sessionId / suggestion 类型错) |
✅ 已包含在 196/196 通过的用例里 |
5. 与 PR body Design notes / Test plan 对账
| PR 声称 | 验证 |
|---|---|
| Wire schema additive,没有版本 bump | ✅ frame v:1,没变;旧 SDK 通过 asKnownDaemonEvent → undefined 静默忽略(SDK 274 单测覆盖) |
仅 stopReason === 'end_turn' 触发 |
✅ 实测两次 prompt 都是 end_turn,触发 1 次 suggestion;负向(abort / cancel)路径由 Session.test.ts 单测覆盖 |
enableFollowupSuggestions !== true 时不发 |
✅ Negative 测试 0 帧 |
MIN_ASSISTANT_TURNS = 2 门控、getFilterReason() 在 server 侧过滤 |
✅ Positive 实测 prompt 1 不发、prompt 2 发,filterReason 路径走 telemetry(不上 wire) |
客户端在 sendPrompt 时调 clearFollowupSuggestion() 自失效,server 不再发 {suggestion: null} 占 ring slot |
✅ prompt 1 → prompt 2 之间 wire 上没有 {suggestion: null} 的失效帧 |
6. 结论
- ✅ 静态检查(4 个 package tsc + 17 个文件 eslint)全 0
- ✅ 单测 SDK 274 / acp-bridge 196 / CLI Session 81 全绿
- ✅ 真 daemon + SSE 端到端:positive 1 个 followup_suggestion 帧、shape 完全对得上、
originatorClientId字段确实不再出现;negative 0 帧 - ✅ R5 改动两个点都验到:
0166d432c的 wire schema 清洁化在实测帧上看得到;579347762的[demux]日志格式对齐在源码 + 单测都对得上 - ✅
MIN_ASSISTANT_TURNS门控、enableFollowupSuggestionsfeature gate、stopReason guard 都在实测里复现
从本地真实测试角度,该 PR 可以合并。 R5 这两个 commit 是纯收敛性 fix——一个删 dead code 让 wire 更干净,一个对齐日志格式让 grep 更省心,都不改外部契约。Wire schema 在两轮 review 中保持 stable(v:1 没动、字段集只减不增、originatorClientId 的移除让 frame shape 反而更窄)。下游 webui 的 useDaemonFollowupSuggestion 拿到的载荷与单测 fixture 完全一致,可以安心合上。
The hook was only exported from src/daemon/index.ts but not from the top-level src/index.ts — consumers importing from @qwen-code/webui could not access it. Add the hook and its return type to the public export list.
chiga0 review response (255da80)
Ready for re-review. |
…nd_turn - transcript.ts: clear lastFollowupSuggestion when a new user prompt starts (first user.text.delta), so peer clients in shared sessions don't render stale ghost text from the prior turn - Session.ts: skip suggestion generation when the last history entry is not from the model (slash commands, blocked hooks return end_turn without a model turn — no point running a suggestion LLM call against stale history)
chiga0 P2 fixes pushed (f64b82a)
Ready for re-review. |
wenshao
left a comment
There was a problem hiding this comment.
[Suggestion] packages/core/src/followup/suggestionGenerator.ts (generateViaForkedQuery) — abortSignal is received but not forwarded to runForkedAgent in the cache-sharing path. Cancelled suggestions waste a full LLM roundtrip. The non-cache path (generateViaBaseLlm via runSideQuery) correctly forwards the signal. Fix: add abortSignal to the runForkedAgent call parameters. — qwen3.7-max via Qwen Code /review
| if (ac.signal.aborted) return; | ||
| if (r.suggestion) { | ||
| await this.client.extNotification( | ||
| 'qwen/notify/session/prompt-suggestion', |
There was a problem hiding this comment.
[Suggestion] await this.client.extNotification(...) is not covered by the AbortSignal. After generation completes and the abort guard at line 635 passes, a stalled ACP connection causes this delivery call to hang indefinitely. Each turn spawns a new fire-and-forget IIFE that accumulates, holding references to Session, config, and conversationHistory — potential memory leak on flaky connections.
Consider adding an abort check before delivery or racing against the signal:
if (ac.signal.aborted) return;
if (r.suggestion) {
await Promise.race([
this.client.extNotification(...),
new Promise<void>((_, reject) =>
ac.signal.addEventListener('abort', () =>
reject(new Error('delivery aborted')),
),
),
]);
}— qwen3.7-max via Qwen Code /review
…gth cap - Session.ts: move chat.getHistory(true) + role check + slice inside the async IIFE's try-catch so structuredClone failures don't propagate through prompt() - bridgeClient.ts: cap suggestion string at 500 chars (defense-in-depth at the SSE trust boundary) - daemonUi.test.ts: restore A4 disambiguation test comments removed during rebase conflict resolution
R7 review response (bb6e234)
Ready for re-review. |
Verification Report — PR #4507Reviewer: wenshao Build Verification
Test Results
Test Bug Analysis1. The test dispatches a raw The implementation is correct (line 169-171 of 2. All failures share the same root cause: Fix: add Implementation ReviewArchitecture: Clean 3-layer approach:
Wire contract: Additive only. Old SDKs ignore unknown events via normalizer fallback. Old ACP children never emit the new Key design decisions verified:
Lint: All modified source files pass ESLint cleanly. Verdict✅ Implementation is correct and merge-ready pending the 5 test fixture bugs (all are test-side issues, not implementation regressions). The implementation logic, type safety, wire contract, and abort semantics are all sound. Recommend: fix the test event shapes in a follow-up commit, then merge. |
- Session.test.ts: seed model-role history in followup-suggestion beforeEach so the new lastEntry.role !== 'model' guard doesn't early-return before generatePromptSuggestion is called - daemonUi.test.ts: use correct session_update envelope for user_message_chunk (it's a sessionUpdate discriminator, not a top-level event type)
R8 review response (2a9e982)
Ready for re-review. |
… constant - Session.ts: log when role !== 'model' guard skips suggestion generation (observability for debugging missing suggestions) - bridgeClient.ts: extract 500 → MAX_SUGGESTION_LENGTH constant
Thread resolution + R9 nit fixes (c276fd9)Resolved 27/34 review threads. 7 remain open — all are deferred/pushed-back items (not actionable in this PR):
New fixes in this commit:
|
Maintainer Verification ReportReviewer: wenshao Build
Test Results
Code Review SummaryArchitecture: Clean 3-layer design (SDK schema → bridge handler → session generator + webui hook). Each layer is independently testable and the wire contract is additive (old SDKs ignore unknown events via SDK layer (
Bridge layer (
Session layer (
Webui hook (
Export Chain VerificationAll public surface re-exports verified present. VerdictLGTM for merge, pending CI validation of The changes are well-structured, additive-only, follow existing patterns, and introduce no regressions in the 977 tests that run locally. |
wenshao
left a comment
There was a problem hiding this comment.
R10: both R9b Criticals fixed (event shape at daemonUi.test.ts:5348, getHistory mock seeding at Session.test.ts:3026). Magic number extracted to MAX_SUGGESTION_LENGTH. Debug log added for role guard early-return. 552 tests pass (196 bridge + 275 SDK + 81 CLI). eslint clean, no new tsc findings. No new high-confidence issues from 9 parallel agents + reverse audit. — qwen3.7-max via Qwen Code /review
Summary
followup_suggestionthat lets the ACP child push server-generated ghost-text suggestions ("what you might want to ask next") to attached clients after every clean assistant turn. Mirrors the in-process CLI'sAppContainer.tsxintegration but goes through the daemon event bus so the webui (and future TUI/IDE daemon adapters) can render the feature without any direct LLM access.<InputForm followupState={...}>prop was already wired but unfed — this PR fills it via a newuseDaemonFollowupSuggestionhook that subscribes to the SDK store's sidechannel.asKnownDaemonEvent → undefined, old ACP children just never send the newextNotificationmethod. No protocol version bump, no feature flag — reuses the existingenableFollowupSuggestionssetting.Commit breakdown (each independently shippable)
feat(sdk): add followup_suggestion daemon event type— schema-only addition acrossevents.ts,ui/normalizer.ts,ui/types.ts,ui/transcript.ts,ui/store.ts,ui/terminal.ts. NewDaemonAssistEventunion (reserved for future assist hints like server-side speculation),DaemonFollowupSuggestionData+ envelope + predicate,lastFollowupSuggestionfield onDaemonSessionViewStateandDaemonTranscriptSidechannelState, newclearFollowupSuggestion()store action mirroringclearAwaitingResync.feat(acp-bridge): publish followup_suggestion from extNotification—BridgeClient.extNotificationrecognizes the newqwen/notify/session/prompt-suggestionmethod and translates it into the SSE frame. Mirrors the existingmcp-budget-eventprecedent in the same handler. No early-event buffering since the new method only fires after a prompt completes.feat(daemon+webui): generate and surface followup suggestions per turn— the activating change.Session.prompt()fire-and-forget IIFE calls existinggeneratePromptSuggestionafterstopReason === 'end_turn'and forwards throughextNotification. Webui'suseDaemonFollowupSuggestionhook subscribes to the store's sidechannel and drives the existinguseFollowupSuggestionscontroller. Per-turnfollowupAbortcancels mid-flight generations on the next prompt / cancel.Design notes
session/update:session/updateis the ACP-standardSessionNotificationshape; adding a non-standard kind would pollute the spec namespace and surface asdebugblocks in the normalizer default case.extNotificationis the project's existing namespaced channel for this exact use case.DaemonAssistEventunion (rather than folding intoDaemonControlEvent): control = mutations; this is an assist/UX hint. Empty-ish today but ready to grow when server-side speculation lands.actions.sendPrompt(...)viastore.clearFollowupSuggestion()(exposed through the hook'sclear()). Server-emitted{suggestion: null}events on every prompt boundary would double SSE traffic and burn ring slots — rejected.generatePromptSuggestionrunsgetFilterReason()server-side; the wire only carries post-filter suggestions. Suppression telemetry stays on the daemon via existingPromptSuggestionEvent.cloneTranscriptStateshares thelastFollowupSuggestionreference between snapshots (the reducer only assigns, never mutates), souseSyncExternalStoresubscribers skip re-renders on unrelated events (assistant text deltas, tool updates, etc.).Test plan
clearFollowupSuggestionstore action, terminal renderer line. Run:cd packages/sdk-typescript && npx vitest run test/unit/daemonEvents.test.ts test/unit/daemonUi.test.ts— 269/269 passing.cd packages/acp-bridge && npx vitest run src/bridge.test.ts— 186/186 passing.undici/@google/genaitransitive deps andSession.test.tsfails to load entirely (pre-existing). Needs CI run.testscript, not in rootvitest.config.tsprojects). E2E verification via daemon integration.End-to-end manual verification
npm run buildfrom repo root.qwen serve --port 8765to start the daemon.enableFollowupSuggestions: truein the workspace settings.MIN_ASSISTANT_TURNS = 2gate insidegeneratePromptSuggestion).clear().curl -N http://localhost:8765/sessions/<id>/eventsshould showdata: {"id":N,"v":1,"type":"followup_suggestion","data":{...}}after each end_turn.🤖 Generated with Qwen Code