diff --git a/.agents/notes/implemented/bug-fix/2026-09-22-input-echo-admission-ownership.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-09-22-input-echo-admission-ownership.i18n.yaml index b9720ff269..6ba4b3a015 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-22-input-echo-admission-ownership.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-09-22-input-echo-admission-ownership.i18n.yaml @@ -2,5 +2,5 @@ # 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/bug-fix/2026-09-22-input-echo-admission-ownership.md -2026-09-22-input-echo-admission-ownership.md: 806d524fff3ae7331dbb25c270a95282f540fa87 -2026-09-22-input-echo-admission-ownership.zh.md: 2ad6008c19cfb021e730f3c503c21eb862fc04aa +2026-09-22-input-echo-admission-ownership.md: 227857bd8168aa1d7f51b621737c2ed0ab572632 +2026-09-22-input-echo-admission-ownership.zh.md: 1d64de220a3c6d5c9357770175c79266b8f9f063 diff --git a/.agents/notes/implemented/bug-fix/2026-09-22-input-echo-admission-ownership.md b/.agents/notes/implemented/bug-fix/2026-09-22-input-echo-admission-ownership.md index 806d524fff..227857bd81 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-22-input-echo-admission-ownership.md +++ b/.agents/notes/implemented/bug-fix/2026-09-22-input-echo-admission-ownership.md @@ -13,8 +13,8 @@ These transitions concern display ownership, not scrolling or composer geometry. ## Decision - A submission keeps its locally selected placement: idle input stays in Chat, running steering stays at the process tail, and explicitly queued input stays in QueueDock. Inbox acceptance does not change that placement. -- Chat hides an echo by matching the durable input's rpcId in the same render. QueueDock excludes only Inbox rows matched by local transcript echoes; unrelated queued messages remain visible and actionable. -- Session retains an admitted transcript's local suppression identity until the Inbox projection watermark reaches its claim sequence. Higher-sequence projection retention then prevents an older accepted row from reappearing after that identity retires. Claim tracking and cancellation use the existing per-submission receipt for both Inbox targets; no new SessionSnapshot field, Host event, or persisted format is needed. +- Chat hides an echo by matching the durable input's rpcId in the same render, and excludes stale next-step Inbox rows matched by admitted local Chat submissions. QueueDock excludes only Inbox rows matched by local transcript echoes; unrelated queued messages remain visible and actionable. +- Session retains an admitted transcript or steering submission's local suppression identity until the Inbox projection watermark reaches its claim sequence. Higher-sequence projection retention then prevents an older accepted row from reappearing after that identity retires. Claim tracking and cancellation use the existing per-submission receipt for both Inbox targets; no new SessionSnapshot field, Host event, or persisted format is needed. - At an open, input-free progress control at the end of Chat, the first transcript echo precedes the control. Other echoes remain at the tail. Last-input Turn tracking shares the existing rpcId scan; it does not add a history traversal or predict a sequence of future Turns. - Pending bubbles and durable seats share one keyed React list. Inserting a progress title preserves the pending bubble's mounted identity. Once admitted, the durable node owns ordering and ordinary Group segmentation. @@ -26,7 +26,7 @@ The [durable Inbox recovery decision](2026-08-17-durable-web-queue-recovery.md) **Retain the echo without changing the other readers.** Chat-side Inbox deduplication can still remove the echo, and QueueDock can still display it. The Session lifetime, Chat handoff and Dock exclusion must agree. -**Delete the suppression identity as soon as user/message arrives.** A later Inbox acceptance projection can resurrect a Dock row. The claim watermark supplies the required ordering evidence without assuming the streams publish together. +**Delete the suppression identity as soon as user/message arrives.** A later Inbox acceptance projection can resurrect a Dock or pending-steering row. The claim watermark supplies the required ordering evidence without assuming the streams publish together. **Move all pending inputs before the newest Turn control.** Running steering belongs after existing process content, and several ordinary submissions can target different Turns. Only the first local transcript echo receives the empty-control placement adjustment. @@ -41,11 +41,13 @@ The [durable Inbox recovery decision](2026-08-17-durable-web-queue-recovery.md) | [Chat rendering](../../../../packages/client/ui-chat/tests/chat-view.client.spec.tsx) | Same-render rpcId replacement, stable echo mounting, title-before-input delivery in all three modes, steering remaining behind the title, historical empty Turns, and six local-versus-Host orderings. | | [QueueDock](../../../../packages/client/ui-conversation/tests/queue-dock.client.spec.tsx) | Local transcript exclusion, unrelated rows/actions, and all six three-message acceptance orders followed by FIFO claims. | | [Recorded Web replay](../../../../apps/web/tests/idle-submission-handoff.e2e.ts) | Real Web composition with FIFO-preserving delivery barriers: both Inbox/history arrival orders at 6× CPU throttling, an explicit turn/start-to-user/message gap, and complete three-Turn handoff for all six request orders. | +| [Steering Web replay](../../../../apps/web/tests/steering.e2e.ts) | Local shortcut steering remains unique when durable admission precedes its Inbox claim projection. | The single-input regression fails before the fix: Inbox acceptance moves the bubble into Dock, and a separately delivered Turn title precedes the echo. The corrected path retains one Chat representation at a stable vertical position. Multi-input replay verifies durable order and absence of duplicate or residual echoes; it does not assert that optimistic ordering predicts Host order. ## Consequences +- Steering without a locally tracked submission still follows the Inbox projection; a delayed projection can briefly duplicate its durable row. Local deduplication adds no history scan for other clients' submissions. - Reconnect may discard receipt-confirmed optimistic echoes and display the new authoritative baseline. A claimed input outside that baseline can briefly have no bubble; reconnect does not promise DOM continuity. - Submissions made before the local running update can all remain transcript echoes. If A, B and C are submitted locally in that order but B or C enters first, durable admission changes their visible order. Even when A enters first, remaining local echoes can differ from the Host queue order. - Cross-client competition and request-preparation races can change the actual opening input. The client does not migrate an idle-classified echo into Dock merely because the Host queued it behind another Turn. diff --git a/.agents/notes/implemented/bug-fix/2026-09-22-input-echo-admission-ownership.zh.md b/.agents/notes/implemented/bug-fix/2026-09-22-input-echo-admission-ownership.zh.md index 2ad6008c19..1d64de220a 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-22-input-echo-admission-ownership.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-09-22-input-echo-admission-ownership.zh.md @@ -13,8 +13,8 @@ Status: implemented ## 决策 - 提交保留本地发送时选择的 placement:空闲输入留在 Chat,运行中的 steering 留在过程末尾,明确排队的输入留在 QueueDock。Inbox 受理不改变展示归属。 -- Chat 在同一次渲染中按持久输入的 rpcId 隐藏回显。QueueDock 只排除匹配本地 transcript 回显的 Inbox 行;其他排队消息仍可见且可操作。 -- Session 保留已入档 transcript 的本地排除身份,直到 Inbox 投影水位达到其领取序号。随后,较高序号投影优先的规则阻止旧受理行在该身份回收后重新出现。领取跟踪和取消共用已有逐提交 receipt,并覆盖两种 Inbox target;不新增 SessionSnapshot 字段、Host 事件或持久格式。 +- Chat 在同一次渲染中按持久输入的 rpcId 隐藏回显,并排除匹配已入档本地 Chat 提交的旧 next-step Inbox 行。QueueDock 只排除匹配本地 transcript 回显的 Inbox 行;其他排队消息仍可见且可操作。 +- Session 保留已入档 transcript 或 steering 提交的本地排除身份,直到 Inbox 投影水位达到其领取序号。随后,较高序号投影优先的规则阻止旧受理行在该身份回收后重新出现。领取跟踪和取消共用已有逐提交 receipt,并覆盖两种 Inbox target;不新增 SessionSnapshot 字段、Host 事件或持久格式。 - Chat 末尾为进行中且没有真实输入的过程控制行时,第一条 transcript 回显显示在控制行前,其他回显留在末尾。最后输入的轮次信息复用现有 rpcId 扫描,不新增历史遍历,也不预测未来轮次序列。 - 待处理气泡与持久节点容器共用一个 keyed React 列表。插入过程标题保留回显气泡的挂载身份。入档后,由持久节点决定排序及普通 Group 切分。 @@ -26,7 +26,7 @@ Status: implemented **只延长回显生命周期,不调整其他读取方。** Chat 侧按 Inbox 去重仍会移除回显,QueueDock 仍可能展示同一消息。Session 生命周期、Chat 接管和 Dock 排除必须一致。 -**user/message 到达后立即删除排除身份。** 较晚的 Inbox 受理投影会复活 Dock 行。领取水位提供必要的排序依据,不要求两条流同时发布。 +**user/message 到达后立即删除排除身份。** 较晚的 Inbox 受理投影会复活 Dock 行或待处理 steering 行。领取水位提供必要的排序依据,不要求两条流同时发布。 **把全部待处理输入移到最新 Turn 控制行前。** 运行中的 steering 属于已有过程内容之后,多条普通提交也可能属于不同轮次。只有第一条本地 transcript 回显参与空控制行前的定位调整。 @@ -41,11 +41,13 @@ Status: implemented | [Chat 渲染](../../../../packages/client/ui-chat/tests/chat-view.client.spec.tsx) | 同帧 rpcId 替换、回显稳定挂载、三种模式下标题先于输入到达、steering 留在标题之后、历史空轮次及六种本地与 Host 排列。 | | [QueueDock](../../../../packages/client/ui-conversation/tests/queue-dock.client.spec.tsx) | 本地 transcript 排除、其他行及操作、三条消息的全部六种受理顺序及后续 FIFO 领取。 | | [录制 Web 回放](../../../../apps/web/tests/idle-submission-handoff.e2e.ts) | 真实 Web 装配配合保留 FIFO 的投递屏障:6× CPU 降速下的 Inbox/历史两种到达顺序、明确的 turn/start 至 user/message 空档,以及六种请求排列的完整三轮接管。 | +| [Steering Web 回放](../../../../apps/web/tests/steering.e2e.ts) | 本地快捷键 steering 的持久入档先于 Inbox 领取投影到达时,仍只显示一份。 | 单条输入回归在修复前失败:Inbox 受理将气泡移入 Dock,单独交付的 Turn 标题又会出现在回显之前。修复后的路径保留唯一的 Chat 表示,且纵向位置稳定。多输入回放验证持久顺序及无重复、无残留回显,不断言乐观排列可以预测 Host 顺序。 ## 后果 +- 没有本地提交身份的 steering 仍按 Inbox 投影显示;投影迟到时可能短暂与持久节点重复。本地去重不为其他客户端的提交增加历史扫描。 - 重连可撤销已有接收回执的乐观回显,并展示新的权威基线。已领取但不在该基线中的输入可能短暂无气泡;重连不保证 DOM 连续性。 - 本地 running 更新前连续提交的消息可能都留为 transcript 回显。若本地按 A、B、C 提交,但 B 或 C 先入档,持久接管会调整可见顺序。即使 A 先入档,其余本地回显也可能与 Host 队列顺序不同。 - 跨客户端竞争和请求准备的先后差异可能改变实际开场输入。客户端不会仅因 Host 将输入排在另一轮后面,就把已按空闲分类的回显迁入 Dock。 diff --git a/apps/web/tests/steering.e2e.ts b/apps/web/tests/steering.e2e.ts index 0006353491..8b83176777 100644 --- a/apps/web/tests/steering.e2e.ts +++ b/apps/web/tests/steering.e2e.ts @@ -11,6 +11,7 @@ import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' import { parseSessionLog } from '@deepseek-ai/dsh-llm-replay' import type { SessionEvent } from '@deepseek-ai/dsh-session' import { expandAssistantStream } from '@deepseek-ai/dsh-llm' +import { parseRemoteStreamServerMessage } from '@deepseek-ai/dsh-api-gateway/stream-protocol' import { acknowledgeReloadConnectionLoss, assertFixtureInventory, captureExpandedTurnProcessAria, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts, @@ -191,13 +192,21 @@ describe('web e2e: mid-turn steering lands durably and visibly', () => { }) }) -describe('web e2e: composer shortcut steers directly', () => { +describe.each(['reconnect', 'delayed-inbox'] as const)('web e2e: composer shortcut steers directly (%s)', (handoff) => { let scaffold: WebScaffold let browser: Browser let page: Page let tripwire: ReturnType let remoteSocket: WebSocketRoute | undefined const sessionEvents: SessionEvent[] = [] + let holdInbox = false + let heldStream: string | undefined + let heldControl: (() => void)[] = [] + const releaseInbox = (): void => { + holdInbox = false + for (const send of heldControl) send() + heldControl = [] + } beforeAll(async () => { scaffold = await launchWebScaffold({ replayFixture: FIXTURE, paceMs: REPLAY_PACE_MS, compareReplaySession: false }) @@ -206,7 +215,17 @@ describe('web e2e: composer shortcut steers directly', () => { page = await newEnglishPage(browser) await page.routeWebSocket('**/api/remote.mux', (route) => { remoteSocket = route - route.connectToServer() + const server = route.connectToServer() + server.onMessage((message) => { + const frame = parseRemoteStreamServerMessage(String(message)) + if (holdInbox && frame.type === 'item') { + const value = frame.value as { type?: string; key?: string } + if (value.type === 'projection' && value.key === 'inbox') heldStream = frame.streamId + } + // Hold the remaining control stream in FIFO order, while history keeps flowing. + if (holdInbox && frame.streamId === heldStream) heldControl.push(() => { route.send(message) }) + else route.send(message) + }) }) tripwire = watchConsole(page) await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) @@ -251,6 +270,7 @@ describe('web e2e: composer shortcut steers directly', () => { return next() }) try { + holdInbox = handoff === 'delayed-inbox' await composer.getByRole('radio', { name: 'Yes' }).click() await composer.getByRole('radio', { name: 'Yes' }).press('Enter') await expect.poll(() => claimed, { timeout: 10_000 }).toBe(true) @@ -259,16 +279,25 @@ describe('web e2e: composer shortcut steers directly', () => { })) expect(await page.getByText(STEER, { exact: true }).count()).toBe(1) expect(await page.locator('[data-submission-echo]').filter({ hasText: STEER }).count()).toBe(1) - const socket = remoteSocket - if (socket === undefined) throw new Error('steering page has no Remote connection') - const warningStart = tripwire.warnings.length - await socket.close({ code: 1012, reason: 'steering reconnect checkpoint' }) - await expect.poll(() => remoteSocket !== socket, { timeout: 10_000 }).toBe(true) - await expect.poll(() => page.locator('[data-submission-echo]').count(), { timeout: 10_000 }).toBe(0) - expect(await page.getByText(STEER, { exact: true }).count()).toBe(0) - acknowledgeReloadConnectionLoss(tripwire, warningStart) + if (handoff === 'reconnect') { + const socket = remoteSocket + if (socket === undefined) throw new Error('steering page has no Remote connection') + const warningStart = tripwire.warnings.length + await socket.close({ code: 1012, reason: 'steering reconnect checkpoint' }) + await expect.poll(() => remoteSocket !== socket, { timeout: 10_000 }).toBe(true) + await expect.poll(() => page.locator('[data-submission-echo]').count(), { timeout: 10_000 }).toBe(0) + expect(await page.getByText(STEER, { exact: true }).count()).toBe(0) + acknowledgeReloadConnectionLoss(tripwire, warningStart) + } else { + await expect.poll(() => heldControl.length, { timeout: 10_000 }).toBeGreaterThan(0) + admission.resolve(undefined) + await page.locator('[data-chat-flow-kind="steering"]').filter({ hasText: STEER }).waitFor({ timeout: 10_000 }) + expect(await page.getByText(STEER, { exact: true }).count()).toBe(1) + expect(await pendingSteering.count()).toBe(0) + } } finally { admission.resolve(undefined) + releaseInbox() stopHolding() await settled } diff --git a/packages/api/session-controller/README.i18n.yaml b/packages/api/session-controller/README.i18n.yaml index f0350ddf0e..c283462c19 100644 --- a/packages/api/session-controller/README.i18n.yaml +++ b/packages/api/session-controller/README.i18n.yaml @@ -2,5 +2,5 @@ # 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 packages/api/session-controller/README.md -README.md: 7d3e2a8a34c2a18db369a65a880c31417cdc68e2 -README.zh.md: be0ab5bc7a4bc3f9cae950ac3e14a531a32a3db7 +README.md: fa2a251e94cfa2855790d7d71457ac4bdf2ff752 +README.zh.md: d1a73ed5843fddadfc2f401fee1a86eece4e5272 diff --git a/packages/api/session-controller/README.md b/packages/api/session-controller/README.md index 7d3e2a8a34..fa2a251e94 100644 --- a/packages/api/session-controller/README.md +++ b/packages/api/session-controller/README.md @@ -48,7 +48,7 @@ The Client adapter exposes `SessionEventStream`, a Gateway `RemoteJournalStream` The Session object also carries local submission echoes: `session.beginSubmission` inserts one into `SessionSnapshot.pendingSubmissions` synchronously, before the caller serializes and prompts, so a conversation UI can show the message on the submit click's own frame. The echo stores ordered image previews and durable file references. Session derives its `transcript`, `queued`, or `steering` placement from the current running state and requested delivery mode, then retains that placement until the display handoff. The prompt's `requestId` is the correlation identity: the Host echoes it as the durable user source's `rpcId`, including pending messages in the `inbox` projection. Queued echoes retire one animation frame after queue acceptance; Chat echoes follow the admission rule below. Identified failures and abandonment retire unadmitted echoes immediately. Disposal preserves observed admission and fails other unsettled submissions. Each retirement fires `onRetire` exactly once; an observed retirement includes the ordered durable attachment references so the composer can release successful cards while preserving failed drafts. Echoes are Client memory only; reload and reconnect rebuild the conversation from durable events alone. -During uninterrupted following, transcript and steering echoes stay in Chat through Inbox acceptance and claim until their durable messages arrive. After a transcript message is admitted, its local identity remains until the Inbox watermark reaches its next-turn claim seq: Chat already renders the durable Node, while QueueDock excludes that identity even if an older Inbox frame arrives late. Other queued messages remain visible. A replacement follow baseline withdraws receipt-confirmed echoes as observed, using their accepted attachment references; unconfirmed submissions remain pending. Host data then supplies the pending or admitted rows. Reconnecting between claim and admission can briefly omit that bubble; withdrawal confirms acceptance, not execution or failure. +During uninterrupted following, transcript and steering echoes stay in Chat through Inbox acceptance and claim until their durable messages arrive. After a local Chat message is admitted, its identity remains until the Inbox watermark reaches its next-turn or next-step claim seq: Chat already renders the durable Node, while Chat and QueueDock exclude matching stale Inbox rows. Other queued messages remain visible. A replacement follow baseline withdraws receipt-confirmed echoes as observed, using their accepted attachment references; unconfirmed submissions remain pending. Host data then supplies the pending or admitted rows. Reconnecting between claim and admission can briefly omit that bubble; withdrawal confirms acceptance, not execution or failure. The user-invocable `skills/list` metadata includes the winning provider’s optional instruction-file `path`. The composer can preview that file without loading every skill body or activating a cold Agent. diff --git a/packages/api/session-controller/README.zh.md b/packages/api/session-controller/README.zh.md index be0ab5bc7a..d1a73ed584 100644 --- a/packages/api/session-controller/README.zh.md +++ b/packages/api/session-controller/README.zh.md @@ -48,7 +48,7 @@ Client 适配器提供 `SessionEventStream`,即绑定到一个普通 Session Session 对象还承载本地提交回显:`session.beginSubmission` 在调用方序列化与提示词之前,同步把一条回显写入 `SessionSnapshot.pendingSubmissions`,会话 UI 因此能在点击提交的当帧显示消息。回显按顺序存放图片预览与持久文件引用。Session 根据当前运行状态与请求的投递模式推导其 `transcript`、`queued` 或 `steering` 位置,并保留该位置直到展示接管。提示词的 `requestId` 是关联标识:Host 把它回显为 durable user source 的 `rpcId`,`inbox` 投影中的待处理消息也保留同一 source。排队回显在队列接受后延迟一个动画帧退休;Chat 回显遵循下述入档规则。尚未入档的回显在带标识的提示词失败或被放弃时立即退休。销毁时保留已观察到的入档结果,其余未结算提交按 failed 退休。每次退休恰好触发一次 `onRetire`;observed 退休还会携带有序的持久附件引用,让 composer 释放成功卡片并保留失败草稿。回显只存在于 Client 内存;刷新与重连只从持久事件重建会话。 -正常在线时,transcript 与 steering 回显在 Inbox 接受与领取期间留在 Chat,直到持久消息到达。transcript 消息入档后,本地身份保留到 Inbox 水位覆盖其 next-turn 领取序号:Chat 已显示真实 Node,而 QueueDock 继续排除该身份,避免迟到的旧 Inbox 帧重复展示它。其他排队消息照常显示。新的 follow 基线会将已有接收回执的回显按 observed 撤去,使用已接受的附件引用完成结算;尚未确认接收的提交继续保留。待处理或已入档的消息随后由 Host 数据呈现。在领取与入档之间重连时,气泡可能短暂消失;撤去回显只确认接收,不代表执行或失败。 +正常在线时,transcript 与 steering 回显在 Inbox 接受与领取期间留在 Chat,直到持久消息到达。本地 Chat 消息入档后,其身份保留到 Inbox 水位覆盖 next-turn 或 next-step 领取序号:Chat 已显示真实 Node,而 Chat 与 QueueDock 排除匹配的旧 Inbox 行。其他排队消息照常显示。新的 follow 基线会将已有接收回执的回显按 observed 撤去,使用已接受的附件引用完成结算;尚未确认接收的提交继续保留。待处理或已入档的消息随后由 Host 数据呈现。在领取与入档之间重连时,气泡可能短暂消失;撤去回显只确认接收,不代表执行或失败。 面向用户调用的 `skills/list` 元数据包含胜出提供方可选的指令文件 `path`。输入框可据此预览文件,无需加载每个 skill 的正文或激活冷态 Agent。 diff --git a/packages/api/session-controller/src/client/sessions/session.ts b/packages/api/session-controller/src/client/sessions/session.ts index 3bf9a8f2e9..7ad5c637be 100644 --- a/packages/api/session-controller/src/client/sessions/session.ts +++ b/packages/api/session-controller/src/client/sessions/session.ts @@ -812,12 +812,12 @@ export class Session implements SessionFace { this.retireAdmittedSubmission(source.rpcId) } - /** Retire admitted Chat identities only after stale Dock rows can no longer reappear. */ + /** Retire admitted Chat identities only after stale Inbox rows can no longer reappear. */ private retireAdmittedSubmission(requestId: SessionRequestId): void { const settlement = this.submissionSettlements.get(requestId) if (settlement?.admitted === undefined) return const receipt = settlement.receipt - if (settlement.placement === 'transcript' && receipt?.target === 'next-turn' && receipt.index === null + if (receipt?.index === null && (this.projections.seqOf('inbox') ?? -1) < receipt.seq) return this.scheduleObservedRetirement(requestId, settlement.admitted) } diff --git a/packages/api/session-controller/tests/session-pending-submissions.client.spec.ts b/packages/api/session-controller/tests/session-pending-submissions.client.spec.ts index a19f5720ef..af3c06a0b6 100644 --- a/packages/api/session-controller/tests/session-pending-submissions.client.spec.ts +++ b/packages/api/session-controller/tests/session-pending-submissions.client.spec.ts @@ -215,27 +215,33 @@ describe('observed retirement', () => { expect(onRetire).toHaveBeenCalledExactlyOnceWith({ reason: 'observed', attachments: refs }) }) - for (const order of ['inbox-first', 'transcript-first'] as const) { - it(`keeps an idle Chat identity until admission and the Inbox claim watermark (${order})`, async ({ mock, start }) => { + for (const { mode, target, order } of [ + { mode: 'queue', target: 'next-turn', order: 'inbox-first' }, + { mode: 'queue', target: 'next-turn', order: 'transcript-first' }, + { mode: 'steer', target: 'next-step', order: 'inbox-first' }, + { mode: 'steer', target: 'next-step', order: 'transcript-first' }, + ] as const) { + it(`keeps a ${mode} Chat identity until admission and the Inbox claim watermark (${order})`, async ({ mock, start }) => { const session = await sessionBench(mock, start, SID) await session.open() session.projections.apply('inbox', { 'next-turn': [], 'next-step': [] }, -1) const frames: FrameRequestCallback[] = [] vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => frames.push(callback)) const onRetire = vi.fn() - const handle = session.beginSubmission({ mode: 'queue', text: 'idle', attachments: [], onRetire }) + session.handleRunning(mode === 'steer') + const handle = session.beginSubmission({ mode, text: 'local input', attachments: [], onRetire }) const refs = [imageRef('idle-image'), fileRef('idle-file')] const message = queuedItem(handle.requestId, refs) - const queued = { 'next-turn': [message], 'next-step': [] } + const queued = { 'next-turn': [], 'next-step': [], [target]: [message] } const empty = { 'next-turn': [], 'next-step': [] } if (order === 'inbox-first') session.projections.apply('inbox', queued, SessionSeq(0)) await pushEvent(mock, { type: 'agent/inbox/spliced', seq: SessionSeq(0), time: 1, - data: { target: 'next-turn', start: 0, inserted: [message] }, + data: { target, start: 0, inserted: [message] }, }) await pushEvent(mock, { type: 'agent/inbox/spliced', seq: SessionSeq(1), time: 2, - data: { target: 'next-turn', start: 0, removedCount: 1, inserted: [] }, + data: { target, start: 0, removedCount: 1, inserted: [] }, }) expect(session.getSnapshot().pendingSubmissions).toHaveLength(1) expect(frames).toHaveLength(0) @@ -298,15 +304,13 @@ describe('observed retirement', () => { await pushEvent(mock, ev.stepStart(SessionSeq(5), 1, 1)) await pushEvent(mock, { type: 'user/message', seq: SessionSeq(6), time: 7, surfaceOp: 'append', data: steerMessage }) flush() - expect(pending()).toEqual(['opening']) + expect(pending()).toEqual(projectionFirst ? ['opening'] : ['opening', 'steering']) expect(normalRetired).not.toHaveBeenCalled() - expect(steerRetired).toHaveBeenCalledExactlyOnceWith({ - reason: 'observed', attachments: [imageRef('steer-image'), fileRef('steer-file')], - }) + if (!projectionFirst) expect(steerRetired).not.toHaveBeenCalled() await pushEvent(mock, { type: 'user/message', seq: SessionSeq(7), time: 8, surfaceOp: 'append', data: normalMessage }) flush() if (!projectionFirst) { - expect(pending()).toEqual(['opening']) + expect(pending()).toEqual(['opening', 'steering']) session.projections.apply('inbox', accepted, SessionSeq(1)) flush() expect(normalRetired).not.toHaveBeenCalled() @@ -316,6 +320,9 @@ describe('observed retirement', () => { } expect(pending()).toEqual([]) expect(normalRetired).toHaveBeenCalledExactlyOnceWith({ reason: 'observed', attachments: [fileRef('opening-file')] }) + expect(steerRetired).toHaveBeenCalledExactlyOnceWith({ + reason: 'observed', attachments: [imageRef('steer-image'), fileRef('steer-file')], + }) await pushEvent(mock, ev.stepEnd(SessionSeq(8), 1, 1)) await pushEvent(mock, ev.turnEnd(SessionSeq(9), 1)) await pushEvent(mock, ev.turnStart(SessionSeq(10), 2)) diff --git a/packages/client/ui-chat/README.i18n.yaml b/packages/client/ui-chat/README.i18n.yaml index 5688915123..f30604f200 100644 --- a/packages/client/ui-chat/README.i18n.yaml +++ b/packages/client/ui-chat/README.i18n.yaml @@ -2,5 +2,5 @@ # 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 packages/client/ui-chat/README.md -README.md: 12f9b2470c107775ff57c2c7f04c649841dd6f7a -README.zh.md: 4d25db34d283ed2dc62fcfbb15d6ab4797511881 +README.md: 1aa7d71b3f932a7a14ef6d23e9ace7eda444fc3b +README.zh.md: 2239234448e1e00fd395634de39a96c2a80a35f7 diff --git a/packages/client/ui-chat/README.md b/packages/client/ui-chat/README.md index 12f9b2470c..1aa7d71b3f 100644 --- a/packages/client/ui-chat/README.md +++ b/packages/client/ui-chat/README.md @@ -72,7 +72,7 @@ The completed-turn action footer follows the recorded Turn end. Its action row s ## Turn Process Folding -During uninterrupted following, local transcript and steering echoes remain mounted through Inbox acceptance and claim until the durable message arrives, without triggering tail following twice. Pending steering follows Inbox order across clients, using matching local echoes in place. After reconnect, Host-owned rows replace receipt-confirmed local echoes; a claim awaiting admission may briefly have no bubble. +During uninterrupted following, local transcript and steering echoes remain mounted through Inbox acceptance and claim until the durable message arrives, without triggering tail following twice. Pending steering follows Inbox order across clients, using matching local echoes in place. Admitted local steering also suppresses matching stale Inbox rows until the claim projection arrives; steering without a locally tracked submission continues to follow the Inbox projection. After reconnect, Host-owned rows replace receipt-confirmed local echoes; a claim awaiting admission may briefly have no bubble. When Chat ends with an open Turn control and that Turn has no visible input, the first local transcript echo precedes the control. Other echoes remain at the flow tail. The control and echoes share one keyed list, so arrival of the control preserves the echo's mounted identity. Durable inputs replace their matching echoes in the same render. diff --git a/packages/client/ui-chat/README.zh.md b/packages/client/ui-chat/README.zh.md index 4d25db34d2..2239234448 100644 --- a/packages/client/ui-chat/README.zh.md +++ b/packages/client/ui-chat/README.zh.md @@ -72,7 +72,7 @@ Assistant 尝试结束且没有可见消息时,Chat 隐藏已发布的 Node, ## 轮次过程折叠 -正常在线时,本地 transcript 与 steering 回显在 Inbox 接受与领取期间保持挂载,直到持久消息到达,不会重复触发跟随底部。跨客户端的待处理 steering 遵循 Inbox 顺序,在匹配位置使用本地回显。重连后,Host 消息替代已有接收回执的本地回显;已领取但尚未入档时,气泡可能短暂消失。 +正常在线时,本地 transcript 与 steering 回显在 Inbox 接受与领取期间保持挂载,直到持久消息到达,不会重复触发跟随底部。跨客户端的待处理 steering 遵循 Inbox 顺序,在匹配位置使用本地回显。已入档的本地 steering 还会排除匹配的旧 Inbox 行,直到领取投影到达;没有本地提交身份的 steering 仍按 Inbox 投影显示。重连后,Host 消息替代已有接收回执的本地回显;已领取但尚未入档时,气泡可能短暂消失。 Chat 末尾为进行中的 Turn 控制行,且该轮尚无可见输入时,第一条本地 transcript 回显显示在控制行前。其他回显保留在正文末尾。控制行与回显共用一个 keyed 列表,因此控制行到达时不会重新挂载回显。持久输入在同一次渲染中替换匹配的回显。 diff --git a/packages/client/ui-chat/src/client/chat/ChatView.tsx b/packages/client/ui-chat/src/client/chat/ChatView.tsx index b449abb26a..a7c9ef0bae 100644 --- a/packages/client/ui-chat/src/client/chat/ChatView.tsx +++ b/packages/client/ui-chat/src/client/chat/ChatView.tsx @@ -177,16 +177,19 @@ export function ChatView({ }, [pendingSubmissions, order, nodeStore]) const pendingInputs = useMemo(() => { const local = new Map(visibleSubmissions.map(submission => [submission.requestId, submission])) - const pending = inboxSteering.map((item) => { + // Admitted local identities outlive their bubbles until the Inbox claim watermark. + const localIds = new Set(pendingSubmissions.filter(submission => submission.placement !== 'queued') + .map(submission => submission.requestId)) + const pending = inboxSteering.flatMap((item) => { const source = item.source - if (source.kind !== 'user' || !('rpcId' in source)) return item + if (source.kind !== 'user' || !('rpcId' in source)) return [item] const submission = local.get(source.rpcId) - if (submission === undefined) return item + if (submission === undefined) return localIds.has(source.rpcId) ? [] : [item] local.delete(source.rpcId) - return submission + return [submission] }) return [...pending, ...local.values()] - }, [inboxSteering, visibleSubmissions]) + }, [inboxSteering, pendingSubmissions, visibleSubmissions]) const renderMessageImages = useCallback( owner => renderSlot('conversation.message.images', { ...owner, loadImage }), [loadImage, renderSlot], diff --git a/packages/client/ui-chat/tests/chat-view.client.spec.tsx b/packages/client/ui-chat/tests/chat-view.client.spec.tsx index 06098a7dac..dc70093fc5 100644 --- a/packages/client/ui-chat/tests/chat-view.client.spec.tsx +++ b/packages/client/ui-chat/tests/chat-view.client.spec.tsx @@ -1825,6 +1825,39 @@ describe('ChatView', () => { expect(view.getByLabelText('回到底部')).toBeTruthy() }) + it.each(['compact', 'detailed', 'expanded'] as const)( + 'hides an admitted local steer while its Inbox claim projection is delayed (%s)', (mode) => { + const pending: InboxState['next-step'] = ['first', 'second'].map(text => ({ + id: text as never, role: 'user', source: { kind: 'user', rpcId: text as never }, + content: [{ type: 'text', text: `steer ${text}` }], + })) + const submissions: SessionSnapshot['pendingSubmissions'] = ['first', 'second'].map(text => ({ + requestId: text as never, placement: 'steering', time: 5_000, + text: `steer ${text}`, attachments: [], + })) + const h = makeHarness({ nodes: [assistant(1, 'working')] }, { + running: true, pendingSubmissions: submissions, + testInbox: { 'next-turn': [], 'next-step': pending }, + }) + h.setTranscriptView(mode) + const view = render() + const second = view.getByText('steer second').closest('[data-submission-echo]') + expect(view.getAllByText('steer first')).toHaveLength(1) + + act(() => { h.setChat({ nodes: [assistant(1, 'working'), { + ...steering(2, 'steer first', 1), source: pending[0]!.source, + }] }) }) + expect(view.getAllByText('steer first')).toHaveLength(1) + expect(view.getByText('steer first').closest('[data-pending-steering]')).toBeNull() + expect(view.getByText('steer second').closest('[data-submission-echo]')).toBe(second) + + act(() => { h.setSession({ testInbox: { 'next-turn': [], 'next-step': [pending[1]!] } }) }) + act(() => { h.setSession({ pendingSubmissions: [submissions[1]!] }) }) + expect(view.getAllByText('steer first')).toHaveLength(1) + expect(view.getByText('steer second').closest('[data-submission-echo]')).toBe(second) + }, + ) + it('retains the same local steer bubble through Host acceptance and claim until its durable node arrives', () => { const h = makeHarness( { nodes: [assistant(1, 'working')] },