feat(web): steer queued messages into active turns

This commit is contained in:
kingwl
2026-07-30 03:17:54 +08:00
committed by imccyu
parent fff0172208
commit 955a12cca4
49 changed files with 508 additions and 147 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: f956be22384a42e9ed30e8aa5f25fe8173cc9f9c
README.zh.md: 49449c51d89d957b5bd39798c9167607f72a5c3a
README.md: a98e97d796ca4e4be07d8d8d25ebc0a24d066a8b
README.zh.md: a973c4fbbf16633fed11d137ae548604516d8a51

View File

@@ -22,7 +22,7 @@ SlotsService gives the renderer separate bare observables for `useSessions` and
## Pending queue projection
`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`.
`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, and strict-steer operations without optimistic mutation, so the next Host snapshot is the sole visible commit; claim and closed-window races surface `queue-item-not-found` and `steer-unavailable`.
## The human transcript

View File

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

View File

@@ -39,9 +39,9 @@ export interface ISession {
*/
prompt(content: ContentBlock[], mode: 'queue' | 'steer'): Promise<RpcResult<{ accepted: true }>>
/**
* Apply one mutation to a still-pending queue occurrence.
* Apply one edit, remove, or strict steer action to a still-pending queue occurrence.
* @param itemId - agent-owned inbox occurrence identity.
* @param action - edit or remove operation.
* @param action - requested queue operation.
* @returns acceptance, or a business/transport error.
*/
updateQueue(itemId: InboxItemId, action: QueueAction): Promise<RpcResult<{ accepted: true }>>

View File

@@ -110,11 +110,20 @@ describe('queue operation transport', () => {
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: 'edit', content: text('next') },
}])
await expect(session.updateQueue(iid('q-op'), { kind: 'steer' }))
.resolves.toEqual({ ok: true, value: { accepted: true } })
expect(api.callsOf('session.updateQueue')).toEqual([
{
sessionId: SID,
itemId: 'q-op',
action: { kind: 'edit', content: text('next') },
},
{
sessionId: SID,
itemId: 'q-op',
action: { kind: 'steer' },
},
])
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: d92202e6dc2b1003db0cdf97910746a0de133357
README.zh.md: a75ee75a5fb2b3d8e5278283b6b6da15bf9c02cd
README.md: 2bf74454fab14303f305b9822e1032f67de0d3ae
README.zh.md: 2677ab5cc85c16fffeca688a4648caf2410ba895

View File

@@ -62,5 +62,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.
- **Web exposes pending Queue only** — the composer and `conversation.send` never submit `mode:'steer'`. The Host omits pending steering from the Queue snapshot. A consumed `steering/message` still folds into the durable transcript as a plain bubble (no interjection chrome) so external/host 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 and strict steer with save and cancel; Enter saves and Escape cancels.
- **Queue strict steer preserves complete messages** — while the Agent is running, the steer action atomically transfers the addressed Queue occurrence into the current next-step window. Mixed-content rows remain eligible because the action forwards the immutable message instead of the text projection. The Host omits pending steering from the Queue snapshot; a consumed `steering/message` still folds into the durable transcript as a plain bubble so replay remains truthful.

View File

@@ -62,5 +62,5 @@ todo 两个面就是在该形状上的两个注册项,都是普通注册方插
- **others 工具行的闪光图标是手绘近似版本**:无法在本地导出设计字形的矢量几何;等到存在精确导出后再将其提升到 ui-primitives。
- **审批面板的「始终允许此类」暂缓**:持久授权需要授权存储设计;今天只能回答允许一次/拒绝。
- **TodoPanel 将过长条目截成单行省略号**:figma 条没有换行或展开入口,完整文本无法在行内读完。
- **Queue 编辑仅支持文本**:包含非文本块的行仍显示扁平化预览,但由于内联编辑器无法保留这些块,其编辑控件会被禁用。文本行进入编辑模式后,删除会替换为保存和取消;Enter 保存,Escape 取消。QueueDock 不提供立即发送控件。
- **Web 仅暴露待处理 Queue**:composer 与 `conversation.send` 从不提交 `mode:'steer'`。Host 不会把待处理 steering(中途引导)纳入 Queue 快照。已消费的 `steering/message` 仍会折叠进持久 transcript(文本记录),并以无「插话」徽章的普通气泡呈现,因此从外部/Host 提交的 steering 在回放时仍能如实呈现。
- **Queue 编辑仅支持文本**:包含非文本块的行仍显示扁平化预览,但由于内联编辑器无法保留这些块,其编辑控件会被禁用。文本行进入编辑模式后,删除和严格 steering(中途引导)操作会被保存和取消取代;Enter 保存,Escape 取消。
- **Queue 严格 steering 会保留完整消息**:Agent 运行期间,steering 操作会以原子方式把所寻址的 Queue 单次入队项转移到当前 next-step 窗口。包含混合内容的行仍可使用此操作,因为它会转发不可变消息,而非文本投影。Host 不会把待处理 steering 纳入 Queue 快照;已消费的 `steering/message` 仍会折叠进持久 transcript(文本记录),并以普通气泡呈现,因此回放仍然如实。

View File

@@ -89,8 +89,11 @@ export const zh = {
'queue.save': '保存排队消息',
'queue.cancelEdit': '取消编辑',
'queue.remove': '删除排队消息',
'queue.steer': '插话发送',
'queue.steer.unavailable': '仅运行中可插话发送',
'queue.editFailed': '编辑失败:这条消息可能已经开始发送。',
'queue.removeFailed': '删除失败:这条消息可能已经开始发送。',
'queue.steerFailed': '插话失败:当前回复已结束,或这条消息已经开始发送。',
'terminal.signal': '信号 {signal}',
'terminal.exitCode': '退出码 {code}',
'terminal.running': '运行中',
@@ -189,8 +192,11 @@ export const en = {
'queue.save': 'Save queued message',
'queue.cancelEdit': 'Cancel editing',
'queue.remove': 'Remove queued message',
'queue.steer': 'Steer queued message',
'queue.steer.unavailable': 'Steering is available only while the agent is running',
'queue.editFailed': 'Edit failed: this message may have already started sending.',
'queue.removeFailed': 'Removal failed: this message may have already started sending.',
'queue.steerFailed': 'Steering failed: the current response ended or this message already started sending.',
'terminal.signal': 'signal {signal}',
'terminal.exitCode': 'exit code {code}',
'terminal.running': 'Running',

View File

@@ -9,7 +9,7 @@ import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
import {
IconCheckOutline16, IconChevronDownOutline14, IconChevronUpOutline14,
IconCloseOutline16, IconEditOutline16, IconTrashOutline16,
IconCloseOutline16, IconEditOutline16, IconRightUpOutline16, IconTrashOutline16,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { QueueAction, QueueItemId } from '../contract/queue.ts'
import { NS } from '../locales.ts'
@@ -30,6 +30,7 @@ export type QueueDockProps = PropsRuntime<'conversation.input.dock'> & QueueDock
*/
export function QueueDock({ useSession, updateQueue, notify, t }: QueueDockProps) {
const queue = useSession(s => s.queue)
const running = useSession(s => s.running)
const [editing, setEditing] = useState<{ id: QueueItemId; text: string } | null>(null)
const [busy, setBusy] = useState<QueueItemId | null>(null)
const [collapsed, setCollapsed] = useState(true)
@@ -170,6 +171,22 @@ export function QueueDock({ useSession, updateQueue, notify, t }: QueueDockProps
>
<IconTrashOutline16 size={14} />
</button>
<button
type="button"
className={css.action}
aria-label={t('queue.steer')}
title={running ? t('queue.steer') : t('queue.steer.unavailable')}
disabled={busy !== null || !running}
onClick={() => {
void applyAction(
row.id,
{ kind: 'steer' },
t('queue.steerFailed'),
)
}}
>
<IconRightUpOutline16 size={14} />
</button>
</>
)}
</div>

View File

@@ -31,9 +31,9 @@ export interface IConversation {
*/
send(text: string): Promise<void>
/**
* Apply one operation to a pending queue occurrence.
* Apply one edit, remove, or strict steer operation to a pending queue occurrence.
* @param itemId - agent-owned inbox occurrence identity.
* @param action - edit or remove operation.
* @param action - requested queue operation.
* @returns completion; business failures reject.
*/
updateQueue(itemId: QueueItemId, action: QueueAction): Promise<void>

View File

@@ -1,7 +1,7 @@
// @vitest-environment jsdom
/**
* QueueDock rendering and operations: authoritative rows, inline editing,
* collapse state, removal, failure notices, and live retirement.
* collapse state, removal, strict steering, failure notices, and live retirement.
*/
import { afterEach, describe, expect, it, vi } from 'vitest'
import { act, cleanup, fireEvent, render, waitFor } from '@testing-library/react'
@@ -187,10 +187,10 @@ describe('QueueDock', () => {
fireEvent.click(getByRole('button', { name: '2 条排队消息' }))
expect([...container.querySelectorAll('li')].map(item => item.textContent))
.toEqual(['第一条排队消息', 'image [image]'])
expect(container.querySelectorAll('button')).toHaveLength(5)
expect(container.querySelectorAll('button')).toHaveLength(7)
expect(container.querySelectorAll('[aria-label="编辑排队消息"]')).toHaveLength(2)
expect(container.querySelectorAll('[aria-label="删除排队消息"]')).toHaveLength(2)
expect(container.querySelectorAll('[aria-label="立即发送排队消息"]')).toHaveLength(0)
expect(container.querySelectorAll('[aria-label="插话发送"]')).toHaveLength(2)
expect((container.querySelectorAll('[aria-label="编辑排队消息"]')[0] as HTMLButtonElement).disabled).toBe(false)
expect((container.querySelectorAll('[aria-label="编辑排队消息"]')[1] as HTMLButtonElement).disabled).toBe(true)
expect(container.querySelectorAll('[aria-label="编辑排队消息"]')[1]?.getAttribute('title'))
@@ -273,6 +273,45 @@ describe('QueueDock', () => {
})
})
it('strictly steers complete row content only while the agent is running', async () => {
const running = snapshotWith([row('i-steer', null, 'image [image]')])
const source = liveSession(running)
const updateQueue = vi.fn(() => Promise.resolve())
const rendered = render(
<QueueDock {...kitFor(running, { updateQueue })} useSession={source.useSession} />,
)
const button = rendered.getByLabelText('插话发送')
expect(button).toHaveProperty('disabled', false)
fireEvent.click(button)
await waitFor(() => {
expect(updateQueue).toHaveBeenCalledWith(iid('i-steer'), { kind: 'steer' })
})
act(() => { source.push({ ...running, running: false }) })
expect(rendered.getByLabelText('插话发送')).toHaveProperty('disabled', true)
expect(rendered.getByLabelText('插话发送').getAttribute('title')).toBe('仅运行中可插话发送')
})
it('keeps the row and reports a strict steer race', async () => {
const snap = snapshotWith([row('i-steer-race', 'pending steer')])
const source = liveSession(snap)
const notify = vi.fn()
const updateQueue = vi.fn(() => Promise.reject(new Error('steer unavailable')))
const { getByLabelText, getByText } = render(
<QueueDock {...kitFor(snap, { updateQueue, notify })} useSession={source.useSession} />,
)
fireEvent.click(getByLabelText('插话发送'))
await waitFor(() => {
expect(notify).toHaveBeenCalledWith(
'error',
'插话失败:当前回复已结束,或这条消息已经开始发送。',
)
})
expect(getByText('pending steer')).toBeTruthy()
})
it('keeps the row and surfaces a notice when an operation loses the claim race', async () => {
const snap = snapshotWith([row('i-race', 'pending')])
const source = liveSession(snap)

View File

@@ -2025,11 +2025,11 @@ 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};',
declaration: 'export type InboxAction = {\n readonly kind: \'edit\';\n readonly content: ContentBlock[];\n} | {\n readonly kind: \'remove\';\n} | {\n readonly kind: \'steer\';\n};',
},
{
name: 'InboxActionResult',
declaration: 'export type InboxActionResult = \'applied\' | \'not-found\';',
declaration: 'export type InboxActionResult = \'applied\' | \'not-found\' | \'steer-unavailable\';',
},
{
name: 'InboxItemId',

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: 1662b1076cc116888d048cb6af1be1c7ab8196f6
README.zh.md: 2fca32a02fdd73961c912c988933e1cd1a1a5817
README.md: c71b350adfe06a19d4c24cb7e67de895a662bd87
README.zh.md: d30dfc85e1597a8e193019cc23f4c7c39c991776

View File

@@ -59,7 +59,7 @@ The unified `send()` primitive routes content and source by (`target` × `wakeup
`steer()` attaches a one-shot admission receipt to its exact accepted message. After `agent/step` and asynchronous prompt assembly succeed, the loop commits a stable pending batch as `steering/message`, snapshots derived history, and opens `step/start`; only then does each receipt resolve `admitted` with that turn and step. Later arrivals remain pending. Idle steering enters the ordinary FIFO and uses the first request of its eventual turn as the same admission boundary. A turn-concluding tool result, broad cancellation, disposal, or a claimed idle-steering turn that never reaches a request resolves affected receipts `rejected`; `cancel(..., { keepInbox: true })` and non-terminal routing preserve pending delivery. Open-turn `inject()` still commits after all tool results, including accepted context finalized during an interrupted batch, while steering remains provisional until a request admits it.
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`.
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, remove publishes discard, and strict steer transfers the immutable message into an open next-step window as a new steering occurrence. A closed window returns `steer-unavailable` without mutation; pending 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

@@ -59,7 +59,7 @@ interface Config {
`steer()` 会把一次性准入回执附着到其准确的已接收消息。`agent/step` 和异步提示词组装成功后,循环把稳定的待处理批次提交为 `steering/message`、捕获派生历史并开启 `step/start`;只有此时,每个回执才会解析为 `admitted`,并附带轮次与步骤。之后到达的消息继续待处理。空闲 steering 会进入普通 FIFO,并以其最终轮次的首次请求作为相同准入边界。结束轮次的工具结果、广义取消、dispose(资源释放),或已领取 idle-steering 消息却从未到达请求的轮次,会把受影响回执解析为 `rejected`;`cancel(..., { keepInbox: true })` 和非终止型路由会保留待处理投递。活跃轮次内的 `inject()` 仍会在所有工具结果后提交,包括被中断批次中已最终确认的上下文;steering 则保持待准入,直到请求接纳它。
每次 FIFO 接受项时都会铸造一个 `InboxItemId`,并通过 `agent/inbox/enqueue` 发布完整的单次入队项。`updateInbox()` 持有同步 queued 项边界:编辑会冻结替换内容,但不改变消息标识或位置;移除会发布 discard。编辑会发布 `agent/inbox/update`;steering 项和已被认领的项会返回 `not-found`。认领操作会发布 `agent/inbox/dequeue`,并在提示词接纳前不可逆地移除实时寻址标识,因此竞态中的更新无法改写持久历史;`cancel()` 在不带 `keepInbox` 时会发布 `agent/inbox/discard`。
每次 FIFO 接受项时都会铸造一个 `InboxItemId`,并通过 `agent/inbox/enqueue` 发布完整的单次入队项。`updateInbox()` 持有同步 queued 项边界:编辑会冻结替换内容,但不改变消息标识或位置;移除会发布 discard;严格 steering 会把不可变消息作为新的 steering 单次入队项转移到开放的 next-step 窗口。窗口关闭时返回 `steer-unavailable`,且不做任何变更;待处理 steering 和已被认领的项会返回 `not-found`。认领操作会发布 `agent/inbox/dequeue`,并在提示词接纳前不可逆地移除实时寻址标识,因此竞态中的更新无法改写持久历史;`cancel()` 在不带 `keepInbox` 时会发布 `agent/inbox/discard`。
### 循环生命周期(`agent.ts`)

View File

@@ -256,6 +256,22 @@ export class ReactLoopAgent implements Agent {
emitAgentEvent(this.loopCtx, this, 'agent/inbox/discard', [pending.item])
return 'applied'
}
case 'steer': {
if (!this.acceptsNextStep) return 'steer-unavailable'
this.queued.splice(queuedIndex, 1)
const item: InboxItem = Object.freeze({
id: InboxItemId(randomUUID()),
message: pending.item.message,
placement: 'steering',
})
this.outbox.push({ message: item.message, steering: true, item })
// Publish the replacement only after it is owned by the outbox. Its
// enqueue precedes the old occurrence's discard so reentrant
// cancellation can terminally account for both occurrences.
emitAgentEvent(this.loopCtx, this, 'agent/inbox/enqueue', item)
emitAgentEvent(this.loopCtx, this, 'agent/inbox/discard', [pending.item])
return 'applied'
}
default:
/* v8 ignore next -- InboxAction is a closed discriminated union. */
return assertNever(action)

View File

@@ -156,6 +156,102 @@ describe('addressable inbox operations', () => {
: ''))
.toEqual(['keep me'])
})
it('strictly transfers a queued occurrence into the open turn', async () => {
const adapter = new MockAdapter([textResponse('done')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('queue-to-steer'), { provider: 'mock', model: 'mock' })
const entered = Promise.withResolvers<undefined>()
const decision = Promise.withResolvers<{ kind: 'allow' }>()
ctx.on('agent/prompt-submit', async () => {
entered.resolve(undefined)
return decision.promise
})
const enqueued: InboxItem[] = []
const discarded: InboxItem[] = []
ctx.on('agent/inbox/enqueue', (subject, item) => {
if (subject === agent) enqueued.push(item)
})
ctx.on('agent/inbox/discard', (subject, items) => {
if (subject === agent) discarded.push(...items)
})
const idle = waitForIdle(ctx, agent)
send(agent, 'open the turn')
await entered.promise
send(agent, 'steer this message')
const queued = enqueued.find(item => inboxText(item) === 'steer this message')!
expect(agent.updateInbox(queued.id, { kind: 'steer' })).toBe('applied')
const steering = enqueued.find(item => item.placement === 'steering')!
expect(steering.id).not.toBe(queued.id)
expect(steering.message).toBe(queued.message)
expect(discarded).toEqual([queued])
decision.resolve({ kind: 'allow' })
await idle
expect(agent.session.events.flatMap(event =>
event.type === 'steering/message' ? [event.data.message] : [],
)).toEqual([queued.message])
expect(agent.updateInbox(queued.id, { kind: 'steer' })).toBe('not-found')
})
it('keeps a queued occurrence when the next-step window is closed', () => {
const ctx = new Context()
const session = new Session(SessionId('queue-to-steer-closed'))
const agent = new ReactLoopAgent(ctx, session.id, {}, session)
const enqueued: InboxItem[] = []
const discarded: InboxItem[] = []
ctx.on('agent/inbox/enqueue', (_subject, item) => { enqueued.push(item) })
ctx.on('agent/inbox/discard', (_subject, items) => { discarded.push(...items) })
agent.send(
createUserMessage({ content: [{ type: 'text', text: 'stay queued' }], source: { kind: 'user' } }),
{ target: 'next-turn', wakeup: false },
)
const queued = enqueued[0]!
expect(agent.updateInbox(queued.id, { kind: 'steer' })).toBe('steer-unavailable')
expect(discarded).toEqual([])
expect(agent.updateInbox(queued.id, { kind: 'remove' })).toBe('applied')
})
it('accounts for both occurrences when steering enqueue cancels reentrantly', async () => {
const adapter = new MockAdapter([textResponse('unused')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('queue-to-steer-cancel'), { provider: 'mock', model: 'mock' })
const entered = Promise.withResolvers<undefined>()
const decision = Promise.withResolvers<{ kind: 'allow' }>()
ctx.on('agent/prompt-submit', async () => {
entered.resolve(undefined)
return decision.promise
})
const enqueued: InboxItem[] = []
const discarded: InboxItem[] = []
ctx.on('agent/inbox/enqueue', (subject, item) => {
if (subject !== agent) return
enqueued.push(item)
if (item.placement === 'steering') agent.cancel({ kind: 'user' })
})
ctx.on('agent/inbox/discard', (subject, items) => {
if (subject === agent) discarded.push(...items)
})
const idle = waitForIdle(ctx, agent)
send(agent, 'open the turn')
await entered.promise
send(agent, 'cancel during conversion')
const queued = enqueued.find(item => inboxText(item) === 'cancel during conversion')!
expect(agent.updateInbox(queued.id, { kind: 'steer' })).toBe('applied')
const steering = enqueued.find(item => item.placement === 'steering')!
expect(discarded).toEqual([steering, queued])
decision.resolve({ kind: 'allow' })
await idle
expect(agent.session.events.some(event => event.type === 'steering/message')).toBe(false)
})
})
describe('assistant replay provenance', () => {

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: 8a6028352127c4638c0b5e0e3ee85964d1d7d734
README.zh.md: ffa71ea987ab355ff2f30b6164376199cd5d0170
README.md: 4c6a6dd95541cfa559e95858fede01d7cd76637f
README.zh.md: 07bf887c557b410005bbe0fa1a988e63a765ad99

View File

@@ -62,7 +62,7 @@ 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.reserveTurnAdmission()` — synchronously reserve the idle boundary before any queued waking prompt can claim its turn. An accepted prompt, including a same-tick pending wake, has right of way and makes reservation return `undefined`. Later sends keep their ordinary IDs, FIFO placement, and wakeup facts while held; `acceptsNextStep` remains false, `inject()` is not withheld, `whenIdle()` counts the reservation as activity, and the returned release is idempotent. This narrow coordination capability lets standalone durable operations such as manual compaction finish and flush before queued prompts derive from the session.
- `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.updateInbox(itemId, action)` — synchronously edits, removes, or strictly steers 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. Strict steer requires `acceptsNextStep`, ends the queued occurrence, and accepts the same immutable message as a new steering occurrence with a new `InboxItemId`; a closed window returns `steer-unavailable` without mutation. Pending 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: submit one identified message and receive its `SteeringReceipt`. During prompt admission or an open turn, the message stages for the next safe request boundary without dispatching `agent/prompt-submit`; outside that acceptance window, it becomes a woken queued prompt. `receipt.outcome` resolves `admitted` with the turn and step only after the loop logs the message, captures it in immutable request history, and commits `step/start`. A turn-concluding tool result, broad cancellation, disposal, or pre-admission failure resolves it `rejected`; `cancel(..., { keepInbox: true })` and non-terminal routing preserve pending delivery. Reliable callers await the receipt, while best-effort UI steering may ignore 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

@@ -62,7 +62,7 @@ Agent *创建* 由实现 `AgentFactory` 的插件(`dsh-agent-loop`)提供,
- `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.reserveTurnAdmission()`:在任何已排队唤醒提示词认领其轮次之前,同步预留空闲边界。已获接纳的提示词拥有优先权,包括同一 tick 内仍在等待唤醒的项,此时预留返回 `undefined`。预留期间,之后发送的项保留其普通 ID、FIFO 位置与唤醒信息;`acceptsNextStep` 保持 false,`inject()` 不受阻塞,`whenIdle()` 将该预留计为活动,返回的释放函数可幂等调用。这项范围有限的协调能力使手动压缩(compaction)等独立持久操作能够在排队提示词从会话派生内容前完成并 flush。
- `agent.updateInbox(itemId, action)`:同步编辑或移除一个仍处于待处理状态的 queued 入队项。编辑会替换已冻结的内容,同时保留其 `MessageId`、`InboxItemId`、来源与 FIFO 位置;移除会发出该项的终态 discard。steering 项和已被认领的项会返回 `not-found`。
- `agent.updateInbox(itemId, action)`:同步编辑、移除一个仍处于待处理状态的 queued 入队项,或对其执行严格 steering。编辑会替换已冻结的内容,同时保留其 `MessageId`、`InboxItemId`、来源与 FIFO 位置;移除会发出该项的终态 discard。严格 steering 要求 `acceptsNextStep` 为 true;它会结束 queued 单次入队项,并把同一条不可变消息接受为新的 steering 单次入队项,后者使用新的 `InboxItemId`。窗口关闭时返回 `steer-unavailable`,且不做任何变更。待处理 steering 和已被认领的项会返回 `not-found`。
- `agent.followup(input)`:`send()` 的 `next-turn`/wakeup 预设:排队一个普通后续轮次并唤醒驱动器。
- `agent.steer(input)`:`next-step`/wakeup 预设:提交一条已有标识的消息,并取得其 `SteeringReceipt`。提示词接纳期间或轮次打开时,消息会为下一个安全请求边界暂存,且不分发 `agent/prompt-submit`;该接收窗口之外则成为会唤醒驱动器的排队提示词。只有循环记录消息、将其捕获到不可变请求历史并提交 `step/start` 后,`receipt.outcome` 才会解析为 `admitted`,并附带轮次与步骤。结束轮次的工具结果、广义取消、dispose(资源释放)或准入前故障会使其解析为 `rejected`;`cancel(..., { keepInbox: true })` 和非终止型路由会保留待处理投递。需要可靠投递的调用方应等待回执;尽力执行的 UI steering 可以忽略它。
- `agent.inject(input)`:`next-step`/不唤醒预设:追加面向模型的上下文而不运行模型;下一次请求会看到一条逐字的 user role 消息,其来源由必填的 `input.source` 携带。提示词接纳期间或轮次打开时,注入会在 outbox 中等待下一个安全边界。该接收窗口之外,它会立即追加而不开启轮次;如果接纳结束却未开启轮次,仅含上下文的接纳批次会采用这一回退,而与 steering 一同暂存的上下文则会随其继续待处理。持久化独立地响应 `session/event`。注入不发出 `agent/inbox/*` 事件。

View File

@@ -54,9 +54,10 @@ export interface InboxItem {
export type InboxAction =
| { readonly kind: 'edit'; readonly content: ContentBlock[] }
| { readonly kind: 'remove' }
| { readonly kind: 'steer' }
/** Result of applying an inbox action at the synchronous ownership boundary. */
export type InboxActionResult = 'applied' | 'not-found'
export type InboxActionResult = 'applied' | 'not-found' | 'steer-unavailable'
/** Final admission outcome for one call to {@link Agent.steer}. */
export type SteeringOutcome =
@@ -209,10 +210,13 @@ export interface Agent {
/**
* Mutate one still-pending queued occurrence synchronously. Editing preserves
* the message identity and queue position; removal publishes its terminal
* discard. Steering occurrences and driver-claimed items return `not-found`.
* discard. Steer strictly transfers the message into the current next-step
* window, or returns `steer-unavailable` without changing the queued
* occurrence. 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.
* @param action - edit, remove, or strict steer operation.
* @returns the applied outcome or the reason no mutation occurred.
*/
updateInbox(id: InboxItemId, action: InboxAction): 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/host/apiproxy/README.md
README.md: 933b5f6167263545b3bef5ca9fb8f8b945e86ef9
README.zh.md: f1f1106dbd0c50889eca6f6ae52fbb29d1c4c03f
README.md: 87c477362dc159621a38a1ff923bebf07cbd3c6f
README.zh.md: 7316f3a6d93d30e101537c40608037fedfa77321

View File

@@ -22,7 +22,7 @@ Session model routing is a session-domain contract. `session.models` returns the
Generic Agent-bound session, command, and goal operations serve ordinary sessions only. They return `agent-busy` for a session-backed subagent instead of resuming or driving it; explicit-id `session.create` adoption and the attached-only queue controls enforce the same ownership boundary. Subagent conversation reads and continuation use the dedicated `subagent.*` domain, which retains catalog-mode and direct-parent authorization.
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. `session.cancel` aborts only the active turn and preserves pending inbox work; after cancellation reaches quiescence and the closing turn flushes, AgentLoop claims the next waking occurrence in FIFO order. The browser never resends or promotes that occurrence. A driver claim wins races by retiring the address before admission; a later operation returns `queue-item-not-found`. Queue operations query only an attached Agent and never resume a cold session because process-local inbox identities do not survive restart or disposal. 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, remove discards it, and strict steer transfers its complete message into the current next-step window. A closed window returns `steer-unavailable` without changing the row. `session.cancel` aborts only the active turn and preserves pending inbox work; after cancellation reaches quiescence and the closing turn flushes, AgentLoop claims the next waking occurrence in FIFO order. The browser never resends or promotes that occurrence. A driver claim wins races by retiring the address before admission; a later operation returns `queue-item-not-found`. Queue operations query only an attached ordinary-session Agent and never resume a cold session because process-local inbox identities do not survive restart or disposal. The client never infers retirement from turn or status events.
Workspace and Session lists are separate reconnect baselines. `workspace.create({ name })` creates a uniquely titled directory under the configured root, while `workspace.create({ path })` adopts an existing canonical directory and permits basename-derived titles to repeat. `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. `workspace.archiveSession` adds one session to the registry-global archive set and answers the full updated set; `workspace.list` carries that set as the reconnect baseline and `host/archived-sessions-changed` pushes the full snapshot after every durable change. Archiving hides the session from grouping surfaces without touching its log or its workspace account; a session neither live nor persisted fails with `session-not-found`. 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()`. The `session.list` summaries and `host/session-added` frames also carry the optional durable `origin: 'subagent'` classification so navigation can suppress duplicate child rows immediately and after reconnect; that bit is never continuation authority.

View File

@@ -22,7 +22,7 @@
绑定到 Agent 的通用会话、命令与目标操作只服务普通会话。对于由会话支撑的 subagent,它们会返回 `agent-busy`,而不是恢复或驱动它;显式 id 的 `session.create` 接纳与仅针对已附加会话的队列控件也会执行同一所有权边界。subagent 对话读取与继续执行使用专用的 `subagent.*` 领域,该领域保留目录 mode 与直接 parent 授权。
待处理的 queued 输入属于实时控制平面契约,而非会话历史。网关镜像来自 `agent/inbox/*` 的 queued `InboxItem` 入队项,并在每次 queued 变更和重连时广播权威的 `session/queue` 快照;待处理 steering(中途引导)不进入此 Web 投影。`session.updateQueue` 通过 `InboxItemId` 寻址单个项:编辑会替换待处理内容,移除会将其丢弃。`session.cancel` 仅中止活动轮次,并保留待处理 inbox 工作;取消达到完全停稳且结束中的轮次完成 flush 后,AgentLoop 按 FIFO 顺序认领下一个可唤醒入队项。浏览器绝不重发或提升该入队项。驱动器在接纳前退役寻址标识,因此认领会赢得竞态;之后的操作返回 `queue-item-not-found`。队列操作只查询当前已挂载的 Agent,绝不恢复冷会话,因为进程本地 inbox 标识无法在重启或资源释放后存活。客户端绝不根据轮次或状态事件推断项已退役。
待处理的 queued 输入属于实时控制平面契约,而非会话历史。网关镜像来自 `agent/inbox/*` 的 queued `InboxItem` 入队项,并在每次 queued 变更和重连时广播权威的 `session/queue` 快照;待处理 steering(中途引导)不进入此 Web 投影。`session.updateQueue` 通过 `InboxItemId` 寻址单个项:编辑会替换待处理内容,移除会将其丢弃,严格 steering 会把其完整消息转移到当前 next-step 窗口。窗口关闭时返回 `steer-unavailable`,且不改变该行。`session.cancel` 仅中止活动轮次,并保留待处理 inbox 工作;取消达到完全停稳且结束中的轮次完成 flush 后,AgentLoop 按 FIFO 顺序认领下一个可唤醒入队项。浏览器绝不重发或提升该入队项。驱动器在接纳前退役寻址标识,因此认领会赢得竞态;之后的操作返回 `queue-item-not-found`。队列操作只查询当前已挂载的普通会话 Agent,绝不恢复冷会话,因为进程本地 inbox 标识无法在重启或资源释放后存活。客户端绝不根据轮次或状态事件推断项已退役。
Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.create({ name })` 会在配置根目录下创建显示标题唯一的目录,而 `workspace.create({ path })` 会接纳已有的规范目录,并允许由 basename 派生的标题重复。`workspace.delete` 只移除 Workspace 注册记录,`session.create` 接受可选的预分配 Session id,`host/workspace-changed`、`host/workspace-removed` 与 `host/session-added` 则以任意到达顺序携带已提交的增量。`workspace.archiveSession` 向注册表级全局归档集合添加一个会话,并应答完整的更新后集合;`workspace.list` 携带该集合作为重连基线,`host/archived-sessions-changed` 在每次持久变更后推送完整快照。归档只把会话从各分组视图中隐藏,不触碰其日志和 workspace 记账;既非实时也未持久化的会话以 `session-not-found` 失败。删除注册记录会保留目录和会话日志;相关 Session 仍留在 `session.list` 中,并进入 Ungrouped。`SessionSummary.blank` 与 `host/session-added` 帧携带派生的零事件位:客户端隐藏空白会话并按 workspace 复用它们,在首个 `host/session-status(running:true)` 时翻转 blank,并以 `session.list` 作为重连权威;冷会话摘要永远不是空白:惰性持久化让从未追加过事件的会话根本不出现在 `list()` 中。`session.list` 摘要与 `host/session-added` 帧还会携带可选的持久化分类 `origin: 'subagent'`,使导航在实时创建与重连后都能隐藏重复的 child 行;该标记绝不是继续执行的权威依据。

View File

@@ -1847,13 +1847,28 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
if (agent !== undefined && hasSubagentOwner(agent.session, agent)) {
return Promise.resolve(err(request, subagentOwnershipError(sessionId)))
}
if (agent === undefined || agent.updateInbox(itemId, action) === 'not-found') {
if (agent === undefined) {
return Promise.resolve(err(request, {
code: 'queue-item-not-found',
message: 'queued item is no longer pending',
details: { itemId },
}))
}
const result = agent.updateInbox(itemId, action)
if (result === 'not-found') {
return Promise.resolve(err(request, {
code: 'queue-item-not-found',
message: 'queued item is no longer pending',
details: { itemId },
}))
}
if (result === 'steer-unavailable') {
return Promise.resolve(err(request, {
code: 'steer-unavailable',
message: 'current turn no longer accepts steering',
details: { itemId },
}))
}
return Promise.resolve(ok(request, { accepted: true as const }))
},

View File

@@ -48,6 +48,7 @@ export const rpcErrorSchema: z.ZodType<RpcError> = z.discriminatedUnion('code',
z.object({ code: z.literal('directory-picker-unavailable'), message: z.string(), details: z.object({ capability: z.string() }) }),
z.object({ code: z.literal('agent-busy'), message: z.string(), details: z.object({ reason: z.string() }) }),
z.object({ code: z.literal('queue-item-not-found'), message: z.string(), details: z.object({ itemId: z.string() }) }),
z.object({ code: z.literal('steer-unavailable'), message: z.string(), details: z.object({ itemId: z.string() }) }),
z.object({ code: z.literal('command-error'), message: z.string(), details: z.object({}) }),
z.object({ code: z.literal('unknown-command'), message: z.string(), details: z.object({}) }),
z.object({ code: z.literal('settings-rejected'), message: z.string(), details: z.object({ ns: z.string() }) }),

View File

@@ -46,6 +46,7 @@ export interface RpcErrorDetailsMap {
'directory-picker-unavailable': { capability: string }
'agent-busy': { reason: string }
'queue-item-not-found': { itemId: InboxItemId }
'steer-unavailable': { itemId: InboxItemId }
/** A known slash command reported a usage/state error; the message is the command's own text. */
'command-error': {}
/** A leading-/ prompt named no registered command; the message names the token. */

View File

@@ -269,6 +269,7 @@ 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('steer') }),
]),
}) as unknown as z.ZodType<RequestPayload<'session.updateQueue'>>

View File

@@ -129,6 +129,7 @@ export interface SessionModels {
export type QueueAction =
| { kind: 'edit'; content: ContentBlock[] }
| { kind: 'remove' }
| { kind: 'steer' }
/** Session list entry (v1 builds no index: list does readdir+stat). */
export interface SessionSummary {
@@ -285,7 +286,7 @@ export interface SessionsApi {
Promise<RpcResponse<{ accepted: true; command?: { kind: 'success'; text?: string } }>>
/**
* Edits or removes one pending queued occurrence on an ordinary session.
* Edits, removes, or strictly steers one pending queued occurrence on an ordinary session.
* Session-backed subagents reject with `agent-busy`.
*/
updateQueue(request: RpcRequest<{ sessionId: SessionId; itemId: InboxItemId; action: QueueAction }>):

View File

@@ -291,13 +291,14 @@ function inboxItem(id: string, message: UserMessage, placement: InboxPlacement):
}
describe('session.updateQueue', () => {
it('routes an addressable action and reports a lost claim race', async () => {
it('routes addressable actions and reports strict steer races', async () => {
const ctx = await harness()
const agent = stubAgent(ctx)
const seen: unknown[] = []
agent.updateInbox = (id, action) => {
seen.push({ id, action })
return id === InboxItemId('present') ? 'applied' : 'not-found'
if (id === InboxItemId('present')) return 'applied'
return id === InboxItemId('closed') ? 'steer-unavailable' : 'not-found'
}
const api = createApiProxy(ctx, DEFAULTS)
@@ -319,9 +320,22 @@ describe('session.updateQueue', () => {
},
})
expect(expectErr(missing)).toMatchObject({ code: 'queue-item-not-found' })
const closed = await api.sessions.updateQueue({
rpcId: RpcId('q-closed'),
payload: {
sessionId: agent.id,
itemId: InboxItemId('closed'),
action: { kind: 'steer' },
},
})
expect(expectErr(closed)).toMatchObject({
code: 'steer-unavailable',
details: { itemId: 'closed' },
})
expect(seen).toEqual([
{ id: 'present', action: { kind: 'edit', content: [{ type: 'text', text: 'edited' }] } },
{ id: 'claimed', action: { kind: 'remove' } },
{ id: 'closed', action: { kind: 'steer' } },
])
})

View File

@@ -77,6 +77,7 @@ describe('rpcErrorSchema', () => {
}).code).toBe('model-unavailable')
expect(rpcErrorSchema.parse({ code: 'agent-busy', message: 'm', details: { reason: 'r' } }).code).toBe('agent-busy')
expect(rpcErrorSchema.parse({ code: 'queue-item-not-found', message: 'm', details: { itemId: 'i' } }).code).toBe('queue-item-not-found')
expect(rpcErrorSchema.parse({ code: 'steer-unavailable', message: 'm', details: { itemId: 'i' } }).code).toBe('steer-unavailable')
expect(rpcErrorSchema.parse({ code: 'command-error', message: 'm', details: {} }).code).toBe('command-error')
expect(rpcErrorSchema.parse({ code: 'unknown-command', message: 'm', details: {} }).code).toBe('unknown-command')
expect(rpcErrorSchema.parse({ code: 'title-invalid', message: 'm', details: { sessionId: 's' } }).code).toBe('title-invalid')
@@ -280,6 +281,9 @@ describe('sessions domain schemas', () => {
expect(sessionUpdateQueueRequestSchema.parse({
sessionId: 's1', itemId: 'i1', action: { kind: 'remove' },
}).action.kind).toBe('remove')
expect(sessionUpdateQueueRequestSchema.parse({
sessionId: 's1', itemId: 'i1', action: { kind: 'steer' },
}).action.kind).toBe('steer')
expect(() => sessionUpdateQueueRequestSchema.parse({
sessionId: 's1', itemId: 'i1', action: { kind: 'promote' },
})).toThrow()