Merge remote-tracking branch 'origin/master' into worktree/remove-interjection-caption
This commit is contained in:
@@ -57,6 +57,10 @@ interface BenchOptions {
|
||||
subagent?: Exclude<ConversationSnapshot['subagent'], null>
|
||||
disabled?: boolean
|
||||
promptError?: ConversationSnapshot['promptError']
|
||||
/** Authoritative queue rows served to the machine overlay (empty = none). */
|
||||
queue?: ConversationSnapshot['queue']
|
||||
/** The hub's steer-all face (empty-draft accelerated Enter). */
|
||||
steerQueue?: () => void
|
||||
variant?: 'hero' | 'composer'
|
||||
placeholder?: string
|
||||
t?: InputBarProps['t']
|
||||
@@ -70,14 +74,34 @@ interface BenchOptions {
|
||||
toggleCommandMenu?: (selection: { start: number; end: number }) => void
|
||||
}
|
||||
|
||||
/** One pending queue row (the runtime snapshot shape, as the dock tests build it). */
|
||||
function row(id: string): ConversationSnapshot['queue'][number] {
|
||||
return {
|
||||
id: id as never, messageId: `message-${id}` as never, placement: 'queued',
|
||||
content: [{ type: 'text', text: id }], preview: id, text: id,
|
||||
}
|
||||
}
|
||||
|
||||
/** Real machine behind the bar entry: sink spy, no slash pipeline (plain text goes straight to the sink). */
|
||||
function bench(over?: BenchOptions) {
|
||||
const sink = vi.fn()
|
||||
const lex = over?.lexicon
|
||||
const session = createSnapshotStore<ConversationSnapshot>(snapshotOf({
|
||||
running: over?.running ?? false,
|
||||
subagent: over?.subagent ?? null,
|
||||
removed: over?.disabled ?? false,
|
||||
promptError: over?.promptError ?? null,
|
||||
queue: over?.queue ?? [],
|
||||
}))
|
||||
type ShellDeps = ConstructorParameters<typeof SessionInputShell>[0]
|
||||
const shell = new SessionInputShell({
|
||||
actx: SCTX,
|
||||
defaultSink: sink,
|
||||
queue: {
|
||||
getSnapshot: () => session.getSnapshot().queue,
|
||||
subscribe: fn => session.subscribe(fn),
|
||||
},
|
||||
...(over?.steerQueue !== undefined ? { steerQueue: over.steerQueue } : {}),
|
||||
// Lexicon-only stub: adjudication untouched (undefined slash methods are
|
||||
// never reached — these benches drive plain-draft flows only).
|
||||
...(lex !== undefined
|
||||
@@ -89,12 +113,6 @@ function bench(over?: BenchOptions) {
|
||||
: {}),
|
||||
})
|
||||
if (over?.draft !== undefined && over.draft !== '') shell.setDraft(over.draft)
|
||||
const session = createSnapshotStore<ConversationSnapshot>(snapshotOf({
|
||||
running: over?.running ?? false,
|
||||
subagent: over?.subagent ?? null,
|
||||
removed: over?.disabled ?? false,
|
||||
promptError: over?.promptError ?? null,
|
||||
}))
|
||||
const stop = vi.fn()
|
||||
const menuLauncher = createSnapshotStore<string | null>(over?.commandMenuOpen === true ? 'command' : null)
|
||||
const slotCalls: { key: string; owner: unknown }[] = []
|
||||
@@ -151,10 +169,62 @@ function bench(over?: BenchOptions) {
|
||||
const interruptButton = view.container.querySelector<HTMLButtonElement>('button[aria-label="停止生成"]')
|
||||
return {
|
||||
view, textarea, button, interruptButton, props, sink, shell, wiring: shell, session, stop, slotCalls, menuLauncher,
|
||||
steerQueue: over?.steerQueue,
|
||||
}
|
||||
}
|
||||
|
||||
describe('Enter semantics', () => {
|
||||
it('advertises the empty-draft whole-queue steering gesture when it is available', () => {
|
||||
const { textarea } = bench({ running: true, queue: [row('q-1')], steerQueue: vi.fn() })
|
||||
expect(textarea.placeholder).toBe('Cmd/Ctrl+Enter 插话发送全部排队消息')
|
||||
})
|
||||
|
||||
it('keeps the owning placeholder or ordinary guidance when whole-queue steering is unavailable', () => {
|
||||
expect(bench({ running: true }).textarea.placeholder).toBe('给智能体发消息')
|
||||
expect(bench({ queue: [row('q-1')] }).textarea.placeholder).toBe('给智能体发消息')
|
||||
expect(bench({ running: true, queue: [row('q-1')], draft: '消息' }).textarea.placeholder).toBe('给智能体发消息')
|
||||
expect(bench({
|
||||
running: true,
|
||||
queue: [row('q-1')],
|
||||
subagent: {
|
||||
address: { parentSessionId: 'parent' as SessionId, childSessionId: SID, mode: 'continuable' },
|
||||
parentAvailable: true,
|
||||
},
|
||||
}).textarea.placeholder).toBe('给智能体发消息')
|
||||
expect(bench({
|
||||
running: true,
|
||||
queue: [row('q-1')],
|
||||
placeholder: '上层指定提示',
|
||||
}).textarea.placeholder).toBe('上层指定提示')
|
||||
// The command menu owns Enter while open: neither the hint nor the
|
||||
// gesture may claim the chord.
|
||||
expect(bench({
|
||||
running: true,
|
||||
queue: [row('q-1')],
|
||||
commandMenuOpen: true,
|
||||
}).textarea.placeholder).toBe('给智能体发消息')
|
||||
// The steer hint intentionally outranks the plan placeholder: while it
|
||||
// shows, the whole-queue gesture is genuinely available in plan mode.
|
||||
expect(bench({
|
||||
running: true,
|
||||
queue: [row('q-1')],
|
||||
plan: { active: true, pending: false },
|
||||
}).textarea.placeholder).toBe('Cmd/Ctrl+Enter 插话发送全部排队消息')
|
||||
})
|
||||
|
||||
it('an open command menu withholds the whole-queue steering gesture', () => {
|
||||
const steerQueue = vi.fn()
|
||||
const { textarea, sink } = bench({
|
||||
running: true,
|
||||
queue: [row('q-1')],
|
||||
commandMenuOpen: true,
|
||||
steerQueue,
|
||||
})
|
||||
fireEvent.keyDown(textarea, { key: 'Enter', metaKey: true })
|
||||
expect(steerQueue).not.toHaveBeenCalled()
|
||||
expect(sink).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('plain Enter submits queue mode through the machine; repeat and empty are suppressed', () => {
|
||||
const { textarea, sink } = bench({ draft: 'hello' })
|
||||
fireEvent.keyDown(textarea, { key: 'Enter' })
|
||||
@@ -194,6 +264,78 @@ describe('Enter semantics', () => {
|
||||
expect(busyMeta.sink).toHaveBeenCalledWith('steer with cmd', 'steer')
|
||||
})
|
||||
|
||||
it('empty-draft Cmd/Ctrl+Enter steers the whole queue instead of submitting', () => {
|
||||
const steerQueue = vi.fn()
|
||||
const queue = [row('q-1'), row('q-2')]
|
||||
const meta = bench({ running: true, queue, steerQueue })
|
||||
fireEvent.keyDown(meta.textarea, { key: 'Enter', metaKey: true })
|
||||
expect(meta.steerQueue).toHaveBeenCalledTimes(1)
|
||||
expect(meta.sink).not.toHaveBeenCalled()
|
||||
|
||||
const ctrl = bench({ running: true, queue, steerQueue: vi.fn() })
|
||||
fireEvent.keyDown(ctrl.textarea, { key: 'Enter', ctrlKey: true })
|
||||
expect(ctrl.steerQueue).toHaveBeenCalledTimes(1)
|
||||
expect(ctrl.sink).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('queue steering stays gated: idle, subagent, plain Enter, empty queue, or steering-only rows', () => {
|
||||
// Idle: the gesture falls through to the machine's empty-draft no-op.
|
||||
const idle = bench({ queue: [row('q-1')], steerQueue: vi.fn() })
|
||||
fireEvent.keyDown(idle.textarea, { key: 'Enter', metaKey: true })
|
||||
expect(idle.steerQueue).not.toHaveBeenCalled()
|
||||
expect(idle.sink).not.toHaveBeenCalled()
|
||||
|
||||
// Plain Enter never steers the queue, even under the busy Steer preference.
|
||||
const plain = bench({ running: true, busyEnter: 'steer', queue: [row('q-1')], steerQueue: vi.fn() })
|
||||
fireEvent.keyDown(plain.textarea, { key: 'Enter' })
|
||||
expect(plain.steerQueue).not.toHaveBeenCalled()
|
||||
expect(plain.sink).not.toHaveBeenCalled()
|
||||
|
||||
// Subagent sessions keep the queue transport (no steering face).
|
||||
const subagent = {
|
||||
address: {
|
||||
parentSessionId: 'parent' as SessionId,
|
||||
childSessionId: SID,
|
||||
mode: 'continuable' as const,
|
||||
},
|
||||
parentAvailable: true,
|
||||
}
|
||||
const child = bench({ running: true, subagent, queue: [row('q-1')], steerQueue: vi.fn() })
|
||||
fireEvent.keyDown(child.textarea, { key: 'Enter', metaKey: true })
|
||||
expect(child.steerQueue).not.toHaveBeenCalled()
|
||||
expect(child.sink).not.toHaveBeenCalled()
|
||||
|
||||
// No queued rows: the empty draft stays a no-op.
|
||||
const none = bench({ running: true, steerQueue: vi.fn() })
|
||||
fireEvent.keyDown(none.textarea, { key: 'Enter', metaKey: true })
|
||||
expect(none.steerQueue).not.toHaveBeenCalled()
|
||||
expect(none.sink).not.toHaveBeenCalled()
|
||||
|
||||
// Pending steering rows are not the queue: nothing to flush.
|
||||
const steering = bench({
|
||||
running: true,
|
||||
queue: [{ ...row('s-1'), placement: 'steering' }],
|
||||
steerQueue: vi.fn(),
|
||||
})
|
||||
fireEvent.keyDown(steering.textarea, { key: 'Enter', metaKey: true })
|
||||
expect(steering.steerQueue).not.toHaveBeenCalled()
|
||||
expect(steering.sink).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('draft content outranks the queue: accelerated Enter steers the draft only', () => {
|
||||
const steerQueue = vi.fn()
|
||||
const { textarea, sink } = bench({ running: true, queue: [row('q-1')], draft: '插话', steerQueue })
|
||||
fireEvent.keyDown(textarea, { key: 'Enter', ctrlKey: true })
|
||||
expect(sink).toHaveBeenCalledWith('插话', 'steer')
|
||||
expect(steerQueue).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('empty-draft accelerated Enter without a steerQueue face stays a silent no-op', () => {
|
||||
const { textarea, sink } = bench({ running: true, queue: [row('q-1')] })
|
||||
fireEvent.keyDown(textarea, { key: 'Enter', metaKey: true })
|
||||
expect(sink).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('platform undo/redo chords route to the machine, never the browser stack', () => {
|
||||
const { textarea, shell } = bench({ draft: '' })
|
||||
fireEvent.change(textarea, { target: { value: 'first' } })
|
||||
|
||||
@@ -6,9 +6,12 @@
|
||||
import { Context } from 'cordis'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import type { QueuedMessage } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { ConversationService } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import { ComposerBlockRegistry } from '../src/client/input/blocks.ts'
|
||||
import { InputHub } from '../src/client/input/hub.ts'
|
||||
import { zh } from '../src/client/locales.ts'
|
||||
|
||||
async function bench() {
|
||||
const runtime = await SlotTestRuntime.create()
|
||||
@@ -22,14 +25,16 @@ async function bench() {
|
||||
})
|
||||
// config.input is required (the apply shares its hub with the inject
|
||||
// factories); the bench passes its own instance explicitly.
|
||||
const hub = new InputHub(runtime.ctx, makeTranslate(zh, {}))
|
||||
const fiber = runtime.ctx.plugin(ConversationService, {
|
||||
input: new InputHub(runtime.ctx),
|
||||
input: hub,
|
||||
blocks: new ComposerBlockRegistry(),
|
||||
})
|
||||
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, updateQueue, cancel, loadOlder }
|
||||
const shell = hub.shellFor(runtime.sessions.binding('s1')!)
|
||||
return { runtime, root, scoped, hub, shell, prompt, updateQueue, cancel, loadOlder }
|
||||
}
|
||||
|
||||
describe('ConversationService', () => {
|
||||
@@ -87,10 +92,88 @@ describe('ConversationService', () => {
|
||||
// No SessionsService at all: a bare context (the runtime always provides one).
|
||||
const bare = new Context()
|
||||
await bare.plugin(ConversationService, {
|
||||
input: new InputHub(bare),
|
||||
input: new InputHub(bare, makeTranslate(zh, {})),
|
||||
blocks: new ComposerBlockRegistry(),
|
||||
}).await()
|
||||
const orphan = bare.get('conversation') as ConversationService
|
||||
await expect(orphan.send('x')).rejects.toThrow(/sessions service unavailable/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('InputHub queue steering (empty-draft accelerated Enter)', () => {
|
||||
const row = (id: string): QueuedMessage => ({
|
||||
id: id as never,
|
||||
messageId: `message-${id}` as never,
|
||||
placement: 'queued',
|
||||
content: [{ type: 'text', text: id }],
|
||||
preview: id,
|
||||
text: id,
|
||||
})
|
||||
|
||||
it('steers every queued row in FIFO order and leaves steering rows alone', async () => {
|
||||
const b = await bench()
|
||||
await b.runtime.sessions.updateSnapshot('s1', (draft) => {
|
||||
draft.queue = [row('q-1'), { ...row('q-2'), placement: 'steering' }, row('q-3')]
|
||||
})
|
||||
b.shell.steerQueue()
|
||||
await vi.waitFor(() => {
|
||||
expect(b.updateQueue).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
expect(b.updateQueue).toHaveBeenNthCalledWith(1, 'q-1', { kind: 'steer' })
|
||||
expect(b.updateQueue).toHaveBeenNthCalledWith(2, 'q-3', { kind: 'steer' })
|
||||
expect(b.shell.notices.getSnapshot()).toBeNull()
|
||||
await b.runtime.dispose()
|
||||
})
|
||||
|
||||
it('converges silently when the turn closes or a row is claimed mid-steer', async () => {
|
||||
const b = await bench()
|
||||
await b.runtime.sessions.updateSnapshot('s1', (draft) => {
|
||||
draft.queue = [row('q-1'), row('q-2')]
|
||||
})
|
||||
// The turn closes before the second row: the flush stops, silently.
|
||||
b.updateQueue.mockResolvedValueOnce({
|
||||
ok: false, error: { code: 'steer-unavailable', message: 'closed', details: {} },
|
||||
} as never)
|
||||
b.shell.steerQueue()
|
||||
await vi.waitFor(() => { expect(b.updateQueue).toHaveBeenCalledTimes(1) })
|
||||
expect(b.shell.notices.getSnapshot()).toBeNull()
|
||||
|
||||
// A row the host already claimed (e.g. a repeated empty-draft chord):
|
||||
// the duplicate strict steer is a silent no-op.
|
||||
await b.runtime.sessions.updateSnapshot('s1', (draft) => {
|
||||
draft.queue = [row('q-3')]
|
||||
})
|
||||
b.updateQueue.mockResolvedValueOnce({
|
||||
ok: false, error: { code: 'queue-item-not-found', message: 'claimed', details: {} },
|
||||
} as never)
|
||||
b.shell.steerQueue()
|
||||
await vi.waitFor(() => { expect(b.updateQueue).toHaveBeenCalledTimes(2) })
|
||||
expect(b.shell.notices.getSnapshot()).toBeNull()
|
||||
await b.runtime.dispose()
|
||||
})
|
||||
|
||||
it('surfaces one notice on a genuine steer failure and stops', async () => {
|
||||
const b = await bench()
|
||||
await b.runtime.sessions.updateSnapshot('s1', (draft) => {
|
||||
draft.queue = [row('q-1'), row('q-2')]
|
||||
})
|
||||
b.updateQueue.mockResolvedValueOnce({
|
||||
ok: false, error: { code: 'internal', message: 'broken', details: {} },
|
||||
} as never)
|
||||
b.shell.steerQueue()
|
||||
await vi.waitFor(() => {
|
||||
expect(b.shell.notices.getSnapshot()).toEqual(
|
||||
expect.objectContaining({ level: 'error', text: '插话发送失败,请重试。' }),
|
||||
)
|
||||
})
|
||||
expect(b.updateQueue).toHaveBeenCalledTimes(1)
|
||||
await b.runtime.dispose()
|
||||
})
|
||||
|
||||
it('no-ops without queued rows', async () => {
|
||||
const b = await bench()
|
||||
b.shell.steerQueue()
|
||||
expect(b.updateQueue).not.toHaveBeenCalled()
|
||||
await b.runtime.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user