Merge remote-tracking branch 'origin/master' into worktree/web-multimodal-image-input

# 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
This commit is contained in:
Yichen Jiang
2026-08-08 01:56:29 +08:00
3973 changed files with 144864 additions and 60887 deletions

View File

@@ -1,7 +1,8 @@
/**
* Web session model-directory and selection behavior: dynamic provider grouping,
* provider-local catalog failures, logged-target restoration, advisory unlisted
* models, and the prompt-assembly boundary for a running selection change.
* provider-local catalog failures, logged-target restoration without stale
* catalog injection, advisory pass-through models, and the prompt-assembly
* boundary for a running selection change.
*/
import { describe, expect, it, vi } from 'vitest'
@@ -11,7 +12,8 @@ import type { Agent } from '@deepseek-ai/dsh-agent'
import LlmService, { LlmAdapter, ReasoningEffortId } from '@deepseek-ai/dsh-llm'
import type {
GenerateOptions, LlmCallConfig, LlmModelInfo, LlmModelReasoningInfo, LlmProviderInfo,
LlmResolvedModelInfo, StreamChunk, UserMessage,
LlmResolvedModelInfo, StreamChunk,
UserMessage,
} from '@deepseek-ai/dsh-llm'
import SessionStore from '@deepseek-ai/dsh-session'
import type { SessionId } from '@deepseek-ai/dsh-session'
@@ -107,7 +109,8 @@ async function harness(logged?: {
session,
status: 'running',
ctx,
} as Agent
inbox: { nextTurn: [], nextStep: [] },
} as unknown as Agent
ctx.agents.register(agent)
return { ctx, agent, sessionId: session.id }
}
@@ -126,88 +129,155 @@ function registerTextOnly(ctx: Context): void {
}
describe('Web session model selection', () => {
it('accepts ordered multi-image prompts and rejects configured batch-limit excess before persistence', async () => {
it('validates an ordered image batch before persisting any member', async () => {
const { ctx, agent, sessionId } = await harness()
let secondValidationStarted!: () => void
let releaseSecondValidation!: () => void
const secondStarted = new Promise<void>((resolve) => { secondValidationStarted = resolve })
const secondReleased = new Promise<void>((resolve) => { releaseSecondValidation = resolve })
const validateImage = vi.fn(async (input: { data: Uint8Array }): Promise<void> => {
if (input.data[0] !== 2) return
secondValidationStarted()
await secondReleased
})
const saveImage = vi.fn((input: { data: Uint8Array; mediaType: 'image/png'; name?: string }) => {
return Promise.resolve({
attachmentId: `att-${String(input.data[0])}`,
mediaType: input.mediaType,
bytes: input.data.byteLength,
width: 1,
height: 1,
...(input.name === undefined ? {} : { name: input.name }),
})
})
const followup = vi.fn((_message: UserMessage): void => {})
Object.assign(agent, { followup })
const validateImage = vi.fn((_input: { data: Uint8Array }) => Promise.resolve())
const saveImage = vi.fn((input: { data: Uint8Array; mediaType: 'image/png'; name?: string }) => Promise.resolve({
attachmentId: `att-${String(input.data[0])}`,
mediaType: input.mediaType,
bytes: input.data.byteLength,
width: 1,
height: 1,
...input.name === undefined ? {} : { name: input.name },
}))
ctx.provide('attachments', {
imageLimits: { maxImageBytes: 4, maxImagesPerMessage: 2, maxMessageImageBytes: 4, maxImagePixels: 4, mediaTypes: ['image/png'] },
imageLimits: {
maxImageBytes: 4,
maxImagesPerMessage: 2,
maxMessageImageBytes: 4,
maxImagePixels: 4,
mediaTypes: ['image/png'],
},
validateImage,
saveImage,
} as never)
const api = createApiProxy(ctx, { provider: 'deepseek-official', model: 'deepseek-chat', cwd: '/tmp', workspaceRoot: '/tmp' })
const first = { type: 'image' as const, mediaType: 'image/png' as const, data: 'AQ==', name: 'first.png' }
const second = { type: 'image' as const, mediaType: 'image/png' as const, data: 'Ag==', name: 'second.png' }
const accepting = api.sessions.prompt(request({
sessionId,
mode: 'queue' as const,
content: [first, { type: 'text' as const, text: 'compare' }, second],
}))
await secondStarted
expect(saveImage).not.toHaveBeenCalled()
releaseSecondValidation()
const accepted = await accepting
expect(accepted.result).toMatchObject({ ok: true, value: { accepted: true } })
expect(validateImage.mock.calls.map(([input]) => [...input.data])).toEqual([[1], [2]])
expect(saveImage.mock.calls.map(([input]) => [...input.data])).toEqual([[1], [2]])
expect(followup.mock.calls[0]?.[0].content).toEqual([
{ type: 'image', attachment: { attachmentId: 'att-1', mediaType: 'image/png', bytes: 1, width: 1, height: 1, name: 'first.png' } },
{ type: 'text', text: 'compare' },
{ type: 'image', attachment: { attachmentId: 'att-2', mediaType: 'image/png', bytes: 1, width: 1, height: 1, name: 'second.png' } },
])
const tooMany = await api.sessions.prompt(request({
sessionId,
mode: 'queue' as const,
content: [first, second, first],
}))
expect(tooMany.result).toMatchObject({
ok: false, error: { code: 'attachment-error', details: { reason: 'TOO_MANY_IMAGES' } },
const followup = vi.fn()
Object.assign(agent, { followup })
const api = createApiProxy(ctx, {
defaultTarget: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }),
cwd: '/tmp',
workspaceRoot: '/tmp',
})
const tooLarge = await api.sessions.prompt(request({
const result = await api.sessions.prompt(request({
sessionId,
mode: 'queue' as const,
content: [
{ ...first, data: 'AQID' },
{ ...second, data: 'BAUG' },
{ type: 'image' as const, mediaType: 'image/png' as const, data: 'AQ==', name: 'first.png' },
{ type: 'text' as const, text: 'compare' },
{ type: 'image' as const, mediaType: 'image/png' as const, data: 'Ag==' },
],
}))
expect(tooLarge.result).toMatchObject({
expect(result.result.ok).toBe(true)
expect(validateImage.mock.calls.map(([input]) => [...input.data])).toEqual([[1], [2]])
expect(saveImage.mock.calls.map(([input]) => [...input.data])).toEqual([[1], [2]])
expect((followup.mock.calls[0]?.[0] as UserMessage).content).toEqual([
{
type: 'image',
attachment: {
attachmentId: 'att-1', mediaType: 'image/png', bytes: 1, width: 1, height: 1, name: 'first.png',
},
},
{ type: 'text', text: 'compare' },
{ type: 'image', attachment: { attachmentId: 'att-2', mediaType: 'image/png', bytes: 1, width: 1, height: 1 } },
])
const denied = await api.sessions.prompt(request({
sessionId,
mode: 'queue' as const,
content: Array.from({ length: 3 }, () => ({
type: 'image' as const, mediaType: 'image/png' as const, data: 'AQ==',
})),
}))
expect(denied.result).toMatchObject({
ok: false,
error: { code: 'attachment-error', details: { reason: 'IMAGES_TOO_LARGE' } },
error: { code: 'attachment-error', details: { reason: 'TOO_MANY_IMAGES' } },
})
expect(validateImage).toHaveBeenCalledTimes(2)
expect(saveImage).toHaveBeenCalledTimes(2)
expect(followup).toHaveBeenCalledTimes(1)
await ctx.fiber.dispose()
})
it('groups successful providers, isolates failures, and preserves an unlisted current model', async () => {
it('refuses a text-only selection while durable or pending image content remains visible', async () => {
const { ctx, agent, sessionId } = await harness()
registerTextOnly(ctx)
const api = createApiProxy(ctx, {
defaultTarget: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }),
cwd: '/tmp',
workspaceRoot: '/tmp',
})
const image = {
type: 'image' as const,
attachment: { attachmentId: 'att-history', mediaType: 'image/png' as const, bytes: 1, width: 1, height: 1 },
}
agent.session.append('user/message', {
id: 'image-message', role: 'user', source: { kind: 'user' }, content: [image],
} as never, { surfaceOp: 'append' })
expect((await api.sessions.selectModel(request({
sessionId, provider: 'text-only', model: 'plain',
}))).result).toMatchObject({ ok: false, error: { code: 'model-unavailable' } })
agent.session.append('user/message', {
id: 'summary', role: 'user', source: { kind: 'plugin', plugin: 'compact' },
content: [{ type: 'text', text: 'image summarized' }],
} as never, {
surfaceOp: { op: 'replace', start: 0, end: agent.session.events.length - 1 },
sourceEventSeqs: agent.session.events.map(event => event.seq),
})
;(agent.inbox.nextTurn as UserMessage[]).push({
id: 'pending-image', role: 'user', source: { kind: 'user' }, content: [image],
} as never)
expect((await api.sessions.selectModel(request({
sessionId, provider: 'text-only', model: 'plain',
}))).result.ok).toBe(false)
;(agent.inbox.nextTurn as UserMessage[]).length = 0
expect(expectValue(await api.sessions.selectModel(request({
sessionId, provider: 'text-only', model: 'plain',
}))).selected).toEqual({ provider: 'text-only', model: 'plain' })
await ctx.fiber.dispose()
})
it('authorizes attachment bytes only when the session event stream references the id', async () => {
const { ctx, agent, sessionId } = await harness()
const ref = {
attachmentId: 'att-authorized', mediaType: 'image/png' as const, bytes: 2, width: 1, height: 1,
}
const readImage = vi.fn(() => Promise.resolve({ ref, data: Uint8Array.of(1, 2) }))
ctx.provide('attachments', { readImage } as never)
const api = createApiProxy(ctx, {
defaultTarget: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }),
cwd: '/tmp',
workspaceRoot: '/tmp',
})
agent.session.append('agent/inbox/spliced', {
target: 'next-turn',
start: 0,
inserted: [{
id: 'queued-image', role: 'user', source: { kind: 'user' },
content: [{ type: 'image', attachment: ref }],
}],
} as never)
const allowed = await api.sessions.attachment(request({
sessionId, attachmentId: 'att-authorized' as never,
}))
expect(allowed.result).toMatchObject({ ok: true, value: { attachment: ref, data: 'AQI=' } })
const denied = await api.sessions.attachment(request({
sessionId, attachmentId: 'att-other' as never,
}))
expect(denied.result).toMatchObject({
ok: false,
error: { code: 'attachment-error', details: { reason: 'ATTACHMENT_NOT_REFERENCED' } },
})
expect(readImage).toHaveBeenCalledOnce()
await ctx.fiber.dispose()
})
it('groups successful providers and leaves an unlisted current target out of the catalog', async () => {
const { ctx, sessionId } = await harness({
provider: 'deepseek-official',
model: 'private-preview',
reasoningEffort: ReasoningEffortId('max'),
})
const api = createApiProxy(ctx, { provider: 'deepseek-official', model: 'deepseek-chat', cwd: '/tmp', workspaceRoot: '/tmp' })
const api = createApiProxy(ctx, { defaultTarget: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }), cwd: '/tmp', workspaceRoot: '/tmp' })
const catalog = expectValue(await api.sessions.models(request({ sessionId })))
expect(catalog.current).toEqual({
@@ -226,12 +296,6 @@ describe('Web session model selection', () => {
description: 'Reasoning model',
reasoning: REASONING,
},
{
id: 'private-preview',
name: 'private-preview',
unlisted: true,
reasoning: REASONING,
},
],
}])
expect(catalog.failures).toEqual([
@@ -248,7 +312,7 @@ describe('Web session model selection', () => {
it('accepts an advisory-unlisted model, rejects an unavailable provider, and switches only after the next assembly', async () => {
const { ctx, agent, sessionId } = await harness()
const api = createApiProxy(ctx, { provider: 'deepseek-official', model: 'deepseek-chat', cwd: '/tmp', workspaceRoot: '/tmp' })
const api = createApiProxy(ctx, { defaultTarget: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }), cwd: '/tmp', workspaceRoot: '/tmp' })
const seed: LlmCallConfig = { provider: 'seed', model: 'seed', temperature: 0.2 }
const signal = new AbortController().signal
@@ -269,13 +333,13 @@ describe('Web session model selection', () => {
reasoningEffort: 'max',
})
await expect(agentEvents(ctx, agent).waterfall(
'agent/request', 1, 0, signal, () => Promise.resolve(seed),
'agent/request', { turn: 1, step: 0, signal }, () => Promise.resolve(seed),
)).resolves.toMatchObject({ provider: 'deepseek-official', model: 'deepseek-chat' })
expect((await ctx.systemPrompt.assemble()).variables)
.toMatchObject({ provider: 'deepseek-official', model: 'private-preview' })
await expect(agentEvents(ctx, agent).waterfall(
'agent/request', 1, 1, signal, () => Promise.resolve(seed),
'agent/request', { turn: 1, step: 1, signal }, () => Promise.resolve(seed),
)).resolves.toMatchObject({
provider: 'deepseek-official',
model: 'private-preview',
@@ -314,245 +378,131 @@ describe('Web session model selection', () => {
await ctx.fiber.dispose()
})
it('refuses a text-only selection while current derived history carries an image', async () => {
const { ctx, sessionId, agent } = await harness()
registerTextOnly(ctx)
ctx.llm.registerAdapter(['vision'], new class extends CatalogAdapter {
override resolveModel(provider: string, model: string): Promise<LlmResolvedModelInfo> {
return Promise.resolve({ provider, id: model, name: model, inputModalities: ['text', 'image'] })
}
}('Vision', []))
const api = createApiProxy(ctx, { provider: 'deepseek-official', model: 'deepseek-chat', cwd: '/tmp', workspaceRoot: '/tmp' })
// Before any image lands, a text-only selection is legitimate.
expect(expectValue(await api.sessions.selectModel(request({
sessionId, provider: 'text-only', model: 'plain',
}))).selected).toEqual({ provider: 'text-only', model: 'plain' })
agent.session.append('user/message', {
id: 'msg-image', role: 'user', source: { kind: 'user' },
content: [{ type: 'image', attachment: { attachmentId: 'att-1', mediaType: 'image/png', bytes: 8, width: 1, height: 1 } }],
} as never, { surfaceOp: 'append' })
// The image remains on the current request surface, so a text-only route would fail the next turn.
const stranded = await api.sessions.selectModel(request({
sessionId, provider: 'text-only', model: 'plain',
}))
expect(stranded.result).toMatchObject({
ok: false,
error: { code: 'model-unavailable', message: expect.stringMatching(/history already contains images/) as unknown },
it('reads the host default live for a session whose log names no route', async () => {
const { ctx, sessionId } = await harness()
let stored = { provider: 'deepseek-official', model: 'deepseek-chat' }
const api = createApiProxy(ctx, {
defaultTarget: () => stored,
cwd: '/tmp',
workspaceRoot: '/tmp',
})
// Image-capable and modality-unknown routes stay selectable.
expect(expectValue(await api.sessions.selectModel(request({
sessionId, provider: 'vision', model: 'sees',
}))).selected).toEqual({ provider: 'vision', model: 'sees' })
expect(expectValue(await api.sessions.selectModel(request({
expect(expectValue(await api.sessions.models(request({ sessionId }))).current)
.toEqual({ provider: 'deepseek-official', model: 'deepseek-chat' })
// The default moving after the session exists still reaches it: New
// Session reuses a blank session rather than minting another, so a seed
// captured at creation would show the superseded model there.
stored = { provider: 'deepseek-official', model: 'deepseek-reasoner' }
expect(expectValue(await api.sessions.models(request({ sessionId }))).current)
.toEqual({ provider: 'deepseek-official', model: 'deepseek-reasoner' })
expect(expectValue(await api.host.describe(request({}))))
.toMatchObject({ provider: 'deepseek-official', model: 'deepseek-reasoner' })
await ctx.fiber.dispose()
})
it('keeps a session that logged a route on it when the host default moves', async () => {
const { ctx, sessionId } = await harness({
provider: 'deepseek-official',
model: 'deepseek-chat',
})
let stored = { provider: 'deepseek-official', model: 'deepseek-chat' }
const api = createApiProxy(ctx, {
defaultTarget: () => stored,
cwd: '/tmp',
workspaceRoot: '/tmp',
})
stored = { provider: 'duplicate', model: 'same' }
expect(expectValue(await api.sessions.models(request({ sessionId }))).current)
.toEqual({ provider: 'deepseek-official', model: 'deepseek-chat' })
await ctx.fiber.dispose()
})
it('saves an accepted selection as the default and survives a storage failure', async () => {
const { ctx, sessionId } = await harness()
const saved: unknown[] = []
let reject = false
const api = createApiProxy(ctx, {
defaultTarget: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }),
persistDefaultTarget: (target) => {
saved.push(target)
return reject ? Promise.reject(new Error('read-only document')) : Promise.resolve()
},
cwd: '/tmp',
workspaceRoot: '/tmp',
})
expectValue(await api.sessions.selectModel(request({
sessionId, provider: 'deepseek-official', model: 'deepseek-reasoner', reasoningEffort: 'max',
})))
expect(saved).toEqual([
{ provider: 'deepseek-official', model: 'deepseek-reasoner', reasoningEffort: 'max' },
])
// A refused selection never becomes anyone's default.
await api.sessions.selectModel(request({ sessionId, provider: 'missing', model: 'model' }))
expect(saved).toHaveLength(1)
// Storage failing is not the selection failing: the switch already applies
// to this session, so the call still succeeds.
reject = true
const stillAccepted = expectValue(await api.sessions.selectModel(request({
sessionId, provider: 'deepseek-official', model: 'deepseek-chat',
}))).selected).toEqual({ provider: 'deepseek-official', model: 'deepseek-chat', reasoningEffort: 'high' })
})))
expect(stillAccepted.selected).toEqual({ provider: 'deepseek-official', model: 'deepseek-chat', reasoningEffort: 'high' })
expect(expectValue(await api.sessions.models(request({ sessionId }))).current)
.toEqual({ provider: 'deepseek-official', model: 'deepseek-chat', reasoningEffort: 'high' })
await ctx.fiber.dispose()
})
it('keeps a dequeued image pending until publication, then follows the compacted surface', async () => {
const { ctx, sessionId, agent } = await harness()
registerTextOnly(ctx)
const api = createApiProxy(ctx, { provider: 'deepseek-official', model: 'deepseek-chat', cwd: '/tmp', workspaceRoot: '/tmp' })
const queued = {
id: 'q-1', role: 'user', source: { kind: 'user' },
content: [{ type: 'image', attachment: { attachmentId: 'att-q', mediaType: 'image/png', bytes: 8, width: 1, height: 1 } }],
} as never
const queuedItem = { id: 'i-q-1', message: queued, placement: 'queued' } as never
ctx.emit('agent/inbox/enqueue', agent, queuedItem)
expect((await api.sessions.selectModel(request({ sessionId, provider: 'text-only', model: 'plain' }))).result.ok).toBe(false)
// Dequeue precedes the authoritative append, so it cannot open a switch window.
ctx.emit('agent/inbox/dequeue', agent, queuedItem)
expect((await api.sessions.selectModel(request({ sessionId, provider: 'text-only', model: 'plain' }))).result.ok).toBe(false)
const imageEvent = agent.session.append('user/message', queued, { surfaceOp: 'append' })
expect((await api.sessions.selectModel(request({ sessionId, provider: 'text-only', model: 'plain' }))).result.ok).toBe(false)
// Publication retires the mirror; once compaction shadows the image, the
// current model-visible surface no longer requires an image-capable route.
agent.session.append('user/message', {
id: 'summary', role: 'user', source: { kind: 'plugin', plugin: 'compact' },
content: [{ type: 'text', text: 'image summarized' }],
} as never, {
surfaceOp: { op: 'replace', start: imageEvent.seq, end: imageEvent.seq },
sourceEventSeqs: [imageEvent.seq],
it('refuses a prompt no adapter can route, and reports it on the directory', async () => {
const { ctx, sessionId } = await harness()
const api = createApiProxy(ctx, {
defaultTarget: () => ({ provider: 'deleted-gateway', model: 'deleted-model' }),
cwd: '/tmp',
workspaceRoot: '/tmp',
})
expect(expectValue(await api.sessions.selectModel(request({
sessionId, provider: 'text-only', model: 'plain',
}))).selected).toEqual({ provider: 'text-only', model: 'plain' })
await ctx.fiber.dispose()
})
it('gates selection on a steering image from enqueue until its event publishes', async () => {
const { ctx, sessionId, agent } = await harness()
registerTextOnly(ctx)
const api = createApiProxy(ctx, { provider: 'deepseek-official', model: 'deepseek-chat', cwd: '/tmp', workspaceRoot: '/tmp' })
const steering = {
id: 's-1', role: 'user', source: { kind: 'user' },
content: [{ type: 'image', attachment: { attachmentId: 'att-s', mediaType: 'image/png', bytes: 8, width: 1, height: 1 } }],
} as never
const steeringItem = { id: 'i-s-1', message: steering, placement: 'steering' } as never
// A steering carrier never enters the queued mirror, yet the outbox hop
// between steer() and its append must not open a text-only switch window.
ctx.emit('agent/inbox/enqueue', agent, steeringItem)
expect((await api.sessions.selectModel(request({ sessionId, provider: 'text-only', model: 'plain' }))).result.ok).toBe(false)
ctx.emit('agent/inbox/dequeue', agent, steeringItem)
expect((await api.sessions.selectModel(request({ sessionId, provider: 'text-only', model: 'plain' }))).result.ok).toBe(false)
// A failed turn returns idle while leaving steering staged in the outbox;
// only publication or discard may retire this carrier.
ctx.emit('agent/status', agent, 'idle')
expect((await api.sessions.selectModel(request({ sessionId, provider: 'text-only', model: 'plain' }))).result.ok).toBe(false)
// Publication hands the gate over to the durable surface.
agent.session.append('steering/message', { turn: 1, message: steering }, { surfaceOp: 'append' })
expect((await api.sessions.selectModel(request({ sessionId, provider: 'text-only', model: 'plain' }))).result.ok).toBe(false)
await ctx.fiber.dispose()
})
it('re-opens selection when an admission ends idle without publication', async () => {
const { ctx, sessionId, agent } = await harness()
registerTextOnly(ctx)
const api = createApiProxy(ctx, { provider: 'deepseek-official', model: 'deepseek-chat', cwd: '/tmp', workspaceRoot: '/tmp' })
const rejected = {
id: 'r-1', role: 'user', source: { kind: 'user' },
content: [{ type: 'image', attachment: { attachmentId: 'att-r', mediaType: 'image/png', bytes: 8, width: 1, height: 1 } }],
} as never
const rejectedItem = { id: 'i-r-1', message: rejected, placement: 'queued' } as never
ctx.emit('agent/inbox/enqueue', agent, rejectedItem)
ctx.emit('agent/inbox/dequeue', agent, rejectedItem)
expect((await api.sessions.selectModel(request({ sessionId, provider: 'text-only', model: 'plain' }))).result.ok).toBe(false)
// Idle proves the admission ended without publication; nothing durable
// requires an image route, so the text-only switch must be accepted again.
ctx.emit('agent/status', agent, 'idle')
expect(expectValue(await api.sessions.selectModel(request({
sessionId, provider: 'text-only', model: 'plain',
}))).selected).toEqual({ provider: 'text-only', model: 'plain' })
await ctx.fiber.dispose()
})
it('rejects a queue edit that injects unadmitted image content', async () => {
const { ctx, sessionId, agent } = await harness()
const api = createApiProxy(ctx, { provider: 'deepseek-official', model: 'deepseek-chat', cwd: '/tmp', workspaceRoot: '/tmp' })
Object.assign(agent, { updateInbox: () => 'applied' })
const denied = await api.sessions.updateQueue(request({
sessionId,
itemId: 'i-x' as never,
action: {
kind: 'edit' as const,
content: [{ type: 'image', attachment: { attachmentId: 'att-x', mediaType: 'image/png', bytes: 8, width: 1, height: 1 } }] as never,
},
// The client disabling its input is an affordance; this method stays
// callable, so the refusal has to live here.
const refused = await api.sessions.prompt(request({
sessionId, mode: 'queue' as const, content: [{ type: 'text' as const, text: 'hi' }],
}))
expect(denied.result).toMatchObject({
expect(refused.result).toMatchObject({
ok: false,
error: { code: 'attachment-error', details: { reason: 'QUEUE_EDIT_NON_TEXT' } },
error: { code: 'model-unavailable', details: { provider: 'deleted-gateway', model: 'deleted-model' } },
})
expect(expectValue(await api.sessions.models(request({ sessionId }))).routable).toBe(false)
// An advisory-unlisted model on a live route is NOT this: the route
// serves it, so the prompt goes through and nothing blocks.
expectValue(await api.sessions.selectModel(request({
sessionId, provider: 'deepseek-official', model: 'unlisted-but-served',
})))
const catalog = expectValue(await api.sessions.models(request({ sessionId })))
expect(catalog.routable).toBe(true)
expect(catalog.groups.flatMap(group => group.models.map(model => model.id)))
.not.toContain('unlisted-but-served')
await ctx.fiber.dispose()
})
it('serializes an image save with a concurrent model selection', async () => {
const { ctx, sessionId, agent } = await harness()
registerTextOnly(ctx)
let saveStarted!: () => void
let releaseSave!: () => void
const started = new Promise<void>((resolve) => { saveStarted = resolve })
const released = new Promise<void>((resolve) => { releaseSave = resolve })
const ref = { attachmentId: 'att-race', mediaType: 'image/png' as const, bytes: 1, width: 1, height: 1 }
ctx.provide('attachments', {
imageLimits: {
maxImageBytes: 1,
maxImagesPerMessage: 1,
maxMessageImageBytes: 1,
maxImagePixels: 1,
mediaTypes: ['image/png'],
},
validateImage: () => Promise.resolve(),
saveImage: async () => {
saveStarted()
await released
return ref
},
} as never)
Object.assign(agent, {
followup(message: UserMessage) {
ctx.emit('agent/inbox/enqueue', agent, { id: `i-${message.id}`, message, placement: 'queued' } as never)
},
it('serves a session and its catalog when the stored default names a route that is gone', async () => {
const { ctx, sessionId } = await harness()
const api = createApiProxy(ctx, {
// What a Models-page removal leaves behind: the settings document still
// names the route the user last picked, and nothing serves it.
defaultTarget: () => ({ provider: 'deleted-gateway', model: 'deleted-model' }),
cwd: '/tmp',
workspaceRoot: '/tmp',
})
const api = createApiProxy(ctx, { provider: 'deepseek-official', model: 'deepseek-chat', cwd: '/tmp', workspaceRoot: '/tmp' })
const prompt = api.sessions.prompt(request({
sessionId,
mode: 'queue' as const,
content: [{ type: 'image' as const, mediaType: 'image/png' as const, data: 'AA==' }],
}))
await started
const selection = api.sessions.selectModel(request({ sessionId, provider: 'text-only', model: 'plain' }))
expect(await Promise.race([
selection.then(() => 'settled' as const),
new Promise<'pending'>((resolve) => { setTimeout(() => { resolve('pending') }, 0) }),
])).toBe('pending')
releaseSave()
expect((await prompt).result.ok).toBe(true)
expect((await selection).result.ok).toBe(false)
const catalog = expectValue(await api.sessions.models(request({ sessionId })))
// Passed through rather than repaired: matching no group is precisely what
// makes the composer seat prompt for a selection instead of naming a model
// the deployment cannot reach.
expect(catalog.current).toEqual({ provider: 'deleted-gateway', model: 'deleted-model' })
expect(catalog.groups.flatMap(group => group.models.map(model => `${group.id}/${model.id}`)))
.not.toContain('deleted-gateway/deleted-model')
await ctx.fiber.dispose()
})
it('authorizes an attachment read referenced only from wrapped message content', async () => {
const { ctx, sessionId, agent } = await harness()
const ref = { attachmentId: 'att-w', mediaType: 'image/png' as const, bytes: 4, width: 1, height: 1 }
ctx.provide('attachments', {
readImage: () => Promise.resolve({ ref, data: new Uint8Array([1, 2, 3, 4]) }),
} as never)
const api = createApiProxy(ctx, { provider: 'deepseek-official', model: 'deepseek-chat', cwd: '/tmp', workspaceRoot: '/tmp' })
// The only reference lives inside an assistant/message wrapper; the
// authorization walk must follow that durable event shape.
agent.session.append('assistant/message', {
turn: 1, step: 0,
message: { id: 'a-1', role: 'assistant', source: { kind: 'model', provider: 'p', model: 'm' }, content: [{ type: 'image', attachment: ref }] },
} as never, { surfaceOp: 'append' })
const got = await api.sessions.attachment(request({ sessionId, attachmentId: 'att-w' as never }))
expect(got.result).toMatchObject({ ok: true, value: { attachment: ref } })
const denied = await api.sessions.attachment(request({ sessionId, attachmentId: 'att-other' as never }))
expect(denied.result).toMatchObject({ ok: false, error: { details: { reason: 'ATTACHMENT_NOT_REFERENCED' } } })
await ctx.fiber.dispose()
})
it('detects images in wrapped messages and nested tool results on the current surface', async () => {
const image = { type: 'image', attachment: { attachmentId: 'att-x', mediaType: 'image/png', bytes: 8, width: 1, height: 1 } }
const cases: { label: string; append: (agent: Agent) => void }[] = [
{
label: 'steering message wrapper',
append: (agent) => {
agent.session.append('steering/message', {
turn: 1, message: { id: 'st-1', role: 'user', source: { kind: 'user' }, content: [image] },
} as never, { surfaceOp: 'append' })
},
},
{
label: 'nested tool-result content',
append: (agent) => {
agent.session.append('user/message', {
id: 'tr-1', role: 'user', source: { kind: 'tool', callId: 'c1' },
content: [{ type: 'tool-result', toolCallId: 'c1', content: [image], isError: false }],
} as never, { surfaceOp: 'append' })
},
},
]
for (const { label, append } of cases) {
const { ctx, sessionId, agent } = await harness()
registerTextOnly(ctx)
const api = createApiProxy(ctx, { provider: 'deepseek-official', model: 'deepseek-chat', cwd: '/tmp', workspaceRoot: '/tmp' })
append(agent)
const stranded = await api.sessions.selectModel(request({ sessionId, provider: 'text-only', model: 'plain' }))
expect(stranded.result.ok, label).toBe(false)
await ctx.fiber.dispose()
}
})
})