Skip to content

feat(daemon): in-session model switch reaches the bus (A1)#4546

Merged
chiga0 merged 3 commits into
daemon_mode_b_mainfrom
feat/daemon-in-session-model-update
May 26, 2026
Merged

feat(daemon): in-session model switch reaches the bus (A1)#4546
chiga0 merged 3 commits into
daemon_mode_b_mainfrom
feat/daemon-in-session-model-update

Conversation

@chiga0

@chiga0 chiga0 commented May 26, 2026

Copy link
Copy Markdown
Collaborator

What this does

Implements A1 from the side-channel coordination design (#4511). A /model slash command or a plan-mode model switch now reaches attached clients — previously only the HTTP POST /session/:id/model path published model_switched, so an in-session switch left peers' model badge silently stale.

Transport (design v7)

current_model_update is not an ACP SessionUpdate variant — SessionUpdate is the external @agentclientprotocol/sdk union, which has current_mode_update but no model equivalent. So the agent emits the change over the existing agent→bridge extNotification side-channel (the same mechanism used for MCP-budget events today), and the bridge demuxes it to the existing model_switched bus event. No ACP-spec change, no SDK change (consumers keep seeing model_switched).

Changes

  • Agent (Session.setModel): emits qwen/notify/session/model-update after switchModel resolves (success-only; captures the previous model id). Fire-and-forget — a failed notification never fails the switch.
  • Bridge (BridgeClient.extNotification): demuxes it → model_switched (currentModelId → data.modelId), suppressed while the bridge is driving its own model roundtrip (entry.modelRoundtripInFlight, set around setSessionModel / applyModelServiceId). The HTTP path also flows through Session.setModel, so without suppression it would double-publish. Structured demux log records promoted / suppressed / dropped.

Scope

This PR is the core A1 path + suppress + observability + tests. The design's §2.2 post-roundtrip reconciliation and the timeout-race staleness check (for the rarer concurrent-in-session and timed-out-then-late races) are a tracked follow-up — they harden edge races on top of this path.

Tests

  • Agent emits the notification on a successful switch; does not on failure.
  • Bridge promotes it to model_switched when idle; suppresses it during a bridge-driven roundtrip (no double publish).
  • acp-bridge: 302 pass.

Design

Second implementation PR of #4511 (after A4 / #4539). A2 remains blocked on #4510 (approvalModeQueue); A5 is the session_snapshot frame.

Implements A1 from the side-channel coordination design (#4511): a /model
slash command or plan-mode model switch now reaches attached clients, where
previously only the HTTP POST /session/:id/model path published model_switched.

Transport (per design v7): current_model_update is NOT an ACP SessionUpdate
variant (the type is the external @agentclientprotocol/sdk union — it has
current_mode_update but no model equivalent), so the agent emits the change
over the agent->bridge extNotification side-channel.

- Agent: Session.setModel emits a `qwen/notify/session/model-update`
  extNotification after switchModel resolves (success-only; captures the
  previous model id). Fire-and-forget — a failed notification never fails the
  switch.
- Bridge: BridgeClient.extNotification demuxes it to a model_switched bus
  event (currentModelId -> data.modelId), SUPPRESSED while the bridge is
  driving its own model roundtrip (entry.modelRoundtripInFlight, set around
  setSessionModel / applyModelServiceId) so the HTTP path — which also flows
  through Session.setModel — does not double-publish. Structured demux log
  records promoted / suppressed / dropped decisions.

Scope: this is the core A1 path + suppress + observability. The §2.2
post-roundtrip reconciliation and the timeout-race staleness check (for the
rarer concurrent-in-session / timed-out-then-late races documented in the
design) are a tracked follow-up.

Tests: agent emits the notification on success and not on failure; bridge
promotes it to model_switched when idle and suppresses it during a bridge
roundtrip. acp-bridge 302 pass.
@github-actions

Copy link
Copy Markdown
Contributor

📋 Review Summary

This PR implements A1 from the side-channel coordination design (#4511), enabling in-session model switches (via /model slash command or plan-mode model switch) to reach attached clients by emitting current_model_update notifications over the extNotification side-channel. The implementation is well-designed, with proper suppression logic to prevent double-publishing during bridge-driven model roundtrips, and comprehensive test coverage.

🔍 General Feedback

  • Clean architectural decision: Using the existing extNotification side-channel (same as MCP-budget events) rather than modifying the ACP SessionUpdate spec is the right call—minimizes spec churn and keeps changes localized.
  • Good separation of concerns: Agent emits notification, bridge demuxes and promotes/suppresses based on roundtrip state—each component has a clear responsibility.
  • Observability is solid: Structured logging with promoted / suppressed / dropped actions makes the demux behavior observable and debuggable.
  • Fire-and-forget pattern: Matching the MCP-budget extNotification pattern (notification failures don't fail the switch) is appropriate for this advisory use case.
  • Test coverage is comprehensive: Both promotion and suppression scenarios are covered, including the race condition where a bridge roundtrip is in flight.

🎯 Specific Feedback

🟢 Medium

  • packages/acp-bridge/src/bridgeClient.ts:567 — The handleInSessionModelUpdate method catches and silently ignores bus errors (catch { /* bus closed */ }). While this matches the fire-and-forget intent, consider adding a debug-level log (even if suppressed in production) to aid debugging during development:

    } catch (err) {
      // Bus closed — advisory notification, no action needed.
      if (process.env.DEBUG) {
        writeStderrLine(`[demux] session=${sessionId} type=current_model_update action=failed reason=bus_closed`);
      }
    }
  • packages/cli/src/acp-integration/session/Session.ts:1601 — The previousModelId capture is done before the switch, which is correct. However, the conditional spread ...(previousModelId && previousModelId !== parsed.modelId ? { previousModelId } : {}) could be simplified since previousModelId is already truthy from getModel(). Consider always including it for consistency:

    ...(previousModelId ? { previousModelId } : {}),

    This ensures the field is present even if the model somehow switches to the same value (edge case, but makes the notification schema more predictable).

🔵 Low

  • packages/acp-bridge/src/bridge.ts:215 — The JSDoc comment for modelRoundtripInFlight is excellent and clear. Consider adding a similar inline comment at the two finally blocks (lines 1215 and 2884) where the flag is reset, just to make it obvious why the flag is being cleared in both success and failure paths:

    } finally {
      // A1: clear the roundtrip flag so subsequent in-session notifications are promoted.
      entry.modelRoundtripInFlight = false;
    }
  • packages/acp-bridge/src/bridgeClient.ts:558 — The comment mentions "field mapping: currentModelIddata.modelId", but the actual mapping in the published event uses modelId: currentModelId. This is correct, but the comment could be slightly clearer:

    * to a `model_switched` bus event (maps `currentModelId` from the notification
    * to `modelId` in the event data).
  • packages/cli/src/acp-integration/session/Session.test.ts:473 — The test assertion uses expect.objectContaining which is good, but consider also asserting the previousModelId field is present in the notification to ensure the full payload shape is verified:

    expect(mockClient.extNotification).toHaveBeenCalledWith(
      'qwen/notify/session/model-update',
      expect.objectContaining({
        v: 1,
        sessionId: 'test-session-id',
        currentModelId: 'qwen3-coder-plus',
        previousModelId: expect.any(String), // Verify previousModelId is included
      }),
    );
  • packages/acp-bridge/src/bridge.test.ts:6027 — The test comment says "Hang the agent's unstable_setSessionModel" but the proxy implementation returns a promise that never resolves until releaseModel is called. This is a clever pattern, but consider naming the variable resolveModel instead of releaseModel to be more explicit about what the function does (it resolves the hanging promise).

✅ Highlights

  • Excellent design doc reference: The PR description and inline comments consistently reference docs(design): daemon side-channel coordination (A1/A2/A4/A5) #4511 and the A1 initiative, making it easy to understand the broader context.
  • Smart suppression mechanism: The modelRoundtripInFlight flag is a simple but effective solution to the double-publish problem—cleaner than complex deduplication logic.
  • Comprehensive test scenarios: The suppression test (bridge.test.ts:6027-6107) is particularly well-crafted, using a hanging promise to simulate the race condition and verifying only the authoritative model_switched is published.
  • Consistent patterns: Fire-and-forget notification pattern, structured logging format, and queue-based model change serialization all follow established patterns from the MCP-budget implementation.
  • Clear commit scope: The PR explicitly scopes to "core A1 path + suppress + observability + tests" and calls out the follow-up work (post-roundtrip reconciliation and timeout-race staleness check), setting clear expectations.

Comment thread packages/acp-bridge/src/bridgeClient.ts
Comment thread packages/cli/src/acp-integration/session/Session.ts Outdated
Comment thread packages/acp-bridge/src/bridgeClient.ts
Comment thread packages/acp-bridge/src/bridge.ts
Comment thread packages/acp-bridge/src/bridgeClient.ts
Comment thread packages/cli/src/acp-integration/session/Session.ts Outdated
Comment thread packages/acp-bridge/src/bridgeClient.ts
Comment thread packages/acp-bridge/src/bridge.ts
- Update the extNotification JSDoc to list both recognized methods
  (mcp-budget-event + model-update).
- Drop previousModelId from the model-update notification — nothing consumed
  it end-to-end (dead data); model_switched is {sessionId, modelId}.
- setSessionModel: publish model_switched INSIDE the modelChangeQueue work
  callback (while modelRoundtripInFlight is still true), mirroring
  applyModelServiceId, so the agent notification can't slip through after the
  flag clears if transport ordering ever changes.

acp-bridge 302 pass; typecheck + lint clean.
Comment thread packages/acp-bridge/src/bridgeClient.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 #4507, PR #4520, and PR #4545.

Key shared functions:

  • publish / pushpackages/acp-bridge/src/eventBus.ts
  • setModelpackages/cli/src/acp-integration/session/Session.ts
  • createInMemoryChannelpackages/acp-bridge/src/inMemoryChannel.ts
  • shutdown — multiple files (DualOutputBridge.ts, agent-interactive.ts, config.ts, etc.)
  • expectCompressBeforeSendpackages/cli/src/acp-integration/session/Session.test.ts

Recommendation: This PR adds in-session model switch which shares event bus and session management code with other PRs. Coordinate with the group before merging.


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 #4507, PR #4520, and PR #4545.

Key shared functions:

  • publish / pushpackages/acp-bridge/src/eventBus.ts
  • setModelpackages/cli/src/acp-integration/session/Session.ts
  • createInMemoryChannelpackages/acp-bridge/src/inMemoryChannel.ts
  • shutdown — multiple files (DualOutputBridge.ts, agent-interactive.ts, config.ts, etc.)
  • expectCompressBeforeSendpackages/cli/src/acp-integration/session/Session.test.ts

Recommendation: This PR adds in-session model switch which shares event bus and session management code with other PRs. Coordinate with the group before merging.


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.

No issues found. LGTM! ✅ — qwen3.7-max via Qwen Code /review

Add the three branch tests wenshao flagged: malformed model-update params
(non-string ids → early return, no emit), unknown sessionId (dropped, not
buffered), and originatorClientId propagation (a model-update during an
in-flight prompt inherits activePromptOriginatorClientId on the promoted
model_switched).
Comment thread packages/acp-bridge/src/bridge.ts

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

LGTM✅

@chiga0 chiga0 merged commit 0725cf9 into daemon_mode_b_main May 26, 2026
5 checks passed
@chiga0 chiga0 deleted the feat/daemon-in-session-model-update branch May 26, 2026 16:06
@chiga0

chiga0 commented May 26, 2026

Copy link
Copy Markdown
Collaborator Author

Note: this PR merged before I pushed the applyModelServiceId publish-guard fix for your suggestion above, so it landed as a small follow-up PR instead.

doudouOUC added a commit that referenced this pull request May 27, 2026
Squashed feature work from daemon_mode_b_main branch, rebased onto
latest main to establish proper merge-base and clean PR diff.

Original commits:
- perf(core): F2 cleanup PR A — R9/W11/W12/R10 (post-merge follow-ups) (#4411)
- refactor(acp-bridge): F1 test split — lift bridge.test.ts (6861 LOC) to acp-bridge (#4445)
- fix(core): F2 cleanup PR B — self-heal observability (W133-a + W134) (#4460)
- feat(sdk/daemon-ui): unified completeness follow-up to #4328 (#4353)
- docs(serve): v0.16-alpha known limits + SDK QWEN_SERVER_TOKEN env fallback (PR 27) (#4473)
- docs(deploy): local launch templates for v0.16-alpha (PR 30a) (#4483)
- feat(daemon+sdk): cross-client real-time sync completeness (#4484)
- feat(serve): add POST /session/:id/recap (#4504)
- feat(daemon): add voterClientId to permission_resolved (A4) (#4539)
- feat(serve): --allow-origin <pattern> CORS allowlist (T2.4 #4514) (#4527)
- feat(daemon): in-session model switch reaches the bus (A1) (#4546)
- feat(serve): prompt absolute deadline + SSE writer idle timeout (#4514 T2.9) (#4530)
- Feat/daemon react cli (#4380)
doudouOUC pushed a commit that referenced this pull request May 27, 2026
* feat(daemon): in-session model switch reaches the bus (A1)

Implements A1 from the side-channel coordination design (#4511): a /model
slash command or plan-mode model switch now reaches attached clients, where
previously only the HTTP POST /session/:id/model path published model_switched.

Transport (per design v7): current_model_update is NOT an ACP SessionUpdate
variant (the type is the external @agentclientprotocol/sdk union — it has
current_mode_update but no model equivalent), so the agent emits the change
over the agent->bridge extNotification side-channel.

- Agent: Session.setModel emits a `qwen/notify/session/model-update`
  extNotification after switchModel resolves (success-only; captures the
  previous model id). Fire-and-forget — a failed notification never fails the
  switch.
- Bridge: BridgeClient.extNotification demuxes it to a model_switched bus
  event (currentModelId -> data.modelId), SUPPRESSED while the bridge is
  driving its own model roundtrip (entry.modelRoundtripInFlight, set around
  setSessionModel / applyModelServiceId) so the HTTP path — which also flows
  through Session.setModel — does not double-publish. Structured demux log
  records promoted / suppressed / dropped decisions.

Scope: this is the core A1 path + suppress + observability. The §2.2
post-roundtrip reconciliation and the timeout-race staleness check (for the
rarer concurrent-in-session / timed-out-then-late races documented in the
design) are a tracked follow-up.

Tests: agent emits the notification on success and not on failure; bridge
promotes it to model_switched when idle and suppresses it during a bridge
roundtrip. acp-bridge 302 pass.

* fix(daemon): address review on A1 in-session model update

- Update the extNotification JSDoc to list both recognized methods
  (mcp-budget-event + model-update).
- Drop previousModelId from the model-update notification — nothing consumed
  it end-to-end (dead data); model_switched is {sessionId, modelId}.
- setSessionModel: publish model_switched INSIDE the modelChangeQueue work
  callback (while modelRoundtripInFlight is still true), mirroring
  applyModelServiceId, so the agent notification can't slip through after the
  flag clears if transport ordering ever changes.

acp-bridge 302 pass; typecheck + lint clean.

* test(daemon): cover A1 demux defensive branches

Add the three branch tests wenshao flagged: malformed model-update params
(non-string ids → early return, no emit), unknown sessionId (dropped, not
buffered), and originatorClientId propagation (a model-update during an
in-flight prompt inherits activePromptOriginatorClientId on the promoted
model_switched).

---------

Co-authored-by: 秦奇 <gary.gq@alibaba-inc.com>
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.

4 participants