1026 lines
41 KiB
TypeScript
1026 lines
41 KiB
TypeScript
import { createServer } from 'node:http'
|
|
import type { IncomingMessage, Server, ServerResponse } from 'node:http'
|
|
import { mkdtemp, rm } from 'node:fs/promises'
|
|
import { join } from 'node:path'
|
|
import { tmpdir } from 'node:os'
|
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
import { Context } from 'cordis'
|
|
import AgentRegistry, { AgentMessageId, type Agent, type AgentHandle } from '@deepseek-ai/dsh-agent'
|
|
|
|
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
|
|
import * as agentCore from '@deepseek-ai/dsh-agent-spine-demo'
|
|
import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl'
|
|
import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek'
|
|
import SubagentService, { type SubagentResult, type SubagentRunEndInfo } from '@deepseek-ai/dsh-subagent'
|
|
import type { JsonRpcTransportPeer } from '@deepseek-ai/dsh-sdk-protocol'
|
|
import { HarnessSdkServer } from '../src/index.ts'
|
|
|
|
class FakeTransport implements JsonRpcTransportPeer {
|
|
notifications: { method: string; params?: Record<string, unknown> }[] = []
|
|
|
|
async request(method: string, params: object): Promise<unknown> {
|
|
throw new Error(`the SDK server should not call host JSON-RPC method ${method} with ${JSON.stringify(params)}`)
|
|
}
|
|
|
|
notify(method: string, params?: object): void {
|
|
this.notifications.push(params === undefined ? { method } : { method, params: params as Record<string, unknown> })
|
|
}
|
|
}
|
|
|
|
const servers: Server[] = []
|
|
|
|
afterEach(async () => {
|
|
await Promise.all(servers.splice(0).map(server => new Promise(resolve => server.close(resolve))))
|
|
vi.unstubAllEnvs()
|
|
})
|
|
|
|
async function mockCompletionServer(): Promise<{ url: string; requests: unknown[]; headers: IncomingMessage['headers'][] }> {
|
|
const requests: unknown[] = []
|
|
const headers: IncomingMessage['headers'][] = []
|
|
const server = createServer((request: IncomingMessage, response: ServerResponse) => {
|
|
let body = ''
|
|
request.on('data', (chunk: Buffer) => { body += chunk.toString('utf8') })
|
|
request.on('end', () => {
|
|
requests.push(JSON.parse(body))
|
|
headers.push(request.headers)
|
|
response.writeHead(200, { 'content-type': 'text/event-stream' })
|
|
response.write('data: {"choices":[{"delta":{"role":"assistant","content":null,"reasoning_content":""}}]}\n\n')
|
|
response.write('data: {"choices":[{"delta":{"content":"done"}}]}\n\n')
|
|
response.write('data: {"choices":[{"delta":{"content":""},"finish_reason":"stop"}],"usage":{"prompt_tokens":3,"completion_tokens":1}}\n\n')
|
|
response.write('data: [DONE]\n\n')
|
|
response.end()
|
|
})
|
|
})
|
|
servers.push(server)
|
|
await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve))
|
|
const address = server.address()
|
|
if (address === null || typeof address === 'string') throw new Error('no port')
|
|
return { url: `http://127.0.0.1:${address.port}`, requests, headers }
|
|
}
|
|
|
|
async function makeHarness(storageDir: string) {
|
|
const ctx = new Context()
|
|
await ctx.plugin(agentCore, { workspaceContext: false })
|
|
await ctx.plugin(SubagentService)
|
|
await ctx.plugin(SessionPersistenceJsonl, { root: storageDir })
|
|
await new Promise(resolve => setTimeout(resolve, 50))
|
|
return ctx
|
|
}
|
|
|
|
/** Drive the owning service so test lifecycle events carry the real parent scope. */
|
|
async function settleSubagent(
|
|
ctx: Context,
|
|
parent: Agent,
|
|
info: Omit<SubagentRunEndInfo, 'runId' | 'local'> & { localAgent: Agent | undefined },
|
|
beforeSettle?: () => Promise<void>,
|
|
): Promise<void> {
|
|
const result = Promise.withResolvers<SubagentResult>()
|
|
const disposeProvider = ctx.subagents.registerProvider({
|
|
name: info.provider,
|
|
capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false },
|
|
inheritsParentContext: false,
|
|
async start() {
|
|
return {
|
|
id: info.id,
|
|
localAgent: info.localAgent,
|
|
result: result.promise,
|
|
dispose: () => Promise.resolve(),
|
|
}
|
|
},
|
|
})
|
|
try {
|
|
const run = await ctx.subagents.start(info.provider, {
|
|
parent,
|
|
prompt: [],
|
|
signal: new AbortController().signal,
|
|
})
|
|
await beforeSettle?.()
|
|
if (info.lastAssistantMessage === undefined) {
|
|
result.reject(new Error('synthetic infrastructure failure'))
|
|
} else {
|
|
result.resolve({ output: info.lastAssistantMessage, stopReason: info.stopReason })
|
|
}
|
|
await run.result.then(() => undefined, () => undefined)
|
|
await run.dispose()
|
|
} finally {
|
|
disposeProvider()
|
|
}
|
|
}
|
|
|
|
describe('HarnessSdkServer', () => {
|
|
it('creates a harness agent and calls the configured OpenAI-compatible endpoint', async () => {
|
|
const storageDir = await mkdtemp(join(tmpdir(), 'dsh-jsonrpc-'))
|
|
const llmServer = await mockCompletionServer()
|
|
vi.stubEnv('DEEPSEEK_API_KEY', 'test-key')
|
|
vi.stubEnv('DEEPSEEK_BASE_URL', llmServer.url)
|
|
const ctx = await makeHarness(storageDir)
|
|
try {
|
|
const transport = new FakeTransport()
|
|
const server = new HarnessSdkServer(ctx, transport)
|
|
|
|
const init = await server.handleRequest('initialize', {
|
|
cwd: storageDir,
|
|
provider: 'deepseek',
|
|
model: 'dsagent-model',
|
|
}) as { serverInfo: { name: string } }
|
|
expect(init.serverInfo.name).toBe('deepseek-harness-sdk-runtime')
|
|
|
|
await server.handleRequest('session/prompt', {
|
|
sessionId: 'main',
|
|
contentBlocks: [{ type: 'text', text: 'fix it' }],
|
|
})
|
|
|
|
expect(llmServer.requests).toHaveLength(1)
|
|
const body = llmServer.requests[0] as { model: string; messages: { role: string }[] }
|
|
expect(body.model).toBe('dsagent-model')
|
|
expect(body.messages[0]?.role).toBe('system')
|
|
expect(body.messages.at(-1)?.role).toBe('user')
|
|
expect(llmServer.headers[0]?.authorization).toBe('Bearer test-key')
|
|
expect(transport.notifications.some(n => n.method === 'session.event')).toBe(true)
|
|
expect(transport.notifications.at(-1)).toMatchObject({
|
|
method: 'session.finished',
|
|
params: { sessionId: 'main', status: 'ok' },
|
|
})
|
|
|
|
await server.handleRequest('session/prompt', {
|
|
sessionId: 'main',
|
|
contentBlocks: [{ type: 'text', text: 'again' }],
|
|
})
|
|
expect(llmServer.requests).toHaveLength(2)
|
|
|
|
const orphanHandle = await ctx.agents.create({
|
|
sessionId: SessionId('orphan-session'),
|
|
meta: { cwd: storageDir },
|
|
agentOptions: { provider: 'deepseek', model: 'dsagent-model' },
|
|
})
|
|
orphanHandle.agent.followup({ content: [{ type: 'text', text: 'outside the sdk session map' }], source: { kind: 'user' } })
|
|
await orphanHandle.agent.whenIdle()
|
|
await orphanHandle.dispose()
|
|
expect(llmServer.requests).toHaveLength(3)
|
|
|
|
await server.handleRequest('shutdown', undefined)
|
|
} finally {
|
|
await ctx.fiber.dispose()
|
|
await rm(storageDir, { recursive: true, force: true })
|
|
}
|
|
})
|
|
|
|
it('rejects overlapping prompts for one session without serializing other sessions', async () => {
|
|
let releaseMain: (() => void) | undefined
|
|
const firstMainIdle = new Promise<void>((resolve) => { releaseMain = resolve })
|
|
const mainWhenIdle = vi.fn<() => Promise<void>>()
|
|
.mockReturnValueOnce(firstMainIdle)
|
|
.mockResolvedValue(undefined)
|
|
const mainFollowup = vi.fn<Agent['followup']>().mockReturnValue(AgentMessageId('main-followup'))
|
|
const mainAgent = ({
|
|
id: SessionId('main'),
|
|
followup: mainFollowup,
|
|
whenIdle: mainWhenIdle,
|
|
} satisfies Pick<Agent, 'id' | 'followup' | 'whenIdle'>) as unknown as Agent
|
|
const otherFollowup = vi.fn<Agent['followup']>().mockReturnValue(AgentMessageId('other-followup'))
|
|
const otherAgent = ({
|
|
id: SessionId('other'),
|
|
followup: otherFollowup,
|
|
whenIdle: vi.fn(() => Promise.resolve()),
|
|
} satisfies Pick<Agent, 'id' | 'followup' | 'whenIdle'>) as unknown as Agent
|
|
const mainHandle = { agent: mainAgent, dispose: vi.fn(() => Promise.resolve()) }
|
|
const otherHandle = { agent: otherAgent, dispose: vi.fn(() => Promise.resolve()) }
|
|
const create = vi.fn(async (options: { sessionId: SessionId }) =>
|
|
String(options.sessionId) === 'main' ? mainHandle : otherHandle)
|
|
const liveAgents = new Map<string, Agent>([['main', mainAgent], ['other', otherAgent]])
|
|
const ctx = {
|
|
on: vi.fn(() => () => undefined),
|
|
agents: { create, get: (id: SessionId) => liveAgents.get(String(id)) },
|
|
get: () => undefined,
|
|
} as unknown as Context
|
|
const server = new HarnessSdkServer(ctx, new FakeTransport())
|
|
const prompt = (sessionId: string, text: string) => server.prompt({
|
|
sessionId,
|
|
contentBlocks: [{ type: 'text', text }],
|
|
})
|
|
|
|
const first = prompt('main', 'first')
|
|
await vi.waitFor(() => { expect(mainFollowup).toHaveBeenCalledOnce() })
|
|
|
|
await expect(prompt('main', 'overlap')).rejects.toThrow('session already has an active prompt: main')
|
|
await expect(prompt('other', 'independent')).resolves.toEqual({ accepted: true })
|
|
releaseMain?.()
|
|
await expect(first).resolves.toEqual({ accepted: true })
|
|
await expect(prompt('main', 'sequential')).resolves.toEqual({ accepted: true })
|
|
|
|
mainWhenIdle.mockRejectedValueOnce(new Error('turn wait failed'))
|
|
await expect(prompt('main', 'failing')).rejects.toThrow('turn wait failed')
|
|
await expect(prompt('main', 'after failure')).resolves.toEqual({ accepted: true })
|
|
|
|
expect(mainFollowup).toHaveBeenCalledTimes(4)
|
|
expect(otherFollowup).toHaveBeenCalledOnce()
|
|
await server.shutdown()
|
|
expect(mainHandle.dispose).toHaveBeenCalledOnce()
|
|
expect(otherHandle.dispose).toHaveBeenCalledOnce()
|
|
})
|
|
|
|
it('rejects a prompt for a session whose agent was disposed outside the server', async () => {
|
|
const followup = vi.fn<Agent['followup']>().mockReturnValue(AgentMessageId('stub'))
|
|
const agent = ({
|
|
id: SessionId('zombie'),
|
|
followup,
|
|
whenIdle: vi.fn(() => Promise.resolve()),
|
|
} satisfies Pick<Agent, 'id' | 'followup' | 'whenIdle'>) as unknown as Agent
|
|
const handle = { agent, dispose: vi.fn(() => Promise.resolve()) }
|
|
// The registry drops the agent after creation, modelling an agent-loop-only
|
|
// reload that leaves the server's SessionRecord pointing at a detached agent.
|
|
let live = true
|
|
const ctx = {
|
|
on: vi.fn(() => () => undefined),
|
|
agents: {
|
|
create: vi.fn(async () => handle),
|
|
get: (id: SessionId) => (live && String(id) === 'zombie' ? agent : undefined),
|
|
},
|
|
get: () => undefined,
|
|
} as unknown as Context
|
|
const server = new HarnessSdkServer(ctx, new FakeTransport())
|
|
const prompt = (text: string) => server.prompt({
|
|
sessionId: 'zombie',
|
|
contentBlocks: [{ type: 'text', text }],
|
|
})
|
|
|
|
await expect(prompt('while live')).resolves.toEqual({ accepted: true })
|
|
live = false
|
|
await expect(prompt('after detach')).rejects.toThrow('session agent was disposed outside the server: zombie')
|
|
// The detached agent was never driven by the rejected prompt.
|
|
expect(followup).toHaveBeenCalledOnce()
|
|
await server.shutdown()
|
|
})
|
|
|
|
it('reports the message-turn outcome when a later non-message turn settles before idle', async () => {
|
|
const ctx = new Context()
|
|
await ctx.plugin(SessionStore)
|
|
await ctx.plugin(AgentRegistry)
|
|
const transport = new FakeTransport()
|
|
const server = new HarnessSdkServer(ctx, transport) as unknown as {
|
|
prompt(params: { sessionId: string; contentBlocks: { type: 'text'; text: string }[] }): Promise<unknown>
|
|
sessions: Map<string, { handle: AgentHandle; lastTurnEnd: undefined; activePrompt: boolean }>
|
|
shutdown(): Promise<Record<string, never>>
|
|
}
|
|
const session = ctx.sessions.create(SessionId('message-outcome'))
|
|
const agent = ({
|
|
id: SessionId('message-outcome'),
|
|
session,
|
|
followup(input: { content: { type: 'text'; text: string }[]; source: { kind: 'user' } }) {
|
|
session.append('turn/start', {
|
|
turn: 1,
|
|
trigger: { kind: 'message', source: input.source },
|
|
})
|
|
session.append('user/message', input, { surfaceOp: 'append' })
|
|
session.append('turn/end', { turn: 1, reason: { kind: 'max-tokens' } })
|
|
session.append('turn/start', {
|
|
turn: 2,
|
|
trigger: { kind: 'injection', source: { kind: 'plugin', plugin: 'late-metadata' } },
|
|
})
|
|
session.append('user/message', {
|
|
content: [{ type: 'text', text: 'late metadata' }],
|
|
source: { kind: 'plugin', plugin: 'late-metadata' },
|
|
}, { surfaceOp: 'append' })
|
|
session.append('turn/end', { turn: 2, reason: { kind: 'completed' } })
|
|
return AgentMessageId('message-outcome')
|
|
},
|
|
whenIdle: () => Promise.resolve(),
|
|
} satisfies Pick<Agent, 'id' | 'session' | 'followup' | 'whenIdle'>) as unknown as Agent
|
|
ctx.agents.register(agent)
|
|
server.sessions.set('message-outcome', {
|
|
handle: { agent, dispose: () => Promise.resolve() },
|
|
lastTurnEnd: undefined,
|
|
activePrompt: false,
|
|
})
|
|
|
|
await server.prompt({
|
|
sessionId: 'message-outcome',
|
|
contentBlocks: [{ type: 'text', text: 'bounded prompt' }],
|
|
})
|
|
|
|
expect(transport.notifications.findLast(notification => notification.method === 'session.finished'))
|
|
.toEqual({
|
|
method: 'session.finished',
|
|
params: {
|
|
sessionId: 'message-outcome',
|
|
status: 'error',
|
|
reason: { kind: 'max-tokens' },
|
|
},
|
|
})
|
|
await server.shutdown()
|
|
await ctx.fiber.dispose()
|
|
})
|
|
|
|
it('notifies the host when a child session is created with parent lineage', async () => {
|
|
const storageDir = await mkdtemp(join(tmpdir(), 'dsh-jsonrpc-subagent-'))
|
|
const ctx = await makeHarness(storageDir)
|
|
try {
|
|
const transport = new FakeTransport()
|
|
const server = new HarnessSdkServer(ctx, transport)
|
|
|
|
ctx.sessions.create(SessionId('root-session'), {
|
|
meta: { cwd: storageDir },
|
|
})
|
|
ctx.sessions.create(SessionId('child-session'), {
|
|
meta: { cwd: storageDir, parentSession: SessionId('main') },
|
|
})
|
|
|
|
expect(transport.notifications).toContainEqual({
|
|
method: 'subagent.started',
|
|
params: {
|
|
parentSessionId: 'main',
|
|
childSessionId: 'child-session',
|
|
},
|
|
})
|
|
|
|
await server.shutdown()
|
|
} finally {
|
|
await ctx.fiber.dispose()
|
|
await rm(storageDir, { recursive: true, force: true })
|
|
}
|
|
})
|
|
|
|
it('creates an SDK session without an optional system prompt', async () => {
|
|
const storageDir = await mkdtemp(join(tmpdir(), 'dsh-jsonrpc-no-system-'))
|
|
const llmServer = await mockCompletionServer()
|
|
vi.stubEnv('DEEPSEEK_API_KEY', 'test-key')
|
|
vi.stubEnv('DEEPSEEK_BASE_URL', llmServer.url)
|
|
const ctx = await makeHarness(storageDir)
|
|
try {
|
|
const server = new HarnessSdkServer(ctx, new FakeTransport())
|
|
|
|
await server.initialize({ cwd: storageDir, provider: 'deepseek', model: 'plain-model' })
|
|
await server.prompt({
|
|
sessionId: 'plain',
|
|
contentBlocks: [{ type: 'text', text: 'hello' }],
|
|
})
|
|
|
|
expect(llmServer.requests).toHaveLength(1)
|
|
await server.shutdown()
|
|
} finally {
|
|
await ctx.fiber.dispose()
|
|
await rm(storageDir, { recursive: true, force: true })
|
|
}
|
|
})
|
|
|
|
it('notifies the host when a subagent run settles', async () => {
|
|
const storageDir = await mkdtemp(join(tmpdir(), 'dsh-jsonrpc-subagent-end-'))
|
|
const ctx = await makeHarness(storageDir)
|
|
try {
|
|
const transport = new FakeTransport()
|
|
const server = new HarnessSdkServer(ctx, transport)
|
|
|
|
const parentHandle = await ctx.agents.create({
|
|
sessionId: SessionId('main'),
|
|
meta: { cwd: storageDir },
|
|
agentOptions: { provider: 'deepseek', model: 'deepseek' },
|
|
})
|
|
// A custom in-process provider may own its child at the provider/root
|
|
// scope while preserving durable parent lineage.
|
|
const handle = await ctx.agents.create({
|
|
sessionId: SessionId('child-session'),
|
|
meta: { cwd: storageDir, parentSession: SessionId('main') },
|
|
agentOptions: { provider: 'deepseek', model: 'deepseek' },
|
|
})
|
|
expect(ctx.agents.roots()).toContain(handle.agent)
|
|
const parentlessHandle = await parentHandle.agent.ctx.agents.create({
|
|
sessionId: SessionId('parentless-child-session'),
|
|
meta: { cwd: storageDir },
|
|
agentOptions: { model: 'deepseek' },
|
|
})
|
|
await settleSubagent(ctx, parentHandle.agent, {
|
|
provider: 'spawn',
|
|
id: SessionId('child-session'),
|
|
localAgent: handle.agent,
|
|
stopReason: 'completed',
|
|
lastAssistantMessage: [{ type: 'text', text: 'child done' }],
|
|
}, () => handle.dispose())
|
|
await settleSubagent(ctx, parentHandle.agent, {
|
|
provider: 'spawn',
|
|
id: SessionId('parentless-child-session'),
|
|
localAgent: parentlessHandle.agent,
|
|
stopReason: 'error',
|
|
}, () => parentlessHandle.dispose())
|
|
|
|
expect(transport.notifications).toContainEqual({
|
|
method: 'subagent.finished',
|
|
params: {
|
|
provider: 'spawn',
|
|
agentId: 'child-session',
|
|
parentSessionId: 'main',
|
|
childSessionId: 'child-session',
|
|
status: 'ok',
|
|
stopReason: 'completed',
|
|
lastAssistantMessage: [{ type: 'text', text: 'child done' }],
|
|
},
|
|
})
|
|
expect(transport.notifications).toContainEqual({
|
|
method: 'subagent.finished',
|
|
params: {
|
|
provider: 'spawn',
|
|
agentId: 'parentless-child-session',
|
|
parentSessionId: 'main',
|
|
childSessionId: 'parentless-child-session',
|
|
status: 'error',
|
|
stopReason: 'error',
|
|
},
|
|
})
|
|
|
|
await parentHandle.dispose()
|
|
await server.shutdown()
|
|
} finally {
|
|
await ctx.fiber.dispose()
|
|
await rm(storageDir, { recursive: true, force: true })
|
|
}
|
|
})
|
|
|
|
it('ignores a remote run id that collides with a local child of the same parent', async () => {
|
|
const storageDir = await mkdtemp(join(tmpdir(), 'dsh-jsonrpc-subagent-remote-collision-'))
|
|
const ctx = await makeHarness(storageDir)
|
|
try {
|
|
const transport = new FakeTransport()
|
|
const server = new HarnessSdkServer(ctx, transport)
|
|
const parentHandle = await ctx.agents.create({
|
|
sessionId: SessionId('collision-parent'),
|
|
meta: { cwd: storageDir },
|
|
agentOptions: { model: 'deepseek' },
|
|
})
|
|
const collidingChild = await parentHandle.agent.ctx.agents.create({
|
|
sessionId: SessionId('remote-run-id'),
|
|
meta: { cwd: storageDir, parentSession: SessionId('collision-parent') },
|
|
agentOptions: { model: 'deepseek' },
|
|
})
|
|
|
|
await settleSubagent(ctx, parentHandle.agent, {
|
|
provider: 'remote',
|
|
id: SessionId('remote-run-id'),
|
|
localAgent: undefined,
|
|
stopReason: 'completed',
|
|
lastAssistantMessage: [],
|
|
})
|
|
|
|
expect(transport.notifications.some(notification =>
|
|
notification.method === 'subagent.finished'
|
|
&& notification.params?.agentId === 'remote-run-id',
|
|
)).toBe(false)
|
|
|
|
await collidingChild.dispose()
|
|
await parentHandle.dispose()
|
|
await server.shutdown()
|
|
} finally {
|
|
await ctx.fiber.dispose()
|
|
await rm(storageDir, { recursive: true, force: true })
|
|
}
|
|
})
|
|
|
|
it('retains locality across continuation runs on one live child', async () => {
|
|
const storageDir = await mkdtemp(join(tmpdir(), 'dsh-jsonrpc-subagent-continuation-'))
|
|
const ctx = await makeHarness(storageDir)
|
|
try {
|
|
const transport = new FakeTransport()
|
|
const server = new HarnessSdkServer(ctx, transport)
|
|
const parentHandle = await ctx.agents.create({
|
|
sessionId: SessionId('continuation-parent'),
|
|
meta: { cwd: storageDir },
|
|
agentOptions: { model: 'deepseek' },
|
|
})
|
|
const childHandle = await parentHandle.agent.ctx.agents.create({
|
|
sessionId: SessionId('continuation-child'),
|
|
meta: { cwd: storageDir, parentSession: SessionId('continuation-parent') },
|
|
agentOptions: { model: 'deepseek' },
|
|
})
|
|
|
|
await settleSubagent(ctx, parentHandle.agent, {
|
|
provider: 'continuation',
|
|
id: SessionId('continuation-child'),
|
|
localAgent: childHandle.agent,
|
|
stopReason: 'completed',
|
|
lastAssistantMessage: [{ type: 'text', text: 'first' }],
|
|
})
|
|
await settleSubagent(ctx, parentHandle.agent, {
|
|
provider: 'continuation',
|
|
id: SessionId('continuation-child'),
|
|
localAgent: childHandle.agent,
|
|
stopReason: 'completed',
|
|
lastAssistantMessage: [{ type: 'text', text: 'second' }],
|
|
}, () => childHandle.dispose())
|
|
|
|
expect(transport.notifications.filter(notification =>
|
|
notification.method === 'subagent.finished'
|
|
&& notification.params?.childSessionId === 'continuation-child',
|
|
)).toHaveLength(2)
|
|
|
|
await parentHandle.dispose()
|
|
await server.shutdown()
|
|
} finally {
|
|
await ctx.fiber.dispose()
|
|
await rm(storageDir, { recursive: true, force: true })
|
|
}
|
|
})
|
|
|
|
it('correlates reused local ids by parent scope when runs settle out of order', async () => {
|
|
const storageDir = await mkdtemp(join(tmpdir(), 'dsh-jsonrpc-subagent-reuse-'))
|
|
const ctx = await makeHarness(storageDir)
|
|
try {
|
|
const transport = new FakeTransport()
|
|
const server = new HarnessSdkServer(ctx, transport)
|
|
const oldParent = await ctx.agents.create({
|
|
sessionId: SessionId('old-parent'),
|
|
meta: { cwd: storageDir },
|
|
agentOptions: { model: 'deepseek' },
|
|
})
|
|
const oldChild = await oldParent.agent.ctx.agents.create({
|
|
sessionId: SessionId('reused-child'),
|
|
meta: { cwd: storageDir, parentSession: SessionId('old-parent') },
|
|
agentOptions: { model: 'deepseek' },
|
|
})
|
|
const first = Promise.withResolvers<SubagentResult>()
|
|
const sameLifetime = Promise.withResolvers<SubagentResult>()
|
|
const replacement = Promise.withResolvers<SubagentResult>()
|
|
const results = [first.promise, sameLifetime.promise, replacement.promise]
|
|
let starts = 0
|
|
let currentLocalAgent = oldChild.agent
|
|
const disposeProvider = ctx.subagents.registerProvider({
|
|
name: 'reused',
|
|
capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false },
|
|
inheritsParentContext: false,
|
|
start() {
|
|
const result = results[starts]
|
|
starts += 1
|
|
if (result === undefined) throw new Error('unexpected fourth reused-id run')
|
|
return Promise.resolve({ id: SessionId('reused-child'), localAgent: currentLocalAgent, result, dispose: () => Promise.resolve() })
|
|
},
|
|
})
|
|
|
|
const firstRun = await ctx.subagents.start('reused', {
|
|
parent: oldParent.agent,
|
|
prompt: [],
|
|
signal: new AbortController().signal,
|
|
})
|
|
const sameLifetimeRun = await ctx.subagents.start('reused', {
|
|
parent: oldParent.agent,
|
|
prompt: [],
|
|
signal: new AbortController().signal,
|
|
})
|
|
sameLifetime.resolve({ output: [{ type: 'text', text: 'same lifetime' }], stopReason: 'completed' })
|
|
await sameLifetimeRun.result
|
|
await oldChild.dispose()
|
|
const newParent = await ctx.agents.create({
|
|
sessionId: SessionId('new-parent'),
|
|
meta: { cwd: storageDir },
|
|
agentOptions: { model: 'deepseek' },
|
|
})
|
|
const newChild = await newParent.agent.ctx.agents.create({
|
|
sessionId: SessionId('reused-child'),
|
|
meta: { cwd: storageDir, parentSession: SessionId('new-parent') },
|
|
agentOptions: { model: 'deepseek' },
|
|
})
|
|
currentLocalAgent = newChild.agent
|
|
const secondRun = await ctx.subagents.start('reused', {
|
|
parent: newParent.agent,
|
|
prompt: [],
|
|
signal: new AbortController().signal,
|
|
})
|
|
|
|
replacement.resolve({ output: [{ type: 'text', text: 'new lifetime' }], stopReason: 'completed' })
|
|
await secondRun.result
|
|
first.resolve({ output: [{ type: 'text', text: 'old lifetime' }], stopReason: 'completed' })
|
|
await firstRun.result
|
|
await Promise.resolve()
|
|
|
|
const finished = transport.notifications.filter(notification =>
|
|
notification.method === 'subagent.finished'
|
|
&& notification.params?.childSessionId === 'reused-child',
|
|
)
|
|
expect(finished.map(notification => notification.params?.lastAssistantMessage)).toEqual([
|
|
[{ type: 'text', text: 'same lifetime' }],
|
|
[{ type: 'text', text: 'new lifetime' }],
|
|
[{ type: 'text', text: 'old lifetime' }],
|
|
])
|
|
expect(finished.map(notification => notification.params?.parentSessionId)).toEqual([
|
|
'old-parent',
|
|
'new-parent',
|
|
'old-parent',
|
|
])
|
|
|
|
await firstRun.dispose()
|
|
await sameLifetimeRun.dispose()
|
|
await secondRun.dispose()
|
|
disposeProvider()
|
|
await newChild.dispose()
|
|
await oldParent.dispose()
|
|
await newParent.dispose()
|
|
await server.shutdown()
|
|
} finally {
|
|
await ctx.fiber.dispose()
|
|
await rm(storageDir, { recursive: true, force: true })
|
|
}
|
|
})
|
|
|
|
it('keeps locality bound to the accepted run across provider re-registration', async () => {
|
|
const storageDir = await mkdtemp(join(tmpdir(), 'dsh-jsonrpc-subagent-provider-reuse-'))
|
|
const ctx = await makeHarness(storageDir)
|
|
try {
|
|
const transport = new FakeTransport()
|
|
const server = new HarnessSdkServer(ctx, transport)
|
|
const parent = await ctx.agents.create({
|
|
sessionId: SessionId('provider-reuse-parent'),
|
|
meta: { cwd: storageDir },
|
|
agentOptions: { model: 'deepseek' },
|
|
})
|
|
const child = await parent.agent.ctx.agents.create({
|
|
sessionId: SessionId('provider-reuse-child'),
|
|
meta: { cwd: storageDir, parentSession: SessionId('provider-reuse-parent') },
|
|
agentOptions: { model: 'deepseek' },
|
|
})
|
|
const localResult = Promise.withResolvers<SubagentResult>()
|
|
const remoteResult = Promise.withResolvers<SubagentResult>()
|
|
const unregisterLocal = ctx.subagents.registerProvider({
|
|
name: 'reused-provider',
|
|
capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false },
|
|
inheritsParentContext: false,
|
|
start: () => Promise.resolve({
|
|
id: SessionId('provider-reuse-child'),
|
|
localAgent: child.agent,
|
|
result: localResult.promise,
|
|
dispose: () => Promise.resolve(),
|
|
}),
|
|
})
|
|
const localRun = await ctx.subagents.start('reused-provider', {
|
|
parent: parent.agent,
|
|
prompt: [],
|
|
signal: new AbortController().signal,
|
|
})
|
|
unregisterLocal()
|
|
|
|
const unregisterRemote = ctx.subagents.registerProvider({
|
|
name: 'reused-provider',
|
|
capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false },
|
|
inheritsParentContext: false,
|
|
start: () => Promise.resolve({
|
|
id: SessionId('provider-reuse-child'),
|
|
localAgent: undefined,
|
|
result: remoteResult.promise,
|
|
dispose: () => Promise.resolve(),
|
|
}),
|
|
})
|
|
const remoteRun = await ctx.subagents.start('reused-provider', {
|
|
parent: parent.agent,
|
|
prompt: [],
|
|
signal: new AbortController().signal,
|
|
})
|
|
|
|
remoteResult.resolve({ output: [{ type: 'text', text: 'remote' }], stopReason: 'completed' })
|
|
await remoteRun.result
|
|
await Promise.resolve()
|
|
expect(transport.notifications.some(notification =>
|
|
notification.method === 'subagent.finished'
|
|
&& notification.params?.lastAssistantMessage !== undefined,
|
|
)).toBe(false)
|
|
|
|
await child.dispose()
|
|
localResult.resolve({ output: [{ type: 'text', text: 'local' }], stopReason: 'completed' })
|
|
await localRun.result
|
|
await Promise.resolve()
|
|
expect(transport.notifications.filter(notification =>
|
|
notification.method === 'subagent.finished'
|
|
&& notification.params?.childSessionId === 'provider-reuse-child',
|
|
)).toEqual([{
|
|
method: 'subagent.finished',
|
|
params: {
|
|
provider: 'reused-provider',
|
|
agentId: 'provider-reuse-child',
|
|
parentSessionId: 'provider-reuse-parent',
|
|
childSessionId: 'provider-reuse-child',
|
|
status: 'ok',
|
|
stopReason: 'completed',
|
|
lastAssistantMessage: [{ type: 'text', text: 'local' }],
|
|
},
|
|
}])
|
|
|
|
await localRun.dispose()
|
|
await remoteRun.dispose()
|
|
unregisterRemote()
|
|
await parent.dispose()
|
|
await server.shutdown()
|
|
} finally {
|
|
await ctx.fiber.dispose()
|
|
await rm(storageDir, { recursive: true, force: true })
|
|
}
|
|
})
|
|
|
|
it('uses explicit local provenance when start was missed and ignores remote runs', async () => {
|
|
const storageDir = await mkdtemp(join(tmpdir(), 'dsh-jsonrpc-subagent-fallback-'))
|
|
const ctx = await makeHarness(storageDir)
|
|
let parentHandle: AgentHandle | undefined
|
|
let handle: AgentHandle | undefined
|
|
let failedHandle: AgentHandle | undefined
|
|
try {
|
|
parentHandle = await ctx.agents.create({
|
|
sessionId: SessionId('fallback-parent'),
|
|
meta: { cwd: storageDir },
|
|
agentOptions: { provider: 'deepseek', model: 'deepseek' },
|
|
})
|
|
handle = await parentHandle.agent.ctx.agents.create({
|
|
sessionId: SessionId('fallback-child-session'),
|
|
meta: { cwd: storageDir, parentSession: SessionId('fallback-parent') },
|
|
agentOptions: { provider: 'deepseek', model: 'deepseek' },
|
|
})
|
|
const fallbackChild = handle.agent
|
|
failedHandle = await parentHandle.agent.ctx.agents.create({
|
|
sessionId: SessionId('failed-child-session'),
|
|
meta: { cwd: storageDir },
|
|
agentOptions: { provider: 'deepseek', model: 'deepseek' },
|
|
})
|
|
const missedStartResult = Promise.withResolvers<SubagentResult>()
|
|
const disposeMissedStartProvider = ctx.subagents.registerProvider({
|
|
name: 'fork',
|
|
capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false },
|
|
inheritsParentContext: true,
|
|
start: () => Promise.resolve({
|
|
id: SessionId('fallback-child-session'),
|
|
localAgent: fallbackChild,
|
|
result: missedStartResult.promise,
|
|
dispose: () => Promise.resolve(),
|
|
}),
|
|
})
|
|
// Start before the server subscribes. The terminal payload still carries
|
|
// this run's exact local child without reconstructing it from ids.
|
|
const missedStartRun = await ctx.subagents.start('fork', {
|
|
parent: parentHandle.agent,
|
|
prompt: [],
|
|
signal: new AbortController().signal,
|
|
})
|
|
const transport = new FakeTransport()
|
|
const server = new HarnessSdkServer(ctx, transport, { maxTokensAsSuccess: true })
|
|
|
|
missedStartResult.resolve({ output: [], stopReason: 'max-tokens' })
|
|
await missedStartRun.result
|
|
await Promise.resolve()
|
|
await missedStartRun.dispose()
|
|
disposeMissedStartProvider()
|
|
// The server also missed this agent's creation but sees the exact child
|
|
// on the run lifecycle payload.
|
|
await settleSubagent(ctx, parentHandle.agent, {
|
|
provider: 'fork-live-fallback',
|
|
id: SessionId('fallback-child-session'),
|
|
localAgent: fallbackChild,
|
|
stopReason: 'completed',
|
|
lastAssistantMessage: [],
|
|
})
|
|
await settleSubagent(ctx, parentHandle.agent, {
|
|
provider: 'fork',
|
|
id: SessionId('failed-child-session'),
|
|
localAgent: failedHandle.agent,
|
|
stopReason: 'error',
|
|
})
|
|
await settleSubagent(ctx, parentHandle.agent, {
|
|
provider: 'fork',
|
|
id: SessionId('missing-child-agent'),
|
|
localAgent: undefined,
|
|
stopReason: 'error',
|
|
})
|
|
|
|
expect(transport.notifications).toContainEqual({
|
|
method: 'subagent.finished',
|
|
params: {
|
|
provider: 'fork',
|
|
agentId: 'fallback-child-session',
|
|
parentSessionId: 'fallback-parent',
|
|
childSessionId: 'fallback-child-session',
|
|
status: 'ok',
|
|
stopReason: 'max-tokens',
|
|
lastAssistantMessage: [],
|
|
},
|
|
})
|
|
expect(transport.notifications).toContainEqual({
|
|
method: 'subagent.finished',
|
|
params: {
|
|
provider: 'fork',
|
|
agentId: 'failed-child-session',
|
|
parentSessionId: 'fallback-parent',
|
|
childSessionId: 'failed-child-session',
|
|
status: 'error',
|
|
stopReason: 'error',
|
|
},
|
|
})
|
|
expect(transport.notifications.some(n =>
|
|
n.method === 'subagent.finished'
|
|
&& n.params?.agentId === 'missing-child-agent',
|
|
)).toBe(false)
|
|
|
|
await server.shutdown()
|
|
} finally {
|
|
await handle?.dispose()
|
|
await failedHandle?.dispose()
|
|
await parentHandle?.dispose()
|
|
await ctx.fiber.dispose()
|
|
await rm(storageDir, { recursive: true, force: true })
|
|
}
|
|
})
|
|
|
|
it('does not re-register an LLM adapter whose provider already has an owner', async () => {
|
|
const storageDir = await mkdtemp(join(tmpdir(), 'dsh-jsonrpc-existing-llm-'))
|
|
const ctx = await makeHarness(storageDir)
|
|
vi.stubEnv('DEEPSEEK_API_KEY', 'test-key')
|
|
await ctx.plugin(LlmDeepSeek)
|
|
try {
|
|
const server = new HarnessSdkServer(ctx, new FakeTransport())
|
|
const inspect = server as unknown as { hasAdapterFor(provider: string): boolean }
|
|
|
|
expect(inspect.hasAdapterFor('deepseek')).toBe(true)
|
|
expect(inspect.hasAdapterFor('missing-provider')).toBe(false)
|
|
await server.initialize({ cwd: storageDir, provider: 'deepseek', model: 'preinstalled-model' })
|
|
|
|
expect(ctx.get('llm')?.listProviders().filter(provider => provider.id === 'deepseek')).toEqual([{ id: 'deepseek', name: 'DeepSeek' }])
|
|
await server.shutdown()
|
|
} finally {
|
|
await ctx.fiber.dispose()
|
|
await rm(storageDir, { recursive: true, force: true })
|
|
}
|
|
})
|
|
|
|
it('rejects a missing non-DeepSeek provider when an LLM service already exists', async () => {
|
|
const storageDir = await mkdtemp(join(tmpdir(), 'dsh-jsonrpc-new-llm-'))
|
|
const ctx = await makeHarness(storageDir)
|
|
vi.stubEnv('DEEPSEEK_API_KEY', 'test-key')
|
|
await ctx.plugin(LlmDeepSeek)
|
|
try {
|
|
const server = new HarnessSdkServer(ctx, new FakeTransport())
|
|
|
|
await expect(server.initialize({ cwd: storageDir, provider: 'private', model: 'new-model' }))
|
|
.rejects.toThrow('no adapter registered for provider "private"')
|
|
|
|
expect(ctx.get('llm')?.listProviders()).toEqual([{ id: 'deepseek', name: 'DeepSeek' }])
|
|
await server.shutdown()
|
|
} finally {
|
|
await ctx.fiber.dispose()
|
|
await rm(storageDir, { recursive: true, force: true })
|
|
}
|
|
})
|
|
|
|
it('classifies defensive finish states', async () => {
|
|
const storageDir = await mkdtemp(join(tmpdir(), 'dsh-jsonrpc-finish-states-'))
|
|
const ctx = await makeHarness(storageDir)
|
|
try {
|
|
const server = new HarnessSdkServer(ctx, new FakeTransport()) as unknown as {
|
|
finishedStatus(reason: unknown): 'ok' | 'error'
|
|
shutdown(): Promise<Record<string, never>>
|
|
}
|
|
|
|
expect(server.finishedStatus(undefined)).toBe('error')
|
|
expect(server.finishedStatus({ kind: 'max-tokens' })).toBe('error')
|
|
expect(server.finishedStatus({ kind: 'error' })).toBe('error')
|
|
await server.shutdown()
|
|
} finally {
|
|
await ctx.fiber.dispose()
|
|
await rm(storageDir, { recursive: true, force: true })
|
|
}
|
|
})
|
|
|
|
it('can report max-token turn termination as an accepted evaluation result', async () => {
|
|
const storageDir = await mkdtemp(join(tmpdir(), 'dsh-jsonrpc-max-tokens-success-'))
|
|
const ctx = await makeHarness(storageDir)
|
|
try {
|
|
const server = new HarnessSdkServer(ctx, new FakeTransport(), { maxTokensAsSuccess: true }) as unknown as {
|
|
finishedStatus(reason: unknown): 'ok' | 'error'
|
|
shutdown(): Promise<Record<string, never>>
|
|
}
|
|
|
|
expect(server.finishedStatus({ kind: 'max-tokens' })).toBe('ok')
|
|
expect(server.finishedStatus({ kind: 'error' })).toBe('error')
|
|
await server.shutdown()
|
|
} finally {
|
|
await ctx.fiber.dispose()
|
|
await rm(storageDir, { recursive: true, force: true })
|
|
}
|
|
})
|
|
|
|
it('reports no adapter when the LLM service is absent', async () => {
|
|
const ctx = new Context()
|
|
try {
|
|
const server = new HarnessSdkServer(ctx, new FakeTransport()) as unknown as {
|
|
hasAdapterFor(model: string): boolean
|
|
shutdown(): Promise<Record<string, never>>
|
|
}
|
|
|
|
expect(server.hasAdapterFor('missing-model')).toBe(false)
|
|
await server.shutdown()
|
|
} finally {
|
|
await ctx.fiber.dispose()
|
|
}
|
|
})
|
|
|
|
it('rejects unknown JSON-RPC runtime methods', async () => {
|
|
const storageDir = await mkdtemp(join(tmpdir(), 'dsh-jsonrpc-unknown-'))
|
|
const ctx = await makeHarness(storageDir)
|
|
try {
|
|
const server = new HarnessSdkServer(ctx, new FakeTransport())
|
|
|
|
await expect(server.handleRequest('does/not/exist', {}))
|
|
.rejects
|
|
.toThrow('unknown DeepSeek Harness SDK runtime method: does/not/exist')
|
|
|
|
await server.shutdown()
|
|
} finally {
|
|
await ctx.fiber.dispose()
|
|
await rm(storageDir, { recursive: true, force: true })
|
|
}
|
|
})
|
|
|
|
it('coalesces concurrent session creation and retries a failed creation', async () => {
|
|
let resolveShared: ((handle: AgentHandle) => void) | undefined
|
|
const sharedCreation = new Promise<AgentHandle>((resolve) => { resolveShared = resolve })
|
|
const sharedHandle = { agent: {} as Agent, dispose: vi.fn(() => Promise.resolve()) }
|
|
const retryHandle = { agent: {} as Agent, dispose: vi.fn(() => Promise.resolve()) }
|
|
const create = vi.fn<(options: unknown) => Promise<AgentHandle>>()
|
|
.mockReturnValueOnce(sharedCreation)
|
|
.mockRejectedValueOnce(new Error('creation failed'))
|
|
.mockResolvedValueOnce(retryHandle)
|
|
const ctx = {
|
|
on: vi.fn(() => () => undefined),
|
|
agents: { create, get: () => undefined },
|
|
get: () => undefined,
|
|
} as unknown as Context
|
|
const server = new HarnessSdkServer(ctx, new FakeTransport()) as unknown as {
|
|
getOrCreateSession(sessionId: string): Promise<{ handle: AgentHandle }>
|
|
shutdown(): Promise<Record<string, never>>
|
|
}
|
|
|
|
const first = server.getOrCreateSession('shared')
|
|
const second = server.getOrCreateSession('shared')
|
|
expect(create).toHaveBeenCalledTimes(1)
|
|
resolveShared?.(sharedHandle)
|
|
const [firstRecord, secondRecord] = await Promise.all([first, second])
|
|
expect(firstRecord).toBe(secondRecord)
|
|
|
|
await expect(server.getOrCreateSession('retry')).rejects.toThrow('creation failed')
|
|
await expect(server.getOrCreateSession('retry')).resolves.toMatchObject({ handle: retryHandle })
|
|
expect(create).toHaveBeenCalledTimes(3)
|
|
|
|
await server.shutdown()
|
|
expect(sharedHandle.dispose).toHaveBeenCalledOnce()
|
|
expect(retryHandle.dispose).toHaveBeenCalledOnce()
|
|
await expect(server.getOrCreateSession('after-shutdown')).rejects.toThrow('SDK server is shutting down')
|
|
})
|
|
|
|
it('resolves a relative cwd before creating the session', async () => {
|
|
const create = vi.fn<(options: unknown) => Promise<AgentHandle>>()
|
|
.mockResolvedValue({ agent: {} as Agent, dispose: () => Promise.resolve() })
|
|
const ctx = {
|
|
on: vi.fn(() => () => undefined),
|
|
agents: { create, get: () => undefined },
|
|
get: () => ({ listProviders: () => [{ id: 'mock', name: 'Mock' }] }),
|
|
} as unknown as Context
|
|
const server = new HarnessSdkServer(ctx, new FakeTransport()) as unknown as {
|
|
initialize(params: { cwd: string; provider: string; model: string }): Promise<unknown>
|
|
getOrCreateSession(sessionId: string): Promise<unknown>
|
|
shutdown(): Promise<Record<string, never>>
|
|
}
|
|
|
|
await server.initialize({ cwd: '.', provider: 'mock', model: 'model' })
|
|
await server.getOrCreateSession('relative')
|
|
|
|
expect(create).toHaveBeenCalledWith(expect.objectContaining({ meta: { cwd: process.cwd() } }))
|
|
await server.shutdown()
|
|
})
|
|
|
|
it('settles every teardown and aggregates multiple failures', async () => {
|
|
const firstDispose = vi.fn(() => { throw new Error('first teardown failed') })
|
|
const secondDispose = vi.fn(() => Promise.reject(new Error('second teardown failed')))
|
|
const ctx = {
|
|
on: vi.fn(() => () => undefined),
|
|
agents: { create: vi.fn(), get: () => undefined },
|
|
get: () => undefined,
|
|
} as unknown as Context
|
|
const server = new HarnessSdkServer(ctx, new FakeTransport()) as unknown as {
|
|
sessions: Map<string, { handle: AgentHandle; lastTurnEnd: undefined; activePrompt: boolean }>
|
|
shutdown(): Promise<Record<string, never>>
|
|
}
|
|
server.sessions.set('first', { handle: { agent: {} as Agent, dispose: firstDispose }, lastTurnEnd: undefined, activePrompt: false })
|
|
server.sessions.set('second', { handle: { agent: {} as Agent, dispose: secondDispose }, lastTurnEnd: undefined, activePrompt: false })
|
|
|
|
await expect(server.shutdown()).rejects.toThrow('SDK server teardown failed')
|
|
expect(firstDispose).toHaveBeenCalledOnce()
|
|
expect(secondDispose).toHaveBeenCalledOnce()
|
|
})
|
|
|
|
it('continues teardown after a subscription disposer fails', async () => {
|
|
let subscription = 0
|
|
const listenerFailure = new Error('listener teardown failed')
|
|
const on = vi.fn(() => {
|
|
subscription += 1
|
|
return subscription === 1 ? () => { throw listenerFailure } : () => undefined
|
|
})
|
|
const ctx = {
|
|
on,
|
|
agents: { create: vi.fn(), get: () => undefined },
|
|
get: () => undefined,
|
|
} as unknown as Context
|
|
const server = new HarnessSdkServer(ctx, new FakeTransport())
|
|
|
|
await expect(server.shutdown()).rejects.toBe(listenerFailure)
|
|
expect(on).toHaveBeenCalledTimes(3)
|
|
})
|
|
})
|