fix(chat): deduplicate admitted local steering
This commit is contained in:
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
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
Reference in new issue
Block a user