Skip to content

feat(daemon): server-pushed followup_suggestion event for the webui#4507

Merged
doudouOUC merged 12 commits into
daemon_mode_b_mainfrom
feat/daemon-followup-suggestion
May 27, 2026
Merged

feat(daemon): server-pushed followup_suggestion event for the webui#4507
doudouOUC merged 12 commits into
daemon_mode_b_mainfrom
feat/daemon-followup-suggestion

Conversation

@doudouOUC

Copy link
Copy Markdown
Collaborator

Summary

  • Adds a new daemon SSE event type followup_suggestion that 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's AppContainer.tsx integration 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.
  • The webui's <InputForm followupState={...}> prop was already wired but unfed — this PR fills it via a new useDaemonFollowupSuggestion hook that subscribes to the SDK store's sidechannel.
  • Wire contract is additive: old SDKs ignore the unknown event via asKnownDaemonEvent → undefined, old ACP children just never send the new extNotification method. No protocol version bump, no feature flag — reuses the existing enableFollowupSuggestions setting.

Commit breakdown (each independently shippable)

  1. feat(sdk): add followup_suggestion daemon event type — schema-only addition across events.ts, ui/normalizer.ts, ui/types.ts, ui/transcript.ts, ui/store.ts, ui/terminal.ts. New DaemonAssistEvent union (reserved for future assist hints like server-side speculation), DaemonFollowupSuggestionData + envelope + predicate, lastFollowupSuggestion field on DaemonSessionViewState and DaemonTranscriptSidechannelState, new clearFollowupSuggestion() store action mirroring clearAwaitingResync.
  2. feat(acp-bridge): publish followup_suggestion from extNotificationBridgeClient.extNotification recognizes the new qwen/notify/session/prompt-suggestion method and translates it into the SSE frame. Mirrors the existing mcp-budget-event precedent in the same handler. No early-event buffering since the new method only fires after a prompt completes.
  3. feat(daemon+webui): generate and surface followup suggestions per turn — the activating change. Session.prompt() fire-and-forget IIFE calls existing generatePromptSuggestion after stopReason === 'end_turn' and forwards through extNotification. Webui's useDaemonFollowupSuggestion hook subscribes to the store's sidechannel and drives the existing useFollowupSuggestions controller. Per-turn followupAbort cancels mid-flight generations on the next prompt / cancel.

Design notes

  • Why a new extNotification method, not piggyback on session/update: session/update is the ACP-standard SessionNotification shape; adding a non-standard kind would pollute the spec namespace and surface as debug blocks in the normalizer default case. extNotification is the project's existing namespaced channel for this exact use case.
  • Why a new DaemonAssistEvent union (rather than folding into DaemonControlEvent): control = mutations; this is an assist/UX hint. Empty-ish today but ready to grow when server-side speculation lands.
  • Invalidation semantics: clients self-invalidate on actions.sendPrompt(...) via store.clearFollowupSuggestion() (exposed through the hook's clear()). Server-emitted {suggestion: null} events on every prompt boundary would double SSE traffic and burn ring slots — rejected.
  • Filtering: generatePromptSuggestion runs getFilterReason() server-side; the wire only carries post-filter suggestions. Suppression telemetry stays on the daemon via existing PromptSuggestionEvent.
  • Webui re-render perf: cloneTranscriptState shares the lastFollowupSuggestion reference between snapshots (the reducer only assigns, never mutates), so useSyncExternalStore subscribers skip re-renders on unrelated events (assistant text deltas, tool updates, etc.).

Test plan

  • SDK: schema narrowing (happy + malformed + empty-string + wrong-type), reducer state propagation, normalizer mapping, transcript sidechannel storage, clearFollowupSuggestion store action, terminal renderer line. Run: cd packages/sdk-typescript && npx vitest run test/unit/daemonEvents.test.ts test/unit/daemonUi.test.ts269/269 passing.
  • Bridge: happy path emit (extNotification → SSE frame with full payload + monotonic id), malformed payload drops (missing fields / empty suggestion / wrong types), post-close notification drops without throwing (no early-event buffering). Run: cd packages/acp-bridge && npx vitest run src/bridge.test.ts186/186 passing.
  • CLI Session: 6 cases covering emit-after-end_turn, disabled-setting, PLAN mode, suppressed filterReason logging, abort-on-next-prompt, abort-on-cancelPendingPrompt. Tests are written but cannot validate locally — the worktree's dev env is missing undici / @google/genai transitive deps and Session.test.ts fails to load entirely (pre-existing). Needs CI run.
  • Webui: hook compiles + typechecks; webui package has no automated test runner in this repo (no test script, not in root vitest.config.ts projects). E2E verification via daemon integration.

End-to-end manual verification

  1. npm run build from repo root.
  2. qwen serve --port 8765 to start the daemon.
  3. Open webui pointing at the daemon; set enableFollowupSuggestions: true in the workspace settings.
  4. Send a prompt that triggers ≥ 2 assistant turns (the MIN_ASSISTANT_TURNS = 2 gate inside generatePromptSuggestion).
  5. After the assistant finishes, a ghost-text suggestion should appear in the input placeholder within ~1 second (300ms controller debounce + LLM side-call latency).
  6. Tab / → to accept; Esc / typing to dismiss; sending a new prompt invalidates synchronously via clear().
  7. curl -N http://localhost:8765/sessions/<id>/events should show data: {"id":N,"v":1,"type":"followup_suggestion","data":{...}} after each end_turn.

🤖 Generated with Qwen Code

@github-actions

Copy link
Copy Markdown
Contributor

📋 Review Summary

This 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

  • Excellent commit breakdown: Each of the three commits is independently shippable and follows a clear schema → bridge → activation progression.
  • Strong pattern reuse: The implementation mirrors established precedents (MCP budget events, auth device-flow events) for event schema evolution, validation predicates, and reducer handling.
  • Comprehensive test coverage: Tests cover happy paths, malformed payloads, feature disabled states, approval mode exclusions, and abort semantics.
  • Forward-compat design: Unknown event kinds, malformed payloads, and future error kinds are handled gracefully via the existing unrecognizedKnownEventCount diagnostic path.
  • Well-documented design decisions: The PR body explains why new event unions were created, why client-side invalidation was chosen, and filtering semantics.

🎯 Specific Feedback

🟡 High

  • File: packages/cli/src/acp-integration/session/Session.ts:#maybeEmitFollowupSuggestion - The fire-and-forget IIFE swallows all errors silently. While the comment explains this is intentional ("A failed suggestion is invisible to the user"), consider adding a rate-limit or circuit-breaker if suggestion generation fails repeatedly. A counter like consecutiveSuggestionFailures that disables the feature after N failures would prevent wasting resources on a broken suggestion pipeline.

  • File: packages/acp-bridge/src/bridgeClient.ts:extNotification - The followup_suggestion handler returns early without publishing if !entry. This is correct for the "session already closed" case, but there's no diagnostic signal. Consider incrementing a droppedSuggestionCount on the bridge entry (similar to unrecognizedKnownEventCount) so operators can detect if suggestions are being dropped due to race conditions.

🟢 Medium

  • File: packages/sdk-typescript/src/daemon/events.ts:isFollowupSuggestionData - The predicate comment says "Empty suggestion is protocol garbage" but the validation only checks isNonEmptyString. Consider adding a maxLength guard (e.g., 200 characters) to prevent pathological suggestions from breaking UI layouts. The daemon should filter these, but defense-in-depth at the wire boundary is valuable.

  • File: packages/webui/src/daemon/useDaemonFollowupSuggestion.ts:useEffect - The effect dependency array includes lastFollowupSuggestion which is a reference from useSyncExternalStore. The comment explains reference stability via cloneTranscriptState, but this relies on the reducer never mutating in-place. Consider adding a useMemo wrapper around the suggestion object comparison to make the intent more explicit:

    const suggestionKey = useMemo(
      () => `${lastFollowupSuggestion?.promptId}:${lastFollowupSuggestion?.suggestion}`,
      [lastFollowupSuggestion?.promptId, lastFollowupSuggestion?.suggestion],
    );
  • File: packages/cli/src/acp-integration/session/Session.ts:#maybeEmitFollowupSuggestion - The history slice (fullHistory.slice(-40)) is hardcoded. Consider extracting this to a constant MAX_SUGGESTION_HISTORY_LENGTH at the module level for consistency with other history-slicing code and easier tuning.

🔵 Low

  • File: packages/sdk-typescript/src/daemon/events.ts:DAEMON_KNOWN_EVENT_TYPE_VALUES - The comment for followup_suggestion says "Old SDK consumers silently drop this event via asKnownDaemonEvent returning undefined (no protocol bump required)." Consider adding a // Added: <date> <PR#> comment style here (like some other entries have) for future maintainers tracing when this event was introduced.

  • File: packages/sdk-typescript/src/daemon/ui/terminal.ts:daemonUiEventToTerminalText - The terminal renderer uses ANSI color '2' (green) for suggestions. This is a minor stylistic choice, but consider whether a different color (e.g., yellow '33' like cancelled prompts) might better convey the "ghost-text hint" semantics vs. a first-class terminal output.

  • File: packages/webui/src/daemon/useDaemonFollowupSuggestion.ts:UseDaemonFollowupSuggestionReturn - The interface JSDoc says "Wire to <InputForm onAcceptFollowup={...} />" but doesn't document what the skipOnAccept option does. A one-liner explaining when a consumer would use { skipOnAccept: true } would help future maintainers.

✅ Highlights

  • Excellent schema validation: The isFollowupSuggestionData predicate correctly guards against NaN/Infinity via isNonEmptyString and isRecord helpers, showing attention to edge cases.
  • Clean abort semantics: The followupAbort controller is properly reset in both prompt() and cancelPendingPrompt(), ensuring no stale suggestions land after the user moves on.
  • Reference stability optimization: The cloneTranscriptState sharing of lastFollowupSuggestion reference is a nice touch for useSyncExternalStore subscribers to skip re-renders on unrelated events.
  • Comprehensive test scenarios: The test suite covers malformed payloads, early-event buffering absence, feature disabled states, PLAN approval mode exclusion, filter-reason logging, and abort signal propagation.
  • Strong documentation: JSDoc comments throughout explain not just what the code does but why design decisions were made (e.g., why client-side invalidation, why no early buffering).

@doudouOUC

Copy link
Copy Markdown
Collaborator Author

Thanks — declining all eight, brief reasoning per:

High

  • Session.ts circuit breaker for suggestion failures: defer. generatePromptSuggestion is single-shot (maxAttempts: 1), no retry storm to break. A counter for a fire-and-forget that already swallows is YAGNI without a repro of repeated failures.
  • bridgeClient.ts droppedSuggestionCount: defer. The sibling mcp-budget-event early-drop path has no such counter either; adding it just for this event is inconsistent. If we want bridge-side drop telemetry, do it uniformly across all extNotification branches in a separate change.

Medium

  • events.ts maxLength guard: defer. Length filtering lives in generatePromptSuggestion's getFilterReason() (the daemon's source of truth). Stamping an arbitrary SDK-side cap (200?) would diverge from server-side semantics.
  • useDaemonFollowupSuggestion.ts useMemo on promptId:suggestion: defer. lastPushedPromptIdRef already gates re-runs on promptId, which is the unique-per-turn key — the suggestion text alone doesn't disambiguate.
  • Extract slice(-40) to a constant: defer for symmetry. The CLI's AppContainer.tsx also hardcodes 40 inline; extracting only here introduces asymmetry. Should be one shared constant in core if/when extracted.

Low

  • Add // Added: <date> <PR#> comment: defer. Existing entries are inconsistent on this (some carry "PR 14b" / "proposal(serve): Mode B feature-priority roadmap toward v0.16 production-ready #4175 F4 prereq", many don't). Maintainers can backfill post-merge if desired.
  • Terminal color choice ('2' dim vs '33' yellow): keeping dim. Yellow is used for warnings/cancellations in this file; assist hints aren't warnings.
  • Document skipOnAccept in the wrapper hook: defer. Semantics live on the underlying useFollowupSuggestions controller; duplicating docs at the wrapper invites drift.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_suggestion as a known daemon event, normalizes it into a followup.suggestion UI event, and stores it in transcript sidechannel state with a new clearFollowupSuggestion() store action.
  • ACP bridge + CLI: emits followup_suggestion SSE frames via extNotification('qwen/notify/session/prompt-suggestion', …) after clean end_turn completion; adds cancellation to avoid stale suggestions.
  • Webui: adds useDaemonFollowupSuggestion() to wire the daemon sidechannel into the existing useFollowupSuggestions controller 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.

Comment thread packages/webui/src/daemon/useDaemonFollowupSuggestion.ts
Comment thread packages/acp-bridge/src/bridgeClient.ts
@doudouOUC doudouOUC requested review from chiga0 and yiliang114 May 25, 2026 12:57
Comment thread packages/cli/src/acp-integration/session/Session.test.ts
Comment thread packages/cli/src/acp-integration/session/Session.ts
Comment thread packages/sdk-typescript/src/daemon/ui/transcript.ts
@longbinlai

Copy link
Copy Markdown

⚠️ Cross-PR Conflict Detected — Large Conflict Group (5 PRs)

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:

  • publish / pushpackages/acp-bridge/src/eventBus.ts
  • pushpackages/channels/base/src/BlockStreamer.ts
  • promptpackages/cli/src/acp-integration/session/Session.ts
  • cancelPendingPromptpackages/cli/src/acp-integration/session/Session.ts
  • asKnownDaemonEvent, reduceDaemonSessionEventpackages/sdk-typescript/src/daemon/events.ts
  • shutdown — multiple files (DualOutputBridge.ts, ControlDispatcher.ts, etc.)
  • subscribeEventspackages/sdk-typescript/src/daemon/DaemonClient.ts, DaemonSessionClient.ts

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.

@longbinlai

Copy link
Copy Markdown

⚠️ Cross-PR Conflict Detected — Large Conflict Group (5 PRs)

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:

  • publish / pushpackages/acp-bridge/src/eventBus.ts
  • pushpackages/channels/base/src/BlockStreamer.ts
  • promptpackages/cli/src/acp-integration/session/Session.ts
  • cancelPendingPromptpackages/cli/src/acp-integration/session/Session.ts
  • asKnownDaemonEvent, reduceDaemonSessionEventpackages/sdk-typescript/src/daemon/events.ts
  • shutdown — multiple files (DualOutputBridge.ts, ControlDispatcher.ts, etc.)
  • subscribeEventspackages/sdk-typescript/src/daemon/DaemonClient.ts, DaemonSessionClient.ts

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 wenshao left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 produce end_turn; a regression removing the guard would go undetected.
  • Session.ts:658 — No test for the error-swallowing catch block. Critical invariant ("failed suggestion is invisible to the user") is unverified.
  • bridgeClient.ts:528 — originatorClientId conditional 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-side clear() 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 — lastFollowupSuggestion not 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

Comment thread packages/cli/src/acp-integration/session/Session.ts Outdated
@doudouOUC doudouOUC self-assigned this May 26, 2026
@doudouOUC doudouOUC force-pushed the feat/daemon-followup-suggestion branch from 72422b6 to 479158b Compare May 26, 2026 17:23
@doudouOUC doudouOUC requested a review from wenshao May 26, 2026 17:38
@doudouOUC

doudouOUC commented May 26, 2026

Copy link
Copy Markdown
Collaborator Author

Review fixes pushed (f059338)

# Comment Action
1 [Critical] Session.test.ts:3131 — mockImplementation hangs on second prompt's suggestion call Fixed: mockImplementationOnce(...).mockResolvedValue(...) chain
2 [Suggestion] cancelPendingPrompt() followupAbort after guard Fixed: moved cleanup before the !hadPrompt && !hadCron guard — runs unconditionally
3 [Suggestion] catch swallows errors silently with .debug Fixed: split to .debug for abort, .warn for real errors

Ready for re-review.

@wenshao wenshao left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_suggestion events get ring IDs; lastPushedPromptIdRef resets 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
  • writerIdleTimeoutMs missing assertTimerDelayInRange validation (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);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.

Suggested change
const fullHistory = chat.getHistory(true);
const conversationHistory = chat.getHistoryTail(40, true);

— qwen3.7-max via Qwen Code /review

doudouOUC added 4 commits May 27, 2026 10:47
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
wenshao
wenshao previously approved these changes May 27, 2026

@wenshao wenshao left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Comment thread packages/webui/src/daemon/index.ts
Comment thread packages/sdk-typescript/src/daemon/ui/transcript.ts
Comment thread packages/cli/src/acp-integration/session/Session.ts
@wenshao

wenshao commented May 27, 2026

Copy link
Copy Markdown
Collaborator

本地真实测试报告(追加,针对最新 commit 0166d432c — merge 参考)

接续我前一份 本地真实测试报告(commit a4b95be83)。这一轮覆盖在那之后追加的两个 R5 review fix commit:

  • 579347762[demux] 日志 key 顺序对齐到 session=/type=/action=/reason=(与 current_model_update 一致),并把 bridge.test.ts 里 "silently" 字样从用例名删掉(drops 现在已经会打日志)
  • 0166d432c — 删除 followup_suggestion 上 dead 的 originatorClientId 条件 spread 和对应的 false-confidence 测试(理由:activePromptOriginatorClientId 在 prompt 完成时的 .finally() 里被清空,suggestion 是在 prompt 完成后才 fire 的——这个字段在生产里永远 undefined)

测试环境

测试 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 - 10166d432c 删了 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:103if (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:609if (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 门控、enableFollowupSuggestions feature 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.
@doudouOUC

Copy link
Copy Markdown
Collaborator Author

chiga0 review response (255da80)

# Tag File:line Verdict Action
1 P1 webui/src/daemon/index.ts:23 — hook not exported from top-level Fixed Added useDaemonFollowupSuggestion + UseDaemonFollowupSuggestionReturn to src/index.ts re-exports
2 P2 sdk-typescript/.../transcript.ts:268 — peer clients see stale ghost text Deferring Multi-client invalidation is a Phase 2 concern; reducer-level clear on prompt-start is the right approach but expands scope
3 P2 cli/.../Session.ts:578 — suggestions after slash commands Deferring Valid edge case; needs #executePrompt to return whether a model turn actually occurred — follow-up PR

Ready for re-review.

wenshao
wenshao previously approved these changes May 27, 2026
…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)
@doudouOUC

Copy link
Copy Markdown
Collaborator Author

chiga0 P2 fixes pushed (f64b82a)

# Tag File:line Action
1 P2 transcript.ts:268 — peer clients see stale ghost text Fixed: clear lastFollowupSuggestion on first user.text.delta of a new prompt (no active user block = new prompt boundary)
2 P2 Session.ts:578 — suggestions after slash commands Fixed: skip #maybeEmitFollowupSuggestion when last history entry is not role: 'model' (slash commands / blocked hooks return end_turn without a model turn)

Ready for re-review.

@wenshao wenshao left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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

Comment thread packages/cli/src/acp-integration/session/Session.ts Outdated
if (ac.signal.aborted) return;
if (r.suggestion) {
await this.client.extNotification(
'qwen/notify/session/prompt-suggestion',

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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

Comment thread packages/acp-bridge/src/bridgeClient.ts
Comment thread packages/sdk-typescript/test/unit/daemonUi.test.ts
…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
@doudouOUC

Copy link
Copy Markdown
Collaborator Author

R7 review response (bb6e234)

# Tag File:line Verdict Action
0 Review [Suggestion] suggestionGenerator.ts — abort not forwarded to runForkedAgent Not taking Pre-existing code, not in this PR's diff
1 [Critical] Session.ts:619 — getHistory throws before try-catch Fixed Moved chat.getHistory(true) + role check + slice inside the IIFE's try-catch
2 [Suggestion] Session.ts:637 — extNotification not abort-guarded Deferring Best-effort path; ACP connection hang is a broader issue
3 [Suggestion] bridgeClient.ts:505 — no suggestion length cap Fixed Added 500-char cap at the SSE trust boundary
4 [Suggestion] daemonUi.test.ts:5184 — A4 comments removed Fixed Restored — accidental removal during rebase conflict resolution

Ready for re-review.

@doudouOUC doudouOUC requested review from chiga0 and wenshao May 27, 2026 09:43
Comment thread packages/cli/src/acp-integration/session/Session.ts
Comment thread packages/sdk-typescript/src/daemon/ui/transcript.ts
Comment thread packages/cli/src/acp-integration/session/Session.ts
Comment thread packages/cli/src/acp-integration/session/Session.ts
Comment thread packages/acp-bridge/src/bridgeClient.ts Outdated
@wenshao

wenshao commented May 27, 2026

Copy link
Copy Markdown
Collaborator

Verification Report — PR #4507

Reviewer: wenshao
Branch: feat/daemon-followup-suggestiondaemon_mode_b_main
Test environment: macOS Darwin 25.4.0, Node v22.17.0


Build Verification

Package TypeScript Build Result
sdk-typescript tsc --build ✅ Clean
acp-bridge tsc --build ✅ Clean
webui tsc --noEmit ✅ Clean

Note: Full monorepo build has pre-existing TS errors in cli/src/acp-integration/acpAgent.ts (unrelated imports from ../serve/status.js). Not introduced by this PR — confirmed via git diff origin/daemon_mode_b_main...HEAD --name-only showing acpAgent.ts is not in the diff.


Test Results

Test Suite Result Detail
daemonEvents.test.ts 80/80 passed All followup_suggestion normalization + predicate tests pass
daemonUi.test.ts ⚠️ 194/195 passed 1 failure (test bug, see below)
bridge.test.ts 196/196 passed All extNotification routing + malformed-drop tests pass
Session.test.ts ⚠️ 77/81 passed 4 failures (test setup bug, see below)

Test Bug Analysis

1. daemonUi.test.ts — "clears lastFollowupSuggestion when a new user prompt starts"

The test dispatches a raw { type: 'user_message_chunk', data: { text: 'next question' } } event at line 5351, but the normalizer expects { type: 'session_update', data: { update: { sessionUpdate: 'user_message_chunk', content: { type: 'text', text: 'next question' } } } }. The incorrectly formatted event gets dropped by the normalizer, so the reducer never receives a user.text.delta and never clears lastFollowupSuggestion.

The implementation is correct (line 169-171 of transcript.ts clears on the first user delta). Only the test's event shape is wrong.

2. Session.test.ts — All 4 "follow-up suggestion" tests

All failures share the same root cause: mockChat.getHistory returns [] (empty array per the default beforeEach setup). In #maybeEmitFollowupSuggestion, line 624 checks if (!lastEntry || lastEntry.role !== 'model') return; — with an empty history, lastEntry is undefined and the method bails before calling generatePromptSuggestion.

Fix: add mockChat.getHistory.mockReturnValue([{ role: 'model', parts: [{ text: 'response' }] }]) in the followup test suite's beforeEach.


Implementation Review

Architecture: Clean 3-layer approach:

  1. Session.ts — fire-and-forget IIFE after end_turn, proper abort semantics
  2. bridgeClient.tsextNotification demux with validation (string type, non-empty, ≤500 chars, promptId required) + malformed logging
  3. SDK — new DaemonAssistEvent union, sidechannel reducer (no transcript block), clearFollowupSuggestion() store action

Wire contract: Additive only. Old SDKs ignore unknown events via normalizer fallback. Old ACP children never emit the new extNotification method. No protocol version bump required.

Key design decisions verified:

  • followup_suggestion is correctly a sidechannel (stored in lastFollowupSuggestion, not as a transcript block)
  • Self-invalidation semantics correct: reducer clears on first user.text.delta, store exposes clearFollowupSuggestion() for adapters
  • Abort chain: followupAbort cancelled on both prompt() entry and cancelPendingPrompt()
  • Suggestion length cap (500 chars) in bridge prevents oversized payloads

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)
@doudouOUC

Copy link
Copy Markdown
Collaborator Author

R8 review response (2a9e982)

# Tag File:line Action
1 [Critical] Session.ts:625 — getHistory mock returns [], role guard kills tests Fixed: seeded [{role:'user',...},{role:'model',...}] in followup-suggestion beforeEach
2 [Critical] transcript.ts:170 — test uses wrong wire format for user_message_chunk Fixed: changed to session_update envelope with sessionUpdate: 'user_message_chunk' discriminator

Ready for re-review.

Comment thread packages/sdk-typescript/test/unit/daemonUi.test.ts Outdated
Comment thread packages/cli/src/acp-integration/session/Session.test.ts
Comment thread packages/sdk-typescript/src/daemon/events.ts
Comment thread packages/cli/src/acp-integration/session/Session.ts
Comment thread packages/acp-bridge/src/bridgeClient.ts
… constant

- Session.ts: log when role !== 'model' guard skips suggestion
  generation (observability for debugging missing suggestions)
- bridgeClient.ts: extract 500 → MAX_SUGGESTION_LENGTH constant
@doudouOUC

Copy link
Copy Markdown
Collaborator Author

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):

Thread File Reason left open
getHistoryTail Session.ts Blocked by P2 role-check needing fullHistory
bufferEarlyEvent x2 bridgeClient.ts Design decision — prompt-suggestion never fires during newSession
split try/catch Session.ts Deferred — over-engineering for best-effort path
negative tests Session.test.ts Tests exist at lines 3068-3114
options arg dropped useDaemonFollowupSuggestion.ts Existing controller limitation
SSE reconnect replay useDaemonFollowupSuggestion.ts Partially addressed by P2 clear-on-new-prompt
extNotification abort Session.ts Deferred — best-effort path

New fixes in this commit:

  • Session.ts:625: added debugLogger.debug for role guard skip (observability)
  • bridgeClient.ts: extracted MAX_SUGGESTION_LENGTH constant (no magic numbers)

@doudouOUC doudouOUC requested a review from wenshao May 27, 2026 11:59
@wenshao

wenshao commented May 27, 2026

Copy link
Copy Markdown
Collaborator

Maintainer Verification Report

Reviewer: wenshao
Branch: feat/daemon-followup-suggestion @ c276fd946
Base: daemon_mode_b_main
Date: 2026-05-27


Build

Package Status
sdk-typescript PASS — compiled, tsc --noEmit clean
acp-bridge PASS — compiled, tsc --noEmit clean
webui PASStsc --noEmit clean
cli FAIL — pre-existing TS errors in workspaceAgents.ts / workspaceMemory.ts (unrelated to this PR, no files from this PR touched)

Test Results

Suite Tests Status
SDK — daemonEvents.test.ts + daemonUi.test.ts 275/275 PASS
SDK — full suite (14 test files) 667/667 PASS (no regressions)
Bridge — bridge.test.ts 196/196 PASS
Bridge — full suite (7 test files) 310/310 PASS (no regressions)
CLI — Session.test.ts SKIP — pre-existing vite transform / dep resolution failure on base branch (test-setup.ts missing, @qwen-code/acp-bridge/bridgeErrors URL resolution), not introduced by this PR. Needs CI environment.

Code Review Summary

Architecture: 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 asKnownDaemonEvent → undefined).

SDK layer (events.ts, ui/*):

  • New DaemonAssistEvent union correctly separated from DaemonControlEvent (assist/UX hints vs mutations)
  • isFollowupSuggestionData validates all three required fields as non-empty strings
  • clearFollowupSuggestion() store action follows clearAwaitingResync pattern
  • cloneTranscriptState shares reference for re-render perf — correct since reducer assigns, never mutates
  • Exhaustive switch in terminal renderer updated

Bridge layer (bridgeClient.ts):

  • extNotification handler follows existing mcp-budget-event pattern
  • MAX_SUGGESTION_LENGTH = 500 cap — defense-in-depth at SSE trust boundary
  • Malformed payloads logged via writeStderrLine and dropped (consistent with other handlers)
  • No originatorClientId spread (correctly removed — field is cleared in .finally() before suggestion fires)

Session layer (Session.ts):

  • Fire-and-forget IIFE with proper error swallowing (won't break primary response path)
  • Guards: stopReason === 'end_turn', enableFollowupSuggestions === true, not PLAN mode, last history entry role === 'model'
  • followupAbort correctly aborted in both prompt() (line 528) and cancelPendingPrompt() (line 482, before the hadPrompt/hadCron guard — fix from R1 review)
  • getHistory + structuredClone moved inside IIFE try-catch (fix from P2)
  • Debug/warn log split for abort vs real errors

Webui hook (useDaemonFollowupSuggestion.ts):

  • Clean useSyncExternalStore subscription to store sidechannel
  • lastPushedPromptIdRef prevents re-push after dismiss — correct dedup mechanism
  • All three callbacks (accept/dismiss/clear) properly clear both controller state and store
  • Well-typed return interface, correctly re-exported through daemon and package entry points

Export Chain Verification

events.ts → daemon/index.ts → sdk-typescript/src/index.ts  (types)
useDaemonFollowupSuggestion.ts → webui/src/daemon/index.ts → webui/src/index.ts  (hook + return type)

All public surface re-exports verified present.

Verdict

LGTM for merge, pending CI validation of Session.test.ts (6 new test cases for the generator — cannot validate locally due to pre-existing dev env issue on the base branch).

The changes are well-structured, additive-only, follow existing patterns, and introduce no regressions in the 977 tests that run locally.

@wenshao wenshao left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

@doudouOUC doudouOUC merged commit 8a2cdb3 into daemon_mode_b_main May 27, 2026
2 checks passed
@doudouOUC doudouOUC deleted the feat/daemon-followup-suggestion branch May 27, 2026 13:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants