Bladeren bron

feat: remove "插话" badge displayed in user message

07akioni 1 maand geleden
bovenliggende
commit
f9dc4aa702
28 gewijzigde bestanden met toevoegingen van 191 en 129 verwijderingen
  1. 2 2
      .agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.i18n.yaml
  2. 1 1
      .agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md
  3. 1 1
      .agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.zh.md
  4. 6 0
      .agents/notes/implemented/simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.i18n.yaml
  5. 38 0
      .agents/notes/implemented/simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.md
  6. 38 0
      .agents/notes/implemented/simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.zh.md
  7. 1 1
      apps/web/tests/snapshots/steering/settled.expected.md
  8. 25 28
      apps/web/tests/steering.e2e.ts
  9. 2 2
      packages/client/ui-conversation/README.i18n.yaml
  10. 1 1
      packages/client/ui-conversation/README.md
  11. 1 1
      packages/client/ui-conversation/README.zh.md
  12. 0 10
      packages/client/ui-conversation/src/client/chat/MessageItem.module.css
  13. 4 5
      packages/client/ui-conversation/src/client/chat/MessageItem.tsx
  14. 4 4
      packages/client/ui-conversation/src/client/input/contract.ts
  15. 8 9
      packages/client/ui-conversation/src/client/input/facade.ts
  16. 3 3
      packages/client/ui-conversation/src/client/input/hub.ts
  17. 8 9
      packages/client/ui-conversation/src/client/input/machine.ts
  18. 0 2
      packages/client/ui-conversation/src/client/locales.ts
  19. 4 6
      packages/client/ui-conversation/src/client/service.ts
  20. 2 2
      packages/client/ui-conversation/src/client/skeleton/InputBar.tsx
  21. 5 5
      packages/client/ui-conversation/tests/apply-inject.spec.tsx
  22. 2 2
      packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx
  23. 4 4
      packages/client/ui-conversation/tests/input-bar.spec.tsx
  24. 20 20
      packages/client/ui-conversation/tests/input-machine.spec.ts
  25. 2 2
      packages/client/ui-conversation/tests/input-matrix.spec.tsx
  26. 2 2
      packages/client/ui-conversation/tests/input-scenarios.spec.tsx
  27. 6 6
      packages/client/ui-conversation/tests/service-orchestration.spec.ts
  28. 1 1
      packages/client/ui-conversation/tests/skeleton.spec.tsx

+ 2 - 2
.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md
-2026-07-25-web-input-machine-and-slash-pipeline.md: 92bb91c3e892d928cedf18ec57c725a116b6ffc8
-2026-07-25-web-input-machine-and-slash-pipeline.zh.md: 5bee6df52f16d935aa4f4ccff8627a2d43d44c8c
+2026-07-25-web-input-machine-and-slash-pipeline.md: fbff722279b7f625a650602adce6ab6d21183076
+2026-07-25-web-input-machine-and-slash-pipeline.zh.md: adc3e22334f4115373aa3e48015518a033b26d91

+ 1 - 1
.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md

@@ -72,7 +72,7 @@ A trigger/menu/pick pipeline with zero knowledge of "commands":
 - Each materialized Session has exactly one `SessionInputShell` (the facade), created and torn down with the session scope; with no session, no input machine is built. `ConversationRoot` is itself the `session-maybe` resident shell, holding HeroShell, the Workspace picker, the composer stack, and the chain-fallback frame.
 - Each materialized Session has exactly one `SessionInputShell` (the facade), created and torn down with the session scope; with no session, no input machine is built. `ConversationRoot` is itself the `session-maybe` resident shell, holding HeroShell, the Workspace picker, the composer stack, and the chain-fallback frame.
 - The composer bar is one `session-maybe` slot entry rendered unconditionally: with no session the same InputBar renders inert (machine faces absent, `disabled` owner prop), and once `connectWorkspace` returns a blank session the same instance goes live — the textarea DOM survives the no-session → blank transition and every later phase flip; `ConversationRoot`, the Hero, and the layout skeleton hold throughout.
 - The composer bar is one `session-maybe` slot entry rendered unconditionally: with no session the same InputBar renders inert (machine faces absent, `disabled` owner prop), and once `connectWorkspace` returns a blank session the same instance goes live — the textarea DOM survives the no-session → blank transition and every later phase flip; `ConversationRoot`, the Hero, and the layout skeleton hold throughout.
 - ConversationRoot's Hero criterion is `sessionId === undefined || (composerPhase === 'blank' && (openState === 'open' || openState === 'loading'))`. The first submit enters engaging synchronously, and a failure keeps the composer and the error context rather than falling back to the blank Hero; the sidebar's blank bit flips false only after a prompt is successfully accepted.
 - ConversationRoot's Hero criterion is `sessionId === undefined || (composerPhase === 'blank' && (openState === 'open' || openState === 'loading'))`. The first submit enters engaging synchronously, and a failure keeps the composer and the error context rather than falling back to the blank Hero; the sidebar's blank bit flips false only after a prompt is successfully accepted.
-- Sending unifies in the hub defaultSink: after an optimistic draft clear it goes only through `session.prompt {mode:'queue'|'steer'}`; backfill happens only when it fails and the live draft is still empty — a user who has kept typing is never overwritten. No Draft materialize or attach transaction exists.
+- Sending unifies in the hub defaultSink: after an optimistic draft clear it goes only through `session.prompt` with `mode:'queue'` (the Web UI has no steer entry; host-wire `mode:'steer'` remains outside this machine); backfill happens only when it fails and the live draft is still empty — a user who has kept typing is never overwritten. No Draft materialize or attach transaction exists.
 - When the blank Hero re-picks the Workspace, the shell calls `connectWorkspace`; if the target session differs, the non-empty draft moves from the current shell to the target shell before the new id is opened, and the old blank session survives but is no longer current.
 - When the blank Hero re-picks the Workspace, the shell calls `connectWorkspace`; if the target session differs, the non-empty draft moves from the current shell to the target shell before the new id is opened, and the old blank session survives but is no longer current.
 - The Notifier's two-bit contract: `dirty` (snapshot freshness, clearable by an `ensureFresh` pull) and `notifyPending` (notification debt, cleared only by a flush) are mutually independent — a pull must not swallow a push, and object-layer push subscribers (watchTransaction) depend on this guarantee.
 - The Notifier's two-bit contract: `dirty` (snapshot freshness, clearable by an `ensureFresh` pull) and `notifyPending` (notification debt, cleared only by a flush) are mutually independent — a pull must not swallow a push, and object-layer push subscribers (watchTransaction) depend on this guarantee.
 
 

+ 1 - 1
.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.zh.md

@@ -72,7 +72,7 @@ occurrence 表与 chip 三投影:
 - 每个实体 Session 只有一个 `SessionInputShell`(facade),随 session scope 创建和拆除;无 session 时不造 input machine。`ConversationRoot` 自身是 `session-maybe` 常驻外壳,持有 HeroShell、Workspace picker、composer stack 与 chain fallback 外框。
 - 每个实体 Session 只有一个 `SessionInputShell`(facade),随 session scope 创建和拆除;无 session 时不造 input machine。`ConversationRoot` 自身是 `session-maybe` 常驻外壳,持有 HeroShell、Workspace picker、composer stack 与 chain fallback 外框。
 - composer bar 是一个无条件渲染的 `session-maybe` slot entry:无 session 时同一个 InputBar 以惰性态渲染(machine face 缺席、`disabled` owner prop),`connectWorkspace` 返回 blank session 后同一实例转为 live——textarea DOM 在无 session → blank 切换及其后每次 phase 翻转中都不重建;`ConversationRoot`、Hero 与布局骨架全程保持。
 - composer bar 是一个无条件渲染的 `session-maybe` slot entry:无 session 时同一个 InputBar 以惰性态渲染(machine face 缺席、`disabled` owner prop),`connectWorkspace` 返回 blank session 后同一实例转为 live——textarea DOM 在无 session → blank 切换及其后每次 phase 翻转中都不重建;`ConversationRoot`、Hero 与布局骨架全程保持。
 - ConversationRoot 的 Hero 判据是 `sessionId === undefined || (composerPhase === 'blank' && (openState === 'open' || openState === 'loading'))`。首次 submit 同步进入 engaging,失败也保留 composer 与错误上下文,不退回 blank Hero;sidebar 的 blank 位只在 prompt 成功受理后翻 false。
 - ConversationRoot 的 Hero 判据是 `sessionId === undefined || (composerPhase === 'blank' && (openState === 'open' || openState === 'loading'))`。首次 submit 同步进入 engaging,失败也保留 composer 与错误上下文,不退回 blank Hero;sidebar 的 blank 位只在 prompt 成功受理后翻 false。
-- 发送统一在 hub defaultSink:乐观清稿后只走 `session.prompt {mode:'queue'|'steer'}`;失败且 live draft 仍为空才回填,用户已经继续输入则不覆盖。不存在 Draft materialize 或 attach 事务。
+- 发送统一在 hub defaultSink:乐观清稿后只走 `session.prompt` 且固定 `mode:'queue'`(Web UI 无 steer 入口;host 线缆上的 `mode:'steer'` 不经此 machine);失败且 live draft 仍为空才回填,用户已经继续输入则不覆盖。不存在 Draft materialize 或 attach 事务。
 - blank Hero 改选 Workspace 时,外壳调用 `connectWorkspace`;目标 session 不同时把非空 draft 从当前 shell 搬到目标 shell,再 open 新 id,旧 blank session 留存但不再 current。
 - blank Hero 改选 Workspace 时,外壳调用 `connectWorkspace`;目标 session 不同时把非空 draft 从当前 shell 搬到目标 shell,再 open 新 id,旧 blank session 留存但不再 current。
 - Notifier 双位契约:`dirty`(快照新鲜度,`ensureFresh` 拉取可清)与 `notifyPending`(通知欠账,只有 flush 清)各自独立——拉取不得吞推送,对象层推订阅者(watchTransaction)依赖这一保证。
 - Notifier 双位契约:`dirty`(快照新鲜度,`ensureFresh` 拉取可清)与 `notifyPending`(通知欠账,只有 flush 清)各自独立——拉取不得吞推送,对象层推订阅者(watchTransaction)依赖这一保证。
 
 

+ 6 - 0
.agents/notes/implemented/simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+#   pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.md
+2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.md: 6f397668106a6c74f327fc799327752c54824d8c
+2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.zh.md: f127bd57006747465a1ece87406f5f97086b6a34

+ 38 - 0
.agents/notes/implemented/simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.md

@@ -0,0 +1,38 @@
+# Agent Note: Web UI drops steer entry and interjection chrome
+
+Status: implemented
+
+English | [中文](2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.zh.md)
+
+## Problem
+
+Mid-turn steering is a host/agent-loop capability (`mode:'steer'`, durable `steering/message`). The Web product already locked the composer while a turn runs and never shipped a queue/steer menu, yet the client still threaded `'queue' | 'steer'` through the input machine, `conversation.send`, and locale keys, and rendered consumed steering as a badged 「插话」/「Interjection」 bubble. That left a half-built UI surface: an unused submit mode, a product label for a gesture users cannot perform, and e2e goldens that pinned chrome the product does not own.
+
+## Decision
+
+Keep host and runtime steering intact. Remove only the Web UI entry and chrome:
+
+- `InputMachine` / `SessionInput` / `InputActions.submit` / hub `defaultSink` are queue-only; they always call `session.prompt(..., 'queue')`.
+- `ConversationService.send(text)` drops its mode argument and always queues.
+- `MessageItem`'s `steering` arm still folds durable `steering/message` content into a plain right-aligned bubble (no badge, no user IconActions) so external/host steers stay visible on replay.
+- Delete `message.steering` locale strings and the unused badge CSS.
+- The web steering e2e still POSTs `mode:'steer'` over `/api/session.prompt` and asserts durable + model-visible obedience; it no longer expects interjection chrome. Update [web input machine note](../architecture/2026-07-25-web-input-machine-and-slash-pipeline.md) fact lines to match.
+
+## Alternatives considered
+
+**Delete host steering entirely.** Out of scope; the user asked only for Web UI display and entry. Agent-loop drain, session events, and the wire mode remain load-bearing for ACP/TUI/automation.
+
+**Hide `steering/message` from the transcript.** Would lie on replay when an external client steers; rejected in favor of a plain bubble.
+
+**Keep the mode parameter but only ever pass `'queue'`.** Leaves dead API surface and tests that invent `'steer'` paths the composer cannot reach.
+
+## Consequences
+
+- Web users cannot steer from the composer or `ctx.conversation.send`; stop/cancel and Queue remain the only mid-turn controls.
+- Host-wire and non-Web clients can still steer; the Web client shows those messages without labeling them as interjections.
+- Reintroducing a dedicated steer UI would need a new product decision; do not revive the mode union or badge without one.
+
+## Testing
+
+- `packages/client/ui-conversation` unit/jsdom coverage: input machine enter/sink, ConversationService routing, MessageItem steering arm (no 「插话」), InputBar submit.
+- `apps/web/tests/steering.e2e.ts` keyless replay plus updated `settled.expected.md` (steer text without badge).

+ 38 - 0
.agents/notes/implemented/simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.zh.md

@@ -0,0 +1,38 @@
+# Agent Note: Web UI 去掉 steer 入口与插话 chrome
+
+Status: implemented
+
+[English](2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.md) | 中文
+
+## 问题
+
+中途 steering 是 host/agent-loop 能力(`mode:'steer'`、持久 `steering/message`)。Web 产品已在 turn 运行中锁定 composer,且从未交付排队/steer 菜单,但客户端仍把 `'queue' | 'steer'` 穿进 input machine、`conversation.send` 与 locale 键,并把已消费的 steering 渲染成带「插话」/「Interjection」徽章的气泡。这留下半成品 UI:用不到的提交 mode、用户做不到的手势却有产品文案,以及把产品并不拥有的 chrome 钉死在 e2e golden 上。
+
+## 决策
+
+保留 host 与 runtime 的 steering。只去掉 Web UI 入口与 chrome:
+
+- `InputMachine`/`SessionInput`/`InputActions.submit`/hub `defaultSink` 仅 queue;始终调用 `session.prompt(..., 'queue')`。
+- `ConversationService.send(text)` 去掉 mode 参数,始终排队。
+- `MessageItem` 的 `steering` 分支仍把持久 `steering/message` 内容折成右对齐普通气泡(无徽章、无用户 IconActions),以便外部/host steer 在回放时仍可见。
+- 删除 `message.steering` locale 字符串与未使用的徽章 CSS。
+- web steering e2e 仍通过 `/api/session.prompt` POST `mode:'steer'`,并断言持久化与模型可见服从;不再期望插话 chrome。同步更新 [web input machine note](../architecture/2026-07-25-web-input-machine-and-slash-pipeline.md) 中的事实行。
+
+## 曾考虑的替代方案
+
+**整段删除 host steering。** 超出范围;用户只要求清 Web UI 展示与入口。agent-loop 排空、session 事件与线缆 mode 对 ACP/TUI/自动化仍是承重能力。
+
+**在 transcript 中隐藏 `steering/message`。** 外部客户端 steer 时回放会撒谎;改为普通气泡。
+
+**保留 mode 参数但永远只传 `'queue'`。** 留下死 API 面与只会虚构 composer 到不了的 `'steer'` 路径的测试。
+
+## 后果
+
+- Web 用户无法从 composer 或 `ctx.conversation.send` steer;中途控制只剩停止/取消与 Queue。
+- Host 线缆与非 Web 客户端仍可 steer;Web 客户端展示这些消息时不再标成插话。
+- 若要重新引入专用 steer UI,需要新的产品决策;没有决策就不要复活 mode 联合类型或徽章。
+
+## 测试
+
+- `packages/client/ui-conversation` unit/jsdom 覆盖:input machine enter/sink、ConversationService 路由、MessageItem steering 分支(无「插话」)、InputBar submit。
+- `apps/web/tests/steering.e2e.ts` 无密钥回放,以及更新后的 `settled.expected.md`(有 steer 正文、无徽章)。

+ 1 - 1
apps/web/tests/snapshots/steering/settled.expected.md

@@ -19,7 +19,7 @@
   - img
   - img
   - img
   - img
   - text: Ask question 1/1 answered
   - text: Ask question 1/1 answered
-- text: "Interjection Interjection: include the word BANANA in your final reply."
+- text: "Interjection: include the word BANANA in your final reply."
 - button "Think The user selected \"Yes\" and wants me to include the word \"BANANA\" in my final reply. Let me acknowledge their answer.":
 - button "Think The user selected \"Yes\" and wants me to include the word \"BANANA\" in my final reply. Let me acknowledge their answer.":
   - img
   - img
   - img
   - img

+ 25 - 28
apps/web/tests/steering.e2e.ts

@@ -1,16 +1,15 @@
-// Web e2e scenario: mid-turn steering, end to end. The product composer
-// deliberately exposes Queue only, so the steer is POSTed from the page
-// itself over the same same-origin /api transport the client uses.
-// TODO(web-steer-ui): Drive this through a dedicated steering interaction
-// once one exists. Everything downstream is product: the gateway
-// routes mode:'steer' to Agent.steer, the loop drains it at the step
-// boundary into a durable steering/message event, the SSE mux pushes it, and
-// the transcript renders the badged interjection bubble. The question
-// composer supplies the deterministic mid-turn window: while ask_user_question
-// blocks, the turn is provably running, so record and replay perform the
-// identical steer-then-answer sequence with zero timing dependence — and the
-// recorded final reply proves the steer reached the MODEL (it obeys an
-// instruction that only the steering message carries).
+// Web e2e scenario: mid-turn steering over the host wire. The Web UI has no
+// steer entry, so the steer is POSTed from the page over the same
+// same-origin /api transport the client uses. Everything downstream is
+// product: the gateway routes mode:'steer' to Agent.steer, the loop drains
+// it at the step boundary into a durable steering/message event, the SSE mux
+// pushes it, and the transcript shows the text as a plain bubble (no
+// interjection chrome). The question composer supplies the deterministic
+// mid-turn window: while ask_user_question blocks, the turn is provably
+// running, so record and replay perform the identical steer-then-answer
+// sequence with zero timing dependence — and the recorded final reply proves
+// the steer reached the MODEL (it obeys an instruction that only the
+// steering message carries).
 import { readFile } from 'node:fs/promises'
 import { readFile } from 'node:fs/promises'
 import { fileURLToPath } from 'node:url'
 import { fileURLToPath } from 'node:url'
 import { join } from 'node:path'
 import { join } from 'node:path'
@@ -29,11 +28,11 @@ const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/steering', import.meta.u
 const FIXTURE = join(SNAPSHOT_DIR, 'session.jsonl')
 const FIXTURE = join(SNAPSHOT_DIR, 'session.jsonl')
 // Two goldens for the two distinct states this interaction produces: the
 // Two goldens for the two distinct states this interaction produces: the
 // mid-turn moment (steer ACCEPTED but deliberately invisible — the loop
 // mid-turn moment (steer ACCEPTED but deliberately invisible — the loop
-// drains steering at the step boundary, so no interjection bubble exists
-// while the question still blocks the step) and the settled transcript
-// (badged bubble in place, final reply obeying it). The pair pins the
-// timing semantics visually: if the client ever starts rendering pending
-// steers eagerly, the mid-steer golden flips first.
+// drains steering at the step boundary, so no steering text exists while
+// the question still blocks the step) and the settled transcript (plain
+// bubble in place, final reply obeying it). The pair pins the timing
+// semantics visually: if the client ever starts rendering pending steers
+// eagerly, the mid-steer golden flips first.
 const MID_EXPECTED = join(SNAPSHOT_DIR, 'mid-steer.expected.md')
 const MID_EXPECTED = join(SNAPSHOT_DIR, 'mid-steer.expected.md')
 const SETTLED_EXPECTED = join(SNAPSHOT_DIR, 'settled.expected.md')
 const SETTLED_EXPECTED = join(SNAPSHOT_DIR, 'settled.expected.md')
 const MODE = webSnapshotMode()
 const MODE = webSnapshotMode()
@@ -80,7 +79,7 @@ describe('web e2e: mid-turn steering lands durably and visibly', () => {
     await scaffold?.close()
     await scaffold?.close()
   })
   })
 
 
-  it('steers during the blocked step; the interjection is logged, rendered, and obeyed', async () => {
+  it('steers during the blocked step; the message is logged, rendered, and obeyed', async () => {
     onTestFailed(() => saveFailureShot(page, 'web-e2e-steering'))
     onTestFailed(() => saveFailureShot(page, 'web-e2e-steering'))
     if (MODE !== 'record') {
     if (MODE !== 'record') {
       // The steer must NOT be a user/message — it lands as steering/message.
       // The steer must NOT be a user/message — it lands as steering/message.
@@ -118,10 +117,9 @@ describe('web e2e: mid-turn steering lands durably and visibly', () => {
     if (MODE !== 'record') {
     if (MODE !== 'record') {
       // Mid-turn golden: the ACCEPTED steer is durable in the inbox but the
       // Mid-turn golden: the ACCEPTED steer is durable in the inbox but the
       // loop drains steering only at the step boundary, so no steering/message
       // loop drains steering only at the step boundary, so no steering/message
-      // exists yet and no interjection bubble renders — the composer still
-      // blocks, alone. The DOM is stable here (no further SSE frames can
-      // arrive until the question is answered), making this state capturable.
-      expect(await page.getByText('Interjection', { exact: true }).count()).toBe(0)
+      // exists yet and no steer text renders — the composer still blocks,
+      // alone. The DOM is stable here (no further SSE frames can arrive until
+      // the question is answered), making this state capturable.
       expect(await page.getByText(STEER, { exact: true }).count()).toBe(0)
       expect(await page.getByText(STEER, { exact: true }).count()).toBe(0)
       expect(await page.getByRole('button', { name: 'Edit queued message' }).count()).toBe(0)
       expect(await page.getByRole('button', { name: 'Edit queued message' }).count()).toBe(0)
       const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)
       const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)
@@ -155,14 +153,13 @@ describe('web e2e: mid-turn steering lands durably and visibly', () => {
     expect(turnEnds).toHaveLength(1)
     expect(turnEnds).toHaveLength(1)
     expect((turnEnds[0] as SessionEvent & { data: { reason: { kind: string } } }).data.reason.kind).toBe('completed')
     expect((turnEnds[0] as SessionEvent & { data: { reason: { kind: string } } }).data.reason.kind).toBe('completed')
 
 
-    // Visible: the badged interjection bubble plus the reply that obeys it
+    // Visible: the plain steering bubble plus the reply that obeys it
     // (steer text + final reply each contain the marker word).
     // (steer text + final reply each contain the marker word).
-    await expect.poll(() => page.getByText('Interjection', { exact: true }).count(), { timeout: 15_000 }).toBe(1)
-    await expect.poll(() => page.getByText('Interjection:', { exact: false }).count(), { timeout: 10_000 }).toBe(1)
+    await expect.poll(() => page.getByText(STEER, { exact: true }).count(), { timeout: 15_000 }).toBe(1)
     await expect.poll(() => page.getByText('BANANA', { exact: false }).count(), { timeout: 10_000 }).toBeGreaterThanOrEqual(2)
     await expect.poll(() => page.getByText('BANANA', { exact: false }).count(), { timeout: 10_000 }).toBeGreaterThanOrEqual(2)
     expect(await page.locator('[data-question-key]').count()).toBe(0)
     expect(await page.locator('[data-question-key]').count()).toBe(0)
-    // Settled golden: badge + interjection between the question round trip
-    // and the obeying reply, composer takeover gone.
+    // Settled golden: steer text between the question round trip and the
+    // obeying reply, composer takeover gone.
     const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)
     const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)
     await compareOrRefreshGolden(SETTLED_EXPECTED, snapshot, MODE)
     await compareOrRefreshGolden(SETTLED_EXPECTED, snapshot, MODE)
     expect(tripwire.pageErrors).toEqual([])
     expect(tripwire.pageErrors).toEqual([])

+ 2 - 2
packages/client/ui-conversation/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md
 #   pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md
-README.md: b4b1e5653705c76bac3e0227e6df77143a11cbbe
-README.zh.md: 74e0f3dc0ebaf74e2e065c6b88f3a30fce94b391
+README.md: c2db1fba330a5ba968ba8fbbb5825ee62e699b4c
+README.zh.md: bc2c3297ee75bd30c9090d9d4dadf673cc3e0484

+ 1 - 1
packages/client/ui-conversation/README.md

@@ -45,4 +45,4 @@ None; this package neither assembles nor sends a provider request.
 - **The approval panel's "Always allow this type" is deferred** — durable grants need a grant-storage design; only allow-once/reject answer today.
 - **The approval panel's "Always allow this type" is deferred** — durable grants need a grant-storage design; only allow-once/reject answer today.
 - **TodoPanel truncates long item text to one ellipsized line** — the figma strip has no wrap or expand affordance; full text is not readable inline.
 - **TodoPanel truncates long item text to one ellipsized line** — the figma strip has no wrap or expand affordance; full text is not readable inline.
 - **Queue edit is text-only** — rows containing non-text blocks still show a flattened preview, but their edit control is disabled because the inline editor cannot preserve those blocks. A text row's edit mode replaces delete with save and cancel; Enter saves and Escape cancels. QueueDock exposes no send-now control.
 - **Queue edit is text-only** — rows containing non-text blocks still show a flattened preview, but their edit control is disabled because the inline editor cannot preserve those blocks. A text row's edit mode replaces delete with save and cancel; Enter saves and Escape cancels. QueueDock exposes no send-now control.
-- **Web exposes pending Queue only** — the Host omits pending steering from the Queue snapshot until steering has its own interaction. A consumed `steering/message` still renders in the durable transcript so external steering remains truthful on replay.
+- **Web exposes pending Queue only** — the composer and `conversation.send` never submit `mode:'steer'`. The Host omits pending steering from the Queue snapshot. A consumed `steering/message` still folds into the durable transcript as a plain bubble (no interjection chrome) so external/host steering remains truthful on replay.

+ 1 - 1
packages/client/ui-conversation/README.zh.md

@@ -45,4 +45,4 @@ todo 两个面就是在该形状上的两个注册项,都是普通注册方插
 - **审批面板的「始终允许此类」暂缓**:持久授权需要授权存储设计;今天只能回答允许一次/拒绝。
 - **审批面板的「始终允许此类」暂缓**:持久授权需要授权存储设计;今天只能回答允许一次/拒绝。
 - **TodoPanel 将过长条目截成单行省略号**:figma 条没有换行或展开入口,完整文本无法在行内读完。
 - **TodoPanel 将过长条目截成单行省略号**:figma 条没有换行或展开入口,完整文本无法在行内读完。
 - **Queue 编辑仅支持文本**:包含非文本块的行仍显示扁平化预览,但由于内联编辑器无法保留这些块,其编辑控件会被禁用。文本行进入编辑模式后,删除会替换为保存和取消;Enter 保存,Escape 取消。QueueDock 不提供立即发送控件。
 - **Queue 编辑仅支持文本**:包含非文本块的行仍显示扁平化预览,但由于内联编辑器无法保留这些块,其编辑控件会被禁用。文本行进入编辑模式后,删除会替换为保存和取消;Enter 保存,Escape 取消。QueueDock 不提供立即发送控件。
-- **Web 仅暴露待处理 Queue**:在 steering(中途引导)拥有专用交互之前,Host 不会把待处理 steering 纳入 Queue 快照。已消费的 `steering/message` 仍会渲染到持久 transcript(文本记录)中,因此从外部提交的 steering 在回放时仍能如实呈现。
+- **Web 仅暴露待处理 Queue**:composer 与 `conversation.send` 从不提交 `mode:'steer'`。Host 不会把待处理 steering(中途引导)纳入 Queue 快照。已消费的 `steering/message` 仍会折叠进持久 transcript(文本记录),并以无「插话」徽章的普通气泡呈现,因此从外部/Host 提交的 steering 在回放时仍能如实呈现。

+ 0 - 10
packages/client/ui-conversation/src/client/chat/MessageItem.module.css

@@ -20,16 +20,6 @@
   color: var(--dsw-alias-label-primary);
   color: var(--dsw-alias-label-primary);
 }
 }
 
 
-.badge {
-  display: inline-block;
-  margin-bottom: 4px;
-  padding: 1px 6px;
-  border-radius: 6px;
-  background: var(--dsw-alias-state-warn-primary);
-  color: var(--dsw-alias-label-primary-foreground);
-  font-size: 11px;
-}
-
 .contextRow {
 .contextRow {
   padding: 2px 0;
   padding: 2px 0;
 }
 }

+ 4 - 5
packages/client/ui-conversation/src/client/chat/MessageItem.tsx

@@ -1,8 +1,8 @@
 // MessageItem: the four simple node kinds — user bubble (right-aligned, with
 // MessageItem: the four simple node kinds — user bubble (right-aligned, with
-// clock + copy / branch / edit IconActions), steering (badged bubble), context
-// injection and unknown-surface JSON rows. Props are frozen node slices off
-// the snapshot cache; memo holds across streaming because unchanged nodes
-// keep their references.
+// clock + copy / branch / edit IconActions), steering (same bubble, no
+// actions), context injection and unknown-surface JSON rows. Props are frozen
+// node slices off the snapshot cache; memo holds across streaming because
+// unchanged nodes keep their references.
 
 
 import { memo } from 'react'
 import { memo } from 'react'
 import type { ReactNode } from 'react'
 import type { ReactNode } from 'react'
@@ -94,7 +94,6 @@ export const MessageItem = memo(function MessageItem({ node, onFork, t }: Messag
       return (
       return (
         <div className={css.userRow}>
         <div className={css.userRow}>
           <div className={css.bubble}>
           <div className={css.bubble}>
-            <span className={css.badge}>{t('message.steering')}</span>
             {projectUserText(text)}
             {projectUserText(text)}
             {rest.map((block, i) => <JsonBlock key={i} label={t('message.extraBlock')} payload={block} truncatedLabel={truncated} />)}
             {rest.map((block, i) => <JsonBlock key={i} label={t('message.extraBlock')} payload={block} truncatedLabel={truncated} />)}
           </div>
           </div>

+ 4 - 4
packages/client/ui-conversation/src/client/input/contract.ts

@@ -29,7 +29,7 @@ export interface SessionInput extends InputTarget {
   /** Single write path for draft text (all mutation rides machine events). */
   /** Single write path for draft text (all mutation rides machine events). */
   setDraft(text: string): void
   setDraft(text: string): void
   /** THE complexity sink: enter adjudication, submit transaction, and the default sink live inside. */
   /** THE complexity sink: enter adjudication, submit transaction, and the default sink live inside. */
-  submit(mode?: 'queue' | 'steer'): void
+  submit(): void
   /**
   /**
    * Surface a notice outside the machine's own effect stream: detached
    * Surface a notice outside the machine's own effect stream: detached
    * command results and business notifications render through here.
    * command results and business notifications render through here.
@@ -60,7 +60,7 @@ export interface InputActions {
   /** Single public draft write path (full next draft; occurrence math via diff scan). */
   /** Single public draft write path (full next draft; occurrence math via diff scan). */
   setDraft(text: string): void
   setDraft(text: string): void
   /** Enter submission (adjudication / claim transaction / default sink inside). */
   /** Enter submission (adjudication / claim transaction / default sink inside). */
-  submit(mode?: 'queue' | 'steer'): void
+  submit(): void
 }
 }
 
 
 /** One surfaced notice (command results, adjudication failures). seq keys re-render of repeats. */
 /** One surfaced notice (command results, adjudication failures). seq keys re-render of repeats. */
@@ -239,7 +239,7 @@ export type InputEvent =
   | { readonly type: 'paste-upgrade'; readonly attemptId: number; readonly span: TokenSpan; readonly reference: ReferenceInsert }
   | { readonly type: 'paste-upgrade'; readonly attemptId: number; readonly span: TokenSpan; readonly reference: ReferenceInsert }
   /** Shell-observed attempt killers the machine cannot see itself (caret/selection ops, Slash interaction updates). */
   /** Shell-observed attempt killers the machine cannot see itself (caret/selection ops, Slash interaction updates). */
   | { readonly type: 'invalidate-paste' }
   | { readonly type: 'invalidate-paste' }
-  | { readonly type: 'enter'; readonly mode: 'queue' | 'steer' }
+  | { readonly type: 'enter' }
   | { readonly type: 'adjudicated'; readonly attempt: SubmitAttempt; readonly outcome: PickOutcome }
   | { readonly type: 'adjudicated'; readonly attempt: SubmitAttempt; readonly outcome: PickOutcome }
   | { readonly type: 'adjudication-failed'; readonly attempt: SubmitAttempt; readonly message: string }
   | { readonly type: 'adjudication-failed'; readonly attempt: SubmitAttempt; readonly message: string }
   | { readonly type: 'submit-settled'; readonly attempt: SubmitAttempt; readonly ok: boolean; readonly outcome?: SubmitOutcome; readonly message?: string }
   | { readonly type: 'submit-settled'; readonly attempt: SubmitAttempt; readonly ok: boolean; readonly outcome?: SubmitOutcome; readonly message?: string }
@@ -258,5 +258,5 @@ export type InputEvent =
 export type InputEffect =
 export type InputEffect =
   | { readonly type: 'adjudicate'; readonly attempt: SubmitAttempt; readonly draft: string }
   | { readonly type: 'adjudicate'; readonly attempt: SubmitAttempt; readonly draft: string }
   | { readonly type: 'begin-submit'; readonly attempt: SubmitAttempt; readonly claim: CommandClaim; readonly args: string }
   | { readonly type: 'begin-submit'; readonly attempt: SubmitAttempt; readonly claim: CommandClaim; readonly args: string }
-  | { readonly type: 'default-sink'; readonly draft: string; readonly mode: 'queue' | 'steer' }
+  | { readonly type: 'default-sink'; readonly draft: string }
   | { readonly type: 'notice'; readonly level: 'info' | 'error'; readonly text: string }
   | { readonly type: 'notice'; readonly level: 'info' | 'error'; readonly text: string }

+ 8 - 9
packages/client/ui-conversation/src/client/input/facade.ts

@@ -39,7 +39,7 @@ export interface SessionInputDeps {
   /** Queue read face; overlaid onto InputState.queue (absent = empty). */
   /** Queue read face; overlaid onto InputState.queue (absent = empty). */
   queue?: ObservableSnapshot<readonly QueuedMessage[]> | undefined
   queue?: ObservableSnapshot<readonly QueuedMessage[]> | undefined
   /** The plain-message sink (send choreography / materialize fork — the hub owns it). */
   /** The plain-message sink (send choreography / materialize fork — the hub owns it). */
-  defaultSink(text: string, mode: 'queue' | 'steer'): void
+  defaultSink(text: string): void
 }
 }
 
 
 /** Guard tier from the machine phase. */
 /** Guard tier from the machine phase. */
@@ -68,7 +68,7 @@ export class SessionInputShell implements SessionInput {
   /** The public provide-channel action face (one stable identity per session — decision 20). */
   /** The public provide-channel action face (one stable identity per session — decision 20). */
   readonly actions: InputActions = {
   readonly actions: InputActions = {
     setDraft: (text) => { this.setDraft(text) },
     setDraft: (text) => { this.setDraft(text) },
-    submit: (mode) => { this.submit(mode) },
+    submit: () => { this.submit() },
   }
   }
 
 
   // Real wall clock: the typing-run merge window must actually expire in
   // Real wall clock: the typing-run merge window must actually expire in
@@ -151,10 +151,9 @@ export class SessionInputShell implements SessionInput {
    * from the machine; this method only feeds the event. Lock entry
    * from the machine; this method only feeds the event. Lock entry
    * (adjudicating/submitting) force-closes the transient layers: the popup
    * (adjudicating/submitting) force-closes the transient layers: the popup
    * dismisses and the menu tracks frozen.
    * dismisses and the menu tracks frozen.
-   * @param mode - default-sink mode (queue appends; steer interrupts).
    */
    */
-  submit(mode: 'queue' | 'steer' = 'queue'): void {
-    this.run(this.core.dispatch({ type: 'enter', mode }))
+  submit(): void {
+    this.run(this.core.dispatch({ type: 'enter' }))
     const phase = this.snapshot.phase
     const phase = this.snapshot.phase
     if (phase === 'adjudicating' || phase === 'submitting') {
     if (phase === 'adjudicating' || phase === 'submitting') {
       this.deps.popup?.()?.dismiss()
       this.deps.popup?.()?.dismiss()
@@ -339,7 +338,7 @@ export class SessionInputShell implements SessionInput {
         return
         return
       }
       }
       case 'default-sink': {
       case 'default-sink': {
-        this.sinkSerialized(fx.draft, fx.mode)
+        this.sinkSerialized(fx.draft)
         return
         return
       }
       }
       default:
       default:
@@ -354,10 +353,10 @@ export class SessionInputShell implements SessionInput {
    * send — notice + draft and chips retained, never a silent downgrade to
    * send — notice + draft and chips retained, never a silent downgrade to
    * the clipboard text. Chip-free drafts skip the async detour.
    * the clipboard text. Chip-free drafts skip the async detour.
    */
    */
-  private sinkSerialized(draft: string, mode: 'queue' | 'steer'): void {
+  private sinkSerialized(draft: string): void {
     const occurrences = this.core.state.occurrences
     const occurrences = this.core.state.occurrences
     if (occurrences.length === 0) {
     if (occurrences.length === 0) {
-      this.deps.defaultSink(draft.trim(), mode)
+      this.deps.defaultSink(draft.trim())
       return
       return
     }
     }
     const slash = this.deps.slash?.()
     const slash = this.deps.slash?.()
@@ -377,7 +376,7 @@ export class SessionInputShell implements SessionInput {
           cursor = part.offset + 1
           cursor = part.offset + 1
         }
         }
         out += draft.slice(cursor)
         out += draft.slice(cursor)
-        this.deps.defaultSink(out.trim(), mode)
+        this.deps.defaultSink(out.trim())
       },
       },
       (error: unknown) => {
       (error: unknown) => {
         controller.abort()
         controller.abort()

+ 3 - 3
packages/client/ui-conversation/src/client/input/hub.ts

@@ -56,7 +56,7 @@ export class InputHub implements InputService {
       slash: () => this.controller(actx),
       slash: () => this.controller(actx),
       popup: () => this.popup(actx),
       popup: () => this.popup(actx),
       queue: queueReadFaceOf(session),
       queue: queueReadFaceOf(session),
-      defaultSink: (text, mode) => { this.sink(session, text, mode) },
+      defaultSink: (text) => { this.sink(session, text) },
     })
     })
     this.shells.set(id, shell)
     this.shells.set(id, shell)
     // The one teardown axis: listeners, shell, and map entries all ride the
     // The one teardown axis: listeners, shell, and map entries all ride the
@@ -112,12 +112,12 @@ export class InputHub implements InputService {
    * exactly one path; a failed first prompt is an ordinary prompt failure
    * exactly one path; a failed first prompt is an ordinary prompt failure
    * (error strip via promptError, draft restored only while untouched).
    * (error strip via promptError, draft restored only while untouched).
    */
    */
-  private sink(session: SessionFace, text: string, mode: 'queue' | 'steer'): void {
+  private sink(session: SessionFace, text: string): void {
     if (text === '') return
     if (text === '') return
     const shell = this.shells.get(session.sessionId)
     const shell = this.shells.get(session.sessionId)
     // Commit, not an editable clear: undo must not resurrect sent content.
     // Commit, not an editable clear: undo must not resurrect sent content.
     shell?.commitSend()
     shell?.commitSend()
-    void session.prompt([{ type: 'text', text }], mode).then(
+    void session.prompt([{ type: 'text', text }], 'queue').then(
       (result) => {
       (result) => {
         if (!result.ok && shell?.snapshot.draft === '') shell.setDraft(text)
         if (!result.ok && shell?.snapshot.draft === '') shell.setDraft(text)
       },
       },

+ 8 - 9
packages/client/ui-conversation/src/client/input/machine.ts

@@ -112,7 +112,6 @@ export class InputMachine {
   private inflight: {
   private inflight: {
     readonly attempt: SubmitAttempt
     readonly attempt: SubmitAttempt
     readonly controller: AbortController
     readonly controller: AbortController
-    readonly mode: 'queue' | 'steer'
   } | undefined
   } | undefined
   private log: Transaction[] = []
   private log: Transaction[] = []
   private redoStack: Transaction[] = []
   private redoStack: Transaction[] = []
@@ -163,7 +162,7 @@ export class InputMachine {
         this.paste = undefined
         this.paste = undefined
         return []
         return []
       }
       }
-      case 'enter': return this.onEnter(ev.mode)
+      case 'enter': return this.onEnter()
       case 'adjudicated': return this.onAdjudicated(ev.attempt, ev.outcome)
       case 'adjudicated': return this.onAdjudicated(ev.attempt, ev.outcome)
       case 'adjudication-failed': return this.onAdjudicationFailed(ev.attempt, ev.message)
       case 'adjudication-failed': return this.onAdjudicationFailed(ev.attempt, ev.message)
       case 'submit-settled': return this.onSubmitSettled(ev)
       case 'submit-settled': return this.onSubmitSettled(ev)
@@ -462,18 +461,18 @@ export class InputMachine {
   // ---- submit plane ----
   // ---- submit plane ----
 
 
   /** Mint the next SubmitAttempt and take the in-flight slot. */
   /** Mint the next SubmitAttempt and take the in-flight slot. */
-  private beginAttempt(mode: 'queue' | 'steer'): SubmitAttempt {
+  private beginAttempt(): SubmitAttempt {
     const controller = new AbortController()
     const controller = new AbortController()
     this.seq += 1
     this.seq += 1
     const attempt: SubmitAttempt = { seq: this.seq, signal: controller.signal, draftSnapshot: this.draft }
     const attempt: SubmitAttempt = { seq: this.seq, signal: controller.signal, draftSnapshot: this.draft }
-    this.inflight = { attempt, controller, mode }
+    this.inflight = { attempt, controller }
     return attempt
     return attempt
   }
   }
 
 
-  private onEnter(mode: 'queue' | 'steer'): InputEffect[] {
+  private onEnter(): InputEffect[] {
     if (this.phase === 'adjudicating' || this.phase === 'submitting') return []
     if (this.phase === 'adjudicating' || this.phase === 'submitting') return []
     if (this.phase === 'claimed' && this.claim !== undefined) {
     if (this.phase === 'claimed' && this.claim !== undefined) {
-      const attempt = this.beginAttempt(mode)
+      const attempt = this.beginAttempt()
       this.phase = 'submitting'
       this.phase = 'submitting'
       this.paste = undefined
       this.paste = undefined
       return [{ type: 'begin-submit', attempt, claim: this.claim, args: argsAfter(this.draft, this.claim.token) }]
       return [{ type: 'begin-submit', attempt, claim: this.claim, args: argsAfter(this.draft, this.claim.token) }]
@@ -482,11 +481,11 @@ export class InputMachine {
     if (trimmed === '') return []
     if (trimmed === '') return []
     this.paste = undefined
     this.paste = undefined
     if (trimmed.startsWith('/')) {
     if (trimmed.startsWith('/')) {
-      const attempt = this.beginAttempt(mode)
+      const attempt = this.beginAttempt()
       this.phase = 'adjudicating'
       this.phase = 'adjudicating'
       return [{ type: 'adjudicate', attempt, draft: this.draft }]
       return [{ type: 'adjudicate', attempt, draft: this.draft }]
     }
     }
-    return [{ type: 'default-sink', draft: this.draft, mode }]
+    return [{ type: 'default-sink', draft: this.draft }]
   }
   }
 
 
   private onAdjudicated(attempt: SubmitAttempt, outcome: Extract<InputEvent, { type: 'adjudicated' }>['outcome']): InputEffect[] {
   private onAdjudicated(attempt: SubmitAttempt, outcome: Extract<InputEvent, { type: 'adjudicated' }>['outcome']): InputEffect[] {
@@ -507,7 +506,7 @@ export class InputMachine {
     this.inflight = undefined
     this.inflight = undefined
     this.phase = 'plain'
     this.phase = 'plain'
     return outcome === undefined
     return outcome === undefined
-      ? [{ type: 'default-sink', draft: attempt.draftSnapshot, mode: flight.mode }]
+      ? [{ type: 'default-sink', draft: attempt.draftSnapshot }]
       : []
       : []
   }
   }
 
 

+ 0 - 2
packages/client/ui-conversation/src/client/locales.ts

@@ -42,7 +42,6 @@ export const zh = {
   'chat.loadOlder': '加载更早',
   'chat.loadOlder': '加载更早',
   'chat.toBottom': '回到底部',
   'chat.toBottom': '回到底部',
   'message.extraBlock': '附加内容块',
   'message.extraBlock': '附加内容块',
-  'message.steering': '插话',
   'message.contextInjection': '上下文注入',
   'message.contextInjection': '上下文注入',
   'message.unknownSurface': '未知 surface 事件:{type}',
   'message.unknownSurface': '未知 surface 事件:{type}',
   'message.unknownBlock': '未知内容块',
   'message.unknownBlock': '未知内容块',
@@ -124,7 +123,6 @@ export const en = {
   'chat.loadOlder': 'Load earlier',
   'chat.loadOlder': 'Load earlier',
   'chat.toBottom': 'Back to bottom',
   'chat.toBottom': 'Back to bottom',
   'message.extraBlock': 'Extra content block',
   'message.extraBlock': 'Extra content block',
-  'message.steering': 'Interjection',
   'message.contextInjection': 'Context injection',
   'message.contextInjection': 'Context injection',
   'message.unknownSurface': 'Unknown surface event: {type}',
   'message.unknownSurface': 'Unknown surface event: {type}',
   'message.unknownBlock': 'Unknown content block',
   'message.unknownBlock': 'Unknown content block',

+ 4 - 6
packages/client/ui-conversation/src/client/service.ts

@@ -25,12 +25,11 @@ export interface IConversation {
   /** The per-session input machine registry (InputService face). */
   /** The per-session input machine registry (InputService face). */
   readonly input: InputService
   readonly input: InputService
   /**
   /**
-   * Send a prompt into the caller scope's session.
+   * Send a prompt into the caller scope's session (queued turn).
    * @param text - prompt text, sent verbatim as one text block.
    * @param text - prompt text, sent verbatim as one text block.
-   * @param mode - queue after the current turn, or steer into it.
    * @returns completion; business failures reject (and land in promptError).
    * @returns completion; business failures reject (and land in promptError).
    */
    */
-  send(text: string, mode: 'queue' | 'steer'): Promise<void>
+  send(text: string): Promise<void>
   /**
   /**
    * Apply one operation to a pending queue occurrence.
    * Apply one operation to a pending queue occurrence.
    * @param itemId - agent-owned inbox occurrence identity.
    * @param itemId - agent-owned inbox occurrence identity.
@@ -71,11 +70,10 @@ export class ConversationService extends Service implements IConversation {
    * session snapshot's promptError (object-layer surface); the rejection here
    * session snapshot's promptError (object-layer surface); the rejection here
    * exists for caller choreography (the composer restores the draft on it).
    * exists for caller choreography (the composer restores the draft on it).
    * @param text - prompt text, sent verbatim as one text block.
    * @param text - prompt text, sent verbatim as one text block.
-   * @param mode - queue after the current turn, or steer into it.
    */
    */
-  async send(text: string, mode: 'queue' | 'steer'): Promise<void> {
+  async send(text: string): Promise<void> {
     const session = this.scopedSession('send')
     const session = this.scopedSession('send')
-    const result = await session.prompt([{ type: 'text', text }], mode)
+    const result = await session.prompt([{ type: 'text', text }], 'queue')
     if (!result.ok) throw new Error(`conversation.send failed: ${result.error.code}: ${result.error.message}`)
     if (!result.ok) throw new Error(`conversation.send failed: ${result.error.code}: ${result.error.message}`)
   }
   }
 
 

+ 2 - 2
packages/client/ui-conversation/src/client/skeleton/InputBar.tsx

@@ -172,7 +172,7 @@ export function InputBar({
     e.preventDefault()
     e.preventDefault()
     if (e.repeat) return // held-down Enter must not machine-gun sends
     if (e.repeat) return // held-down Enter must not machine-gun sends
     if (locked || machineBusy) return
     if (locked || machineBusy) return
-    inputActions.submit('queue')
+    inputActions.submit()
   }
   }
 
 
   const onChange = (e: ChangeEvent<HTMLTextAreaElement>): void => {
   const onChange = (e: ChangeEvent<HTMLTextAreaElement>): void => {
@@ -266,7 +266,7 @@ export function InputBar({
       return
       return
     }
     }
     /* v8 ignore next -- defensive: the primary button is disabled while empty||disabled, so a click cannot reach the false arm. */
     /* v8 ignore next -- defensive: the primary button is disabled while empty||disabled, so a click cannot reach the false arm. */
-    if (!empty && !disabled && !machineBusy) inputActions.submit('queue')
+    if (!empty && !disabled && !machineBusy) inputActions.submit()
   }
   }
 
 
   // The Access seat: the projection-fed permission chip (renders nothing
   // The Access seat: the projection-fed permission chip (renders nothing

+ 5 - 5
packages/client/ui-conversation/tests/apply-inject.spec.tsx

@@ -101,7 +101,7 @@ async function bench() {
     }
     }
     const actions = info.props['inputActions'] as {
     const actions = info.props['inputActions'] as {
       setDraft: (text: string) => void
       setDraft: (text: string) => void
-      submit: (mode?: 'queue' | 'steer') => void
+      submit: () => void
     }
     }
     return { state, actions }
     return { state, actions }
   }
   }
@@ -140,25 +140,25 @@ describe('conversation slot inject surface', () => {
     const { state, actions } = b.inputSurface(ROOT)
     const { state, actions } = b.inputSurface(ROOT)
     // Whitespace-only: the machine treats it as empty — no prompt, draft kept.
     // Whitespace-only: the machine treats it as empty — no prompt, draft kept.
     actions.setDraft('   ')
     actions.setDraft('   ')
-    actions.submit('queue')
+    actions.submit()
     expect(b.sessionFake.prompt).not.toHaveBeenCalled()
     expect(b.sessionFake.prompt).not.toHaveBeenCalled()
     expect(state.getSnapshot().draft).toBe('   ')
     expect(state.getSnapshot().draft).toBe('   ')
     // Success: cleared and stays cleared.
     // Success: cleared and stays cleared.
     actions.setDraft('hello')
     actions.setDraft('hello')
-    actions.submit('queue')
+    actions.submit()
     expect(state.getSnapshot().draft).toBe('')
     expect(state.getSnapshot().draft).toBe('')
     await Promise.resolve()
     await Promise.resolve()
     expect(b.sessionFake.prompt).toHaveBeenCalledWith([{ type: 'text', text: 'hello' }], 'queue')
     expect(b.sessionFake.prompt).toHaveBeenCalledWith([{ type: 'text', text: 'hello' }], 'queue')
     // Failure: restored (draft still empty when the rejection lands).
     // Failure: restored (draft still empty when the rejection lands).
     b.sessionFake.prompt.mockResolvedValueOnce({ ok: false, error: { code: 'agent-busy', message: 'b', details: { reason: 'b' } } })
     b.sessionFake.prompt.mockResolvedValueOnce({ ok: false, error: { code: 'agent-busy', message: 'b', details: { reason: 'b' } } })
     actions.setDraft('retry me')
     actions.setDraft('retry me')
-    actions.submit('queue')
+    actions.submit()
     await vi.waitFor(() => {
     await vi.waitFor(() => {
       expect(state.getSnapshot().draft).toBe('retry me')
       expect(state.getSnapshot().draft).toBe('retry me')
     })
     })
     // Failure landing after new typing: no clobber (restore fills empty only).
     // Failure landing after new typing: no clobber (restore fills empty only).
     b.sessionFake.prompt.mockResolvedValueOnce({ ok: false, error: { code: 'agent-busy', message: 'b', details: { reason: 'b' } } })
     b.sessionFake.prompt.mockResolvedValueOnce({ ok: false, error: { code: 'agent-busy', message: 'b', details: { reason: 'b' } } })
-    actions.submit('queue')
+    actions.submit()
     actions.setDraft('typed during flight')
     actions.setDraft('typed during flight')
     await new Promise(r => setTimeout(r, 0))
     await new Promise(r => setTimeout(r, 0))
     expect(state.getSnapshot().draft).toBe('typed during flight')
     expect(state.getSnapshot().draft).toBe('typed during flight')

+ 2 - 2
packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx

@@ -99,7 +99,7 @@ describe('MessageItem arms', () => {
     fireEvent.click(screen.getByRole('button', { name: '复制' }))
     fireEvent.click(screen.getByRole('button', { name: '复制' }))
   })
   })
 
 
-  it('steering bubbles carry the interjection badge and non-text rest blocks, without user actions', () => {
+  it('steering bubbles render text and non-text rest blocks, without user actions or a badge', () => {
     const view = render(
     const view = render(
       <MessageItem t={t} node={{
       <MessageItem t={t} node={{
         kind: 'steering', seq: 2, turn: 1, source: null,
         kind: 'steering', seq: 2, turn: 1, source: null,
@@ -107,7 +107,7 @@ describe('MessageItem arms', () => {
       } as never}
       } as never}
       />,
       />,
     )
     )
-    expect(view.getByText('插话')).toBeTruthy()
+    expect(view.queryByText('插话')).toBeNull()
     expect(view.getByText('steer!')).toBeTruthy()
     expect(view.getByText('steer!')).toBeTruthy()
     expect(view.getByText(/附加内容块/)).toBeTruthy()
     expect(view.getByText(/附加内容块/)).toBeTruthy()
     expect(view.queryByRole('button', { name: '复制' })).toBeNull()
     expect(view.queryByRole('button', { name: '复制' })).toBeNull()

+ 4 - 4
packages/client/ui-conversation/tests/input-bar.spec.tsx

@@ -127,7 +127,7 @@ describe('Enter semantics', () => {
   it('plain Enter submits queue mode through the machine; repeat and empty are suppressed', () => {
   it('plain Enter submits queue mode through the machine; repeat and empty are suppressed', () => {
     const { textarea, sink } = bench({ draft: 'hello' })
     const { textarea, sink } = bench({ draft: 'hello' })
     fireEvent.keyDown(textarea, { key: 'Enter' })
     fireEvent.keyDown(textarea, { key: 'Enter' })
-    expect(sink).toHaveBeenCalledWith('hello', 'queue')
+    expect(sink).toHaveBeenCalledWith('hello')
     fireEvent.keyDown(textarea, { key: 'Enter', repeat: true })
     fireEvent.keyDown(textarea, { key: 'Enter', repeat: true })
     expect(sink).toHaveBeenCalledTimes(1)
     expect(sink).toHaveBeenCalledTimes(1)
     const empty = bench({ draft: '   ' })
     const empty = bench({ draft: '   ' })
@@ -195,7 +195,7 @@ describe('running and lock semantics (queue cut 1)', () => {
     expect(textarea.disabled).toBe(false) // running no longer locks
     expect(textarea.disabled).toBe(false) // running no longer locks
     fireEvent.change(textarea, { target: { value: '排队消息2' } })
     fireEvent.change(textarea, { target: { value: '排队消息2' } })
     fireEvent.keyDown(textarea, { key: 'Enter' })
     fireEvent.keyDown(textarea, { key: 'Enter' })
-    expect(sink).toHaveBeenCalledWith('排队消息2', 'queue')
+    expect(sink).toHaveBeenCalledWith('排队消息2')
     expect(button.getAttribute('aria-label')).toBe('停止生成')
     expect(button.getAttribute('aria-label')).toBe('停止生成')
     fireEvent.click(button)
     fireEvent.click(button)
     expect(stop).toHaveBeenCalledTimes(1)
     expect(stop).toHaveBeenCalledTimes(1)
@@ -211,7 +211,7 @@ describe('running and lock semantics (queue cut 1)', () => {
   it('idle primary sends and disables on empty draft', () => {
   it('idle primary sends and disables on empty draft', () => {
     const { button, sink } = bench({ draft: 'go' })
     const { button, sink } = bench({ draft: 'go' })
     fireEvent.click(button)
     fireEvent.click(button)
-    expect(sink).toHaveBeenCalledWith('go', 'queue')
+    expect(sink).toHaveBeenCalledWith('go')
     const empty = bench()
     const empty = bench()
     expect(empty.button.disabled).toBe(true)
     expect(empty.button.disabled).toBe(true)
   })
   })
@@ -320,7 +320,7 @@ describe('machine pending lock', () => {
         },
         },
         { start: 0, end: 6, draftRev: shell.snapshot.draftRev },
         { start: 0, end: 6, draftRev: shell.snapshot.draftRev },
       )
       )
-      shell.submit('queue')
+      shell.submit()
     })
     })
     expect(shell.snapshot.phase).toBe('submitting')
     expect(shell.snapshot.phase).toBe('submitting')
     const textarea = view.container.querySelector('textarea')!
     const textarea = view.container.querySelector('textarea')!

+ 20 - 20
packages/client/ui-conversation/tests/input-machine.spec.ts

@@ -40,9 +40,9 @@ function effectAt<T extends InputEffect['type']>(
 }
 }
 
 
 /** Drive plain → adjudicating and hand back the minted attempt. */
 /** Drive plain → adjudicating and hand back the minted attempt. */
-function enterAdjudicating(m: InputMachine, draft: string, mode: 'queue' | 'steer' = 'queue'): SubmitAttempt {
+function enterAdjudicating(m: InputMachine, draft: string): SubmitAttempt {
   m.dispatch({ type: 'draft-changed', draft })
   m.dispatch({ type: 'draft-changed', draft })
-  const fx = m.dispatch({ type: 'enter', mode })
+  const fx = m.dispatch({ type: 'enter' })
   return effectAt(fx, 0, 'adjudicate').attempt
   return effectAt(fx, 0, 'adjudicate').attempt
 }
 }
 
 
@@ -52,7 +52,7 @@ function enterSubmitting(m: InputMachine, name: string, args: string): { attempt
   m.dispatch({ type: 'draft-changed', draft: `/${name.slice(0, 2)}` })
   m.dispatch({ type: 'draft-changed', draft: `/${name.slice(0, 2)}` })
   m.dispatch({ type: 'begin-command', claim, span: spanOf(m, 0, m.state.draft.length) })
   m.dispatch({ type: 'begin-command', claim, span: spanOf(m, 0, m.state.draft.length) })
   m.dispatch({ type: 'draft-changed', draft: claim.token + args })
   m.dispatch({ type: 'draft-changed', draft: claim.token + args })
-  const fx = m.dispatch({ type: 'enter', mode: 'queue' })
+  const fx = m.dispatch({ type: 'enter' })
   return { attempt: effectAt(fx, 0, 'begin-submit').attempt, claim }
   return { attempt: effectAt(fx, 0, 'begin-submit').attempt, claim }
 }
 }
 
 
@@ -63,24 +63,24 @@ function staleAttempt(): SubmitAttempt {
 describe('input-machine: plain × enter', () => {
 describe('input-machine: plain × enter', () => {
   it('empty and whitespace-only drafts produce nothing', () => {
   it('empty and whitespace-only drafts produce nothing', () => {
     const m = new InputMachine()
     const m = new InputMachine()
-    expect(m.dispatch({ type: 'enter', mode: 'queue' })).toEqual([])
+    expect(m.dispatch({ type: 'enter' })).toEqual([])
     m.dispatch({ type: 'draft-changed', draft: '  \n ' })
     m.dispatch({ type: 'draft-changed', draft: '  \n ' })
-    expect(m.dispatch({ type: 'enter', mode: 'queue' })).toEqual([])
+    expect(m.dispatch({ type: 'enter' })).toEqual([])
     expect(m.state.phase).toBe('plain')
     expect(m.state.phase).toBe('plain')
   })
   })
 
 
-  it('non-command text falls to the default sink with the given mode', () => {
+  it('non-command text falls to the default sink', () => {
     const m = new InputMachine()
     const m = new InputMachine()
     m.dispatch({ type: 'draft-changed', draft: 'hello world' })
     m.dispatch({ type: 'draft-changed', draft: 'hello world' })
-    expect(m.dispatch({ type: 'enter', mode: 'steer' }))
-      .toEqual([{ type: 'default-sink', draft: 'hello world', mode: 'steer' }])
+    expect(m.dispatch({ type: 'enter' }))
+      .toEqual([{ type: 'default-sink', draft: 'hello world' }])
     expect(m.state.phase).toBe('plain')
     expect(m.state.phase).toBe('plain')
   })
   })
 
 
   it('leading "/" enters adjudicating with a minted attempt carrying the draft snapshot', () => {
   it('leading "/" enters adjudicating with a minted attempt carrying the draft snapshot', () => {
     const m = new InputMachine()
     const m = new InputMachine()
     m.dispatch({ type: 'draft-changed', draft: '/goal x' })
     m.dispatch({ type: 'draft-changed', draft: '/goal x' })
-    const fx = m.dispatch({ type: 'enter', mode: 'queue' })
+    const fx = m.dispatch({ type: 'enter' })
     const eff = effectAt(fx, 0, 'adjudicate')
     const eff = effectAt(fx, 0, 'adjudicate')
     expect(eff.draft).toBe('/goal x')
     expect(eff.draft).toBe('/goal x')
     expect(eff.attempt.draftSnapshot).toBe('/goal x')
     expect(eff.attempt.draftSnapshot).toBe('/goal x')
@@ -91,14 +91,14 @@ describe('input-machine: plain × enter', () => {
   it('leading is judged after trim including newlines', () => {
   it('leading is judged after trim including newlines', () => {
     const m = new InputMachine()
     const m = new InputMachine()
     m.dispatch({ type: 'draft-changed', draft: '\n\n/goal x' })
     m.dispatch({ type: 'draft-changed', draft: '\n\n/goal x' })
-    expect(m.dispatch({ type: 'enter', mode: 'queue' })[0]?.type).toBe('adjudicate')
+    expect(m.dispatch({ type: 'enter' })[0]?.type).toBe('adjudicate')
   })
   })
 
 
   it('a non-whitespace prefix before "/" is not leading — default sink', () => {
   it('a non-whitespace prefix before "/" is not leading — default sink', () => {
     const m = new InputMachine()
     const m = new InputMachine()
     m.dispatch({ type: 'draft-changed', draft: '第一行\n/goal x' })
     m.dispatch({ type: 'draft-changed', draft: '第一行\n/goal x' })
-    expect(m.dispatch({ type: 'enter', mode: 'queue' }))
-      .toEqual([{ type: 'default-sink', draft: '第一行\n/goal x', mode: 'queue' }])
+    expect(m.dispatch({ type: 'enter' }))
+      .toEqual([{ type: 'default-sink', draft: '第一行\n/goal x' }])
   })
   })
 })
 })
 
 
@@ -124,11 +124,11 @@ describe('input-machine: adjudication outcomes', () => {
     expect(effectAt(b.dispatch({ type: 'adjudicated', attempt: attemptB, outcome: { claim: claimOf('goal') } }), 0, 'begin-submit').args).toBe('x')
     expect(effectAt(b.dispatch({ type: 'adjudicated', attempt: attemptB, outcome: { claim: claimOf('goal') } }), 0, 'begin-submit').args).toBe('x')
   })
   })
 
 
-  it('undefined outcome falls back to the default sink preserving the enter mode', () => {
+  it('undefined outcome falls back to the default sink', () => {
     const m = new InputMachine()
     const m = new InputMachine()
-    const attempt = enterAdjudicating(m, '/unknown thing', 'steer')
+    const attempt = enterAdjudicating(m, '/unknown thing')
     expect(m.dispatch({ type: 'adjudicated', attempt, outcome: undefined }))
     expect(m.dispatch({ type: 'adjudicated', attempt, outcome: undefined }))
-      .toEqual([{ type: 'default-sink', draft: '/unknown thing', mode: 'steer' }])
+      .toEqual([{ type: 'default-sink', draft: '/unknown thing' }])
     expect(m.state.phase).toBe('plain')
     expect(m.state.phase).toBe('plain')
   })
   })
 
 
@@ -152,7 +152,7 @@ describe('input-machine: adjudication outcomes', () => {
   it('enter is a no-op while adjudicating (pending lock)', () => {
   it('enter is a no-op while adjudicating (pending lock)', () => {
     const m = new InputMachine()
     const m = new InputMachine()
     enterAdjudicating(m, '/goal x')
     enterAdjudicating(m, '/goal x')
-    expect(m.dispatch({ type: 'enter', mode: 'queue' })).toEqual([])
+    expect(m.dispatch({ type: 'enter' })).toEqual([])
     expect(m.state.phase).toBe('adjudicating')
     expect(m.state.phase).toBe('adjudicating')
   })
   })
 
 
@@ -587,7 +587,7 @@ describe('input-machine: paste plane', () => {
 
 
     const b = new InputMachine()
     const b = new InputMachine()
     b.dispatch({ type: 'paste-begin', text: 'plain text', selection: { start: 0, end: 0 } })
     b.dispatch({ type: 'paste-begin', text: 'plain text', selection: { start: 0, end: 0 } })
-    b.dispatch({ type: 'enter', mode: 'queue' })
+    b.dispatch({ type: 'enter' })
     expect(b.state.paste).toBeUndefined()
     expect(b.state.paste).toBeUndefined()
   })
   })
 
 
@@ -755,7 +755,7 @@ describe('input-machine: submitting transaction', () => {
   it('enter and begin-command are locked; draft-changed is recorded without leaving submitting', () => {
   it('enter and begin-command are locked; draft-changed is recorded without leaving submitting', () => {
     const m = new InputMachine()
     const m = new InputMachine()
     enterSubmitting(m, 'goal', 'x')
     enterSubmitting(m, 'goal', 'x')
-    expect(m.dispatch({ type: 'enter', mode: 'queue' })).toEqual([])
+    expect(m.dispatch({ type: 'enter' })).toEqual([])
     expect(m.dispatch({ type: 'draft-changed', draft: '/goal y' })).toEqual([])
     expect(m.dispatch({ type: 'draft-changed', draft: '/goal y' })).toEqual([])
     expect(m.state).toMatchObject({ phase: 'submitting', draft: '/goal y' })
     expect(m.state).toMatchObject({ phase: 'submitting', draft: '/goal y' })
   })
   })
@@ -768,7 +768,7 @@ describe('input-machine: submitting transaction', () => {
     m.dispatch({ type: 'draft-changed', draft: '/go', editRange: { start: 0, end: 1, insertedLength: 0 } })
     m.dispatch({ type: 'draft-changed', draft: '/go', editRange: { start: 0, end: 1, insertedLength: 0 } })
     m.dispatch({ type: 'begin-command', claim: claimOf('goal'), span: spanOf(m, 0, 3) })
     m.dispatch({ type: 'begin-command', claim: claimOf('goal'), span: spanOf(m, 0, 3) })
     m.dispatch({ type: 'draft-changed', draft: '/goal go' })
     m.dispatch({ type: 'draft-changed', draft: '/goal go' })
-    const attempt = effectAt(m.dispatch({ type: 'enter', mode: 'queue' }), 0, 'begin-submit').attempt
+    const attempt = effectAt(m.dispatch({ type: 'enter' }), 0, 'begin-submit').attempt
     const fx = m.dispatch({ type: 'submit-settled', attempt, ok: true, outcome: { kind: 'success', text: 'goal set' } })
     const fx = m.dispatch({ type: 'submit-settled', attempt, ok: true, outcome: { kind: 'success', text: 'goal set' } })
     expect(fx).toEqual([{ type: 'notice', level: 'info', text: 'goal set' }])
     expect(fx).toEqual([{ type: 'notice', level: 'info', text: 'goal set' }])
     expect(m.state).toMatchObject({ phase: 'plain', draft: '', occurrences: [] })
     expect(m.state).toMatchObject({ phase: 'plain', draft: '', occurrences: [] })
@@ -809,7 +809,7 @@ describe('input-machine: submitting transaction', () => {
     const m = new InputMachine()
     const m = new InputMachine()
     const { attempt: first } = enterSubmitting(m, 'goal', 'x')
     const { attempt: first } = enterSubmitting(m, 'goal', 'x')
     m.dispatch({ type: 'submit-settled', attempt: first, ok: false, message: 'retry' })
     m.dispatch({ type: 'submit-settled', attempt: first, ok: false, message: 'retry' })
-    const second = effectAt(m.dispatch({ type: 'enter', mode: 'queue' }), 0, 'begin-submit').attempt
+    const second = effectAt(m.dispatch({ type: 'enter' }), 0, 'begin-submit').attempt
     expect(second.seq).not.toBe(first.seq)
     expect(second.seq).not.toBe(first.seq)
     expect(m.dispatch({ type: 'submit-settled', attempt: first, ok: true })).toEqual([])
     expect(m.dispatch({ type: 'submit-settled', attempt: first, ok: true })).toEqual([])
     expect(m.state.phase).toBe('submitting')
     expect(m.state.phase).toBe('submitting')

+ 2 - 2
packages/client/ui-conversation/tests/input-matrix.spec.tsx

@@ -85,7 +85,7 @@ describe('matrix row: plain', () => {
     fireEvent.change(textarea, { target: { value: '普通消息' } })
     fireEvent.change(textarea, { target: { value: '普通消息' } })
     expect(shell.snapshot.claim).toBeUndefined()
     expect(shell.snapshot.claim).toBeUndefined()
     fireEvent.keyDown(textarea, { key: 'Enter' })
     fireEvent.keyDown(textarea, { key: 'Enter' })
-    expect(sink).toHaveBeenCalledWith('普通消息', 'queue')
+    expect(sink).toHaveBeenCalledWith('普通消息')
     expect(shell.snapshot.phase).toBe('plain')
     expect(shell.snapshot.phase).toBe('plain')
   })
   })
 })
 })
@@ -184,7 +184,7 @@ describe('matrix row: locked (session disabled)', () => {
     expect((textarea).disabled).toBe(false)
     expect((textarea).disabled).toBe(false)
     fireEvent.change(textarea, { target: { value: '排队' } })
     fireEvent.change(textarea, { target: { value: '排队' } })
     fireEvent.keyDown(textarea, { key: 'Enter' })
     fireEvent.keyDown(textarea, { key: 'Enter' })
-    expect(sink).toHaveBeenCalledWith('排队', 'queue')
+    expect(sink).toHaveBeenCalledWith('排队')
   })
   })
 })
 })
 
 

+ 2 - 2
packages/client/ui-conversation/tests/input-scenarios.spec.tsx

@@ -224,7 +224,7 @@ describe('scenario D: execute-kind /compact', () => {
     act(() => { b2.shell.setDraft('/compact 现在') })
     act(() => { b2.shell.setDraft('/compact 现在') })
     fireEvent.keyDown(b2.textarea, { key: 'Enter' })
     fireEvent.keyDown(b2.textarea, { key: 'Enter' })
     // execute with trailing → matchEnter answers undefined → default sink.
     // execute with trailing → matchEnter answers undefined → default sink.
-    await vi.waitFor(() => { expect(b2.sink).toHaveBeenCalledWith('/compact 现在', 'queue') })
+    await vi.waitFor(() => { expect(b2.sink).toHaveBeenCalledWith('/compact 现在') })
     expect(b2.executed).toHaveLength(0)
     expect(b2.executed).toHaveLength(0)
   })
   })
 })
 })
@@ -278,7 +278,7 @@ describe('scenario I: unknown /xyz + enter', () => {
     const b = await bench()
     const b = await bench()
     act(() => { b.shell.setDraft('/xyz 干点啥') })
     act(() => { b.shell.setDraft('/xyz 干点啥') })
     fireEvent.keyDown(b.textarea, { key: 'Enter' })
     fireEvent.keyDown(b.textarea, { key: 'Enter' })
-    await vi.waitFor(() => { expect(b.sink).toHaveBeenCalledWith('/xyz 干点啥', 'queue') })
+    await vi.waitFor(() => { expect(b.sink).toHaveBeenCalledWith('/xyz 干点啥') })
     expect(b.shell.snapshot.phase).toBe('plain')
     expect(b.shell.snapshot.phase).toBe('plain')
     expect(b.execute).not.toHaveBeenCalled()
     expect(b.execute).not.toHaveBeenCalled()
   })
   })

+ 6 - 6
packages/client/ui-conversation/tests/service-orchestration.spec.ts

@@ -33,11 +33,11 @@ async function bench() {
 describe('ConversationService', () => {
 describe('ConversationService', () => {
   it('routes operations through the public Session binding', async () => {
   it('routes operations through the public Session binding', async () => {
     const b = await bench()
     const b = await bench()
-    await b.scoped.send('hello', 'steer')
+    await b.scoped.send('hello')
     await b.scoped.updateQueue('item-1' as never, { kind: 'remove' })
     await b.scoped.updateQueue('item-1' as never, { kind: 'remove' })
     await b.scoped.cancel()
     await b.scoped.cancel()
     await b.scoped.loadOlder()
     await b.scoped.loadOlder()
-    expect(b.prompt).toHaveBeenCalledWith([{ type: 'text', text: 'hello' }], 'steer')
+    expect(b.prompt).toHaveBeenCalledWith([{ type: 'text', text: 'hello' }], 'queue')
     expect(b.updateQueue).toHaveBeenCalledWith('item-1', { kind: 'remove' })
     expect(b.updateQueue).toHaveBeenCalledWith('item-1', { kind: 'remove' })
     expect(b.cancel).toHaveBeenCalledOnce()
     expect(b.cancel).toHaveBeenCalledOnce()
     expect(b.loadOlder).toHaveBeenCalledOnce()
     expect(b.loadOlder).toHaveBeenCalledOnce()
@@ -47,7 +47,7 @@ describe('ConversationService', () => {
   it('folds Session business failures into callback rejections', async () => {
   it('folds Session business failures into callback rejections', async () => {
     const b = await bench()
     const b = await bench()
     b.prompt.mockResolvedValueOnce({ ok: false, error: { code: 'agent-busy', message: 'busy', details: {} } } as never)
     b.prompt.mockResolvedValueOnce({ ok: false, error: { code: 'agent-busy', message: 'busy', details: {} } } as never)
-    await expect(b.scoped.send('x', 'queue')).rejects.toThrow('conversation.send failed: agent-busy: busy')
+    await expect(b.scoped.send('x')).rejects.toThrow('conversation.send failed: agent-busy: busy')
     b.cancel.mockResolvedValueOnce({ ok: false, error: { code: 'internal', message: 'nope', details: {} } } as never)
     b.cancel.mockResolvedValueOnce({ ok: false, error: { code: 'internal', message: 'nope', details: {} } } as never)
     await expect(b.scoped.cancel()).rejects.toThrow('conversation.cancel failed: internal: nope')
     await expect(b.scoped.cancel()).rejects.toThrow('conversation.cancel failed: internal: nope')
     await b.runtime.dispose()
     await b.runtime.dispose()
@@ -55,9 +55,9 @@ describe('ConversationService', () => {
 
 
   it('fails loudly from the root scope, on an unbound session, or without SessionsService', async () => {
   it('fails loudly from the root scope, on an unbound session, or without SessionsService', async () => {
     const b = await bench()
     const b = await bench()
-    await expect(b.root.send('x', 'queue')).rejects.toThrow(/requires a session scope/)
+    await expect(b.root.send('x')).rejects.toThrow(/requires a session scope/)
     await b.runtime.sessions.remove('s1')
     await b.runtime.sessions.remove('s1')
-    await expect(b.scoped.send('x', 'queue')).rejects.toThrow(/resolved no binding/)
+    await expect(b.scoped.send('x')).rejects.toThrow(/resolved no binding/)
     await b.runtime.dispose()
     await b.runtime.dispose()
     // No SessionsService at all: a bare context (the runtime always provides one).
     // No SessionsService at all: a bare context (the runtime always provides one).
     const bare = new Context()
     const bare = new Context()
@@ -65,6 +65,6 @@ describe('ConversationService', () => {
       input: new InputHub(bare),
       input: new InputHub(bare),
     }).await()
     }).await()
     const orphan = bare.get('conversation') as ConversationService
     const orphan = bare.get('conversation') as ConversationService
-    await expect(orphan.send('x', 'queue')).rejects.toThrow(/sessions service unavailable/)
+    await expect(orphan.send('x')).rejects.toThrow(/sessions service unavailable/)
   })
   })
 })
 })

+ 1 - 1
packages/client/ui-conversation/tests/skeleton.spec.tsx

@@ -206,7 +206,7 @@ describe('ConversationRoot resident composer', () => {
     fireEvent.change(box, { target: { value: 'ordinary revised' } })
     fireEvent.change(box, { target: { value: 'ordinary revised' } })
     expect(b.chat.store.getSnapshot().draft).toBe('ordinary revised')
     expect(b.chat.store.getSnapshot().draft).toBe('ordinary revised')
     fireEvent.keyDown(box, { key: 'Enter' })
     fireEvent.keyDown(box, { key: 'Enter' })
-    expect(b.sink).toHaveBeenCalledWith('ordinary revised', 'queue')
+    expect(b.sink).toHaveBeenCalledWith('ordinary revised')
     fireEvent.click(b.view.getByRole('button', { name: 'Root' }))
     fireEvent.click(b.view.getByRole('button', { name: 'Root' }))
     expect(b.open).toHaveBeenCalledWith(sid('root'))
     expect(b.open).toHaveBeenCalledWith(sid('root'))
   })
   })