Merge remote-tracking branch 'origin/master' into worktree/web-plugin-config

# Conflicts:
#	docs/event-producer-consumer.i18n.yaml
#	docs/event-producer-consumer.md
#	docs/event-producer-consumer.zh.md
#	docs/module-graph.i18n.yaml
#	docs/module-graph.md
#	docs/module-graph.zh.md
#	packages/client/ui-conversation/README.i18n.yaml
#	packages/client/ui-conversation/README.md
#	packages/client/ui-conversation/README.zh.md
#	packages/client/ui-conversation/package.json
#	pnpm-lock.yaml
This commit is contained in:
Yichen Jiang
2026-08-10 15:50:49 +08:00
352 changed files with 7220 additions and 781 deletions
@@ -14,6 +14,7 @@ import type { Agent } from '@deepseek-ai/dsh-agent'
import SessionStore, { SessionId, type Session } from '@deepseek-ai/dsh-session'
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
import { RpcId, type RpcRequest } from '../src/api/rpc.ts'
import type { HostFrame } from '../src/api/events.ts'
import {
InvalidPresetIdError, PresetExistsError, resolveSessionPreset, UnknownPresetError,
} from '@deepseek-ai/dsh-agent-presets'
@@ -350,6 +351,37 @@ describe('agentPreset.select', () => {
.toBe('core-web')
})
it('frames the committed switch so clients can drop that session\'s catalogs', async () => {
const { api, ctx } = await harness(['standard', 'minimal'])
await api.sessions.create(request({ sessionId: SessionId('sel-frame'), agentPreset: 'standard' }))
// The host-stream opener reads the committed-workspace baseline; this
// spec owns preset identity, so the stub suffices (api-proxy-commands
// precedent).
ctx.provide('workspace', { list: () => [] } as never)
const abort = new AbortController()
const frames: HostFrame[] = []
const stream = api.events.host(request({}), abort.signal)
const consume = (async () => {
for await (const frame of stream) {
if (frame.payload.type === 'host/session-preset-changed') frames.push(frame.payload)
}
})()
await api.agentPresets.select(
request({ sessionId: SessionId('sel-frame'), agentPreset: 'minimal' }))
// The queue push rides the synchronous append, so one turn of the loop is
// enough to deliver it; closing the stream bounds the read either way.
await new Promise(resolve => setTimeout(resolve, 0))
abort.abort()
await consume
// Recomposing registers nothing, so this frame — not the registry-wide
// commands one — is what tells a client its cached catalogs are stale.
expect(frames).toEqual([
{ type: 'host/session-preset-changed', sessionId: 'sel-frame', agentPreset: 'minimal' },
])
})
it('serializes two concurrent selects on one session', async () => {
const { api, ctx } = await harness(['standard', 'core-web'])
await api.sessions.create(request({ sessionId: SessionId('sel-race'), agentPreset: 'standard' }))
@@ -5,7 +5,7 @@
* boundary for a running selection change.
*/
import { describe, expect, it } from 'vitest'
import { describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import AgentRegistry, { agentEvents } from '@deepseek-ai/dsh-agent'
import type { Agent } from '@deepseek-ai/dsh-agent'
@@ -13,6 +13,7 @@ import LlmService, { LlmAdapter, ReasoningEffortId } from '@deepseek-ai/dsh-llm'
import type {
GenerateOptions, LlmCallConfig, LlmModelInfo, LlmModelReasoningInfo, LlmProviderInfo,
LlmResolvedModelInfo, StreamChunk,
UserMessage,
} from '@deepseek-ai/dsh-llm'
import SessionStore from '@deepseek-ai/dsh-session'
import type { SessionId } from '@deepseek-ai/dsh-session'
@@ -108,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 }
}
@@ -118,7 +120,157 @@ function expectValue<T>(response: { result: { ok: true; value: T } | { ok: false
return response.result.value
}
function registerTextOnly(ctx: Context): void {
ctx.llm.registerAdapter(['text-only'], new class extends CatalogAdapter {
override resolveModel(provider: string, model: string): Promise<LlmResolvedModelInfo> {
return Promise.resolve({ provider, id: model, name: model, inputModalities: ['text'] })
}
}('Text Only', []))
}
describe('Web session model selection', () => {
it('validates an ordered image batch before persisting any member', async () => {
const { ctx, agent, sessionId } = await harness()
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'],
},
validateImage,
saveImage,
} as never)
const followup = vi.fn()
Object.assign(agent, { followup })
const api = createApiProxy(ctx, {
defaultModelSelection: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }),
cwd: '/tmp',
workspaceRoot: '/tmp',
})
const result = await api.sessions.prompt(request({
sessionId,
mode: 'queue' as const,
content: [
{ 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(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: 'TOO_MANY_IMAGES' } },
})
expect(saveImage).toHaveBeenCalledTimes(2)
await ctx.fiber.dispose()
})
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, {
defaultModelSelection: () => ({ 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, {
defaultModelSelection: () => ({ 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 selection out of the catalog', async () => {
const { ctx, sessionId } = await harness({
provider: 'deepseek-official',
@@ -56,6 +56,10 @@ function scriptedApi(overrides: {
rename: r => ok(r, { title: 'renamed', seq: 0 }),
fork: r => ok(r, { sessionId: sid('s-fork') }),
prompt: r => ok(r, { accepted: true as const }),
attachment: r => ok(r, {
attachment: { attachmentId: 'a' as never, mediaType: 'image/png', bytes: 1, width: 1, height: 1 },
data: 'AA==',
}),
updateQueue: r => ok(r, { accepted: true as const }),
cancel: r => ok(r, { accepted: true as const }),
...overrides.sessions,
@@ -97,6 +97,12 @@ function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFra
async prompt(request) {
return { rpcId: request.rpcId, result: { ok: true, value: { accepted: true as const } } }
},
async attachment(request) {
return {
rpcId: request.rpcId,
result: { ok: true, value: { attachment: { attachmentId: 'a' as never, mediaType: 'image/png' as const, bytes: 1, width: 1, height: 1 }, data: 'AA==' } },
}
},
async updateQueue(request) {
return { rpcId: request.rpcId, result: { ok: true, value: { accepted: true as const } } }
},
@@ -357,6 +363,7 @@ describe('unary round trip (handler ⇄ client, no network)', () => {
const renamed = await c.sessions.rename({ sessionId: 's' as never, title: 'named' })
expect(renamed.result).toMatchObject({ ok: true, value: { title: 'named', seq: 0 } })
expect((await c.sessions.prompt({ sessionId: 's' as never, mode: 'queue', content: [{ type: 'text', text: 'x' }] })).result.ok).toBe(true)
expect((await c.sessions.attachment({ sessionId: 's' as never, attachmentId: 'a' as never })).result.ok).toBe(true)
expect((await c.sessions.updateQueue({
sessionId: 's' as never,
itemId: 'item-1' as never,
@@ -493,6 +493,7 @@ describe('events frame schemas', () => {
} },
{ type: 'host/workspace-removed', workspaceId: 'w' },
{ type: 'host/commands-changed' },
{ type: 'host/session-preset-changed', sessionId: 's', agentPreset: 'minimal' },
{ type: 'stream/error', error: { code: 'internal', message: 'm', details: {} } },
]
for (const frame of frames) expect(hostFrameSchema.parse(frame)).toMatchObject({ type: frame.type })