fix(chat): deduplicate admitted local steering

This commit is contained in:
imccyu committed 2026-09-22 22:47:12 +08:00
1 parent 43a8a22824
commit 08c0e8e71b
14 files changed
+120 -44

No files matched your search

@@ -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
+1 -1
View File
@@ -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.
+1 -1
View File
@@ -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。
@@ -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)
}
@@ -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))