refactor(agent): scope queue actions to edit and remove

This commit is contained in:
kingwl
2026-07-30 01:58:51 +08:00
parent e56c0699a9
commit 7ce330f0e4
49 changed files with 181 additions and 294 deletions

View File

@@ -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/runtime/README.md
README.md: f0b287d31b3aa9dddd2fde8d3f95bddd290bcf8d
README.zh.md: b2e9c29f0f254fd514f201fcedf6a9cb5e05f86c
README.md: 766d8516225cd46cb1a3a80c832d1cf55e816140
README.zh.md: 9b514afca91f604b3e895187de3b5532bf22a692

View File

@@ -18,7 +18,7 @@ SlotsService gives the renderer separate bare observables for `useSessions` and
## Pending queue projection
`ConversationSnapshot.queue` is the Host's authoritative transient inbox snapshot. Each row carries its `InboxItemId`, complete editable text when every content block is text, a flattened preview, and the accepted queued-or-steering placement. `session/queue` replaces the whole projection; reconnect buffering retains only the latest snapshot, and neither durable turn events nor running-status changes guess that an item was claimed. `Session.updateQueue()` sends edit/remove/promote operations without optimistic mutation, so the next Host snapshot is the sole visible commit and a claim race can surface `queue-item-not-found`.
`ConversationSnapshot.queue` is the Host's authoritative transient Queue snapshot; pending steering stays outside this projection. Each row carries its `InboxItemId`, complete editable text when every content block is text, and a flattened preview. `session/queue` replaces the whole projection; reconnect buffering retains only the latest snapshot, and neither durable turn events nor running-status changes guess that an item was claimed. `Session.updateQueue()` sends edit/remove operations without optimistic mutation, so the next Host snapshot is the sole visible commit and a claim race can surface `queue-item-not-found`.
## Code Mode sub-dispatch index

View File

@@ -18,7 +18,7 @@ SlotsService 分别为 renderer 提供 `useSessions` 与 `useWorkspaces` 的裸
## 待处理队列投影
`ConversationSnapshot.queue` 是 Host 提供的权威瞬态 inbox 快照。每行都携带其 `InboxItemId`、所有内容块均为文本时的完整可编辑文本、扁平化预览,以及接受时确定的 queued 或 steering 放置方式。`session/queue` 会整体替换该投影;重连缓冲只保留最新快照,持久轮次事件和 running 状态变化都不会猜测某个项已被认领。`Session.updateQueue()` 发送编辑/移除/前移操作,不进行乐观更新,因此下一份 Host 快照是唯一可见的提交结果,认领竞态则会返回 `queue-item-not-found`。
`ConversationSnapshot.queue` 是 Host 提供的权威瞬态 Queue 快照;待处理 steering(中途引导)不进入此投影。每行都携带其 `InboxItemId`、所有内容块均为文本时的完整可编辑文本,以及扁平化预览。`session/queue` 会整体替换该投影;重连缓冲只保留最新快照,持久轮次事件和 running 状态变化都不会猜测某个项已被认领。`Session.updateQueue()` 发送编辑/移除操作,不进行乐观更新,因此下一份 Host 快照是唯一可见的提交结果,认领竞态则会返回 `queue-item-not-found`。
## Code Mode 子调用索引

View File

@@ -41,7 +41,7 @@ export interface ISession {
/**
* Apply one mutation to a still-pending queue occurrence.
* @param itemId - agent-owned inbox occurrence identity.
* @param action - edit, remove, or promote operation.
* @param action - edit or remove operation.
* @returns acceptance, or a business/transport error.
*/
updateQueue(itemId: InboxItemId, action: QueueAction): Promise<RpcResult<{ accepted: true }>>

View File

@@ -222,7 +222,6 @@ export interface QueuedMessage {
readonly preview: string
/** Complete editable text; null when the message contains non-text blocks. */
readonly text: string | null
readonly placement: 'queued' | 'steering'
}
/** In-progress assistant output (chunk accumulator product). */

View File

@@ -407,7 +407,6 @@ export class Session implements SessionFace {
id: item.id,
preview: queuePreviewOf(item.message.content),
text: queueTextOf(item.message.content),
placement: item.placement,
}))
this.queueRev++
this.notifier.markDirty()

View File

@@ -21,7 +21,6 @@ const iid = (id: string): InboxItemId => id as InboxItemId
interface QueueFixture {
id: string
body: string
placement?: 'queued' | 'steering'
content?: ContentBlock[]
}
@@ -36,7 +35,6 @@ function queueFrame(items: QueueFixture[]): MuxFrame {
content: item.content ?? text(item.body),
source: { kind: 'user', rpcId: rid(`rpc-${item.id}`) } as never,
}),
placement: item.placement ?? 'queued',
})),
}
}
@@ -46,15 +44,13 @@ function makeSession(): Session {
}
describe('queue snapshot intake', () => {
it('projects stable ids, flat previews, complete text, and placement', () => {
it('projects stable ids, flat previews, and complete text', () => {
const session = makeSession()
session.handleMuxEnvelope(rid('env-1'), queueFrame([
{ id: 'q-1', body: '第一条 排队\n消息' },
{ id: 'q-2', body: '插话', placement: 'steering' },
]))
expect(session.getSnapshot().queue).toEqual([
{ id: 'q-1', preview: '第一条 排队 消息', text: '第一条 排队\n消息', placement: 'queued' },
{ id: 'q-2', preview: '插话', text: '插话', placement: 'steering' },
{ id: 'q-1', preview: '第一条 排队 消息', text: '第一条 排队\n消息' },
])
})
@@ -66,7 +62,7 @@ describe('queue snapshot intake', () => {
content: [{ type: 'text', text: 'hi' }, { type: 'image', data: 'x' } as never],
}]))
expect(session.getSnapshot().queue).toEqual([
{ id: 'q-image', preview: 'hi [image]', text: null, placement: 'queued' },
{ id: 'q-image', preview: 'hi [image]', text: null },
])
})
@@ -90,7 +86,7 @@ describe('queue snapshot intake', () => {
{ id: 'q-2', body: 'two edited' },
]))
expect(session.getSnapshot().queue).toEqual([
{ id: 'q-2', preview: 'two edited', text: 'two edited', placement: 'queued' },
{ id: 'q-2', preview: 'two edited', text: 'two edited' },
])
session.handleMuxEnvelope(rid('env-6'), queueFrame([]))
expect(session.getSnapshot().queue).toEqual([])
@@ -112,12 +108,12 @@ describe('queue operation transport', () => {
session.handleMuxEnvelope(rid('env-op'), queueFrame([{ id: 'q-op', body: 'pending' }]))
const before = session.getSnapshot().queue
await expect(session.updateQueue(iid('q-op'), { kind: 'promote' }))
await expect(session.updateQueue(iid('q-op'), { kind: 'edit', content: text('next') }))
.resolves.toEqual({ ok: true, value: { accepted: true } })
expect(api.callsOf('session.updateQueue')).toEqual([{
sessionId: SID,
itemId: 'q-op',
action: { kind: 'promote' },
action: { kind: 'edit', content: text('next') },
}])
expect(session.getSnapshot().queue).toBe(before)
})

View File

@@ -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-conversation/README.md
README.md: 5bf3077c748c6e9be2c0a54474dce06c42eb0b2d
README.zh.md: b4e1c9a23f1786c3dcba2063a9657a29445ed47f
README.md: a017de28e612474dd4ad94b75385e6320e682955
README.zh.md: b352f6f745a1458d9f0555a5d7d269fe67b8e307

View File

@@ -40,5 +40,5 @@ None; this package neither assembles nor sends a provider request.
- **The sparkle icon for the others tool row is a hand-drawn approximation** — the design glyph's vector geometry is not exportable locally; promotion into ui-primitives waits on an exact export.
- **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.
- **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; protocol-level promotion remains separate from the Web interaction.
- **Web exposes pending Queue only** — QueueDock omits pending steering until steering has its own interaction. A consumed `steering/message` still renders in the durable transcript so external steering remains truthful on replay.
- **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.

View File

@@ -40,5 +40,5 @@ todo 两个面就是在该形状上的两个注册项,都是普通注册方插
- **others 工具行的闪光图标是手绘近似版本**:无法在本地导出设计字形的矢量几何;等到存在精确导出后再将其提升到 ui-primitives。
- **审批面板的「始终允许此类」暂缓**:持久授权需要授权存储设计;今天只能回答允许一次/拒绝。
- **TodoPanel 将过长条目截成单行省略号**:figma 条没有换行或展开入口,完整文本无法在行内读完。
- **Queue 编辑仅支持文本**:包含非文本块的行仍显示扁平化预览,但由于内联编辑器无法保留这些块,其编辑控件会被禁用。文本行进入编辑模式后,删除会替换为保存和取消;Enter 保存,Escape 取消。QueueDock 不提供立即发送控件;协议层的前移操作与 Web 交互保持分离。
- **Web 仅暴露待处理 Queue**:在 steering(中途引导)拥有专用交互之前,QueueDock 不展示待处理的 steering。已消费的 `steering/message` 仍会渲染到持久 transcript(文本记录)中,因此从外部提交的 steering 在回放时仍能如实呈现。
- **Queue 编辑仅支持文本**:包含非文本块的行仍显示扁平化预览,但由于内联编辑器无法保留这些块,其编辑控件会被禁用。文本行进入编辑模式后,删除会替换为保存和取消;Enter 保存,Escape 取消。QueueDock 不提供立即发送控件。
- **Web 仅暴露待处理 Queue**:在 steering(中途引导)拥有专用交互之前,Host 不会把待处理 steering 纳入 Queue 快照。已消费的 `steering/message` 仍会渲染到持久 transcript(文本记录)中,因此从外部提交的 steering 在回放时仍能如实呈现。

View File

@@ -105,7 +105,6 @@ export interface QueuedMessage {
readonly id: InboxItemId
readonly preview: string
readonly text: string | null
readonly placement: 'queued' | 'steering'
}
/** Guard union of the scoped consume-token event, checked by the machine. */

View File

@@ -4,7 +4,7 @@
// The 'conversation.input.dock' SlotMap declaration lives in
// ../contract/slots.ts beside the other input-region slots.
import type { Context } from 'cordis'
import { useEffect, useMemo, useState } from 'react'
import { useEffect, useState } from 'react'
import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
import type { InboxItemId, QueueAction } from '@deepseek-ai/dsh-client-connection/client'
@@ -24,10 +24,7 @@ export type QueueDockProps = PropsRuntime<'conversation.input.dock'> & QueueDock
/** Queue strip: one preview line per queued message; renders null when the queue is empty. */
export function QueueDock({ useSession, updateQueue, notify }: QueueDockProps) {
const inbox = useSession(s => s.queue)
// TODO(web-steer-ui): Give pending steering its own interaction before
// exposing it; QueueDock owns only independent queued turns.
const queue = useMemo(() => inbox.filter(row => row.placement === 'queued'), [inbox])
const queue = useSession(s => s.queue)
const [editing, setEditing] = useState<{ id: InboxItemId; text: string } | null>(null)
const [busy, setBusy] = useState<InboxItemId | null>(null)

View File

@@ -34,7 +34,7 @@ export interface IConversation {
/**
* Apply one operation to a pending queue occurrence.
* @param itemId - agent-owned inbox occurrence identity.
* @param action - edit, remove, or promote operation.
* @param action - edit or remove operation.
* @returns completion; business failures reject.
*/
updateQueue(itemId: InboxItemId, action: QueueAction): Promise<void>

View File

@@ -20,7 +20,7 @@ const SID = 's1' as SessionId
const iid = (id: string): InboxItemId => id as InboxItemId
function row(id: string, text: string | null, preview = text ?? '[image]'): QueuedMessage {
return { id: iid(id), preview, text, placement: 'queued' }
return { id: iid(id), preview, text }
}
function snapshotWith(queue: QueuedMessage[]): ConversationSnapshot {
@@ -78,19 +78,6 @@ describe('QueueDock', () => {
expect(container.innerHTML).toBe('')
})
it('hides pending steering until it has a dedicated Web interaction', () => {
const steering = { ...row('i-steer', 'steer separately'), placement: 'steering' as const }
const snap = snapshotWith([steering])
const source = liveSession(snap)
const { container } = render(<QueueDock {...kitFor(snap)} useSession={source.useSession} />)
expect(container.innerHTML).toBe('')
act(() => { source.push(snapshotWith([steering, row('i-queue', 'queue visibly')])) })
expect(container.textContent).toContain('queue visibly')
expect(container.textContent).not.toContain('steer separately')
expect(container.querySelectorAll('button')).toHaveLength(2)
})
it('renders active actions and disables editing for mixed-content rows', () => {
const snap = snapshotWith([
row('i-1', '第一条排队消息'),

View File

@@ -1171,9 +1171,9 @@ export const EVENT_API: readonly EventApiEntry[] = [
{
name: 'agent/inbox/update',
mode: 'emit',
signature: '\'agent/inbox/update\'( this: Scoped<Agent>, agent: Agent, item: InboxItem, action: \'edit\' | \'promote\', ): void',
jsDoc: '/**\n * A still-pending inbox item changed content or position. The item id and\n * placement remain stable; edit carries the replacement message, while\n * promote makes this occurrence first in its current FIFO.\n * @param agent - the owning agent.\n * @param item - the complete post-update occurrence.\n * @param action - the applied non-terminal operation.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */',
summary: 'A still-pending inbox item changed content or position.',
signature: '\'agent/inbox/update\'(this: Scoped<Agent>, agent: Agent, item: InboxItem): void',
jsDoc: '/**\n * A still-pending queued item changed content. The item id, placement, and\n * position remain stable while the event carries the replacement message.\n * @param agent - the owning agent.\n * @param item - the complete post-update occurrence.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */',
summary: 'A still-pending queued item changed content.',
},
{
name: 'agent/prompt-submit',
@@ -1857,7 +1857,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'InboxAction',
declaration: 'export type InboxAction = {\n readonly kind: \'edit\';\n readonly content: ContentBlock[];\n} | {\n readonly kind: \'remove\';\n} | {\n readonly kind: \'promote\';\n};',
declaration: 'export type InboxAction = {\n readonly kind: \'edit\';\n readonly content: ContentBlock[];\n} | {\n readonly kind: \'remove\';\n};',
},
{
name: 'InboxActionResult',

View File

@@ -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/core/agent-loop/README.md
README.md: 86a5c0525cd0ca40cbeb7e4b1acbcefc417e811d
README.zh.md: b128876afb0931a758b472cbac0aa2423b4f4ab0
README.md: a1617a1ef871f61157e0d70a06d055168170dced
README.zh.md: 6ba945a41e700331929dabb557802c14256921fb

View File

@@ -57,7 +57,7 @@ The concrete `ReactLoopAgent`, its queued input, outbox, and run controls are pa
The unified `send()` primitive routes content and source by (`target` × `wakeup`); `followup`/`steer`/`inject` are its fixed-preset aliases. A `next-turn` item joins the queued FIFO, waking the driver unless `wakeup: false`; admission happens before any turn opens. The loop opens a private next-step acceptance window before `agent/prompt-submit` and closes it before `turn/end`. During that window, `steer()` and `inject()` stage in one outbox; an allowed admission opens the turn, records the prompt and returned `additionalContexts`, then drains the staged input before the first request. A blocked or failed admission writes no prompt or hook-produced context. A caller-staged context-only batch then takes idle injection's immediate append, while steering and context staged beside it remain pending for retry or a later admitted prompt. Outside the window, steering becomes a waking queued prompt and injection immediately appends `user/message` without opening a turn or running the model.
Every FIFO acceptance mints an `InboxItemId` and publishes `agent/inbox/enqueue` with the complete occurrence. `updateInbox()` owns the synchronous pending-item boundary: edit freezes replacement content without changing message identity or position, remove publishes discard, and promote moves the occurrence to the head of its queued or steering FIFO; promoting queued work also makes it waking. Edit and promote publish `agent/inbox/update`. Claim publishes `agent/inbox/dequeue` and irrevocably removes the live address before prompt admission, so a racing update returns `not-found`; `cancel()` without `keepInbox` publishes `agent/inbox/discard`.
Every FIFO acceptance mints an `InboxItemId` and publishes `agent/inbox/enqueue` with the complete occurrence. `updateInbox()` owns the synchronous queued-item boundary: edit freezes replacement content without changing message identity or position, while remove publishes discard. Edit publishes `agent/inbox/update`; steering and claimed occurrences return `not-found`. Claim publishes `agent/inbox/dequeue` and irrevocably removes the live address before prompt admission, so a racing update cannot rewrite durable history; `cancel()` without `keepInbox` publishes `agent/inbox/discard`.
### Loop lifecycle (`agent.ts`)

View File

@@ -57,7 +57,7 @@ interface Config {
统一的 `send()` 原语按(`target` × `wakeup`)路由内容与来源;`followup`/`steer`/`inject` 是它的固定预设别名。`next-turn` 项加入排队 FIFO,除非 `wakeup: false`,否则会唤醒驱动器;接纳发生在任何轮次开启之前。循环在 `agent/prompt-submit` 之前打开一个私有的 next-step 接收窗口,并在 `turn/end` 之前关闭它。在该窗口内,`steer()` 与 `inject()` 会暂存到同一个 outbox;接纳获准后会开启轮次,记录提示词及其返回的 `additionalContexts`,再于首次请求前排空暂存输入。接纳被阻止或失败时,不会写入提示词或钩子生成的上下文。之后,仅含调用方暂存上下文的批次会采用空闲注入的立即追加行为,而 steering(中途引导)及与其一同暂存的上下文则继续待处理,以供重试或之后获准的提示词使用。窗口之外,steering 会成为唤醒驱动器的排队提示词,而注入会立即追加 `user/message`,不开启轮次也不运行模型。
每次 FIFO 接受项时都会铸造一个 `InboxItemId`,并通过 `agent/inbox/enqueue` 发布完整的单次入队项。`updateInbox()` 持有同步待处理项边界:编辑会冻结替换内容,但不改变消息标识或位置;移除会发布 discard;前移会把该项移至其 queued 或 steering FIFO 的队首,其中 queued 工作还会变为可唤醒。编辑和前移会发布 `agent/inbox/update`。认领操作会发布 `agent/inbox/dequeue`,并在提示词接纳前不可逆地移除实时寻址标识,因此竞态中的更新会返回 `not-found`;`cancel()` 在不带 `keepInbox` 时会发布 `agent/inbox/discard`。
每次 FIFO 接受项时都会铸造一个 `InboxItemId`,并通过 `agent/inbox/enqueue` 发布完整的单次入队项。`updateInbox()` 持有同步 queued 项边界:编辑会冻结替换内容,但不改变消息标识或位置;移除会发布 discard。编辑会发布 `agent/inbox/update`;steering 项和已被认领的项会返回 `not-found`。认领操作会发布 `agent/inbox/dequeue`,并在提示词接纳前不可逆地移除实时寻址标识,因此竞态中的更新无法改写持久历史;`cancel()` 在不带 `keepInbox` 时会发布 `agent/inbox/discard`。
### 循环生命周期(`agent.ts`)

View File

@@ -137,19 +137,14 @@ export class ReactLoopAgent implements Agent {
emitAgentEvent(this.loopCtx, this, 'agent/inbox/enqueue', item)
}
/** Apply one synchronous mutation to a still-pending inbox occurrence. */
/** Apply one synchronous mutation to a still-pending queued occurrence. */
updateInbox(id: InboxItemIdType, action: InboxAction): InboxActionResult {
const queuedIndex = this.queued.findIndex(candidate => candidate.item.id === id)
const outboxIndex = queuedIndex === -1
? this.outbox.findIndex(candidate => candidate.item?.id === id)
: -1
if (queuedIndex === -1 && outboxIndex === -1) return 'not-found'
if (queuedIndex === -1) return 'not-found'
const pending = queuedIndex === -1 ? this.outbox[outboxIndex] : this.queued[queuedIndex]
/* v8 ignore next 2 -- indices are derived from these arrays in this synchronous method. */
if (pending === undefined || pending.item === undefined) {
throw new Error(`agent "${this.id}" inbox index changed during synchronous update`)
}
const pending = this.queued[queuedIndex]
/* v8 ignore next -- the index was resolved from this array without an async boundary. */
if (pending === undefined) throw new Error(`agent "${this.id}" queued item disappeared during update`)
/* v8 ignore next -- InboxAction is a closed discriminated union; all variants are covered below. */
switch (action.kind) {
@@ -158,42 +153,15 @@ export class ReactLoopAgent implements Agent {
...pending.item,
message: freezeMessage({ ...pending.item.message, content: action.content }),
})
if (queuedIndex !== -1) {
const queued = this.queued[queuedIndex]
/* v8 ignore next -- the index was resolved from this array without an async boundary. */
if (queued === undefined) throw new Error(`agent "${this.id}" queued item disappeared during edit`)
this.queued[queuedIndex] = { ...queued, item }
} else {
const outbox = this.outbox[outboxIndex]
/* v8 ignore next -- the index was resolved from this array without an async boundary. */
if (outbox === undefined) throw new Error(`agent "${this.id}" steering item disappeared during edit`)
this.outbox[outboxIndex] = { ...outbox, message: item.message, item }
}
emitAgentEvent(this.loopCtx, this, 'agent/inbox/update', item, 'edit')
this.queued[queuedIndex] = { ...pending, item }
emitAgentEvent(this.loopCtx, this, 'agent/inbox/update', item)
return 'applied'
}
case 'remove': {
if (queuedIndex !== -1) this.queued.splice(queuedIndex, 1)
else this.outbox.splice(outboxIndex, 1)
this.queued.splice(queuedIndex, 1)
emitAgentEvent(this.loopCtx, this, 'agent/inbox/discard', [pending.item])
return 'applied'
}
case 'promote': {
if (queuedIndex !== -1) {
const queued = this.queued.splice(queuedIndex, 1)[0]
/* v8 ignore next -- the index was resolved from this array without an async boundary. */
if (queued === undefined) throw new Error(`agent "${this.id}" queued item disappeared during promotion`)
this.queued.unshift({ item: queued.item, wakeup: true })
this.scheduleKick()
} else {
const outbox = this.outbox.splice(outboxIndex, 1)[0]
/* v8 ignore next -- the index was resolved from this array without an async boundary. */
if (outbox === undefined) throw new Error(`agent "${this.id}" steering item disappeared during promotion`)
this.outbox.unshift(outbox)
}
emitAgentEvent(this.loopCtx, this, 'agent/inbox/update', pending.item, 'promote')
return 'applied'
}
default:
/* v8 ignore next -- InboxAction is a closed discriminated union. */
return assertNever(action)

View File

@@ -60,10 +60,9 @@ function inboxText(item: InboxItem): string {
}
describe('addressable inbox operations', () => {
it('edits in place, removes exactly one item, and promotes the next independent turn', async () => {
it('edits in place and removes exactly one queued item', async () => {
const adapter = new MockAdapter([
textResponse('first reply'),
textResponse('promoted reply'),
textResponse('edited reply'),
])
const ctx = await harness(adapter)
@@ -79,13 +78,13 @@ describe('addressable inbox operations', () => {
})
const pending: InboxItem[] = []
const updates: { id: string; action: string; text: string }[] = []
const updates: { id: string; text: string }[] = []
const discards: string[][] = []
ctx.on('agent/inbox/enqueue', (subject, item) => {
if (subject === agent && inboxText(item) !== 'first') pending.push(item)
})
ctx.on('agent/inbox/update', (subject, item, action) => {
if (subject === agent) updates.push({ id: item.id, action, text: inboxText(item) })
ctx.on('agent/inbox/update', (subject, item) => {
if (subject === agent) updates.push({ id: item.id, text: inboxText(item) })
})
ctx.on('agent/inbox/discard', (subject, items) => {
if (subject === agent) discards.push(items.map(item => item.id))
@@ -95,22 +94,16 @@ describe('addressable inbox operations', () => {
await admission.promise
send(agent, 'remove me')
send(agent, 'edit me')
send(agent, 'promote me')
expect(pending.map(inboxText)).toEqual(['remove me', 'edit me', 'promote me'])
expect(pending.map(inboxText)).toEqual(['remove me', 'edit me'])
const remove = pending[0]!
const edit = pending[1]!
const promote = pending[2]!
expect(agent.updateInbox(edit.id, {
kind: 'edit',
content: [{ type: 'text', text: 'edited' }],
})).toBe('applied')
expect(agent.updateInbox(remove.id, { kind: 'remove' })).toBe('applied')
expect(agent.updateInbox(promote.id, { kind: 'promote' })).toBe('applied')
expect(updates).toEqual([
{ id: edit.id, action: 'edit', text: 'edited' },
{ id: promote.id, action: 'promote', text: 'promote me' },
])
expect(updates).toEqual([{ id: edit.id, text: 'edited' }])
expect(discards).toEqual([[remove.id]])
const idle = waitForIdle(ctx, agent)
@@ -121,11 +114,11 @@ describe('addressable inbox operations', () => {
.map(event => event.type === 'user/message'
? event.data.content.flatMap(block => block.type === 'text' ? [block.text] : []).join('')
: ''))
.toEqual(['first', 'promote me', 'edited'])
expect(agent.updateInbox(promote.id, { kind: 'remove' })).toBe('not-found')
.toEqual(['first', 'edited'])
expect(agent.updateInbox(edit.id, { kind: 'remove' })).toBe('not-found')
})
it('edits, removes, and promotes steering occurrences before admission commits', async () => {
it('does not mutate steering occurrences', async () => {
const adapter = new MockAdapter([textResponse('done')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('steering-inbox-actions'), { provider: 'mock', model: 'mock' })
@@ -137,40 +130,22 @@ describe('addressable inbox operations', () => {
})
const pending: InboxItem[] = []
const updates: { id: string; action: string; text: string }[] = []
const discards: string[][] = []
ctx.on('agent/inbox/enqueue', (subject, item) => {
if (subject === agent && item.placement === 'steering') pending.push(item)
})
ctx.on('agent/inbox/update', (subject, item, action) => {
if (subject === agent) updates.push({ id: item.id, action, text: inboxText(item) })
})
ctx.on('agent/inbox/discard', (subject, items) => {
if (subject === agent) discards.push(items.map(item => item.id))
})
const idle = waitForIdle(ctx, agent)
send(agent, 'admitted prompt')
await entered.promise
agent.steer(createUserMessage({ content: [{ type: 'text', text: 'remove me' }], source: { kind: 'user' } }))
agent.steer(createUserMessage({ content: [{ type: 'text', text: 'edit me' }], source: { kind: 'user' } }))
agent.steer(createUserMessage({ content: [{ type: 'text', text: 'promote me' }], source: { kind: 'user' } }))
expect(pending.map(inboxText)).toEqual(['remove me', 'edit me', 'promote me'])
agent.steer(createUserMessage({ content: [{ type: 'text', text: 'keep me' }], source: { kind: 'user' } }))
expect(pending.map(inboxText)).toEqual(['keep me'])
const remove = pending[0]!
const edit = pending[1]!
const promote = pending[2]!
expect(agent.updateInbox(edit.id, {
const steering = pending[0]!
expect(agent.updateInbox(steering.id, {
kind: 'edit',
content: [{ type: 'text', text: 'edited' }],
})).toBe('applied')
expect(agent.updateInbox(remove.id, { kind: 'remove' })).toBe('applied')
expect(agent.updateInbox(promote.id, { kind: 'promote' })).toBe('applied')
expect(updates).toEqual([
{ id: edit.id, action: 'edit', text: 'edited' },
{ id: promote.id, action: 'promote', text: 'promote me' },
])
expect(discards).toEqual([[remove.id]])
})).toBe('not-found')
expect(agent.updateInbox(steering.id, { kind: 'remove' })).toBe('not-found')
decision.resolve({ kind: 'allow' })
await idle
@@ -179,7 +154,7 @@ describe('addressable inbox operations', () => {
.map(event => event.type === 'steering/message'
? event.data.message.content.flatMap(block => block.type === 'text' ? [block.text] : []).join('')
: ''))
.toEqual(['promote me', 'edited'])
.toEqual(['keep me'])
})
})

View File

@@ -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/core/agent/README.md
README.md: e71d3454d22fa829af9617083c5e68586970c70a
README.zh.md: 2dac18e50ab6b4acfc899013313e5bb0069ab36c
README.md: 6bd5279ace93b6d2569833be6f102c854105c2eb
README.zh.md: cdbb0c0b70124e0037a0f7a7a03ddedf1e3b78d3

View File

@@ -61,7 +61,7 @@ Turn and step boundaries and the model token stream are durable `session/event`
The handle every plugin programs against:
- `agent.send(message, options)` — the one delivery primitive over the (`target` × `wakeup`) matrix. `message` is an already identified, frozen `UserMessage`; callers normally create it with `createUserMessage()` before routing begins. `SendOptions` owns only the `target` and `wakeup` policy. Each accepted FIFO occurrence receives its own `InboxItemId`, even when callers reuse a `MessageId`; `agent/inbox/enqueue`/`update` and the terminal `dequeue` or `discard` carry that complete `InboxItem`. `target: 'next-turn'` queues one independent FIFO item that, if admitted, becomes the sole ordinary prompt in its turn. `target: 'next-step'` with `wakeup: true` submits steering, while `target: 'next-step'` with `wakeup: false` injects durable context without running the model. The [one-send-one-turn Agent Note](../../../.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md) owns the turn rationale.
- `agent.updateInbox(itemId, action)` — synchronously edits, removes, or promotes one still-pending occurrence. Edit keeps its `MessageId`, `InboxItemId`, source, placement, and FIFO position while replacing frozen content. Remove emits the occurrence's terminal discard. Promote moves it to the front of its current FIFO and makes an ordinary queued item waking. A claimed item has crossed the ownership boundary and returns `not-found`.
- `agent.updateInbox(itemId, action)` — synchronously edits or removes one still-pending queued occurrence. Edit keeps its `MessageId`, `InboxItemId`, source, and FIFO position while replacing frozen content; remove emits the occurrence's terminal discard. Steering and claimed occurrences return `not-found`.
- `agent.followup(input)` — the `next-turn`/wakeup preset of `send()`: queue an ordinary follow-up turn and wake the driver.
- `agent.steer(input)` — the `next-step`/wakeup preset: during prompt admission or an open turn, stage steering for the next safe boundary without dispatching `agent/prompt-submit`; outside that acceptance window, delegate to a woken follow-up. Admission failure leaves staged steering for retry or a later admitted prompt, while cancellation or disposal may discard it.
- `agent.inject(input)` — the `next-step`/no-wakeup preset: append model-facing context without running the model; the next request sees a verbatim user-role message whose provenance is carried by the required `input.source`. During prompt admission or an open turn, injection waits in the outbox for the next safe boundary. Outside that acceptance window, it appends immediately without opening a turn; a context-only admission batch takes this fallback if admission closes without a turn, while context staged beside steering remains pending with it. Persistence reacts to `session/event` independently. Injection emits no `agent/inbox/*` event.

View File

@@ -61,7 +61,7 @@ Agent *创建* 由实现 `AgentFactory` 的插件(`dsh-agent-loop`)提供,
每个插件面向的 handle:
- `agent.send(message, options)`:覆盖(`target` × `wakeup`)矩阵的唯一投递原语。`message` 是已有标识且已冻结的 `UserMessage`;调用方通常会在开始路由前使用 `createUserMessage()` 创建它。`SendOptions` 只持有 `target` 与 `wakeup` 策略。每次获准进入 FIFO 的项都会获得独立的 `InboxItemId`,即使调用方复用了同一个 `MessageId`;`agent/inbox/enqueue`/`update` 及终态 `dequeue` 或 `discard` 都会携带这一完整 `InboxItem`。`target: 'next-turn'` 排队一条独立 FIFO 项,获准后成为其轮次中唯一的普通提示词。`target: 'next-step'` 且 `wakeup: true` 提交 steering(中途引导),而 `target: 'next-step'` 且 `wakeup: false` 注入持久上下文,不运行模型。轮次原理由 [one-send-one-turn Agent Note](../../../.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md)拥有。
- `agent.updateInbox(itemId, action)`:同步编辑、移除或前移一个仍处于待处理状态的项。编辑会替换已冻结的内容,同时保留其 `MessageId`、`InboxItemId`、来源、放置方式与 FIFO 位置。移除会发出该项的终态 discard。前移会把它移至当前 FIFO 的队首,并使普通 queued 项能够唤醒驱动器。已被认领的项已经跨越所有权边界,因此返回 `not-found`。
- `agent.updateInbox(itemId, action)`:同步编辑或移除一个仍处于待处理状态的 queued 入队项。编辑会替换已冻结的内容,同时保留其 `MessageId`、`InboxItemId`、来源与 FIFO 位置;移除会发出该项的终态 discard。steering 项和已被认领的项会返回 `not-found`。
- `agent.followup(input)`:`send()` 的 `next-turn`/wakeup 预设:排队一个普通后续轮次并唤醒驱动器。
- `agent.steer(input)`:`next-step`/wakeup 预设:提示词接纳期间或轮次打开时,为下一个安全边界暂存 steering,且不分发 `agent/prompt-submit`;该接收窗口之外则委托给会唤醒的后续轮次。接纳失败会保留暂存的 steering,以供重试或之后获准的提示词使用,而取消或 dispose 可能丢弃它。
- `agent.inject(input)`:`next-step`/不唤醒预设:追加面向模型的上下文而不运行模型;下一次请求会看到一条逐字的 user role 消息,其来源由必填的 `input.source` 携带。提示词接纳期间或轮次打开时,注入会在 outbox 中等待下一个安全边界。该接收窗口之外,它会立即追加而不开启轮次;如果接纳结束却未开启轮次,仅含上下文的接纳批次会采用这一回退,而与 steering 一同暂存的上下文则会随其继续待处理。持久化独立地响应 `session/event`。注入不发出 `agent/inbox/*` 事件。

View File

@@ -50,11 +50,10 @@ export interface InboxItem {
readonly placement: InboxPlacement
}
/** A user-requested mutation of one still-pending inbox item. */
/** A user-requested mutation of one still-pending queued occurrence. */
export type InboxAction =
| { readonly kind: 'edit'; readonly content: ContentBlock[] }
| { readonly kind: 'remove' }
| { readonly kind: 'promote' }
/** Result of applying an inbox action at the synchronous ownership boundary. */
export type InboxActionResult = 'applied' | 'not-found'
@@ -180,13 +179,11 @@ export interface Agent {
send(message: UserMessage, options: SendOptions): void
/**
* Mutate one still-pending inbox occurrence synchronously. Editing preserves
* Mutate one still-pending queued occurrence synchronously. Editing preserves
* the message identity and queue position; removal publishes its terminal
* discard; promotion moves it to the front of its current FIFO and makes a
* queued item waking. A driver-claimed item is no longer pending and returns
* `not-found`.
* @param id - independently addressable inbox occurrence.
* @param action - edit, remove, or promote operation.
* discard. Steering occurrences and driver-claimed items return `not-found`.
* @param id - independently addressable queued occurrence.
* @param action - edit or remove operation.
* @returns whether the pending occurrence was found and updated.
*/
updateInbox(id: InboxItemId, action: InboxAction): InboxActionResult
@@ -280,21 +277,14 @@ declare module 'cordis' {
*/
'agent/inbox/enqueue'(this: Scoped<Agent>, agent: Agent, item: InboxItem): void
/**
* A still-pending inbox item changed content or position. The item id and
* placement remain stable; edit carries the replacement message, while
* promote makes this occurrence first in its current FIFO.
* A still-pending queued item changed content. The item id, placement, and
* position remain stable while the event carries the replacement message.
* @param agent - the owning agent.
* @param item - the complete post-update occurrence.
* @param action - the applied non-terminal operation.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
* @mode emit
*/
'agent/inbox/update'(
this: Scoped<Agent>,
agent: Agent,
item: InboxItem,
action: 'edit' | 'promote',
): void
'agent/inbox/update'(this: Scoped<Agent>, agent: Agent, item: InboxItem): void
/**
* The driver claimed one item out of the inbox: a queued item at a turn
* boundary, or steering drained between steps. Fires after the item leaves

View File

@@ -50,7 +50,7 @@ describe('scoped-dispatch invariants', () => {
'agent/disposed': [agent],
'agent/status': [agent, 'idle'],
'agent/inbox/enqueue': [agent, item],
'agent/inbox/update': [agent, item, 'edit'],
'agent/inbox/update': [agent, item],
'agent/inbox/dequeue': [agent, item],
'agent/inbox/discard': [agent, []],
'agent/cancel-requested': [agent, { kind: 'user' }],

View File

@@ -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/host/apiproxy/README.md
README.md: c6f9ffae399b9d88b354b3312e36fe05b2d38a63
README.zh.md: 59f31e521c5432dcf2ba64972b6b0d0e87988b23
README.md: e4a29dc288d7279366c2a28ed43573792979aa92
README.zh.md: 9f5eb0c4f84eb909d931dda8cbac764bf83aa2fe

View File

@@ -16,7 +16,7 @@ Session titles ride the generic projection pair like every other domain — the
Session model routing is a session-domain contract. `session.models` returns the selected provider/model/reasoning target with provider-grouped advisory models, exact-route reasoning metadata, and provider-local lookup failures. `session.selectModel` validates the optional adapter-owned reasoning effort and replaces the complete target selected for the next prompt-assembly boundary. Catalog membership is not validation: an adapter may resolve an unlisted model, while an unavailable route or unsupported effort returns `model-unavailable`.
Pending agent input is a live control-plane contract, not session history. The gateway mirrors complete `InboxItem` occurrences from `agent/inbox/*` and broadcasts authoritative `session/queue` snapshots on every change and reconnect. `session.updateQueue` addresses one `InboxItemId`: edit replaces pending content, remove discards it, and promote moves it to the front of its current FIFO while waking ordinary queued work. A driver claim wins races by retiring the address before admission; a later operation returns `queue-item-not-found`. The client never infers retirement from turn or status events.
Pending queued input is a live control-plane contract, not session history. The gateway mirrors queued `InboxItem` occurrences from `agent/inbox/*` and broadcasts authoritative `session/queue` snapshots on every queued change and reconnect; pending steering stays outside this Web projection. `session.updateQueue` addresses one `InboxItemId`: edit replaces pending content and remove discards it. A driver claim wins races by retiring the address before admission; a later operation returns `queue-item-not-found`. The client never infers retirement from turn or status events.
Workspace and Session lists are separate reconnect baselines. `workspace.create` creates a unique name or adopts an existing directory, `workspace.delete` removes only the Workspace registration, `session.create` accepts an optional preallocated Session id, and `host/workspace-changed`, `host/workspace-removed`, plus `host/session-added` carry committed increments in either arrival order. Registration deletion preserves the directory and session logs; its Sessions remain in `session.list` and become Ungrouped. `SessionSummary.blank` and the `host/session-added` frame carry the derived zero-events bit: clients hide blank sessions and reuse them per workspace, flip blank on the first `host/session-status(running:true)`, and treat `session.list` as the reconnect authority; cold summaries are never blank because lazy persistence keeps never-appended sessions out of `list()`.

View File

@@ -16,7 +16,7 @@
会话模型路由属于会话领域契约。`session.models` 返回选中的提供方/模型/推理(reasoning)目标,以及按提供方分组的建议性模型、精确路由推理元数据和逐提供方查询失败记录。`session.selectModel` 校验由适配器持有的可选推理强度,并替换将在下一提示词组装边界使用的完整目标。目录成员关系不构成校验:适配器可以解析未列出的模型,而不可用路由或不受支持的推理强度会返回 `model-unavailable`。
待处理 agent 输入属于实时控制平面契约,而非会话历史。网关镜像来自 `agent/inbox/*` 的完整 `InboxItem` 单次入队项,并在每次变更和重连时广播权威的 `session/queue` 快照。`session.updateQueue` 通过 `InboxItemId` 寻址单个项:编辑会替换待处理内容,移除会将其丢弃,前移会把它移至当前 FIFO 的队首,并使普通 queued 工作能够唤醒驱动器。驱动器在接纳前退役寻址标识,因此认领会赢得竞态;之后的操作返回 `queue-item-not-found`。客户端绝不根据轮次或状态事件推断项已退役。
待处理的 queued 输入属于实时控制平面契约,而非会话历史。网关镜像来自 `agent/inbox/*` 的 queued `InboxItem` 入队项,并在每次 queued 变更和重连时广播权威的 `session/queue` 快照;待处理 steering(中途引导)不进入此 Web 投影。`session.updateQueue` 通过 `InboxItemId` 寻址单个项:编辑会替换待处理内容,移除会将其丢弃。驱动器在接纳前退役寻址标识,因此认领会赢得竞态;之后的操作返回 `queue-item-not-found`。客户端绝不根据轮次或状态事件推断项已退役。
Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.create` 会创建唯一名称或接纳现有目录,`workspace.delete` 只移除 Workspace 注册记录,`session.create` 接受可选的预分配 Session id,`host/workspace-changed`、`host/workspace-removed` 与 `host/session-added` 则以任意到达顺序携带已提交的增量。删除注册记录会保留目录和会话日志;相关 Session 仍留在 `session.list` 中,并进入 Ungrouped。`SessionSummary.blank` 与 `host/session-added` 帧携带派生的零事件位:客户端隐藏空白会话并按 workspace 复用它们,在首个 `host/session-status(running:true)` 时翻转 blank,并以 `session.list` 作为重连权威;冷会话摘要永远不是空白:惰性持久化让从未追加过事件的会话根本不出现在 `list()` 中。

View File

@@ -508,9 +508,9 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
})
/**
* Per-session inbox occurrence mirror serving the mux-open queue snapshot
* Per-session queued-occurrence mirror serving the mux-open queue snapshot
* (the same refresh-recovery baseline as pending questions). Each terminal
* inbox event retires one matching occurrence, so repeated sends of the same
* queue event retires one matching occurrence, so repeated sends of the same
* identified message remain visible until every occurrence is claimed.
*/
const queuedMirror = new Map<SessionId, InboxItem[]>()
@@ -522,7 +522,6 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
items: items.map(item => ({
id: item.id,
message: item.message,
placement: item.placement,
})),
})
}
@@ -531,12 +530,14 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
const entries = queuedMirror.get(agent.id)
if (entries === undefined) return
const index = entries.findIndex(entry => entry.id === item.id)
if (index !== -1) entries.splice(index, 1)
if (index === -1) return
entries.splice(index, 1)
if (entries.length === 0) queuedMirror.delete(agent.id)
publishQueue(agent.id)
}
const disposers = [
ctx.on('agent/inbox/enqueue', (agent: Agent, item: InboxItem) => {
if (item.placement !== 'queued') return
let entries = queuedMirror.get(agent.id)
if (entries === undefined) {
entries = []
@@ -545,18 +546,12 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
entries.push(item)
publishQueue(agent.id)
}),
ctx.on('agent/inbox/update', (agent: Agent, item: InboxItem, action) => {
ctx.on('agent/inbox/update', (agent: Agent, item: InboxItem) => {
const entries = queuedMirror.get(agent.id)
if (entries === undefined) return
const index = entries.findIndex(entry => entry.id === item.id)
if (index === -1) return
entries.splice(index, 1)
if (action === 'promote') {
const first = entries.findIndex(entry => entry.placement === item.placement)
entries.splice(first === -1 ? entries.length : first, 0, item)
} else {
entries.splice(index, 0, item)
}
entries.splice(index, 1, item)
publishQueue(agent.id)
}),
ctx.on('agent/inbox/dequeue', (agent: Agent, item: InboxItem) => {
@@ -567,6 +562,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
if (entries === undefined) return
const ids = new Set(items.map(item => item.id))
const kept = entries.filter(entry => !ids.has(entry.id))
if (kept.length === entries.length) return
if (kept.length === 0) queuedMirror.delete(agent.id)
else queuedMirror.set(agent.id, kept)
publishQueue(agent.id)
@@ -1540,7 +1536,6 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
items: items.map(item => ({
id: item.id,
message: item.message,
placement: item.placement,
})),
}))
}

View File

@@ -50,7 +50,6 @@ export const muxFrameSchema = z.discriminatedUnion('type', [
items: z.array(z.object({
id: inboxItemIdSchema,
message: messageSchema,
placement: z.union([z.literal('queued'), z.literal('steering')]),
})),
}),
// value stays wide: it already passed its unit's own schema on the host,

View File

@@ -32,14 +32,12 @@ export type ToolEventView =
| { for: 'call'; view: ToolCallView }
| { for: 'result'; view: ToolResultView }
/** One pending inbox occurrence in an authoritative queue snapshot. */
/** One pending queued occurrence in an authoritative queue snapshot. */
export interface QueuedInboxItem {
/** Agent-owned occurrence identity used by queue mutations. */
id: InboxItemId
/** Complete pending message; it is not durable until the Agent claims it. */
message: Message
/** Acceptance-time FIFO classification. */
placement: 'queued' | 'steering'
}
/** Streaming face of the contract: the two SSE stream openers (mux + host). */
@@ -73,10 +71,11 @@ export type MuxFrame =
| { type: 'question/requested'; sessionId: SessionId; questions: AskUserQuestionItem[] }
| { type: 'question/resolved'; sessionId: SessionId; questionRpcId: RpcId; outcome: 'answered' | 'cancelled' }
/**
* Complete transient inbox state after every enqueue, mutation, claim, or
* Complete transient queue state after every enqueue, mutation, claim, or
* discard. Pending work is not model-visible and therefore has no durable
* session event; the whole snapshot makes edit, reorder, deletion, cancel,
* and reconnect converge through one authoritative signal.
* session event; the whole snapshot makes edit, deletion, cancel, and
* reconnect converge through one authoritative signal. Pending steering is
* outside this Web queue projection.
*/
| { type: 'session/queue'; sessionId: SessionId; items: QueuedInboxItem[] }
/**

View File

@@ -225,7 +225,6 @@ export const sessionUpdateQueueRequestSchema = z.object({
action: z.discriminatedUnion('kind', [
z.object({ kind: z.literal('edit'), content: z.array(contentBlockSchema) }),
z.object({ kind: z.literal('remove') }),
z.object({ kind: z.literal('promote') }),
]),
}) as unknown as z.ZodType<RequestPayload<'session.updateQueue'>>

View File

@@ -129,7 +129,6 @@ export interface SessionModels {
export type QueueAction =
| { kind: 'edit'; content: ContentBlock[] }
| { kind: 'remove' }
| { kind: 'promote' }
/** Session list entry (v1 builds no index: list does readdir+stat). */
export interface SessionSummary {
@@ -237,9 +236,7 @@ export interface SessionsApi {
Promise<RpcResponse<{ accepted: true; command?: { kind: 'success'; text?: string } }>>
/**
* Edits, removes, or promotes one pending inbox occurrence. Promotion means
* first in its current FIFO; a queued item is also made waking, so an idle
* agent starts it and a running agent takes it as the next independent turn.
* Edits or removes one pending queued occurrence.
*/
updateQueue(request: RpcRequest<{ sessionId: SessionId; itemId: InboxItemId; action: QueueAction }>):
Promise<RpcResponse<{ accepted: true }>>

View File

@@ -295,7 +295,7 @@ describe('session.updateQueue', () => {
payload: {
sessionId: agent.id,
itemId: InboxItemId('present'),
action: { kind: 'promote' },
action: { kind: 'edit', content: [{ type: 'text', text: 'edited' }] },
},
})
expect(expectOk(applied)).toEqual({ accepted: true })
@@ -309,7 +309,7 @@ describe('session.updateQueue', () => {
})
expect(expectErr(missing)).toMatchObject({ code: 'queue-item-not-found' })
expect(seen).toEqual([
{ id: 'present', action: { kind: 'promote' } },
{ id: 'present', action: { kind: 'edit', content: [{ type: 'text', text: 'edited' }] } },
{ id: 'claimed', action: { kind: 'remove' } },
])
})
@@ -322,8 +322,8 @@ describe('session/queue frames', () => {
const agent = stubAgent(ctx)
const live = new AbortController()
const liveStream = api.events.mux({ rpcId: RpcId('t-mux-live'), payload: {} }, live.signal)
// subscribed baseline + 2 queue snapshots
const liveCollected = collect<MuxFrame>(liveStream, 3, live)
// subscribed baseline + one queued snapshot; pending steering stays off this wire.
const liveCollected = collect<MuxFrame>(liveStream, 2, live)
const queued = inboxItem('i-1', inboxMessage('m-1', 'queued prompt'), 'queued')
const steering = inboxItem('i-2', inboxMessage('m-2', 'steering prompt'), 'steering')
@@ -332,40 +332,41 @@ describe('session/queue frames', () => {
const liveFrames = (await liveCollected).filter(f => f.type === 'session/queue')
expect(liveFrames).toEqual([
{ type: 'session/queue', sessionId: agent.id, items: [queued] },
{ type: 'session/queue', sessionId: agent.id, items: [queued, steering] },
{
type: 'session/queue',
sessionId: agent.id,
items: [{ id: queued.id, message: queued.message }],
},
])
// A fresh mux connection replays only the current authoritative snapshot.
const replay = new AbortController()
const replayFrames = await collect<MuxFrame>(
api.events.mux({ rpcId: RpcId('t-mux-replay'), payload: {} }, replay.signal), 2, replay)
expect(replayFrames.filter(f => f.type === 'session/queue')).toEqual([liveFrames[1]])
expect(replayFrames.filter(f => f.type === 'session/queue')).toEqual([liveFrames[0]])
})
it('publishes edit and promotion in the authoritative order', async () => {
it('publishes edits in place in the authoritative order', async () => {
const ctx = await harness()
const api = createApiProxy(ctx, DEFAULTS)
const agent = stubAgent(ctx)
const abort = new AbortController()
const collected = collect<MuxFrame>(
api.events.mux({ rpcId: RpcId('t-mux-updates'), payload: {} }, abort.signal), 6, abort)
api.events.mux({ rpcId: RpcId('t-mux-updates'), payload: {} }, abort.signal), 5, abort)
const first = inboxItem('i-a', inboxMessage('m-a', 'a'), 'queued')
const second = inboxItem('i-b', inboxMessage('m-b', 'b'), 'queued')
const edited = inboxItem('i-b', inboxMessage('m-b', 'b edited'), 'queued')
ctx.emit('agent/inbox/enqueue', agent, first)
ctx.emit('agent/inbox/enqueue', agent, second)
ctx.emit('agent/inbox/update', agent, edited, 'edit')
ctx.emit('agent/inbox/update', agent, edited, 'promote')
ctx.emit('agent/inbox/update', agent, edited)
ctx.emit('agent/inbox/dequeue', agent, edited)
const frames = (await collected).filter(frame => frame.type === 'session/queue')
expect(frames.map(frame => frame.items)).toEqual([
[first],
[first, second],
[first, edited],
[edited, first],
[first],
[{ id: first.id, message: first.message }],
[{ id: first.id, message: first.message }, { id: second.id, message: second.message }],
[{ id: first.id, message: first.message }, { id: edited.id, message: edited.message }],
[{ id: first.id, message: first.message }],
])
})

View File

@@ -232,7 +232,7 @@ describe('sessions domain schemas', () => {
sessionId: 's1', itemId: 'i1', action: { kind: 'remove' },
}).action.kind).toBe('remove')
expect(() => sessionUpdateQueueRequestSchema.parse({
sessionId: 's1', itemId: '', action: { kind: 'promote' },
sessionId: 's1', itemId: 'i1', action: { kind: 'promote' },
})).toThrow()
expect(sessionCancelValueSchema.parse({ accepted: true }).accepted).toBe(true)
expect(sessionUpdateQueueValueSchema.parse({ accepted: true }).accepted).toBe(true)
@@ -380,8 +380,7 @@ describe('events frame schemas', () => {
{ type: 'question/requested', sessionId: 's', questions: [{ id: 'q', question: 'Q?', options: [{ label: 'L' }], multiSelect: true }] },
{ type: 'question/resolved', sessionId: 's', questionRpcId: 'r', outcome: 'answered' },
{ type: 'session/queue', sessionId: 's', items: [
{ id: 'i1', message: { id: 'm1', role: 'user', content: [{ type: 'text', text: 'queued prompt' }], source: { kind: 'user', rpcId: 'r9' } }, placement: 'queued' },
{ id: 'i2', message: { id: 'm2', role: 'user', content: [{ type: 'text', text: 'steer' }], source: { kind: 'user' } }, placement: 'steering' },
{ id: 'i1', message: { id: 'm1', role: 'user', content: [{ type: 'text', text: 'queued prompt' }], source: { kind: 'user', rpcId: 'r9' } } },
] },
{ type: 'session/projection', sessionId: 's', key: 'todos', value: [{ content: 'x', status: 'pending' }], seq: 7 },
{ type: 'stream/error', error: { code: 'internal', message: 'm', details: {} } },
@@ -402,8 +401,8 @@ describe('events frame schemas', () => {
it('rejects a queue snapshot with malformed items', () => {
expect(() => muxFrameSchema.parse({ type: 'session/queue', sessionId: 's', items: 'x' })).toThrow()
expect(() => muxFrameSchema.parse({ type: 'session/queue', sessionId: 's', items: [{ id: '', message: {}, placement: 'queued' }] })).toThrow()
expect(() => muxFrameSchema.parse({ type: 'session/queue', sessionId: 's', items: [{ id: 'i', message: { id: 'm', role: 'user', content: [], source: {} }, placement: 'later' }] })).toThrow()
expect(() => muxFrameSchema.parse({ type: 'session/queue', sessionId: 's', items: [{ id: '', message: {} }] })).toThrow()
expect(() => muxFrameSchema.parse({ type: 'session/queue', sessionId: 's', items: [{ id: 'i', message: { id: 'm', role: 'user', content: [], source: {} } }] })).toThrow()
})
it('accepts every host frame branch', () => {