25d959aa08
# Conflicts: # .agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.i18n.yaml # .agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md # .agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.zh.md # .agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.i18n.yaml # .agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md # .agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.zh.md # THIRD_PARTY_NOTICES.md # apps/cli/composition.md # apps/cli/config/base.cordis.yml # apps/cli/package.json # apps/cli/src/app-cli-entry.ts # apps/cli/src/bin.ts # apps/cli/tests/args.spec.ts # apps/web/tests/built-boot.snapshot.ts # apps/web/tests/navigation-panes.e2e.ts # docs/architecture.i18n.yaml # docs/architecture.md # docs/architecture.zh.md # docs/config-catalog.md # docs/cordis-catalog/services.md # docs/core-data-structures/core.i18n.yaml # docs/core-data-structures/llm-streaming.i18n.yaml # docs/event-producer-consumer.md # docs/module-graph.md # examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl # packages/README.i18n.yaml # packages/bundle/README.i18n.yaml # packages/client/connection/README.i18n.yaml # packages/client/connection/README.md # packages/client/connection/README.zh.md # packages/client/connection/src/client/fixture.ts # packages/client/connection/src/http-bridge.ts # packages/client/connection/src/index.ts # packages/client/connection/tests/fixture.spec.ts # packages/client/connection/tests/node-half.spec.ts # packages/client/runtime/README.i18n.yaml # packages/client/runtime/README.md # packages/client/runtime/README.zh.md # packages/client/runtime/src/client/contract/session.ts # packages/client/runtime/src/client/sessions/session.ts # packages/client/ui-conversation/README.i18n.yaml # packages/client/ui-conversation/README.md # packages/client/ui-conversation/README.zh.md # packages/client/ui-conversation/src/client/apply.ts # packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx # packages/client/ui-conversation/src/client/chat/ChatView.tsx # packages/client/ui-conversation/src/client/chat/MessageItem.module.css # packages/client/ui-conversation/src/client/chat/MessageItem.tsx # packages/client/ui-conversation/src/client/contract/slots.ts # packages/client/ui-conversation/src/client/index.ts # packages/client/ui-conversation/src/client/input/contract.ts # packages/client/ui-conversation/src/client/input/facade.ts # packages/client/ui-conversation/src/client/input/hub.ts # packages/client/ui-conversation/src/client/locales.ts # packages/client/ui-conversation/src/client/service.ts # packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx # packages/client/ui-conversation/src/client/skeleton/InputBar.tsx # packages/client/ui-conversation/tests/apply-inject.spec.tsx # packages/client/ui-conversation/tests/input-bar.spec.tsx # packages/client/ui-conversation/tests/input-matrix.spec.tsx # packages/client/ui-conversation/tests/input-scenarios.spec.tsx # packages/client/ui-conversation/tests/service-orchestration.spec.ts # packages/client/ui-conversation/tests/skeleton.spec.tsx # packages/client/ui-trajectory/tests/views.spec.tsx # packages/compact/compact-basic/README.i18n.yaml # packages/cordis/tool-cordis/src/api-catalog.ts # packages/host/apiproxy/README.i18n.yaml # packages/host/apiproxy/README.md # packages/host/apiproxy/README.zh.md # packages/host/apiproxy/src/api-proxy.ts # packages/host/apiproxy/src/api/rpc.ts # packages/host/apiproxy/src/api/sessions.ts # packages/host/apiproxy/src/index.ts # packages/host/apiproxy/tests/api-proxy-models.spec.ts # packages/host/apiproxy/tests/rpc-schemas.spec.ts # packages/llm/llm-pi-ai/README.i18n.yaml # packages/llm/llm-pi-ai/README.md # packages/llm/llm-pi-ai/README.zh.md # packages/llm/llm-pi-ai/src/adapter.ts # packages/llm/llm/README.i18n.yaml # packages/ui/tui/README.md # packages/ui/tui/README.zh.md # packages/ui/tui/src/components/content.ts # packages/ui/tui/src/components/transcript.ts # packages/ui/tui/tests/tui.spec.ts # pnpm-lock.yaml
145 lines
7.1 KiB
TypeScript
145 lines
7.1 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 { AttachmentId } from '@deepseek-ai/dsh-attachment'
|
|
import type { SessionFace } from '@deepseek-ai/dsh-client-runtime/client'
|
|
import { ComposerBlockRegistry } from '../src/client/input/blocks.ts'
|
|
import { InputHub } from '../src/client/input/hub.ts'
|
|
import { ConversationService, UnsupportedImageMediaTypeError } from '../src/client/service.ts'
|
|
|
|
async function bench(readAttachment?: SessionFace['readAttachment']) {
|
|
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, ...(readAttachment === undefined ? {} : { readAttachment }) },
|
|
})
|
|
// 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),
|
|
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, fiber, 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('releases draft previews when their session scope is disposed', async () => {
|
|
const b = await bench()
|
|
const created = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:draft-1')
|
|
const revoked = vi.spyOn(URL, 'revokeObjectURL').mockReturnValue(undefined)
|
|
try {
|
|
const [attachment] = b.root.createDraftImages([
|
|
new File([new Uint8Array(4)], 'a.png', { type: 'image/png' }),
|
|
])
|
|
if (attachment === undefined) throw new Error('draft attachment missing')
|
|
b.root.input.for(b.runtime.sessions.scope('s1')!).addImages([attachment.id])
|
|
await b.runtime.sessions.remove('s1')
|
|
expect(b.root.draftImages([attachment.id])).toEqual([])
|
|
expect(revoked).toHaveBeenCalledWith('blob:draft-1')
|
|
} finally {
|
|
created.mockRestore()
|
|
revoked.mockRestore()
|
|
}
|
|
await b.runtime.dispose()
|
|
})
|
|
|
|
it('validates every MIME type before allocating previews', async () => {
|
|
const b = await bench()
|
|
const created = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:preview')
|
|
expect(() => b.root.createDraftImages([
|
|
new File([Uint8Array.of(1)], 'valid.png', { type: 'image/png' }),
|
|
new File([Uint8Array.of(2)], 'invalid.svg', { type: 'image/svg+xml' }),
|
|
])).toThrow(UnsupportedImageMediaTypeError)
|
|
expect(created).not.toHaveBeenCalled()
|
|
created.mockRestore()
|
|
await b.runtime.dispose()
|
|
})
|
|
|
|
it('invalidates pending historical image loads when the rendered session is released', async () => {
|
|
const read = Promise.withResolvers<Awaited<ReturnType<SessionFace['readAttachment']>>>()
|
|
const b = await bench(() => read.promise)
|
|
const sessionId = b.runtime.sessions.behavior('s1').sessionId
|
|
const attachment = {
|
|
attachmentId: AttachmentId('image-1'), mediaType: 'image/png', bytes: 1, width: 1, height: 1,
|
|
} as const
|
|
const pending = b.root.resolveImage(sessionId, attachment)
|
|
b.root.releaseSessionImages(sessionId)
|
|
read.resolve({ ok: true, value: { attachment, data: Uint8Array.of(1) } })
|
|
await expect(pending).rejects.toThrow('historical image scope was released')
|
|
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),
|
|
blocks: new ComposerBlockRegistry(),
|
|
}).await()
|
|
const orphan = bare.get('conversation') as ConversationService
|
|
await expect(orphan.send('x')).rejects.toThrow(/sessions service unavailable/)
|
|
})
|
|
})
|