// @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 { ConversationService } from '@deepseek-ai/dsh-client-ui-conversation/client' 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, updateQueue, cancel, loadOlder }, }) // config.input is required (the apply shares its hub with the inject // factories); the bench passes its own instance explicitly. const fiber = runtime.ctx.plugin(ConversationService, { input: new InputHub(runtime.ctx), }) 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 } } 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), }).await() const orphan = bare.get('conversation') as ConversationService await expect(orphan.send('x')).rejects.toThrow(/sessions service unavailable/) }) })