diff --git a/.agents/notes/implemented/feature/2026-07-30-web-queue-steer-action.i18n.yaml b/.agents/notes/implemented/feature/2026-07-30-web-queue-steer-action.i18n.yaml index bbc7de53b8..0e47c08be0 100644 --- a/.agents/notes/implemented/feature/2026-07-30-web-queue-steer-action.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-30-web-queue-steer-action.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/feature/2026-07-30-web-queue-steer-action.md -2026-07-30-web-queue-steer-action.md: 4135c465166d88873e5b4595758778839efe83ee -2026-07-30-web-queue-steer-action.zh.md: 2717c8a53b6a46c4751b1a8bb1e563b7aa37a42e +2026-07-30-web-queue-steer-action.md: 4193bd9fcb2d20def8b72ff160f3ab3fa0b3e7c4 +2026-07-30-web-queue-steer-action.zh.md: 15dd9719d449afbc5ddab38de31b91aca2ccfed7 diff --git a/.agents/notes/implemented/feature/2026-07-30-web-queue-steer-action.md b/.agents/notes/implemented/feature/2026-07-30-web-queue-steer-action.md index 4135c46516..4193bd9fcb 100644 --- a/.agents/notes/implemented/feature/2026-07-30-web-queue-steer-action.md +++ b/.agents/notes/implemented/feature/2026-07-30-web-queue-steer-action.md @@ -65,3 +65,5 @@ The keyless Web steering scenario queues a message through the real composer whi `inbox` is the domain-owned durable session projection. Pending steering survives reconnect and process restart and appears immediately from `next-step`; after claim, the durable conversation node follows through the ordinary event projection. The running bit can change between rendering and the synchronous operation, so an enabled action may return `steer-unavailable` while the product continues through Queue without reporting failure. The explicit action changes delivery from an independently admitted turn to current-turn steering. Its two durable splices briefly expose the deletion and insertion as adjacent projection revisions, while client snapshot batching converges on the message moving from `next-turn` to `next-step`. + +Claim removes the pending `next-step` value before the later `user/message` reaches the independent history stream, so a client may briefly render neither representation when those notifications cross separate flushes. The product accepts this transient gap instead of retaining a queue-specific handoff mirror; the durable log still converges on exactly one conversation node. diff --git a/.agents/notes/implemented/feature/2026-07-30-web-queue-steer-action.zh.md b/.agents/notes/implemented/feature/2026-07-30-web-queue-steer-action.zh.md index 2717c8a53b..15dd9719d4 100644 --- a/.agents/notes/implemented/feature/2026-07-30-web-queue-steer-action.zh.md +++ b/.agents/notes/implemented/feature/2026-07-30-web-queue-steer-action.zh.md @@ -65,3 +65,5 @@ Host schema 和代理测试覆盖新操作、两种类型化错误、Inbox 投 `inbox` 是领域拥有的持久会话投影。待处理 steering 会从 `next-step` 立即出现,能在重连与进程重启后恢复;认领后,持久会话节点经普通事件投影跟进。running 标志位可能在渲染与同步操作之间发生变化,因此已启用的操作可能返回 `steer-unavailable`,而产品仍经 Queue 继续执行且不显示失败。 这项显式操作会把投递方式从经独立接纳的轮次改为当前轮次 steering。它的两条持久 splice 会短暂地把删除与插入暴露为相邻投影修订,而客户端快照批处理会收敛到消息从 `next-turn` 移入 `next-step`。 + +Claim 会先移除待处理的 `next-step` 值,随后 `user/message` 才到达独立的历史流;若两项通知跨越不同的客户端刷新,客户端可能短暂地两种表示都不渲染。产品接受这一瞬态空隙,以避免保留 Queue 专用的交接镜像;持久日志最终仍只产生一个会话节点。 diff --git a/packages/api/session-controller/tests/queue-store.client.spec.ts b/packages/api/session-controller/tests/inbox-projection.client.spec.ts similarity index 100% rename from packages/api/session-controller/tests/queue-store.client.spec.ts rename to packages/api/session-controller/tests/inbox-projection.client.spec.ts diff --git a/packages/client/ui-conversation/src/client/input/facade.ts b/packages/client/ui-conversation/src/client/input/facade.ts index c08a9f8ab0..82b5962922 100644 --- a/packages/client/ui-conversation/src/client/input/facade.ts +++ b/packages/client/ui-conversation/src/client/input/facade.ts @@ -159,6 +159,7 @@ export class SessionInputShell implements SessionInput { nodes: [ReferenceChipNode, TextRefNode], onError: (error) => { throw error }, }) + this.state = createSnapshotStore(this.compose()) this.unregister = mergeRegister( registerPlainText(this.editor), registerHistory(this.editor, createEmptyHistoryState(), HISTORY_MERGE_DELAY_MS), @@ -166,9 +167,8 @@ export class SessionInputShell implements SessionInput { registerClaimDecoration(this.editor, () => this.activeClaimToken()), registerTextRefDecoration(this.editor, () => this.lexicon.getSnapshot(), () => this.activeClaimToken()), () => { this.lexiconOff?.() }, + deps.inbox?.subscribe(() => { this.publish() }) ?? (() => {}), ) - this.state = createSnapshotStore(this.compose()) - deps.inbox?.subscribe(() => { this.publish() }) } // ---- editor plumbing ---- diff --git a/packages/client/ui-conversation/tests/input-matrix.client.spec.tsx b/packages/client/ui-conversation/tests/input-matrix.client.spec.tsx index 2133d03477..a0a74f8d62 100644 --- a/packages/client/ui-conversation/tests/input-matrix.client.spec.tsx +++ b/packages/client/ui-conversation/tests/input-matrix.client.spec.tsx @@ -115,6 +115,25 @@ function bench(over?: { } describe('matrix row: plain', () => { + it('unsubscribes from the Inbox projection when disposed', () => { + const unsubscribe = vi.fn() + const subscribe = vi.fn(() => unsubscribe) + const shell = new SessionInputShell({ + actx: SCTX, + defaultSink: () => Promise.resolve({ kind: 'success' }), + inbox: { getSnapshot: () => undefined, subscribe }, + commandImages: { + serialize: () => Promise.resolve([]), + release: () => {}, + unsupportedNotice: token => `${token.trim()} images-unsupported`, + }, + }) + + expect(subscribe).toHaveBeenCalledOnce() + shell.dispose() + expect(unsubscribe).toHaveBeenCalledOnce() + }) + it('enter falls to the default sink; no claim on the currency; edits free', async () => { const { textarea, shell, sink } = bench() act(() => { shell.setDraft('普通消息') })