Merge latest master into PR 555

# Conflicts:
#	.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.i18n.yaml
#	.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md
#	.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.zh.md
#	packages/client/ui-conversation/README.i18n.yaml
#	packages/client/ui-conversation/src/client/chat/MessageItem.tsx
#	packages/client/ui-conversation/src/client/input/facade.ts
#	packages/client/ui-conversation/src/client/input/hub.ts
#	packages/client/ui-conversation/src/client/service.ts
#	packages/client/ui-conversation/tests/apply-inject.spec.tsx
#	packages/client/ui-conversation/tests/input-bar.spec.tsx
#	packages/client/ui-conversation/tests/input-matrix.spec.tsx
#	packages/client/ui-conversation/tests/input-scenarios.spec.tsx
#	packages/client/ui-conversation/tests/skeleton.spec.tsx
This commit is contained in:
creatixchu
2026-07-31 20:11:28 +08:00
28 changed files with 236 additions and 175 deletions

View File

@@ -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: c42eeda7ab9936a37f6059b409748563e23da5fc 2026-07-25-web-input-machine-and-slash-pipeline.md: 3982850310ae59bf1b0b4d1552362ef70035aeb2
2026-07-25-web-input-machine-and-slash-pipeline.zh.md: fc0ea83a9095857c09aad991c263f421d6118adb 2026-07-25-web-input-machine-and-slash-pipeline.zh.md: aa6137309aadce9c12bfe28918c1dd2b9f7c7014

View File

@@ -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 and submitted-image clear it goes only through `session.prompt {mode:'queue'|'steer'}`; a failure restores the submitted image identifiers and backfills text only when the live draft is still empty, so text or images added during flight are never overwritten. No Draft materialize or attach transaction exists. - Sending unifies in the hub defaultSink: after an optimistic draft and submitted-image 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); a failure restores the submitted image identifiers and backfills text only when the live draft is still empty, so text or images added during flight are 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 and ordered image identifiers move 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 and ordered image identifiers move 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.

View File

@@ -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 Herosidebar 的 blank 位只在 prompt 成功受理后翻 false。 - ConversationRoot 的 Hero 判据是 `sessionId === undefined || (composerPhase === 'blank' && (openState === 'open' || openState === 'loading'))`。首次 submit 同步进入 engaging失败也保留 composer 与错误上下文,不退回 blank Herosidebar 的 blank 位只在 prompt 成功受理后翻 false。
- 发送统一在 hub defaultSink乐观清除 draft 和已提交图片后只走 `session.prompt {mode:'queue'|'steer'}`;失败时恢复已提交图片标识符,仅当 live draft 仍为空时才回填文本,因此不会覆盖飞行期间新增的文本或图片。不存在 Draft materialize 或 attach 事务。 - 发送统一在 hub defaultSink乐观清除 draft 和已提交图片后只走 `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依赖这一保证。

View File

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

View File

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

View File

@@ -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 是 hostagent-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 对 ACPTUI自动化仍是承重能力。
**在 transcript 中隐藏 `steering/message`。** 外部客户端 steer 时回放会撒谎;改为普通气泡。
**保留 mode 参数但永远只传 `'queue'`。** 留下死 API 面与只会虚构 composer 到不了的 `'steer'` 路径的测试。
## 后果
- Web 用户无法从 composer 或 `ctx.conversation.send` steer中途控制只剩停止取消与 Queue。
- Host 线缆与非 Web 客户端仍可 steerWeb 客户端展示这些消息时不再标成插话。
- 若要重新引入专用 steer UI需要新的产品决策没有决策就不要复活 mode 联合类型或徽章。
## 测试
- `packages/client/ui-conversation` unitjsdom 覆盖input machine entersink、ConversationService 路由、MessageItem steering 分支无「插话」、InputBar submit。
- `apps/web/tests/steering.e2e.ts` 无密钥回放,以及更新后的 `settled.expected.md`(有 steer 正文、无徽章)。

View File

@@ -21,7 +21,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

View File

@@ -1,16 +1,15 @@
// Web e2e scenario: mid-turn steering, end to end. The product composer // Web e2e scenario: mid-turn steering over the host wire. The Web UI has no
// deliberately exposes Queue only, so the steer is POSTed from the page // steer entry, so the steer is POSTed from the page over the same
// itself over the same same-origin /api transport the client uses. // same-origin /api transport the client uses. Everything downstream is
// TODO(web-steer-ui): Drive this through a dedicated steering interaction // product: the gateway routes mode:'steer' to Agent.steer, the loop drains
// once one exists. Everything downstream is product: the gateway // it at the step boundary into a durable steering/message event, the SSE mux
// routes mode:'steer' to Agent.steer, the loop drains it at the step // pushes it, and the transcript shows the text as a plain bubble (no
// boundary into a durable steering/message event, the SSE mux pushes it, and // interjection chrome). The question composer supplies the deterministic
// the transcript renders the badged interjection bubble. The question // mid-turn window: while ask_user_question blocks, the turn is provably
// composer supplies the deterministic mid-turn window: while ask_user_question // running, so record and replay perform the identical steer-then-answer
// blocks, the turn is provably running, so record and replay perform the // sequence with zero timing dependence — and the recorded final reply proves
// identical steer-then-answer sequence with zero timing dependence — and the // the steer reached the MODEL (it obeys an instruction that only the
// recorded final reply proves the steer reached the MODEL (it obeys an // steering message carries).
// 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 // drains steering at the step boundary, so no steering text exists while
// while the question still blocks the step) and the settled transcript // the question still blocks the step) and the settled transcript (plain
// (badged bubble in place, final reply obeying it). The pair pins the // bubble in place, final reply obeying it). The pair pins the timing
// timing semantics visually: if the client ever starts rendering pending // semantics visually: if the client ever starts rendering pending steers
// steers eagerly, the mid-steer golden flips first. // 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 // exists yet and no steer text renders — the composer still blocks,
// blocks, alone. The DOM is stable here (no further SSE frames can // alone. The DOM is stable here (no further SSE frames can arrive until
// arrive until the question is answered), making this state capturable. // the question is answered), making this state capturable.
expect(await page.getByText('Interjection', { exact: true }).count()).toBe(0)
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(STEER, { 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('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 // Settled golden: steer text between the question round trip and the
// and the obeying reply, composer takeover gone. // 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([])

View File

@@ -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: 2dafe3c69bb087550f6c0f7faab12bff1f1324ac README.md: c36e2768bb1562b2e7ec65973f23267a915eb505
README.zh.md: 4201441c6c6397b7b35fe0fab99fc6fc6271719f README.zh.md: f20556bf0031a58ee46e14beec50dbc7ff7dc8e4

View File

@@ -63,4 +63,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.

View File

@@ -63,4 +63,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 在回放时仍能如实呈现。

View File

@@ -29,16 +29,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;
} }

View File

@@ -1,6 +1,7 @@
// MessageItem: simple chat nodes — user bubble (right-aligned, with // MessageItem: simple chat nodes — user bubble (right-aligned, with
// clock + copy / branch IconActions), steering (badged bubble), context // clock + copy / branch IconActions), steering (same bubble, no actions),
// injection, compaction marker, retry disclosure, and unknown-surface JSON rows. // context injection, compaction marker, retry disclosure, and
// unknown-surface JSON rows.
import { memo, useEffect, useMemo, useState } from 'react' import { memo, useEffect, useMemo, useState } from 'react'
import type { ReactNode } from 'react' import type { ReactNode } from 'react'
@@ -178,25 +179,31 @@ function projectUserText(text: string): ReactNode {
return <>{parts}</> return <>{parts}</>
} }
function UserContentStack({ parts, imageLoader, steering = false, t, truncated }: { /** Right-aligned bubble shared by user and steering rows (steering has no actions). */
parts: ReturnType<typeof contentParts> function UserStyleBubble({
content, imageLoader, actions, t,
}: {
content: readonly unknown[]
imageLoader: ImageLoader imageLoader: ImageLoader
steering?: boolean /** Optional IconActions (or similar) below the bubble; receives the joined text. */
actions?: (text: string) => ReactNode
t: ChatViewSlotProps['t'] t: ChatViewSlotProps['t']
truncated: (total: number) => string }): ReactNode {
}) { const { text, images, rest } = contentParts(content)
const { text, images, rest } = parts const truncated = (total: number): string => t('json.truncated', { total })
const showBubble = steering || text !== '' || rest.length > 0 const showBubble = text !== '' || rest.length > 0
return ( return (
<div className={css.userStack}> <div className={css.userRow}>
<ImageGallery images={images} load={imageLoader} align="end" t={t} /> <div className={css.userStack}>
{showBubble && <div className={css.bubble}> <ImageGallery images={images} load={imageLoader} align="end" t={t} />
{steering && <span className={css.badge}>{t('message.steering')}</span>} {showBubble && <div className={css.bubble}>
{projectUserText(text)} {projectUserText(text)}
{rest.map((block, i) => ( {rest.map((block, i) => (
<JsonBlock key={i} label={t('message.extraBlock')} payload={block} truncatedLabel={truncated} /> <JsonBlock key={i} label={t('message.extraBlock')} payload={block} truncatedLabel={truncated} />
))} ))}
</div>} </div>}
</div>
{actions?.(text)}
</div> </div>
) )
} }
@@ -207,30 +214,26 @@ export const MessageItem = memo(function MessageItem({
const imageLoader = loadImage ?? (() => Promise.reject(new Error(t('image.serviceUnavailable')))) const imageLoader = loadImage ?? (() => Promise.reject(new Error(t('image.serviceUnavailable'))))
const truncated = (total: number): string => t('json.truncated', { total }) const truncated = (total: number): string => t('json.truncated', { total })
switch (node.kind) { switch (node.kind) {
case 'user': { case 'user':
const parts = contentParts(node.content)
return ( return (
<div className={css.userRow}> <UserStyleBubble
<UserContentStack parts={parts} imageLoader={imageLoader} t={t} truncated={truncated} /> content={node.content}
<MessageIconActions imageLoader={imageLoader}
text={parts.text} t={t}
time={node.time} actions={text => (
clock="start" <MessageIconActions
onBranch={onFork === undefined ? undefined : () => { onFork(node.seq) }} text={text}
className={css.actions} time={node.time}
t={t} clock="start"
/> onBranch={onFork === undefined ? undefined : () => { onFork(node.seq) }}
</div> className={css.actions}
t={t}
/>
)}
/>
) )
} case 'steering':
case 'steering': { return <UserStyleBubble content={node.content} imageLoader={imageLoader} t={t} />
const parts = contentParts(node.content)
return (
<div className={css.userRow}>
<UserContentStack parts={parts} imageLoader={imageLoader} steering t={t} truncated={truncated} />
</div>
)
}
case 'context': case 'context':
return ( return (
<ContextInjectionRow content={node.content} source={node.source} t={t} /> <ContextInjectionRow content={node.content} source={node.source} t={t} />

View File

@@ -43,7 +43,7 @@ export interface SessionInput extends InputTarget {
/** Drop ids whose browser objects no longer exist. */ /** Drop ids whose browser objects no longer exist. */
pruneImages(ids: readonly DraftAttachmentId[]): void pruneImages(ids: readonly DraftAttachmentId[]): 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.
@@ -83,7 +83,7 @@ export interface InputActions {
/** Drop ids whose browser objects no longer exist. */ /** Drop ids whose browser objects no longer exist. */
pruneImages(ids: readonly DraftAttachmentId[]): void pruneImages(ids: readonly DraftAttachmentId[]): 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. */
@@ -264,7 +264,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 }
@@ -283,5 +283,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 }

View File

@@ -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', imageIds: readonly DraftAttachmentId[]): void defaultSink(text: string, imageIds: readonly DraftAttachmentId[]): void
} }
/** Guard tier from the machine phase. */ /** Guard tier from the machine phase. */
@@ -71,7 +71,7 @@ export class SessionInputShell implements SessionInput {
addImages: ids => this.addImages(ids), addImages: ids => this.addImages(ids),
removeImage: (id) => { this.removeImage(id) }, removeImage: (id) => { this.removeImage(id) },
pruneImages: (ids) => { this.pruneImages(ids) }, pruneImages: (ids) => { this.pruneImages(ids) },
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
@@ -200,14 +200,13 @@ 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 { submit(): void {
if (this.snapshot.draft.trim() === '' && this.imageIds.length > 0) { if (this.snapshot.draft.trim() === '' && this.imageIds.length > 0) {
if (this.snapshot.phase === 'plain') this.deps.defaultSink('', mode, [...this.imageIds]) if (this.snapshot.phase === 'plain') this.deps.defaultSink('', [...this.imageIds])
return return
} }
this.run(this.core.dispatch({ type: 'enter', mode })) 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()
@@ -392,7 +391,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:
@@ -407,11 +406,11 @@ 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 imageIds = [...this.imageIds] const imageIds = [...this.imageIds]
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, imageIds) this.deps.defaultSink(draft.trim(), imageIds)
return return
} }
const slash = this.deps.slash?.() const slash = this.deps.slash?.()
@@ -431,7 +430,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, imageIds) this.deps.defaultSink(out.trim(), imageIds)
}, },
(error: unknown) => { (error: unknown) => {
controller.abort() controller.abort()

View File

@@ -25,7 +25,6 @@ interface ConversationAttachmentFace {
sendSession( sendSession(
session: SessionFace, session: SessionFace,
text: string, text: string,
mode: 'queue' | 'steer',
imageIds: readonly DraftAttachmentId[], imageIds: readonly DraftAttachmentId[],
): Promise<void> ): Promise<void>
releaseDraftImage(id: DraftAttachmentId): void releaseDraftImage(id: DraftAttachmentId): void
@@ -67,7 +66,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, imageIds) => { this.sink(session, text, mode, imageIds) }, defaultSink: (text, imageIds) => { this.sink(session, text, imageIds) },
}) })
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
@@ -148,14 +147,13 @@ export class InputHub implements InputService {
private sink( private sink(
session: SessionFace, session: SessionFace,
text: string, text: string,
mode: 'queue' | 'steer',
imageIds: readonly DraftAttachmentId[], imageIds: readonly DraftAttachmentId[],
): void { ): void {
if (text === '' && imageIds.length === 0) return if (text === '' && imageIds.length === 0) 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(imageIds) shell?.commitSend(imageIds)
void this.conversation().sendSession(session, text, mode, imageIds).catch(() => { void this.conversation().sendSession(session, text, imageIds).catch(() => {
// Restore only into the shell that still owns the session: if the scope // Restore only into the shell that still owns the session: if the scope
// died while the send was in flight, `commitSend` already removed the // died while the send was in flight, `commitSend` already removed the
// ids from the (now disposed) shell, so the teardown release could not // ids from the (now disposed) shell, so the teardown release could not

View File

@@ -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[] = []
@@ -164,7 +163,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)
@@ -463,18 +462,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) }]
@@ -483,11 +482,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[] {
@@ -508,7 +507,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 }]
: [] : []
} }

View File

@@ -61,7 +61,6 @@ export const zh = {
'chat.loadOlder': '加载更早', 'chat.loadOlder': '加载更早',
'chat.toBottom': '回到底部', 'chat.toBottom': '回到底部',
'message.extraBlock': '附加内容块', 'message.extraBlock': '附加内容块',
'message.steering': '插话',
'message.contextInjection': '上下文注入', 'message.contextInjection': '上下文注入',
'message.compaction': '上下文已压缩', 'message.compaction': '上下文已压缩',
'message.compaction.expand': '点击查看压缩摘要', 'message.compaction.expand': '点击查看压缩摘要',
@@ -176,7 +175,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.compaction': 'Context compacted', 'message.compaction': 'Context compacted',
'message.compaction.expand': 'View compaction summary', 'message.compaction.expand': 'View compaction summary',

View File

@@ -27,12 +27,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.
@@ -110,11 +109,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 when non-empty. * @param text - prompt text, sent verbatim as one text block when non-empty.
* @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')
await this.sendFiles(session, text, mode, []) await this.sendFiles(session, text, [])
} }
/** /**
@@ -123,32 +121,29 @@ export class ConversationService extends Service implements IConversation {
* persisted or stale id. * persisted or stale id.
* @param session - target session. * @param session - target session.
* @param text - serialized prompt text. * @param text - serialized prompt text.
* @param mode - queue or steer.
* @param imageIds - ordered draft-local attachment ids. * @param imageIds - ordered draft-local attachment ids.
*/ */
async sendSession( async sendSession(
session: SessionFace, session: SessionFace,
text: string, text: string,
mode: 'queue' | 'steer',
imageIds: readonly DraftAttachmentId[], imageIds: readonly DraftAttachmentId[],
): Promise<void> { ): Promise<void> {
const attachments = this.draftImages(imageIds) const attachments = this.draftImages(imageIds)
if (attachments.length !== imageIds.length) { if (attachments.length !== imageIds.length) {
throw new Error('conversation.sendSession: one or more draft images are no longer available') throw new Error('conversation.sendSession: one or more draft images are no longer available')
} }
await this.sendFiles(session, text, mode, attachments.map(attachment => attachment.file)) await this.sendFiles(session, text, attachments.map(attachment => attachment.file))
this.releaseDraftImages(attachments) this.releaseDraftImages(attachments)
} }
private async sendFiles( private async sendFiles(
session: SessionFace, session: SessionFace,
text: string, text: string,
mode: 'queue' | 'steer',
images: readonly File[], images: readonly File[],
): Promise<void> { ): Promise<void> {
const uploaded = await this.serializeImages(images) const uploaded = await this.serializeImages(images)
const content = [...uploaded, ...(text === '' ? [] : [{ type: 'text' as const, text }])] const content = [...uploaded, ...(text === '' ? [] : [{ type: 'text' as const, text }])]
const result = await session.prompt(content, mode) const result = await session.prompt(content, '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}`)
} }

View File

@@ -213,7 +213,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 => {
@@ -355,7 +355,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

View File

@@ -107,7 +107,7 @@ async function bench() {
const actions = info.props['inputActions'] as { const actions = info.props['inputActions'] as {
setDraft: (text: string) => void setDraft: (text: string) => void
addImages: (ids: readonly DraftAttachmentId[]) => boolean addImages: (ids: readonly DraftAttachmentId[]) => boolean
submit: (mode?: 'queue' | 'steer') => void submit: () => void
} }
return { state, actions } return { state, actions }
} }
@@ -146,25 +146,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')

View File

@@ -102,7 +102,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,
@@ -110,7 +110,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()

View File

@@ -147,7 +147,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: ' ' })
@@ -215,7 +215,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)
@@ -231,7 +231,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)
}) })
@@ -371,7 +371,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')!
@@ -390,7 +390,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')
// A refused batch must be observable (the workspace-switch transfer keeps // A refused batch must be observable (the workspace-switch transfer keeps
@@ -588,7 +588,7 @@ describe('image draft rail', () => {
const send = view.getByRole('button', { name: '发送消息' }) as HTMLButtonElement const send = view.getByRole('button', { name: '发送消息' }) as HTMLButtonElement
expect(send.disabled).toBe(false) expect(send.disabled).toBe(false)
fireEvent.keyDown(textarea, { key: 'Enter' }) fireEvent.keyDown(textarea, { key: 'Enter' })
expect(sink).toHaveBeenCalledWith('', 'queue', ['draft-1']) expect(sink).toHaveBeenCalledWith('', ['draft-1'])
fireEvent.click(view.getByRole('button', { name: '移除图片 pixel.png' })) fireEvent.click(view.getByRole('button', { name: '移除图片 pixel.png' }))
expect(removeImage).toHaveBeenCalledWith('draft-1') expect(removeImage).toHaveBeenCalledWith('draft-1')

View File

@@ -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' })) expect(m.dispatch({ type: 'enter' }))
.toEqual([{ type: 'default-sink', draft: 'hello world', mode: 'steer' }]) .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' })) expect(m.dispatch({ type: 'enter' }))
.toEqual([{ type: 'default-sink', draft: '第一行\n/goal x', mode: 'queue' }]) .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')

View File

@@ -90,7 +90,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')
}) })
}) })
@@ -189,7 +189,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('排队', [])
}) })
}) })

View File

@@ -237,7 +237,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)
}) })
}) })
@@ -291,7 +291,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()
}) })

View File

@@ -34,11 +34,11 @@ async function bench(readAttachment?: SessionFace['readAttachment']) {
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()
@@ -48,7 +48,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()
@@ -110,7 +110,7 @@ describe('ConversationService', () => {
let reject!: (error: Error) => void let reject!: (error: Error) => void
b.prompt.mockReturnValueOnce(new Promise((_resolve, rej) => { reject = rej }) as never) b.prompt.mockReturnValueOnce(new Promise((_resolve, rej) => { reject = rej }) as never)
shell.setDraft('x') shell.setDraft('x')
shell.submit('queue') shell.submit()
// commitSend already removed the ids from the shell; kill the scope // commitSend already removed the ids from the shell; kill the scope
// while the RPC is still pending, then land the failure. // while the RPC is still pending, then land the failure.
await b.runtime.sessions.remove('s1') await b.runtime.sessions.remove('s1')
@@ -141,7 +141,7 @@ describe('ConversationService', () => {
shell.addImages([first.id]) shell.addImages([first.id])
shell.setDraft('describe') shell.setDraft('describe')
shell.submit('queue') shell.submit()
expect(shell.addImages([second.id])).toBe(true) expect(shell.addImages([second.id])).toBe(true)
expect(shell.snapshot.imageIds).toEqual([second.id]) expect(shell.snapshot.imageIds).toEqual([second.id])
@@ -186,9 +186,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()
@@ -196,6 +196,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/)
}) })
}) })

View File

@@ -221,7 +221,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'))
}) })