# Conflicts: # packages/client/ui-conversation/README.i18n.yaml # packages/client/ui-conversation/README.zh.md # packages/client/ui-conversation/src/client/skeleton/InputBar.tsx # packages/client/ui-conversation/tests/input-bar.spec.tsx # packages/client/ui-conversation/tests/service-orchestration.spec.ts
180 lines
8.0 KiB
TypeScript
180 lines
8.0 KiB
TypeScript
// @vitest-environment jsdom
|
|
// ConversationService scope addressing over the runtime's real scope tag:
|
|
// TestSessions mints tagged scopes through the production createScope, so the
|
|
// service's scopeOf/binding path runs against production resolution (no local
|
|
// tag probe).
|
|
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()
|
|
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, updateQueue, cancel, loadOlder },
|
|
})
|
|
// 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: 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
|
|
const shell = hub.shellFor(runtime.sessions.binding('s1')!)
|
|
return { runtime, root, scoped, hub, shell, prompt, updateQueue, cancel, loadOlder }
|
|
}
|
|
|
|
describe('ConversationService', () => {
|
|
it('routes operations through the public Session binding', async () => {
|
|
const b = await bench()
|
|
await b.scoped.send('hello')
|
|
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' }], 'queue')
|
|
expect(b.updateQueue).toHaveBeenCalledWith('item-1', { kind: 'remove' })
|
|
expect(b.cancel).toHaveBeenCalledOnce()
|
|
expect(b.loadOlder).toHaveBeenCalledOnce()
|
|
await b.runtime.dispose()
|
|
})
|
|
|
|
it('folds Session business failures into callback rejections', async () => {
|
|
const b = await bench()
|
|
b.prompt.mockResolvedValueOnce({ ok: false, error: { code: 'agent-busy', message: 'busy', details: {} } } as never)
|
|
await expect(b.scoped.send('x')).rejects.toThrow('conversation.send failed: agent-busy: busy')
|
|
b.cancel.mockResolvedValueOnce({ ok: false, error: { code: 'internal', message: 'nope', details: {} } } as never)
|
|
await expect(b.scoped.cancel()).rejects.toThrow('conversation.cancel failed: internal: nope')
|
|
b.updateQueue.mockResolvedValueOnce({
|
|
ok: false, error: { code: 'internal', message: 'broken', details: {} },
|
|
} as never)
|
|
await expect(b.scoped.updateQueue('item-1' as never, { kind: 'steer' }))
|
|
.rejects.toThrow('conversation.updateQueue failed: internal: broken')
|
|
await b.runtime.dispose()
|
|
})
|
|
|
|
it('treats strict-steer races as converged Queue delivery', async () => {
|
|
const b = await bench()
|
|
b.updateQueue.mockResolvedValueOnce({
|
|
ok: false, error: { code: 'steer-unavailable', message: 'closed', details: {} },
|
|
} as never)
|
|
await expect(b.scoped.updateQueue('item-1' as never, { kind: 'steer' })).resolves.toBeUndefined()
|
|
b.updateQueue.mockResolvedValueOnce({
|
|
ok: false, error: { code: 'queue-item-not-found', message: 'claimed', details: {} },
|
|
} as never)
|
|
await expect(b.scoped.updateQueue('item-2' as never, { kind: 'steer' })).resolves.toBeUndefined()
|
|
b.updateQueue.mockResolvedValueOnce({
|
|
ok: false, error: { code: 'queue-item-not-found', message: 'claimed', details: {} },
|
|
} as never)
|
|
await expect(b.scoped.updateQueue('item-3' as never, { kind: 'remove' }))
|
|
.rejects.toThrow('conversation.updateQueue failed: queue-item-not-found: claimed')
|
|
await b.runtime.dispose()
|
|
})
|
|
|
|
it('fails loudly from the root scope, on an unbound session, or without SessionsService', async () => {
|
|
const b = await bench()
|
|
await expect(b.root.send('x')).rejects.toThrow(/requires a session scope/)
|
|
await b.runtime.sessions.remove('s1')
|
|
await expect(b.scoped.send('x')).rejects.toThrow(/resolved no binding/)
|
|
await b.runtime.dispose()
|
|
// No SessionsService at all: a bare context (the runtime always provides one).
|
|
const bare = new Context()
|
|
await bare.plugin(ConversationService, {
|
|
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()
|
|
})
|
|
})
|