feat(agent): add addressable queue operations

This commit is contained in:
kingwl
2026-07-30 00:05:00 +08:00
parent 87c9ab06b3
commit f893e2281d
108 changed files with 1785 additions and 608 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/ui-conversation/README.md
README.md: e2148cfca658196540e3800912dccd0568ae8d0e
README.zh.md: 45f05ce2e03e015701e85f2853a4a656511058a9
README.md: 1d5c3704a86ad11ce35b2bd2d404391301400016
README.zh.md: 33a1f5187c0ae51ab18833766856fda33ba1ffde

View File

@@ -38,3 +38,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.

View File

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

View File

@@ -6,6 +6,7 @@
* (machine.ts) is package-private and never exported.
*/
import type { ClientContext, SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import type { InboxItemId } from '@deepseek-ai/dsh-client-connection/client'
import type {
ArbitrateKey, ArbitrateOutcome, CommandClaim, ConsumeTokenRequest, PickOutcome,
ReferenceInsert, SubmitOutcome, TokenSpan,
@@ -99,11 +100,12 @@ export interface ComposerKeyboard {
dismissPopup(): void
}
/** One queued-message row projected from the session/queued frames (T9 supplies the store). */
/** One independently addressable row projected from the transient queue snapshot. */
export interface QueuedMessage {
/** Stable row key: the enqueueing prompt's rpcId. */
readonly key: string
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

@@ -1,30 +1,114 @@
/* Neutral stacked strip above the input (queue rows are informational, not a warn state). */
/* Figma .FileContainerText 1:791: 776px wrapper around the inset 752px panel. */
.dock {
margin: 6px 0;
padding: 8px 12px;
border: 1px solid var(--dsw-alias-separator-primary);
border-radius: 10px;
background: var(--dsw-alias-bg-base);
box-sizing: border-box;
flex: none;
width: 100%;
max-width: 776px;
/* Eat InputBar's 6px top padding and tuck the panel 2px under the card;
the later composer sibling paints its surface and shadow over this edge. */
margin: 0 auto -10px;
padding: 2px 12px;
}
.title {
font-size: 12px;
font-weight: 500;
color: var(--dsw-alias-label-secondary);
.panel {
position: relative;
overflow: hidden;
width: 100%;
padding-top: 2px;
border-radius: 14px 14px 0 0;
background: var(--dsw-specific-tip);
}
.panel::after {
position: absolute;
inset: 0;
border: 1px solid var(--dsw-alias-border-l1);
border-bottom: none;
border-radius: inherit;
content: '';
pointer-events: none;
}
.list {
margin: 4px 0 0;
margin: 0;
padding: 0;
list-style: none;
}
.row {
overflow: hidden;
font-size: 12px;
line-height: 20px;
color: var(--dsw-alias-label-primary);
white-space: nowrap;
text-overflow: ellipsis;
box-sizing: border-box;
display: flex;
align-items: center;
gap: 10px;
width: 100%;
height: 36px;
padding: 4px 5px 4px 12px;
border-radius: 8px;
}
.preview,
.editor {
flex: 1 1 auto;
min-width: 0;
font: var(--dsw-font-xs-13);
font-family: Inter, var(--dsw-font-family);
}
.preview {
overflow: hidden;
color: var(--dsw-alias-label-primary-dimmed);
text-overflow: ellipsis;
white-space: nowrap;
word-break: break-word;
}
.editor {
box-sizing: border-box;
height: 28px;
padding: 0 8px;
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 6px;
outline: none;
background: var(--dsw-alias-bg-base);
color: var(--dsw-alias-label-primary);
}
.editor:focus {
border-color: var(--dsw-alias-state-business-primary);
}
.actions {
display: flex;
flex: none;
align-items: center;
gap: 10px;
}
.action {
display: grid;
flex: none;
place-items: center;
width: 28px;
height: 28px;
padding: 0;
border: none;
border-radius: 999px;
background: transparent;
color: var(--dsw-alias-label-tertiary);
cursor: pointer;
}
.action:hover:not(:disabled) {
background: var(--dsw-alias-interactive-bg-hover);
}
.action:focus-visible {
outline: 2px solid var(--dsw-alias-label-tertiary);
outline-offset: -2px;
}
.action:disabled {
cursor: default;
opacity: 0.45;
}

View File

@@ -1,48 +1,188 @@
// Read-only queue dock entry (design v4 queue cut 1): renders the session's
// inbox mirror (session/queued frames + connect baseline) as one stacked
// strip above the input. No per-row actions — the host inbox has no
// addressable entries yet (queue cut 2 ledger).
// Queue dock entry: renders the authoritative transient inbox snapshot and
// addresses per-row mutations through the session-scoped conversation face.
//
// 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 type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
import type {} from '@deepseek-ai/dsh-client-runtime/client'
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
import type { InboxItemId, QueueAction } from '@deepseek-ai/dsh-client-connection/client'
import {
IconCheckOutline16, IconCloseOutline16, IconEditOutline16, IconTrashOutline16,
} from '@deepseek-ai/dsh-client-ui-primitives'
import css from './QueueDock.module.css'
/** Queue operations injected by the session-scoped registration. */
export interface QueueDockInjected {
updateQueue: (itemId: InboxItemId, action: QueueAction) => Promise<void>
notify: (level: 'info' | 'error', text: string) => void
}
/** Full props of a dock entry: InputZone owner share + session standard kit + global seat. */
export type QueueDockProps = PropsRuntime<'conversation.input.dock'>
export type QueueDockProps = PropsRuntime<'conversation.input.dock'> & QueueDockInjected
/** Queue strip: one preview line per queued message; renders null when the queue is empty. */
export function QueueDock({ useSession }: QueueDockProps) {
const queue = useSession(s => s.queue)
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 [editing, setEditing] = useState<{ id: InboxItemId; text: string } | null>(null)
const [busy, setBusy] = useState<InboxItemId | null>(null)
useEffect(() => {
if (editing !== null && !queue.some(row => row.id === editing.id)) setEditing(null)
}, [editing, queue])
if (queue.length === 0) return null
const applyAction = async (
itemId: InboxItemId,
action: QueueAction,
failure: string,
): Promise<boolean> => {
setBusy(itemId)
try {
await updateQueue(itemId, action)
return true
} catch {
notify('error', failure)
return false
} finally {
setBusy(current => current === itemId ? null : current)
}
}
const saveEdit = async (): Promise<void> => {
if (editing === null || editing.text.trim() === '') return
if (await applyAction(
editing.id,
{ kind: 'edit', content: [{ type: 'text', text: editing.text }] },
'编辑失败:这条消息可能已经开始发送。',
)) setEditing(null)
}
return (
<div className={css.dock}>
<div className={css.title}>已排队 {queue.length} 条</div>
<ul className={css.list}>
{queue.map(row => (
<li key={row.key} className={css.row}>{row.preview}</li>
))}
</ul>
<div className={css.panel}>
<ul className={css.list}>
{queue.map(row => (
<li key={row.id} className={css.row}>
{editing?.id === row.id
? (
<input
autoFocus
className={css.editor}
aria-label="编辑排队消息"
value={editing.text}
onChange={(event) => { setEditing({ id: row.id, text: event.currentTarget.value }) }}
onKeyDown={(event) => {
if (event.key === 'Escape') {
setEditing(null)
return
}
if (event.key === 'Enter' && !event.nativeEvent.isComposing) {
event.preventDefault()
void saveEdit()
}
}}
/>
)
: <span className={css.preview}>{row.preview}</span>}
<div className={css.actions}>
{editing?.id === row.id
? (
<>
<button
type="button"
className={css.action}
aria-label="保存排队消息"
title="保存排队消息"
disabled={busy !== null || editing.text.trim() === ''}
onClick={() => { void saveEdit() }}
>
<IconCheckOutline16 size={14} />
</button>
<button
type="button"
className={css.action}
aria-label="取消编辑"
title="取消编辑"
disabled={busy !== null}
onClick={() => { setEditing(null) }}
>
<IconCloseOutline16 size={14} />
</button>
</>
)
: (
<>
<button
type="button"
className={css.action}
aria-label="编辑排队消息"
title={row.text === null ? '包含非文本内容,暂不支持编辑' : '编辑排队消息'}
disabled={busy !== null || row.text === null}
onClick={() => {
if (row.text !== null) setEditing({ id: row.id, text: row.text })
}}
>
<IconEditOutline16 size={14} />
</button>
<button
type="button"
className={css.action}
aria-label="删除排队消息"
title="删除排队消息"
disabled={busy !== null}
onClick={() => {
void applyAction(
row.id,
{ kind: 'remove' },
'删除失败:这条消息可能已经开始发送。',
)
}}
>
<IconTrashOutline16 size={14} />
</button>
</>
)}
</div>
</li>
))}
</ul>
</div>
</div>
)
}
/**
* The dock entry as a plain registrant plugin (bash posture).
* `inject: ['conversation']` is the ordering seam: the conversation service
* mounts after ui-conversation's slot registrations, so the
* 'conversation.input.dock' declaration is on the ledger by then.
* The dock entry as a plain registrant plugin. The conversation service is the
* ordering and action seam; session scopes provide the exact queue owner.
*/
export const queueDockEntry = {
name: 'conversation-queue-dock',
inject: ['slots', 'conversation'],
inject: ['slots', 'conversation', 'sessions'],
/**
* Register the queue strip into the input dock (list entry, order 0).
* @param ctx - registrant context (disposal rides ctx.effect inside slots.register).
*/
apply(ctx: Context): void {
ctx.slots.register({ name: 'conversation.input.dock', id: 'queue', order: 0 }, QueueDock)
ctx.slots.register({
name: 'conversation.input.dock',
id: 'queue',
order: 0,
inject: (sessionId: SessionId): QueueDockInjected => {
const actx = ctx.sessions.scope(sessionId)
if (actx === undefined) throw new Error(`queue dock: session "${sessionId}" resolved no scope`)
const conversation = actx.get('conversation')
if (conversation === undefined) throw new Error('queue dock: conversation service unavailable')
return {
updateQueue: (itemId, action) => conversation.updateQueue(itemId, action),
notify: (level, text) => { conversation.input.for(actx).notify(level, text) },
}
},
}, QueueDock)
},
}

View File

@@ -11,8 +11,8 @@ import type { QueuedMessage } from '../input/contract.ts'
/**
* Project a session's queue rows as a bare observable (subscribe/getSnapshot).
* The wiring layer (T5) overlays this onto InputState.queue; the runtime
* QueuedMessage and the input-contract QueuedMessage are structurally the
* same frozen shape ({key, preview}).
* QueuedMessage and the input-contract QueuedMessage are structurally
* identical.
* @param session - the resident session face.
* @returns the queue read face (snapshot reference stable while the queue is unchanged).
*/

View File

@@ -13,6 +13,7 @@ import type { Context } from 'cordis'
// error, so scope resolution goes through the sessions service (scopeOf
// method) instead of the standalone helper.
import type { ISessions, SessionFace, SessionId } from '@deepseek-ai/dsh-client-runtime/client'
import type { InboxItemId, QueueAction } from '@deepseek-ai/dsh-client-connection/client'
import type { InputService } from './input/contract.ts'
/**
@@ -30,6 +31,13 @@ export interface IConversation {
* @returns completion; business failures reject (and land in promptError).
*/
send(text: string, mode: 'queue' | 'steer'): Promise<void>
/**
* Apply one operation to a pending queue occurrence.
* @param itemId - agent-owned inbox occurrence identity.
* @param action - edit, remove, or promote operation.
* @returns completion; business failures reject.
*/
updateQueue(itemId: InboxItemId, action: QueueAction): Promise<void>
/**
* Cancel the scoped session's in-flight turn.
* @returns completion; failures reject as in send.
@@ -71,6 +79,15 @@ export class ConversationService extends Service implements IConversation {
if (!result.ok) throw new Error(`conversation.send failed: ${result.error.code}: ${result.error.message}`)
}
/** Apply one operation to a pending queue occurrence. */
async updateQueue(itemId: InboxItemId, action: QueueAction): Promise<void> {
const session = this.scopedSession('updateQueue')
const result = await session.updateQueue(itemId, action)
if (!result.ok) {
throw new Error(`conversation.updateQueue failed: ${result.error.code}: ${result.error.message}`)
}
}
/** Cancel the scoped session's in-flight turn (failures land in promptError and reject, as in send). */
async cancel(): Promise<void> {
const session = this.scopedSession('cancel')

View File

@@ -1,20 +1,27 @@
// @vitest-environment jsdom
/**
* QueueDock rendering (web input-triggers queue cut 1): empty queue renders
* nothing, rows render one preview line each keyed by rpcId, and the strip
* follows queue changes through the useSession selector.
* QueueDock rendering and operations: authoritative rows, inline editing,
* removal, failure notices, and live retirement.
*/
import { afterEach, describe, expect, it } from 'vitest'
import { act, cleanup, render } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { act, cleanup, fireEvent, render, waitFor } from '@testing-library/react'
import { useSyncExternalStore } from 'react'
import type { ConversationSnapshot, QueuedMessage, SessionId, SessionListState } from '@deepseek-ai/dsh-client-runtime/client'
import type {
ConversationSnapshot, QueuedMessage, SessionId, SessionListState,
} from '@deepseek-ai/dsh-client-runtime/client'
import type { InboxItemId } from '@deepseek-ai/dsh-client-connection/client'
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
import type { InputState } from '../src/client/input/contract.ts'
import { QueueDock, queueDockEntry } from '../src/client/queue/QueueDock.tsx'
import { QueueDock, queueDockEntry, type QueueDockInjected } from '../src/client/queue/QueueDock.tsx'
afterEach(cleanup)
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' }
}
function snapshotWith(queue: QueuedMessage[]): ConversationSnapshot {
return {
@@ -24,31 +31,30 @@ function snapshotWith(queue: QueuedMessage[]): ConversationSnapshot {
}
}
/** Minimal live source backing the useSession stub (queue swaps notify subscribers). */
/** Minimal live source backing the useSession stub. */
function liveSession(initial: ConversationSnapshot) {
let snapshot = initial
const listeners = new Set<() => void>()
const useSession: SnapshotSelectorHook<ConversationSnapshot> = sel =>
const useSession: SnapshotSelectorHook<ConversationSnapshot> = selector =>
useSyncExternalStore(
(fn) => {
listeners.add(fn)
return () => listeners.delete(fn)
(listener) => {
listeners.add(listener)
return () => listeners.delete(listener)
},
() => sel(snapshot),
() => selector(snapshot),
)
return {
useSession,
push(next: ConversationSnapshot): void {
snapshot = next
for (const fn of [...listeners]) fn()
for (const listener of [...listeners]) listener()
},
}
}
/** InputZone owner stub (the dock reads useSession only; the zone fields satisfy the owner share). */
const INPUT_STATE: InputState = { draft: '', draftRev: 0, phase: 'plain', occurrences: [], queue: [] }
function kitFor(snapshot: ConversationSnapshot) {
function kitFor(snapshot: ConversationSnapshot, injected: Partial<QueueDockInjected> = {}) {
return {
sessionId: SID,
useSessions: (() => { throw new Error('unused') }) as unknown as SnapshotSelectorHook<SessionListState>,
@@ -58,6 +64,9 @@ function kitFor(snapshot: ConversationSnapshot) {
inputActions: { setDraft: () => {}, submit: () => {} } as never,
session: snapshot,
input: INPUT_STATE,
updateQueue: vi.fn(() => Promise.resolve()),
notify: vi.fn(),
...injected,
}
}
@@ -69,20 +78,131 @@ describe('QueueDock', () => {
expect(container.innerHTML).toBe('')
})
it('renders one preview row per queued message with the count strip', () => {
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([
{ key: 'p-1', preview: '第一条排队消息' },
{ key: 'p-2', preview: 'second queued line' },
row('i-1', '第一条排队消息'),
row('i-2', null, 'image [image]'),
])
const source = liveSession(snap)
const { container } = render(<QueueDock {...kitFor(snap)} useSession={source.useSession} />)
expect(container.textContent).toContain('已排队 2 条')
const rows = [...container.querySelectorAll('li')]
expect(rows.map(r => r.textContent)).toEqual(['第一条排队消息', 'second queued line'])
expect([...container.querySelectorAll('li')].map(item => item.textContent))
.toEqual(['第一条排队消息', 'image [image]'])
expect(container.querySelectorAll('button')).toHaveLength(4)
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="编辑排队消息"]')[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'))
.toBe('包含非文本内容,暂不支持编辑')
})
it('follows queue changes: retirement empties the strip back to null', () => {
const snap = snapshotWith([{ key: 'p-1', preview: '在场' }])
it('edits text inline with save and cancel controls, then saves with the same item identity', async () => {
const snap = snapshotWith([row('i-edit', 'before')])
const source = liveSession(snap)
const updateQueue = vi.fn(() => Promise.resolve())
const { getByLabelText, queryByLabelText } = render(
<QueueDock {...kitFor(snap, { updateQueue })} useSession={source.useSession} />,
)
fireEvent.click(getByLabelText('编辑排队消息'))
const editor = getByLabelText('编辑排队消息') as HTMLInputElement
expect(getByLabelText('保存排队消息')).toBeTruthy()
expect(getByLabelText('取消编辑')).toBeTruthy()
expect(queryByLabelText('删除排队消息')).toBeNull()
fireEvent.change(editor, { target: { value: 'after' } })
fireEvent.keyDown(editor, { key: 'Enter' })
await waitFor(() => {
expect(updateQueue).toHaveBeenCalledWith(iid('i-edit'), {
kind: 'edit',
content: [{ type: 'text', text: 'after' }],
})
})
})
it('cancels an edit by button or Escape without mutating the queue', () => {
const snap = snapshotWith([row('i-edit', 'before')])
const source = liveSession(snap)
const updateQueue = vi.fn(() => Promise.resolve())
const { getByLabelText, getByText } = render(
<QueueDock {...kitFor(snap, { updateQueue })} useSession={source.useSession} />,
)
fireEvent.click(getByLabelText('编辑排队消息'))
fireEvent.change(getByLabelText('编辑排队消息'), { target: { value: 'abandoned' } })
fireEvent.click(getByLabelText('取消编辑'))
expect(getByText('before')).toBeTruthy()
fireEvent.click(getByLabelText('编辑排队消息'))
fireEvent.keyDown(getByLabelText('编辑排队消息'), { key: 'Escape' })
expect(getByText('before')).toBeTruthy()
expect(updateQueue).not.toHaveBeenCalled()
})
it('keeps editing during IME composition and disables a blank save', () => {
const snap = snapshotWith([row('i-edit', 'before')])
const source = liveSession(snap)
const updateQueue = vi.fn(() => Promise.resolve())
const { getByLabelText } = render(
<QueueDock {...kitFor(snap, { updateQueue })} useSession={source.useSession} />,
)
fireEvent.click(getByLabelText('编辑排队消息'))
const editor = getByLabelText('编辑排队消息')
fireEvent.change(editor, { target: { value: ' ' } })
expect(getByLabelText('保存排队消息')).toHaveProperty('disabled', true)
fireEvent.change(editor, { target: { value: '输入中' } })
fireEvent.keyDown(editor, { key: 'Enter', isComposing: true })
expect(updateQueue).not.toHaveBeenCalled()
expect(getByLabelText('编辑排队消息')).toBeTruthy()
})
it('removes the addressed row', async () => {
const snap = snapshotWith([row('i-1', 'one'), row('i-2', 'two')])
const source = liveSession(snap)
const updateQueue = vi.fn(() => Promise.resolve())
const { getAllByLabelText } = render(
<QueueDock {...kitFor(snap, { updateQueue })} useSession={source.useSession} />,
)
fireEvent.click(getAllByLabelText('删除排队消息')[0]!)
await waitFor(() => {
expect(updateQueue).toHaveBeenCalledWith(iid('i-1'), { kind: 'remove' })
})
})
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)
const notify = vi.fn()
const updateQueue = vi.fn(() => Promise.reject(new Error('not found')))
const { getByLabelText, getByText } = render(
<QueueDock {...kitFor(snap, { updateQueue, notify })} useSession={source.useSession} />,
)
fireEvent.click(getByLabelText('删除排队消息'))
await waitFor(() => {
expect(notify).toHaveBeenCalledWith('error', '删除失败:这条消息可能已经开始发送。')
})
expect(getByText('pending')).toBeTruthy()
})
it('follows authoritative retirement back to null', () => {
const snap = snapshotWith([row('i-1', '在场')])
const source = liveSession(snap)
const { container } = render(<QueueDock {...kitFor(snap)} useSession={source.useSession} />)
expect(container.textContent).toContain('在场')
@@ -90,11 +210,9 @@ describe('QueueDock', () => {
expect(container.innerHTML).toBe('')
})
it('ships the registrant plugin shape (list entry into conversation.input.dock)', () => {
// Registration itself runs under T5's slot declaration; here we pin the
// frozen registration surface so the wiring layer can mount it verbatim.
it('ships the session-scoped registrant plugin shape', () => {
expect(queueDockEntry.name).toBe('conversation-queue-dock')
expect(queueDockEntry.inject).toEqual(['slots', 'conversation'])
expect(queueDockEntry.inject).toEqual(['slots', 'conversation', 'sessions'])
expect(typeof queueDockEntry.apply).toBe('function')
})
})

View File

@@ -12,11 +12,12 @@ import { InputHub } from '../src/client/input/hub.ts'
async function bench() {
const runtime = await SlotTestRuntime.create()
const prompt = vi.fn(() => Promise.resolve({ ok: true as const, value: { accepted: true as const } }))
const updateQueue = vi.fn(() => Promise.resolve({ ok: true as const, value: { accepted: true as const } }))
const cancel = vi.fn(() => Promise.resolve({ ok: true as const, value: { accepted: true as const } }))
const loadOlder = vi.fn(() => Promise.resolve())
await runtime.sessions.add({
id: 's1',
session: { prompt, cancel, loadOlder },
session: { prompt, updateQueue, cancel, loadOlder },
})
// config.input is required (the apply shares its hub with the inject
// factories); the bench passes its own instance explicitly.
@@ -26,16 +27,18 @@ async function bench() {
await fiber.await()
const root = runtime.ctx.get('conversation') as ConversationService
const scoped = runtime.sessions.scope('s1')!.get('conversation') as ConversationService
return { runtime, root, scoped, prompt, cancel, loadOlder }
return { runtime, root, scoped, prompt, updateQueue, cancel, loadOlder }
}
describe('ConversationService', () => {
it('routes operations through the public Session binding', async () => {
const b = await bench()
await b.scoped.send('hello', 'steer')
await b.scoped.updateQueue('item-1' as never, { kind: 'remove' })
await b.scoped.cancel()
await b.scoped.loadOlder()
expect(b.prompt).toHaveBeenCalledWith([{ type: 'text', text: 'hello' }], 'steer')
expect(b.updateQueue).toHaveBeenCalledWith('item-1', { kind: 'remove' })
expect(b.cancel).toHaveBeenCalledOnce()
expect(b.loadOlder).toHaveBeenCalledOnce()
await b.runtime.dispose()