566 lines
32 KiB
TypeScript
566 lines
32 KiB
TypeScript
import { Context } from '@deepseek-ai/cordis'
|
|
import { once } from 'node:events'
|
|
import { PassThrough } from 'node:stream'
|
|
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
|
|
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
|
|
import CommandRuntime, { CommandDefinitionId, type CommandDefinition, type CommandInvocation } from '@deepseek-ai/dsh-commands'
|
|
import { AttachmentId, type AttachmentStore, type ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
|
|
import { LlmAdapter, type GenerateOptions, type LlmResolvedModelInfo, type StreamChunk } from '@deepseek-ai/dsh-llm'
|
|
import { SessionId } from '@deepseek-ai/dsh-session'
|
|
import { JsonRpcLineTransport, type JsonRpcTransportPeer } from '@deepseek-ai/dsh-sdk-protocol'
|
|
import { describe, expect, it, onTestFinished, vi } from 'vitest'
|
|
import { HarnessSdkJsonRpcServer } from '../src/server.ts'
|
|
|
|
class UnusedModel extends LlmAdapter {
|
|
override resolveModel(provider: string, model: string): Promise<LlmResolvedModelInfo> {
|
|
return Promise.resolve({ provider, id: model, name: model })
|
|
}
|
|
|
|
async *stream(): AsyncIterable<StreamChunk> {
|
|
throw new Error('Command dispatch must not call the model')
|
|
}
|
|
}
|
|
|
|
class TextModel extends LlmAdapter {
|
|
readonly requests: GenerateOptions[] = []
|
|
|
|
override resolveModel(provider: string, model: string): Promise<LlmResolvedModelInfo> {
|
|
return Promise.resolve({ provider, id: model, name: model })
|
|
}
|
|
|
|
override async *stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
|
|
this.requests.push(options)
|
|
yield { type: 'block-start', index: 0, blockType: 'text' }
|
|
yield { type: 'text-delta', index: 0, text: 'done' }
|
|
yield { type: 'block-end', index: 0, block: { type: 'text', text: 'done' } }
|
|
yield { type: 'finish', reason: { kind: 'stop' } }
|
|
}
|
|
}
|
|
|
|
async function setup(registry = true, peer?: JsonRpcTransportPeer) {
|
|
const ctx = new Context()
|
|
onTestFinished(() => ctx.fiber.dispose())
|
|
await mountAgentLoopTestDependencies(ctx, { workingDirectory: true })
|
|
const loop = await ctx.plugin(AgentLoop, { agents: [] })
|
|
if (registry) await ctx.plugin(CommandRuntime)
|
|
ctx.llm.registerAdapter(['mock'], new UnusedModel())
|
|
const transport: JsonRpcTransportPeer = peer ?? {
|
|
request: async () => { throw new Error('Unexpected runtime request') },
|
|
notify: () => {},
|
|
}
|
|
const server = new HarnessSdkJsonRpcServer(ctx, transport)
|
|
onTestFinished(async () => { await server.shutdown() })
|
|
const initialize = () => server.initialize({ cwd: process.cwd(), provider: 'mock', model: 'mock' })
|
|
const register = (handler: CommandDefinition['handler']) => {
|
|
ctx.effect(() => ctx.commands.register({ name: 'compact', definitionId: CommandDefinitionId('@deepseek-ai/dsh-command-compact'), description: 'Fixture command', handler }))
|
|
}
|
|
const command = async () => {
|
|
const result = await server.query({ sessionId: SessionId('commands'), contentBlocks: [{ type: 'text', text: '/compact' }] })
|
|
if (result.kind !== 'command') throw new Error('Fixture command was routed as a prompt')
|
|
return result.execution
|
|
}
|
|
return { ctx, server, loop, initialize, register, command }
|
|
}
|
|
|
|
describe('SDK command dispatch', () => {
|
|
it.each([' ', '\t\n', '\u3000'])('accepts leading whitespace through the query wire endpoint: %j', async (prefix) => {
|
|
const h = await setup()
|
|
await h.initialize()
|
|
const handler = vi.fn((_invocation: CommandInvocation) => ({ kind: 'success' as const }))
|
|
h.register(handler)
|
|
await expect(h.server.handleRequest('session/query', { sessionId: 'query-space', contentBlocks: [{ type: 'text', text: `${prefix}/compact` }] }))
|
|
.resolves.toMatchObject({ kind: 'command', execution: { result: { kind: 'success' } } })
|
|
expect(handler).toHaveBeenCalledOnce()
|
|
expect(handler.mock.calls[0]![0].rawInput).toBe('')
|
|
})
|
|
|
|
it.each(['success', 'error'] as const)('returns the logged native %s without a model turn', async (kind) => {
|
|
const h = await setup()
|
|
await h.initialize()
|
|
const handler = vi.fn((_invocation: CommandInvocation) => ({ kind, text: 'native outcome' }))
|
|
h.register(handler)
|
|
const execution = await h.command()
|
|
const agent = h.ctx.agents.get(SessionId('commands'))!
|
|
expect(handler).toHaveBeenCalledOnce()
|
|
const invocation = handler.mock.calls[0]![0]
|
|
expect(invocation.agent.id).toBe(agent.id)
|
|
expect(invocation.rawInput).toBe('')
|
|
expect(invocation.attachments).toEqual([])
|
|
expect(execution.result).toEqual({ kind, text: 'native outcome' })
|
|
expect(agent.session.snapshotEvents().map(event => event.type)).toEqual(['command/run', 'command/done'])
|
|
expect(agent.session.snapshotEvents().map(event => event.data)).toEqual([
|
|
{ commandId: execution.commandId, name: 'compact', args: '', source: { kind: 'user' } },
|
|
{ commandId: execution.commandId, kind, text: 'native outcome' },
|
|
])
|
|
})
|
|
|
|
it('rejects malformed content before creating a Session', async () => {
|
|
const h = await setup()
|
|
await h.initialize()
|
|
const image = { type: 'image', data: 'AQ==', mimeType: 'image/png' }
|
|
const invalid = [undefined, null, {}, 'image', [], [null], [1], [[]], [{}], [{ type: 1 }],
|
|
[{ type: 'text' }], [{ type: 'text', text: 1 }], [{ type: 'text', text: 'go', extra: true }],
|
|
[{ type: 'text', text: '' }], [{ type: 'text', text: ' ' }, { type: 'text', text: '\n' }],
|
|
[{ type: 'image', mimeType: 'image/png' }], [{ type: 'image', attachment: null }],
|
|
[{ type: 'image', attachment: [] }], [{ type: 'image', attachment: 'missing' }],
|
|
[{ ...image, data: 1 }], [{ type: 'image', data: 'AQ==' }],
|
|
[{ ...image, mimeType: 'image/svg+xml' }], [{ ...image, name: null }],
|
|
[{ ...image, attachment: { attachmentId: 'other-session' } }]]
|
|
for (const contentBlocks of invalid) {
|
|
await expect(h.server.handleRequest('session/query', {
|
|
sessionId: 'invalid-content', contentBlocks,
|
|
})).rejects.toThrow(TypeError)
|
|
expect(h.ctx.agents.list()).toEqual([])
|
|
}
|
|
})
|
|
|
|
it.each(['image/png', 'image/jpeg', 'image/webp', 'image/gif'])('leaves command attachment support to the native registry for %s', async (mimeType) => {
|
|
const h = await setup()
|
|
await h.initialize()
|
|
const handler = vi.fn(() => ({ kind: 'success' as const }))
|
|
h.register(handler)
|
|
const response = await h.server.handleRequest('session/query', {
|
|
sessionId: 'refused-image',
|
|
contentBlocks: [{ type: 'text', text: '/compact' },
|
|
{ type: 'image', data: 'AQ==', mimeType, ...mimeType === 'image/png' ? {} : { name: 'example' } }],
|
|
})
|
|
expect(response).toMatchObject({ kind: 'command', execution: {
|
|
result: { kind: 'error', text: '/compact does not accept attachments' },
|
|
} })
|
|
expect(handler).not.toHaveBeenCalled()
|
|
expect(h.ctx.agents.get(SessionId('refused-image'))!.session.snapshotEvents().map(event => event.type))
|
|
.toEqual(['command/run', 'command/done'])
|
|
})
|
|
|
|
it.each(['image-first', 'text-first'])('admits named images through the native command executor in %s order', async (order) => {
|
|
const h = await setup()
|
|
await h.initialize()
|
|
const ref: ImageAttachmentRef = {
|
|
attachmentId: AttachmentId('image'), mediaType: 'image/png', bytes: 1, width: 1, height: 1, name: 'diagram.png',
|
|
}
|
|
const saveImages = vi.fn<AttachmentStore['saveImages']>(async () => [ref])
|
|
const store: Pick<AttachmentStore, 'saveImages'> = { saveImages }
|
|
h.ctx.provide('attachments', store as AttachmentStore)
|
|
const handler = vi.fn((_invocation: CommandInvocation) => ({ kind: 'success' as const }))
|
|
h.ctx.effect(() => h.ctx.commands.register({
|
|
name: 'plan', definitionId: CommandDefinitionId('@deepseek-ai/dsh-plan-mode'), description: 'Plan', input: { hint: 'Task', attachments: true }, handler,
|
|
}))
|
|
const attachments = [{ type: 'image', data: 'AQ==', mimeType: 'image/png', name: 'diagram.png' }]
|
|
const text = { type: 'text', text: '/plan inspect' }
|
|
const response = await h.server.handleRequest('session/query', {
|
|
sessionId: 'images',
|
|
contentBlocks: order === 'image-first' ? [...attachments, text] : [text, ...attachments],
|
|
})
|
|
expect(response).toMatchObject({ kind: 'command', execution: { result: { kind: 'success' } } })
|
|
expect(saveImages).toHaveBeenCalledWith([{ data: Uint8Array.of(1), mediaType: 'image/png', name: 'diagram.png' }])
|
|
expect(handler.mock.calls[0]![0]).toMatchObject({ rawInput: ' inspect', attachments: [{ type: 'image', attachment: ref }] })
|
|
})
|
|
|
|
it.each([
|
|
{ type: 'text', text: 'another command paragraph' },
|
|
{ type: 'text', text: '' },
|
|
{ type: 'image', attachment: { attachmentId: 'stored-image', mediaType: 'image/png', bytes: 1, width: 1, height: 1 } },
|
|
{ type: 'file', attachment: { attachmentId: 'stored-file', name: 'notes.txt', bytes: 1 } },
|
|
{ type: 'reasoning', text: 'earlier reasoning' },
|
|
])('rejects a recognized command with unsupported content %j without executing or falling back', async (extra) => {
|
|
const h = await setup()
|
|
await h.initialize()
|
|
const saveImages = vi.fn<AttachmentStore['saveImages']>(async () => [])
|
|
const store: Pick<AttachmentStore, 'saveImages'> = { saveImages }
|
|
h.ctx.provide('attachments', store as AttachmentStore)
|
|
const handler = vi.fn(() => ({ kind: 'success' as const }))
|
|
h.ctx.effect(() => h.ctx.commands.register({
|
|
name: 'plan', definitionId: CommandDefinitionId('@deepseek-ai/dsh-plan-mode'), description: 'Plan', input: { hint: 'Task', attachments: true }, handler,
|
|
}))
|
|
await expect(h.server.handleRequest('session/query', {
|
|
sessionId: 'refused-content', contentBlocks: [
|
|
{ type: 'image', data: 'AQ==', mimeType: 'image/png' },
|
|
{ type: 'text', text: '/plan off' }, extra,
|
|
],
|
|
})).rejects.toThrow('Native SDK commands require one text block and optional encoded image blocks')
|
|
expect(handler).not.toHaveBeenCalled()
|
|
expect(saveImages).not.toHaveBeenCalled()
|
|
expect(h.ctx.agents.get(SessionId('refused-content'))!.session.snapshotEvents()).toEqual([])
|
|
})
|
|
|
|
it('cancels a command whose image admission overlaps shutdown before invoking its handler', async () => {
|
|
const h = await setup()
|
|
await h.initialize()
|
|
const entered = Promise.withResolvers<undefined>()
|
|
const release = Promise.withResolvers<readonly ImageAttachmentRef[]>()
|
|
onTestFinished(() => { release.resolve([]) })
|
|
const saveImages: AttachmentStore['saveImages'] = () => { entered.resolve(undefined); return release.promise }
|
|
h.ctx.provide('attachments', { saveImages } as AttachmentStore)
|
|
const handler = vi.fn(() => ({ kind: 'success' as const }))
|
|
h.ctx.effect(() => h.ctx.commands.register({
|
|
name: 'plan', definitionId: CommandDefinitionId('@deepseek-ai/dsh-plan-mode'), description: 'Plan', input: { hint: 'Task', attachments: true }, handler,
|
|
}))
|
|
const request = h.server.query({ sessionId: SessionId('image-shutdown'),
|
|
contentBlocks: [{ type: 'text', text: '/plan inspect' }, { type: 'image', data: 'AQ==', mimeType: 'image/png' }],
|
|
})
|
|
const rejected = expect(request).rejects.toThrow('shutting down')
|
|
await entered.promise
|
|
const shutdown = h.server.shutdown()
|
|
release.resolve([{ attachmentId: AttachmentId('image'), mediaType: 'image/png', bytes: 1, width: 1, height: 1 }])
|
|
await Promise.all([rejected, shutdown])
|
|
expect(handler).not.toHaveBeenCalled()
|
|
expect(h.ctx.agents.list()).toEqual([])
|
|
})
|
|
|
|
it.each([
|
|
['compact', '@deepseek-ai/dsh-command-compact', '/compact', '/压缩'],
|
|
['permission', '@deepseek-ai/dsh-permission-presets', '/permission read-only', '/权限 read-only'],
|
|
['plan', '@deepseek-ai/dsh-plan-mode', '/plan', '/计划'],
|
|
['goal', '@deepseek-ai/dsh-command-goal', '/goal', '/目标'],
|
|
])('accepts the %s English and Chinese aliases and delivers their lifecycle before resolution', async (name, definitionId, line, alias) => {
|
|
const input = new PassThrough()
|
|
const output = new PassThrough()
|
|
const serverWire = new JsonRpcLineTransport(input, output)
|
|
const client = new JsonRpcLineTransport(output, input)
|
|
onTestFinished(async () => {
|
|
serverWire.close()
|
|
client.close()
|
|
const closed = [once(input, 'close'), once(output, 'close')]
|
|
input.destroy()
|
|
output.destroy()
|
|
await Promise.all(closed)
|
|
})
|
|
const h = await setup(true, serverWire)
|
|
const received: { method: string; params: Record<string, unknown> }[] = []
|
|
let receivedAtQueryResolution: typeof received = []
|
|
client.onNotification((method, params) => { received.push({ method, params }) })
|
|
serverWire.onRequest(async (method, params) => {
|
|
const result = await h.server.handleRequest(method, params)
|
|
if (method === 'session/query') receivedAtQueryResolution = [...received]
|
|
return result
|
|
})
|
|
serverWire.start()
|
|
client.start()
|
|
await client.request('initialize', { cwd: process.cwd(), provider: 'mock', model: 'mock' })
|
|
const handler = vi.fn((_invocation: CommandInvocation) => ({ kind: 'success' as const, text: 'native outcome' }))
|
|
h.ctx.effect(() => h.ctx.commands.register({
|
|
name, definitionId: CommandDefinitionId(definitionId), description: 'Fixture command', handler,
|
|
}))
|
|
const sessionId = SessionId('alias-command')
|
|
await expect(client.request('session/query', { sessionId, contentBlocks: [{ type: 'text', text: line }] }))
|
|
.resolves.toMatchObject({ kind: 'command', execution: { result: { kind: 'success', text: 'native outcome' } } })
|
|
expect(handler).toHaveBeenCalledOnce()
|
|
received.length = 0
|
|
const result = await client.request('session/query', { sessionId, contentBlocks: [{ type: 'text', text: alias }] })
|
|
expect(handler).toHaveBeenCalledTimes(2)
|
|
const commandId = handler.mock.calls[1]![0].commandId
|
|
expect(result).toMatchObject({ kind: 'command', execution: { commandId, result: { kind: 'success', text: 'native outcome' } } })
|
|
expect(receivedAtQueryResolution.filter(item => item.method === 'session.event')).toMatchObject([
|
|
{ method: 'session.event', params: { sessionId, event: { type: 'command/run', data: { commandId, name } } } },
|
|
{ method: 'session.event', params: { sessionId, event: { type: 'command/done', data: { commandId, kind: 'success' } } } },
|
|
])
|
|
expect(h.ctx.agents.get(sessionId)!.session.snapshotEvents().map(event => event.type))
|
|
.toEqual(['command/run', 'command/done', 'command/run', 'command/done'])
|
|
})
|
|
|
|
it.each([
|
|
['compact', '@deepseek-ai/dsh-command-compact', '压缩', ''],
|
|
['permission', '@deepseek-ai/dsh-permission-presets', '权限', ' read-only'],
|
|
['plan', '@deepseek-ai/dsh-plan-mode', '计划', ' task'],
|
|
['goal', '@deepseek-ai/dsh-command-goal', '目标', ' task'],
|
|
])('dispatches the renamed %s definition through its registered name and aliases', async (name, id, alias, args) => {
|
|
const h = await setup()
|
|
await h.initialize()
|
|
const registered = `renamed-${name}`
|
|
const handler = vi.fn((_invocation: CommandInvocation) => ({ kind: 'success' as const }))
|
|
h.ctx.effect(() => h.ctx.commands.register({
|
|
name: registered, definitionId: CommandDefinitionId(id), description: 'Renamed builtin', handler,
|
|
}))
|
|
const sessionId = SessionId('renamed')
|
|
for (const token of [name, alias, registered]) {
|
|
await expect(h.server.handleRequest('session/query', {
|
|
sessionId, contentBlocks: [{ type: 'text', text: `/${token}${args}` }],
|
|
})).resolves.toMatchObject({ kind: 'command', execution: { result: { kind: 'success' } } })
|
|
}
|
|
expect(handler).toHaveBeenCalledTimes(3)
|
|
expect(handler.mock.calls.map(([invocation]) => invocation.rawInput)).toEqual(Array.from({ length: 3 }, () => args))
|
|
const events = h.ctx.agents.get(sessionId)!.session.snapshotEvents()
|
|
expect(events.filter(event => event.type === 'command/run').map(event => event.data.name))
|
|
.toEqual(Array.from({ length: 3 }, () => registered))
|
|
expect(events.every(event => event.type === 'command/run' || event.type === 'command/done')).toBe(true)
|
|
})
|
|
|
|
it.each([
|
|
['compact', ''], ['permission', ' read-only'], ['plan', ' task'], ['goal', ' task'],
|
|
])('keeps an unrelated definition named %s outside native SDK dispatch', async (name, args) => {
|
|
const h = await setup()
|
|
const model = new TextModel()
|
|
h.ctx.effect(() => h.ctx.llm.registerAdapter(['query-model'], model))
|
|
await h.server.initialize({ cwd: process.cwd(), provider: 'query-model', model: 'model' })
|
|
const handler = vi.fn(() => ({ kind: 'success' as const }))
|
|
h.ctx.effect(() => h.ctx.commands.register({
|
|
name, definitionId: CommandDefinitionId(`example/${name}`), description: 'Unrelated command', handler,
|
|
}))
|
|
const sessionId = SessionId('unrelated')
|
|
const line = `/${name}${args}`
|
|
await expect(h.server.handleRequest('session/query', {
|
|
sessionId, contentBlocks: [{ type: 'text', text: line }],
|
|
})).resolves.toMatchObject({ kind: 'prompt' })
|
|
await h.server.wait({ sessionId })
|
|
expect(handler).not.toHaveBeenCalled()
|
|
expect(model.requests).toHaveLength(1)
|
|
expect(model.requests[0]!.messages.some(message => message.role === 'user'
|
|
&& message.content.some(block => block.type === 'text' && block.text === line))).toBe(true)
|
|
expect(h.ctx.agents.get(sessionId)!.session.snapshotEvents().some(event => event.type === 'command/run')).toBe(false)
|
|
})
|
|
|
|
it('keeps a renamed permission command without a preset as ordinary input', async () => {
|
|
const h = await setup()
|
|
const model = new TextModel()
|
|
h.ctx.effect(() => h.ctx.llm.registerAdapter(['query-model'], model))
|
|
await h.server.initialize({ cwd: process.cwd(), provider: 'query-model', model: 'model' })
|
|
const handler = vi.fn(() => ({ kind: 'success' as const }))
|
|
h.ctx.effect(() => h.ctx.commands.register({
|
|
name: 'access', definitionId: CommandDefinitionId('@deepseek-ai/dsh-permission-presets'),
|
|
description: 'Permission', handler,
|
|
}))
|
|
const sessionId = SessionId('empty-preset')
|
|
await expect(h.server.query({ sessionId, contentBlocks: [{ type: 'text', text: '/access' }] }))
|
|
.resolves.toMatchObject({ kind: 'prompt' })
|
|
await h.server.wait({ sessionId })
|
|
expect(handler).not.toHaveBeenCalled()
|
|
expect(model.requests[0]!.messages.some(message => message.content.some(block => block.type === 'text' && block.text === '/access'))).toBe(true)
|
|
expect(h.ctx.agents.get(sessionId)!.session.snapshotEvents().some(event => event.type === 'command/run')).toBe(false)
|
|
})
|
|
|
|
it('reports an execution unavailable after catalog resolution', async () => {
|
|
const h = await setup()
|
|
await h.initialize()
|
|
h.register(() => ({ kind: 'success' }))
|
|
vi.spyOn(h.ctx.commands, 'execute').mockResolvedValue(undefined)
|
|
await expect(h.command()).rejects.toThrow('selected command is unavailable')
|
|
})
|
|
|
|
it('passes explicit permission input to the native handler', async () => {
|
|
const h = await setup()
|
|
await h.initialize()
|
|
const handler = vi.fn((_invocation: CommandInvocation) => ({ kind: 'error' as const, text: 'native preset refusal' }))
|
|
h.ctx.effect(() => h.ctx.commands.register({ name: 'permission', definitionId: CommandDefinitionId('@deepseek-ai/dsh-permission-presets'), description: 'Fixture permission', input: { hint: 'Preset' }, handler }))
|
|
const sessionId = SessionId('permission')
|
|
const line = '/permission deployment-preset'
|
|
const result = await h.server.query({ sessionId, contentBlocks: [{ type: 'text', text: line }] })
|
|
expect(handler).toHaveBeenCalledOnce()
|
|
expect(handler.mock.calls[0]![0].rawInput).toBe(' deployment-preset')
|
|
const expected = { result: { kind: 'error', text: 'native preset refusal' } }
|
|
expect(result).toMatchObject({ kind: 'command', execution: expected })
|
|
expect(h.ctx.agents.get(sessionId)!.session.snapshotEvents().map(event => event.type))
|
|
.toEqual(['command/run', 'command/done'])
|
|
})
|
|
|
|
it.each(['/plan', '/plan off', '/plan explain the change'])('delegates %s without reconstructing model input', async (line) => {
|
|
const h = await setup()
|
|
await h.initialize()
|
|
const handler = vi.fn((_invocation: CommandInvocation) => ({ kind: 'success' as const, text: 'native plan result' }))
|
|
h.ctx.effect(() => h.ctx.commands.register({ name: 'plan', definitionId: CommandDefinitionId('@deepseek-ai/dsh-plan-mode'), description: 'Plan', input: { hint: '[off|message]' }, handler }))
|
|
const result = await h.server.query({ sessionId: SessionId('plan'), contentBlocks: [{ type: 'text', text: line }] })
|
|
expect(result).toMatchObject({ kind: 'command', execution: { result: { kind: 'success', text: 'native plan result' } } })
|
|
expect(handler.mock.calls[0]![0].rawInput).toBe(line.slice('/plan'.length))
|
|
expect(h.ctx.agents.get(SessionId('plan'))!.session.snapshotEvents().map(event => event.type))
|
|
.toEqual(['command/run', 'command/done'])
|
|
})
|
|
|
|
it.each([
|
|
['/plan\u3000Inspect\u3000the\nchange', 'plan', '@deepseek-ai/dsh-plan-mode', ' Inspect\u3000the\nchange'],
|
|
['/计划\u00a0\t设计\u00a0方案', 'plan', '@deepseek-ai/dsh-plan-mode', ' \t设计\u00a0方案'],
|
|
['/goal\u2003Finish\u3000the task', 'goal', '@deepseek-ai/dsh-command-goal', ' Finish\u3000the task'],
|
|
['/目标\u3000\u00a0完成任务', 'goal', '@deepseek-ai/dsh-command-goal', ' \u00a0完成任务'],
|
|
['/permission\u00a0read-only', 'permission', '@deepseek-ai/dsh-permission-presets', ' read-only'],
|
|
['/权限\u202f\tread-only', 'permission', '@deepseek-ai/dsh-permission-presets', ' \tread-only'],
|
|
])('executes Unicode-separated input %j through its native handler', async (line, name, definitionId, rawInput) => {
|
|
const h = await setup()
|
|
await h.initialize()
|
|
const handler = vi.fn((_invocation: CommandInvocation) => ({ kind: 'success' as const, text: 'native outcome' }))
|
|
h.ctx.effect(() => h.ctx.commands.register({
|
|
name, definitionId: CommandDefinitionId(definitionId), description: 'Fixture command', input: { hint: 'Input' }, handler,
|
|
}))
|
|
const sessionId = SessionId('unicode-command')
|
|
const result = await h.server.query({ sessionId, contentBlocks: [{ type: 'text', text: line }] })
|
|
expect(result).toMatchObject({ kind: 'command', execution: { result: { kind: 'success', text: 'native outcome' } } })
|
|
expect(handler).toHaveBeenCalledOnce()
|
|
expect(handler.mock.calls[0]![0].rawInput).toBe(rawInput)
|
|
const events = h.ctx.agents.get(sessionId)!.session.snapshotEvents()
|
|
expect(events.map(event => event.type)).toEqual(['command/run', 'command/done'])
|
|
expect(events[0]?.data).toMatchObject({ name, args: rawInput })
|
|
expect(events[1]?.data).toMatchObject({ kind: 'success', text: 'native outcome' })
|
|
})
|
|
|
|
it('passes arguments unchanged and propagates handler failures', async () => {
|
|
const h = await setup()
|
|
await h.initialize()
|
|
h.ctx.effect(() => h.ctx.commands.register({
|
|
name: 'plan', definitionId: CommandDefinitionId('@deepseek-ai/dsh-plan-mode'), description: 'Plan',
|
|
handler: ({ rawInput }) => { throw new Error(`handler saw:${rawInput}`) },
|
|
}))
|
|
await expect(h.server.handleRequest('session/query', { sessionId: 'commands', contentBlocks: [{ type: 'text', text: '/plan extra' }] }))
|
|
.rejects.toThrow('handler saw: extra')
|
|
expect(h.ctx.agents.get(SessionId('commands'))!.session.snapshotEvents().at(-1)?.type).toBe('command/done')
|
|
})
|
|
|
|
it('rejects a disposed Agent retained by the SDK handle', async () => {
|
|
const h = await setup()
|
|
await h.initialize()
|
|
h.register(() => ({ kind: 'success' }))
|
|
await h.command()
|
|
await h.loop.dispose()
|
|
await expect(h.command()).rejects.toThrow('disposed outside the server')
|
|
})
|
|
|
|
it('cancels an in-flight command on shutdown and rejects later admission', async () => {
|
|
const h = await setup()
|
|
await h.initialize()
|
|
const entered = Promise.withResolvers<undefined>()
|
|
const cancelled = vi.fn()
|
|
h.register(({ signal }) => new Promise((_resolve, reject) => {
|
|
signal.addEventListener('abort', () => { cancelled(); reject(new Error('fixture command aborted')) }, { once: true })
|
|
entered.resolve(undefined)
|
|
}))
|
|
const execution = h.command()
|
|
const rejected = expect(execution).rejects.toThrow('shutting down')
|
|
await entered.promise
|
|
await expect(h.server.getWorkingDirectory({ sessionId: 'commands' })).resolves.toHaveProperty('cwd')
|
|
await h.server.shutdown()
|
|
await rejected
|
|
expect(cancelled).toHaveBeenCalledOnce()
|
|
expect(h.ctx.agents.list()).toEqual([])
|
|
await expect(h.command()).rejects.toThrow('shutting down')
|
|
})
|
|
|
|
it.each(['command', 'prompt'] as const)('does not execute %s while its Session is being created at shutdown', async (entry) => {
|
|
const h = await setup()
|
|
await h.initialize()
|
|
const handler = vi.fn(() => ({ kind: 'success' as const }))
|
|
h.register(handler)
|
|
const created = Promise.withResolvers<undefined>()
|
|
const release = Promise.withResolvers<undefined>()
|
|
onTestFinished(() => { release.resolve(undefined) })
|
|
const create = h.ctx.agents.create.bind(h.ctx.agents)
|
|
vi.spyOn(h.ctx.agents, 'create').mockImplementation(async (request) => {
|
|
const handle = await create(request)
|
|
created.resolve(undefined)
|
|
await release.promise
|
|
return handle
|
|
})
|
|
const execution = h.server.query({ sessionId: SessionId('commands'),
|
|
contentBlocks: [{ type: 'text', text: entry === 'command' ? '/compact' : 'ordinary input' }],
|
|
})
|
|
const rejected = expect(execution).rejects.toThrow('shutting down')
|
|
await created.promise
|
|
const shutdown = h.server.shutdown()
|
|
release.resolve(undefined)
|
|
await Promise.all([rejected, shutdown])
|
|
expect(handler).not.toHaveBeenCalled()
|
|
expect(h.ctx.agents.list()).toEqual([])
|
|
})
|
|
})
|
|
|
|
describe('SDK query dispatch', () => {
|
|
it.each([undefined, {}, { sessionId: '', contentBlocks: [{ type: 'text', text: 'go' }] },
|
|
{ sessionId: 'q', text: 'go' },
|
|
{ sessionId: 'q', contentBlocks: [{ type: 'text', text: 'go' }], delivery: 'queue' },
|
|
{ sessionId: 'q', contentBlocks: [{ type: 'text', text: 'go' }], permission: 'full' },
|
|
{ sessionId: 'q', contentBlocks: [{ type: 'text', text: 'go' }], attachments: [] },
|
|
])('rejects invalid query fields %j', async (params) => {
|
|
const h = await setup()
|
|
await h.initialize()
|
|
await expect(h.server.handleRequest('session/query', params)).rejects.toThrow('session/query requires only')
|
|
expect(h.ctx.agents.list()).toEqual([])
|
|
})
|
|
|
|
it.each(['/compact', '/permission read-only'])('preserves %s without a command registry and requires a live initialized server', async (text) => {
|
|
const h = await setup(false)
|
|
const params = { sessionId: SessionId('q'), contentBlocks: [{ type: 'text' as const, text }] }
|
|
await expect(h.server.query(params)).rejects.toThrow('not initialized')
|
|
const model = new TextModel()
|
|
h.ctx.effect(() => h.ctx.llm.registerAdapter(['query-model'], model))
|
|
await h.server.initialize({ cwd: process.cwd(), provider: 'query-model', model: 'model' })
|
|
await expect(h.server.query(params)).resolves.toMatchObject({ kind: 'prompt' })
|
|
await h.server.wait({ sessionId: 'q' })
|
|
expect(model.requests).toHaveLength(1)
|
|
await h.server.shutdown()
|
|
await expect(h.server.query(params)).rejects.toThrow('shutting down')
|
|
})
|
|
|
|
it('returns the native command result without creating a prompt', async () => {
|
|
const h = await setup()
|
|
await h.initialize()
|
|
h.register(() => ({ kind: 'error', text: 'native refusal' }))
|
|
const response = await h.server.handleRequest('session/query', {
|
|
sessionId: 'q', contentBlocks: [{ type: 'text', text: ' \n/compact\t ' }],
|
|
})
|
|
expect(response).toMatchObject({ kind: 'command', execution: { result: { kind: 'error', text: 'native refusal' } } })
|
|
expect(h.ctx.agents.get(SessionId('q'))!.session.snapshotEvents().map(event => event.type))
|
|
.toEqual(['command/run', 'command/done'])
|
|
})
|
|
|
|
it.each(['ordinary text', '/unknown', ' \n/compact extra\t '])('preserves unclaimed query text %j', async (text) => {
|
|
const h = await setup()
|
|
h.register(() => { throw new Error('Must not execute a command') })
|
|
const model = new TextModel()
|
|
h.ctx.effect(() => h.ctx.llm.registerAdapter(['query-model'], model))
|
|
await h.server.initialize({ cwd: process.cwd(), provider: 'query-model', model: 'model' })
|
|
expect(await h.server.query({ sessionId: SessionId('q'), contentBlocks: [{ type: 'text', text }] })).toMatchObject({ kind: 'prompt' })
|
|
await h.server.wait({ sessionId: 'q' })
|
|
expect(model.requests).toHaveLength(1)
|
|
expect(model.requests[0]!.messages.some(message => message.role === 'user'
|
|
&& message.content.some(block => block.type === 'text' && block.text === text))).toBe(true)
|
|
expect(h.ctx.agents.get(SessionId('q'))!.session.snapshotEvents().some(event => event.type === 'command/run')).toBe(false)
|
|
})
|
|
|
|
it('queues ordinary mixed blocks in their original order', async () => {
|
|
const h = await setup()
|
|
await h.initialize()
|
|
h.register(() => ({ kind: 'success' }))
|
|
await h.command()
|
|
const agent = h.ctx.agents.get(SessionId('commands'))!
|
|
const steer = vi.spyOn(agent, 'steer').mockImplementation(() => {})
|
|
const queued = vi.spyOn(agent, 'followup').mockImplementation(() => {})
|
|
const refs: ImageAttachmentRef[] = [
|
|
{ attachmentId: AttachmentId('first'), mediaType: 'image/png', bytes: 1, width: 1, height: 1 },
|
|
{ attachmentId: AttachmentId('second'), mediaType: 'image/png', bytes: 1, width: 1, height: 1 },
|
|
]
|
|
const saveImages = vi.fn<AttachmentStore['saveImages']>(async () => refs)
|
|
const store: Pick<AttachmentStore, 'saveImages'> = { saveImages }
|
|
h.ctx.provide('attachments', store as AttachmentStore)
|
|
const contentBlocks = [
|
|
{ type: 'text', text: 'Keep the original ordering.' },
|
|
{ type: 'image', data: 'AQ==', mimeType: 'image/png' },
|
|
{ type: 'text', text: '/compact' },
|
|
{ type: 'image', attachment: refs[0] },
|
|
{ type: 'image', data: 'Ag==', mimeType: 'image/png' },
|
|
{ type: 'file', attachment: { attachmentId: 'saved-file', name: 'notes.txt', bytes: 1 } },
|
|
{ type: 'reasoning', text: 'existing content' },
|
|
]
|
|
expect(await h.server.handleRequest('session/query', {
|
|
sessionId: 'commands', contentBlocks,
|
|
})).toMatchObject({ kind: 'prompt' })
|
|
expect(saveImages).toHaveBeenCalledWith([
|
|
{ data: Uint8Array.of(1), mediaType: 'image/png' }, { data: Uint8Array.of(2), mediaType: 'image/png' },
|
|
])
|
|
const admitted = [contentBlocks[0], { type: 'image', attachment: refs[0] }, contentBlocks[2], contentBlocks[3],
|
|
{ type: 'image', attachment: refs[1] }, contentBlocks[5], contentBlocks[6]]
|
|
expect(queued).toHaveBeenCalledWith(expect.objectContaining({ content: admitted }))
|
|
expect(steer).not.toHaveBeenCalled()
|
|
expect(agent.session.snapshotEvents().map(event => event.type)).toEqual(['command/run', 'command/done'])
|
|
})
|
|
|
|
it.each(['/feedback inspect', '/model', '/file', '/export', '/permission', ' \n/权限\t '])('preserves existing prompt behavior for unimplemented input %s', async (text) => {
|
|
const h = await setup()
|
|
const model = new TextModel()
|
|
h.ctx.effect(() => h.ctx.llm.registerAdapter(['query-model'], model))
|
|
await h.server.initialize({ cwd: process.cwd(), provider: 'query-model', model: 'model' })
|
|
const handler = vi.fn(() => ({ kind: 'success' as const }))
|
|
h.ctx.effect(() => h.ctx.commands.register({ name: 'feedback', description: 'Feedback', input: { hint: 'Task' }, handler }))
|
|
h.ctx.effect(() => h.ctx.commands.register({ name: 'permission', definitionId: CommandDefinitionId('@deepseek-ai/dsh-permission-presets'), description: 'Permission', input: { hint: 'Preset' }, handler }))
|
|
await expect(h.server.query({ sessionId: SessionId('q'), contentBlocks: [{ type: 'text', text }] })).resolves.toMatchObject({ kind: 'prompt' })
|
|
await h.server.wait({ sessionId: 'q' })
|
|
expect(handler).not.toHaveBeenCalled()
|
|
expect(model.requests[0]!.messages.some(message => message.content.some(block => block.type === 'text' && block.text === text))).toBe(true)
|
|
expect(h.ctx.agents.get(SessionId('q'))!.session.snapshotEvents().some(event => event.type === 'command/run')).toBe(false)
|
|
})
|
|
})
|