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:
@@ -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()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user