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', '第一条排队消息'),