2026-07-11 14:08:12 +08:00
|
|
|
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'
|
2026-07-24 15:39:04 +08:00
|
|
|
import { AgentMessageId, type Agent, type AgentHandle } from '@deepseek-ai/dsh-agent'
|
2026-07-14 01:59:21 +08:00
|
|
|
|
2026-07-21 15:49:37 +08:00
|
|
|
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
|
2026-07-15 15:57:57 +08:00
|
|
|
import * as agentCore from '@deepseek-ai/dsh-agent-spine-demo'
|
2026-07-11 14:08:12 +08:00
|
|
|
import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl'
|
|
|
|
|
import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek'
|
2026-07-14 08:11:13 +08:00
|
|
|
import SubagentService, { type SubagentResult, type SubagentRunEndInfo } from '@deepseek-ai/dsh-subagent'
|
2026-07-11 14:08:12 +08:00
|
|
|
import { HarnessSdkServer, type JsonRpcTransportPeer } from '../src/index.ts'
|
|
|
|
|
|
|
|
|
|
class FakeTransport implements JsonRpcTransportPeer {
|
|
|
|
|
notifications: { method: string; params?: Record<string, unknown> }[] = []
|
|
|
|
|
|
|
|
|
|
async request(method: string, params: Record<string, unknown>): Promise<unknown> {
|
|
|
|
|
throw new Error(`the SDK server should not call host JSON-RPC method ${method} with ${JSON.stringify(params)}`)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
notify(method: string, params?: Record<string, unknown>): void {
|
|
|
|
|
this.notifications.push(params === undefined ? { method } : { method, params })
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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()
|
2026-07-14 19:50:25 +08:00
|
|
|
await ctx.plugin(agentCore, { workspaceContext: false })
|
2026-07-13 15:57:12 +08:00
|
|
|
await ctx.plugin(SubagentService)
|
2026-07-11 14:08:12 +08:00
|
|
|
await ctx.plugin(SessionPersistenceJsonl, { root: storageDir })
|
|
|
|
|
await new Promise(resolve => setTimeout(resolve, 50))
|
|
|
|
|
return ctx
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-13 15:57:12 +08:00
|
|
|
/** Drive the owning service so test lifecycle events carry the real parent scope. */
|
2026-07-14 08:11:13 +08:00
|
|
|
async function settleSubagent(
|
|
|
|
|
ctx: Context,
|
|
|
|
|
parent: Agent,
|
2026-07-18 14:26:14 +08:00
|
|
|
info: Omit<SubagentRunEndInfo, 'runId' | 'local'> & { localAgent: Agent | undefined },
|
2026-07-14 08:11:13 +08:00
|
|
|
beforeSettle?: () => Promise<void>,
|
|
|
|
|
): Promise<void> {
|
|
|
|
|
const result = Promise.withResolvers<SubagentResult>()
|
2026-07-13 15:57:12 +08:00
|
|
|
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,
|
2026-07-18 14:26:14 +08:00
|
|
|
localAgent: info.localAgent,
|
2026-07-14 08:11:13 +08:00
|
|
|
result: result.promise,
|
2026-07-13 15:57:12 +08:00
|
|
|
dispose: () => Promise.resolve(),
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
try {
|
|
|
|
|
const run = await ctx.subagents.start(info.provider, {
|
|
|
|
|
parent,
|
|
|
|
|
prompt: [],
|
|
|
|
|
signal: new AbortController().signal,
|
|
|
|
|
})
|
2026-07-14 08:11:13 +08:00
|
|
|
await beforeSettle?.()
|
|
|
|
|
if (info.lastAssistantMessage === undefined) {
|
|
|
|
|
result.reject(new Error('synthetic infrastructure failure'))
|
|
|
|
|
} else {
|
|
|
|
|
result.resolve({ output: info.lastAssistantMessage, stopReason: info.stopReason })
|
|
|
|
|
}
|
2026-07-13 15:57:12 +08:00
|
|
|
await run.result.then(() => undefined, () => undefined)
|
|
|
|
|
await run.dispose()
|
|
|
|
|
} finally {
|
|
|
|
|
disposeProvider()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-11 14:08:12 +08:00
|
|
|
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,
|
2026-07-14 21:57:52 +08:00
|
|
|
provider: 'deepseek',
|
2026-07-11 14:08:12 +08:00
|
|
|
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)
|
|
|
|
|
|
2026-07-13 15:57:12 +08:00
|
|
|
const orphanHandle = await ctx.agents.create({
|
2026-07-11 14:08:12 +08:00
|
|
|
sessionId: SessionId('orphan-session'),
|
|
|
|
|
meta: { cwd: storageDir },
|
2026-07-14 21:57:52 +08:00
|
|
|
agentOptions: { provider: 'deepseek', model: 'dsagent-model' },
|
2026-07-11 14:08:12 +08:00
|
|
|
})
|
2026-07-24 15:08:36 +08:00
|
|
|
orphanHandle.agent.followup([{ type: 'text', text: 'outside the sdk session map' }])
|
2026-07-11 14:08:12 +08:00
|
|
|
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 })
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
fix(sdk): harden runtime lifecycle and JSON-RPC
Keep DeepSeekHarness.run() reusable, but make ownership of its lazy
runtime process explicit. Document the context-manager/close contract and
update every construction example to use a context manager so repeated runs
remain valid without encouraging leaked subprocesses.
Contain notification predicate failures at the subscription boundary. Remove
only the subscriber whose callback raised, deliver that exception through its
queue, and continue dispatching to healthy subscribers so arbitrary callback
code cannot terminate the shared reader thread or strand later requests.
Enforce one in-flight prompt per server session with an atomic activePrompt
guard. Route overlap through the existing -32603 handler-error response and
clear the guard in finally, preserving parallel prompts across sessions and
sequential reuse without changing JSON-RPC request or notification shapes.
Use StringDecoder for line framing so a UTF-8 code point split across Buffer
chunks is not corrupted. Add a queued-write flush barrier, and make memoized
shutdown await it before disposal and exit while retaining exactly-once
cleanup when shutdown calls race or flushing fails.
Cover callback isolation, same-session exclusion, cross-session concurrency,
split multibyte input, delayed writes, racing shutdown, and flush failure with
deterministic tests.
2026-07-13 20:53:20 +08:00
|
|
|
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)
|
2026-07-24 15:39:04 +08:00
|
|
|
const mainFollowup = vi.fn<Agent['followup']>().mockReturnValue(AgentMessageId('main-followup'))
|
|
|
|
|
const mainAgent = ({
|
|
|
|
|
followup: mainFollowup,
|
fix(sdk): harden runtime lifecycle and JSON-RPC
Keep DeepSeekHarness.run() reusable, but make ownership of its lazy
runtime process explicit. Document the context-manager/close contract and
update every construction example to use a context manager so repeated runs
remain valid without encouraging leaked subprocesses.
Contain notification predicate failures at the subscription boundary. Remove
only the subscriber whose callback raised, deliver that exception through its
queue, and continue dispatching to healthy subscribers so arbitrary callback
code cannot terminate the shared reader thread or strand later requests.
Enforce one in-flight prompt per server session with an atomic activePrompt
guard. Route overlap through the existing -32603 handler-error response and
clear the guard in finally, preserving parallel prompts across sessions and
sequential reuse without changing JSON-RPC request or notification shapes.
Use StringDecoder for line framing so a UTF-8 code point split across Buffer
chunks is not corrupted. Add a queued-write flush barrier, and make memoized
shutdown await it before disposal and exit while retaining exactly-once
cleanup when shutdown calls race or flushing fails.
Cover callback isolation, same-session exclusion, cross-session concurrency,
split multibyte input, delayed writes, racing shutdown, and flush failure with
deterministic tests.
2026-07-13 20:53:20 +08:00
|
|
|
whenIdle: mainWhenIdle,
|
2026-07-24 15:39:04 +08:00
|
|
|
} satisfies Pick<Agent, 'followup' | 'whenIdle'>) as unknown as Agent
|
|
|
|
|
const otherFollowup = vi.fn<Agent['followup']>().mockReturnValue(AgentMessageId('other-followup'))
|
|
|
|
|
const otherAgent = ({
|
|
|
|
|
followup: otherFollowup,
|
fix(sdk): harden runtime lifecycle and JSON-RPC
Keep DeepSeekHarness.run() reusable, but make ownership of its lazy
runtime process explicit. Document the context-manager/close contract and
update every construction example to use a context manager so repeated runs
remain valid without encouraging leaked subprocesses.
Contain notification predicate failures at the subscription boundary. Remove
only the subscriber whose callback raised, deliver that exception through its
queue, and continue dispatching to healthy subscribers so arbitrary callback
code cannot terminate the shared reader thread or strand later requests.
Enforce one in-flight prompt per server session with an atomic activePrompt
guard. Route overlap through the existing -32603 handler-error response and
clear the guard in finally, preserving parallel prompts across sessions and
sequential reuse without changing JSON-RPC request or notification shapes.
Use StringDecoder for line framing so a UTF-8 code point split across Buffer
chunks is not corrupted. Add a queued-write flush barrier, and make memoized
shutdown await it before disposal and exit while retaining exactly-once
cleanup when shutdown calls race or flushing fails.
Cover callback isolation, same-session exclusion, cross-session concurrency,
split multibyte input, delayed writes, racing shutdown, and flush failure with
deterministic tests.
2026-07-13 20:53:20 +08:00
|
|
|
whenIdle: vi.fn(() => Promise.resolve()),
|
2026-07-24 15:39:04 +08:00
|
|
|
} satisfies Pick<Agent, 'followup' | 'whenIdle'>) as unknown as Agent
|
fix(sdk): harden runtime lifecycle and JSON-RPC
Keep DeepSeekHarness.run() reusable, but make ownership of its lazy
runtime process explicit. Document the context-manager/close contract and
update every construction example to use a context manager so repeated runs
remain valid without encouraging leaked subprocesses.
Contain notification predicate failures at the subscription boundary. Remove
only the subscriber whose callback raised, deliver that exception through its
queue, and continue dispatching to healthy subscribers so arbitrary callback
code cannot terminate the shared reader thread or strand later requests.
Enforce one in-flight prompt per server session with an atomic activePrompt
guard. Route overlap through the existing -32603 handler-error response and
clear the guard in finally, preserving parallel prompts across sessions and
sequential reuse without changing JSON-RPC request or notification shapes.
Use StringDecoder for line framing so a UTF-8 code point split across Buffer
chunks is not corrupted. Add a queued-write flush barrier, and make memoized
shutdown await it before disposal and exit while retaining exactly-once
cleanup when shutdown calls race or flushing fails.
Cover callback isolation, same-session exclusion, cross-session concurrency,
split multibyte input, delayed writes, racing shutdown, and flush failure with
deterministic tests.
2026-07-13 20:53:20 +08:00
|
|
|
const mainHandle = { agent: mainAgent, dispose: vi.fn(() => Promise.resolve()) }
|
|
|
|
|
const otherHandle = { agent: otherAgent, dispose: vi.fn(() => Promise.resolve()) }
|
2026-07-14 01:59:21 +08:00
|
|
|
const create = vi.fn(async (options: { sessionId: SessionId }) =>
|
|
|
|
|
String(options.sessionId) === 'main' ? mainHandle : otherHandle)
|
fix(sdk): harden runtime lifecycle and JSON-RPC
Keep DeepSeekHarness.run() reusable, but make ownership of its lazy
runtime process explicit. Document the context-manager/close contract and
update every construction example to use a context manager so repeated runs
remain valid without encouraging leaked subprocesses.
Contain notification predicate failures at the subscription boundary. Remove
only the subscriber whose callback raised, deliver that exception through its
queue, and continue dispatching to healthy subscribers so arbitrary callback
code cannot terminate the shared reader thread or strand later requests.
Enforce one in-flight prompt per server session with an atomic activePrompt
guard. Route overlap through the existing -32603 handler-error response and
clear the guard in finally, preserving parallel prompts across sessions and
sequential reuse without changing JSON-RPC request or notification shapes.
Use StringDecoder for line framing so a UTF-8 code point split across Buffer
chunks is not corrupted. Add a queued-write flush barrier, and make memoized
shutdown await it before disposal and exit while retaining exactly-once
cleanup when shutdown calls race or flushing fails.
Cover callback isolation, same-session exclusion, cross-session concurrency,
split multibyte input, delayed writes, racing shutdown, and flush failure with
deterministic tests.
2026-07-13 20:53:20 +08:00
|
|
|
const ctx = {
|
|
|
|
|
on: vi.fn(() => () => undefined),
|
|
|
|
|
agents: { create, get: () => undefined },
|
|
|
|
|
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')
|
2026-07-24 15:39:04 +08:00
|
|
|
await vi.waitFor(() => { expect(mainFollowup).toHaveBeenCalledOnce() })
|
fix(sdk): harden runtime lifecycle and JSON-RPC
Keep DeepSeekHarness.run() reusable, but make ownership of its lazy
runtime process explicit. Document the context-manager/close contract and
update every construction example to use a context manager so repeated runs
remain valid without encouraging leaked subprocesses.
Contain notification predicate failures at the subscription boundary. Remove
only the subscriber whose callback raised, deliver that exception through its
queue, and continue dispatching to healthy subscribers so arbitrary callback
code cannot terminate the shared reader thread or strand later requests.
Enforce one in-flight prompt per server session with an atomic activePrompt
guard. Route overlap through the existing -32603 handler-error response and
clear the guard in finally, preserving parallel prompts across sessions and
sequential reuse without changing JSON-RPC request or notification shapes.
Use StringDecoder for line framing so a UTF-8 code point split across Buffer
chunks is not corrupted. Add a queued-write flush barrier, and make memoized
shutdown await it before disposal and exit while retaining exactly-once
cleanup when shutdown calls race or flushing fails.
Cover callback isolation, same-session exclusion, cross-session concurrency,
split multibyte input, delayed writes, racing shutdown, and flush failure with
deterministic tests.
2026-07-13 20:53:20 +08:00
|
|
|
|
|
|
|
|
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 })
|
|
|
|
|
|
2026-07-24 15:39:04 +08:00
|
|
|
expect(mainFollowup).toHaveBeenCalledTimes(4)
|
|
|
|
|
expect(otherFollowup).toHaveBeenCalledOnce()
|
fix(sdk): harden runtime lifecycle and JSON-RPC
Keep DeepSeekHarness.run() reusable, but make ownership of its lazy
runtime process explicit. Document the context-manager/close contract and
update every construction example to use a context manager so repeated runs
remain valid without encouraging leaked subprocesses.
Contain notification predicate failures at the subscription boundary. Remove
only the subscriber whose callback raised, deliver that exception through its
queue, and continue dispatching to healthy subscribers so arbitrary callback
code cannot terminate the shared reader thread or strand later requests.
Enforce one in-flight prompt per server session with an atomic activePrompt
guard. Route overlap through the existing -32603 handler-error response and
clear the guard in finally, preserving parallel prompts across sessions and
sequential reuse without changing JSON-RPC request or notification shapes.
Use StringDecoder for line framing so a UTF-8 code point split across Buffer
chunks is not corrupted. Add a queued-write flush barrier, and make memoized
shutdown await it before disposal and exit while retaining exactly-once
cleanup when shutdown calls race or flushing fails.
Cover callback isolation, same-session exclusion, cross-session concurrency,
split multibyte input, delayed writes, racing shutdown, and flush failure with
deterministic tests.
2026-07-13 20:53:20 +08:00
|
|
|
await server.shutdown()
|
|
|
|
|
expect(mainHandle.dispose).toHaveBeenCalledOnce()
|
|
|
|
|
expect(otherHandle.dispose).toHaveBeenCalledOnce()
|
|
|
|
|
})
|
|
|
|
|
|
2026-07-21 15:49:37 +08:00
|
|
|
it('reports the message-turn outcome when a later non-message turn settles before idle', async () => {
|
|
|
|
|
const ctx = new Context()
|
|
|
|
|
await ctx.plugin(SessionStore)
|
|
|
|
|
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'))
|
2026-07-24 15:39:04 +08:00
|
|
|
const agent = ({
|
2026-07-21 15:49:37 +08:00
|
|
|
session,
|
2026-07-24 15:39:04 +08:00
|
|
|
followup(content: { type: 'text'; text: string }[]) {
|
2026-07-21 15:49:37 +08:00
|
|
|
session.append('turn/start', {
|
|
|
|
|
turn: 1,
|
|
|
|
|
trigger: { kind: 'message', source: { kind: 'user' } },
|
|
|
|
|
})
|
|
|
|
|
session.append('user/message', {
|
|
|
|
|
content,
|
|
|
|
|
source: { kind: 'user' },
|
|
|
|
|
}, { 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' } },
|
|
|
|
|
})
|
2026-07-23 19:15:45 +08:00
|
|
|
session.append('user/message', {
|
2026-07-21 15:49:37 +08:00
|
|
|
content: [{ type: 'text', text: 'late metadata' }],
|
|
|
|
|
source: { kind: 'plugin', plugin: 'late-metadata' },
|
|
|
|
|
}, { surfaceOp: 'append' })
|
|
|
|
|
session.append('turn/end', { turn: 2, reason: { kind: 'completed' } })
|
2026-07-24 15:39:04 +08:00
|
|
|
return AgentMessageId('message-outcome')
|
2026-07-21 15:49:37 +08:00
|
|
|
},
|
|
|
|
|
whenIdle: () => Promise.resolve(),
|
2026-07-24 15:39:04 +08:00
|
|
|
} satisfies Pick<Agent, 'session' | 'followup' | 'whenIdle'>) as unknown as Agent
|
2026-07-21 15:49:37 +08:00
|
|
|
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()
|
|
|
|
|
})
|
|
|
|
|
|
2026-07-11 14:08:12 +08:00
|
|
|
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())
|
|
|
|
|
|
2026-07-14 21:57:52 +08:00
|
|
|
await server.initialize({ cwd: storageDir, provider: 'deepseek', model: 'plain-model' })
|
2026-07-11 14:08:12 +08:00
|
|
|
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)
|
|
|
|
|
|
2026-07-13 15:57:12 +08:00
|
|
|
const parentHandle = await ctx.agents.create({
|
|
|
|
|
sessionId: SessionId('main'),
|
|
|
|
|
meta: { cwd: storageDir },
|
2026-07-14 21:57:52 +08:00
|
|
|
agentOptions: { provider: 'deepseek', model: 'deepseek' },
|
2026-07-13 15:57:12 +08:00
|
|
|
})
|
2026-07-14 10:32:02 +08:00
|
|
|
// A custom in-process provider may own its child at the provider/root
|
|
|
|
|
// scope while preserving durable parent lineage.
|
2026-07-13 15:57:12 +08:00
|
|
|
const handle = await ctx.agents.create({
|
2026-07-11 14:08:12 +08:00
|
|
|
sessionId: SessionId('child-session'),
|
|
|
|
|
meta: { cwd: storageDir, parentSession: SessionId('main') },
|
2026-07-14 21:57:52 +08:00
|
|
|
agentOptions: { provider: 'deepseek', model: 'deepseek' },
|
2026-07-11 14:08:12 +08:00
|
|
|
})
|
2026-07-14 10:32:02 +08:00
|
|
|
expect(ctx.agents.roots()).toContain(handle.agent)
|
2026-07-14 09:46:05 +08:00
|
|
|
const parentlessHandle = await parentHandle.agent.ctx.agents.create({
|
2026-07-14 07:29:40 +08:00
|
|
|
sessionId: SessionId('parentless-child-session'),
|
|
|
|
|
meta: { cwd: storageDir },
|
2026-07-13 15:57:12 +08:00
|
|
|
agentOptions: { model: 'deepseek' },
|
2026-07-11 14:08:12 +08:00
|
|
|
})
|
2026-07-13 15:57:12 +08:00
|
|
|
await settleSubagent(ctx, parentHandle.agent, {
|
2026-07-11 14:08:12 +08:00
|
|
|
provider: 'spawn',
|
2026-07-14 01:59:21 +08:00
|
|
|
id: SessionId('child-session'),
|
2026-07-18 14:26:14 +08:00
|
|
|
localAgent: handle.agent,
|
2026-07-11 14:08:12 +08:00
|
|
|
stopReason: 'completed',
|
|
|
|
|
lastAssistantMessage: [{ type: 'text', text: 'child done' }],
|
2026-07-14 08:11:13 +08:00
|
|
|
}, () => handle.dispose())
|
2026-07-14 07:29:40 +08:00
|
|
|
await settleSubagent(ctx, parentHandle.agent, {
|
|
|
|
|
provider: 'spawn',
|
|
|
|
|
id: SessionId('parentless-child-session'),
|
2026-07-18 14:26:14 +08:00
|
|
|
localAgent: parentlessHandle.agent,
|
2026-07-14 07:29:40 +08:00
|
|
|
stopReason: 'error',
|
2026-07-14 08:11:13 +08:00
|
|
|
}, () => parentlessHandle.dispose())
|
2026-07-11 14:08:12 +08:00
|
|
|
|
|
|
|
|
expect(transport.notifications).toContainEqual({
|
|
|
|
|
method: 'subagent.finished',
|
|
|
|
|
params: {
|
|
|
|
|
provider: 'spawn',
|
2026-07-14 01:59:21 +08:00
|
|
|
agentId: 'child-session',
|
2026-07-11 14:08:12 +08:00
|
|
|
parentSessionId: 'main',
|
|
|
|
|
childSessionId: 'child-session',
|
|
|
|
|
status: 'ok',
|
|
|
|
|
stopReason: 'completed',
|
|
|
|
|
lastAssistantMessage: [{ type: 'text', text: 'child done' }],
|
|
|
|
|
},
|
|
|
|
|
})
|
2026-07-14 07:29:40 +08:00
|
|
|
expect(transport.notifications).toContainEqual({
|
|
|
|
|
method: 'subagent.finished',
|
|
|
|
|
params: {
|
|
|
|
|
provider: 'spawn',
|
|
|
|
|
agentId: 'parentless-child-session',
|
2026-07-14 08:41:31 +08:00
|
|
|
parentSessionId: 'main',
|
2026-07-14 07:29:40 +08:00
|
|
|
childSessionId: 'parentless-child-session',
|
|
|
|
|
status: 'error',
|
|
|
|
|
stopReason: 'error',
|
|
|
|
|
},
|
|
|
|
|
})
|
2026-07-11 14:08:12 +08:00
|
|
|
|
2026-07-13 15:57:12 +08:00
|
|
|
await parentHandle.dispose()
|
2026-07-11 14:08:12 +08:00
|
|
|
await server.shutdown()
|
|
|
|
|
} finally {
|
|
|
|
|
await ctx.fiber.dispose()
|
|
|
|
|
await rm(storageDir, { recursive: true, force: true })
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
2026-07-18 14:26:14 +08:00
|
|
|
it('ignores a remote run id that collides with a local child of the same parent', async () => {
|
2026-07-14 09:46:05 +08:00
|
|
|
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' },
|
|
|
|
|
})
|
2026-07-18 14:26:14 +08:00
|
|
|
const collidingChild = await parentHandle.agent.ctx.agents.create({
|
2026-07-14 09:46:05 +08:00
|
|
|
sessionId: SessionId('remote-run-id'),
|
2026-07-18 14:26:14 +08:00
|
|
|
meta: { cwd: storageDir, parentSession: SessionId('collision-parent') },
|
2026-07-14 09:46:05 +08:00
|
|
|
agentOptions: { model: 'deepseek' },
|
|
|
|
|
})
|
2026-07-11 14:08:12 +08:00
|
|
|
|
2026-07-14 09:46:05 +08:00
|
|
|
await settleSubagent(ctx, parentHandle.agent, {
|
|
|
|
|
provider: 'remote',
|
|
|
|
|
id: SessionId('remote-run-id'),
|
2026-07-18 14:26:14 +08:00
|
|
|
localAgent: undefined,
|
2026-07-14 09:46:05 +08:00
|
|
|
stopReason: 'completed',
|
|
|
|
|
lastAssistantMessage: [],
|
2026-07-18 14:26:14 +08:00
|
|
|
})
|
2026-07-14 09:46:05 +08:00
|
|
|
|
|
|
|
|
expect(transport.notifications.some(notification =>
|
|
|
|
|
notification.method === 'subagent.finished'
|
|
|
|
|
&& notification.params?.agentId === 'remote-run-id',
|
|
|
|
|
)).toBe(false)
|
|
|
|
|
|
2026-07-18 14:26:14 +08:00
|
|
|
await collidingChild.dispose()
|
2026-07-13 15:57:12 +08:00
|
|
|
await parentHandle.dispose()
|
2026-07-11 14:08:12 +08:00
|
|
|
await server.shutdown()
|
|
|
|
|
} finally {
|
|
|
|
|
await ctx.fiber.dispose()
|
|
|
|
|
await rm(storageDir, { recursive: true, force: true })
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
2026-07-14 08:11:13 +08:00
|
|
|
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' },
|
|
|
|
|
})
|
2026-07-14 09:46:05 +08:00
|
|
|
const childHandle = await parentHandle.agent.ctx.agents.create({
|
2026-07-14 08:11:13 +08:00
|
|
|
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'),
|
2026-07-18 14:26:14 +08:00
|
|
|
localAgent: childHandle.agent,
|
2026-07-14 08:11:13 +08:00
|
|
|
stopReason: 'completed',
|
|
|
|
|
lastAssistantMessage: [{ type: 'text', text: 'first' }],
|
|
|
|
|
})
|
|
|
|
|
await settleSubagent(ctx, parentHandle.agent, {
|
|
|
|
|
provider: 'continuation',
|
|
|
|
|
id: SessionId('continuation-child'),
|
2026-07-18 14:26:14 +08:00
|
|
|
localAgent: childHandle.agent,
|
2026-07-14 08:11:13 +08:00
|
|
|
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 })
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
2026-07-14 08:41:31 +08:00
|
|
|
it('correlates reused local ids by parent scope when runs settle out of order', async () => {
|
2026-07-14 08:25:52 +08:00
|
|
|
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' },
|
|
|
|
|
})
|
2026-07-14 09:46:05 +08:00
|
|
|
const oldChild = await oldParent.agent.ctx.agents.create({
|
2026-07-14 08:25:52 +08:00
|
|
|
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
|
2026-07-18 14:26:14 +08:00
|
|
|
let currentLocalAgent = oldChild.agent
|
2026-07-14 08:25:52 +08:00
|
|
|
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')
|
2026-07-18 14:26:14 +08:00
|
|
|
return Promise.resolve({ id: SessionId('reused-child'), localAgent: currentLocalAgent, result, dispose: () => Promise.resolve() })
|
2026-07-14 08:25:52 +08:00
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
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' },
|
|
|
|
|
})
|
2026-07-14 09:46:05 +08:00
|
|
|
const newChild = await newParent.agent.ctx.agents.create({
|
2026-07-14 08:25:52 +08:00
|
|
|
sessionId: SessionId('reused-child'),
|
|
|
|
|
meta: { cwd: storageDir, parentSession: SessionId('new-parent') },
|
|
|
|
|
agentOptions: { model: 'deepseek' },
|
|
|
|
|
})
|
2026-07-18 14:26:14 +08:00
|
|
|
currentLocalAgent = newChild.agent
|
2026-07-14 08:25:52 +08:00
|
|
|
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' }],
|
|
|
|
|
])
|
2026-07-14 08:41:31 +08:00
|
|
|
expect(finished.map(notification => notification.params?.parentSessionId)).toEqual([
|
|
|
|
|
'old-parent',
|
|
|
|
|
'new-parent',
|
|
|
|
|
'old-parent',
|
|
|
|
|
])
|
2026-07-14 08:25:52 +08:00
|
|
|
|
|
|
|
|
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 })
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
2026-07-18 14:26:14 +08:00
|
|
|
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 () => {
|
2026-07-11 14:08:12 +08:00
|
|
|
const storageDir = await mkdtemp(join(tmpdir(), 'dsh-jsonrpc-subagent-fallback-'))
|
|
|
|
|
const ctx = await makeHarness(storageDir)
|
2026-07-13 15:57:12 +08:00
|
|
|
let parentHandle: AgentHandle | undefined
|
|
|
|
|
let handle: AgentHandle | undefined
|
|
|
|
|
let failedHandle: AgentHandle | undefined
|
2026-07-11 14:08:12 +08:00
|
|
|
try {
|
2026-07-13 15:57:12 +08:00
|
|
|
parentHandle = await ctx.agents.create({
|
|
|
|
|
sessionId: SessionId('fallback-parent'),
|
|
|
|
|
meta: { cwd: storageDir },
|
2026-07-14 21:57:52 +08:00
|
|
|
agentOptions: { provider: 'deepseek', model: 'deepseek' },
|
2026-07-13 15:57:12 +08:00
|
|
|
})
|
2026-07-14 09:46:05 +08:00
|
|
|
handle = await parentHandle.agent.ctx.agents.create({
|
2026-07-11 14:08:12 +08:00
|
|
|
sessionId: SessionId('fallback-child-session'),
|
|
|
|
|
meta: { cwd: storageDir, parentSession: SessionId('fallback-parent') },
|
2026-07-14 21:57:52 +08:00
|
|
|
agentOptions: { provider: 'deepseek', model: 'deepseek' },
|
2026-07-11 14:08:12 +08:00
|
|
|
})
|
2026-07-18 14:26:14 +08:00
|
|
|
const fallbackChild = handle.agent
|
2026-07-14 09:46:05 +08:00
|
|
|
failedHandle = await parentHandle.agent.ctx.agents.create({
|
2026-07-11 14:08:12 +08:00
|
|
|
sessionId: SessionId('failed-child-session'),
|
|
|
|
|
meta: { cwd: storageDir },
|
2026-07-14 21:57:52 +08:00
|
|
|
agentOptions: { provider: 'deepseek', model: 'deepseek' },
|
2026-07-11 14:08:12 +08:00
|
|
|
})
|
2026-07-14 08:25:52 +08:00
|
|
|
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'),
|
2026-07-18 14:26:14 +08:00
|
|
|
localAgent: fallbackChild,
|
2026-07-14 08:25:52 +08:00
|
|
|
result: missedStartResult.promise,
|
|
|
|
|
dispose: () => Promise.resolve(),
|
|
|
|
|
}),
|
|
|
|
|
})
|
2026-07-18 14:26:14 +08:00
|
|
|
// Start before the server subscribes. The terminal payload still carries
|
|
|
|
|
// this run's exact local child without reconstructing it from ids.
|
2026-07-14 08:25:52 +08:00
|
|
|
const missedStartRun = await ctx.subagents.start('fork', {
|
|
|
|
|
parent: parentHandle.agent,
|
|
|
|
|
prompt: [],
|
|
|
|
|
signal: new AbortController().signal,
|
2026-07-11 14:08:12 +08:00
|
|
|
})
|
|
|
|
|
const transport = new FakeTransport()
|
2026-07-17 17:29:38 +08:00
|
|
|
const server = new HarnessSdkServer(ctx, transport, { maxTokensAsSuccess: true })
|
2026-07-11 14:08:12 +08:00
|
|
|
|
2026-07-14 08:25:52 +08:00
|
|
|
missedStartResult.resolve({ output: [], stopReason: 'max-tokens' })
|
|
|
|
|
await missedStartRun.result
|
|
|
|
|
await Promise.resolve()
|
|
|
|
|
await missedStartRun.dispose()
|
|
|
|
|
disposeMissedStartProvider()
|
2026-07-18 14:26:14 +08:00
|
|
|
// The server also missed this agent's creation but sees the exact child
|
|
|
|
|
// on the run lifecycle payload.
|
2026-07-13 15:57:12 +08:00
|
|
|
await settleSubagent(ctx, parentHandle.agent, {
|
2026-07-14 08:25:52 +08:00
|
|
|
provider: 'fork-live-fallback',
|
2026-07-14 01:59:21 +08:00
|
|
|
id: SessionId('fallback-child-session'),
|
2026-07-18 14:26:14 +08:00
|
|
|
localAgent: fallbackChild,
|
2026-07-14 08:25:52 +08:00
|
|
|
stopReason: 'completed',
|
2026-07-13 15:57:12 +08:00
|
|
|
lastAssistantMessage: [],
|
2026-07-11 14:08:12 +08:00
|
|
|
})
|
2026-07-13 15:57:12 +08:00
|
|
|
await settleSubagent(ctx, parentHandle.agent, {
|
2026-07-11 14:08:12 +08:00
|
|
|
provider: 'fork',
|
2026-07-14 01:59:21 +08:00
|
|
|
id: SessionId('failed-child-session'),
|
2026-07-18 14:26:14 +08:00
|
|
|
localAgent: failedHandle.agent,
|
2026-07-11 14:08:12 +08:00
|
|
|
stopReason: 'error',
|
|
|
|
|
})
|
2026-07-13 15:57:12 +08:00
|
|
|
await settleSubagent(ctx, parentHandle.agent, {
|
2026-07-11 14:08:12 +08:00
|
|
|
provider: 'fork',
|
2026-07-14 01:59:21 +08:00
|
|
|
id: SessionId('missing-child-agent'),
|
2026-07-18 14:26:14 +08:00
|
|
|
localAgent: undefined,
|
2026-07-11 14:08:12 +08:00
|
|
|
stopReason: 'error',
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
expect(transport.notifications).toContainEqual({
|
|
|
|
|
method: 'subagent.finished',
|
|
|
|
|
params: {
|
|
|
|
|
provider: 'fork',
|
2026-07-14 01:59:21 +08:00
|
|
|
agentId: 'fallback-child-session',
|
2026-07-11 14:08:12 +08:00
|
|
|
parentSessionId: 'fallback-parent',
|
|
|
|
|
childSessionId: 'fallback-child-session',
|
2026-07-17 17:29:38 +08:00
|
|
|
status: 'ok',
|
2026-07-11 14:08:12 +08:00
|
|
|
stopReason: 'max-tokens',
|
2026-07-13 15:57:12 +08:00
|
|
|
lastAssistantMessage: [],
|
2026-07-11 14:08:12 +08:00
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
expect(transport.notifications).toContainEqual({
|
|
|
|
|
method: 'subagent.finished',
|
|
|
|
|
params: {
|
|
|
|
|
provider: 'fork',
|
2026-07-14 01:59:21 +08:00
|
|
|
agentId: 'failed-child-session',
|
2026-07-14 08:41:31 +08:00
|
|
|
parentSessionId: 'fallback-parent',
|
2026-07-11 14:08:12 +08:00
|
|
|
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()
|
2026-07-13 15:57:12 +08:00
|
|
|
await parentHandle?.dispose()
|
2026-07-11 14:08:12 +08:00
|
|
|
await ctx.fiber.dispose()
|
|
|
|
|
await rm(storageDir, { recursive: true, force: true })
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
2026-07-14 21:57:52 +08:00
|
|
|
it('does not re-register an LLM adapter whose provider already has an owner', async () => {
|
2026-07-11 14:08:12 +08:00
|
|
|
const storageDir = await mkdtemp(join(tmpdir(), 'dsh-jsonrpc-existing-llm-'))
|
|
|
|
|
const ctx = await makeHarness(storageDir)
|
|
|
|
|
vi.stubEnv('DEEPSEEK_API_KEY', 'test-key')
|
2026-07-14 21:57:52 +08:00
|
|
|
await ctx.plugin(LlmDeepSeek)
|
2026-07-11 14:08:12 +08:00
|
|
|
try {
|
|
|
|
|
const server = new HarnessSdkServer(ctx, new FakeTransport())
|
2026-07-14 21:57:52 +08:00
|
|
|
const inspect = server as unknown as { hasAdapterFor(provider: string): boolean }
|
2026-07-11 14:08:12 +08:00
|
|
|
|
2026-07-14 21:57:52 +08:00
|
|
|
expect(inspect.hasAdapterFor('deepseek')).toBe(true)
|
|
|
|
|
expect(inspect.hasAdapterFor('missing-provider')).toBe(false)
|
|
|
|
|
await server.initialize({ cwd: storageDir, provider: 'deepseek', model: 'preinstalled-model' })
|
2026-07-11 14:08:12 +08:00
|
|
|
|
2026-07-15 13:33:42 +08:00
|
|
|
expect(ctx.get('llm')?.listProviders().filter(provider => provider.id === 'deepseek')).toEqual([{ id: 'deepseek', name: 'DeepSeek' }])
|
2026-07-11 14:08:12 +08:00
|
|
|
await server.shutdown()
|
|
|
|
|
} finally {
|
|
|
|
|
await ctx.fiber.dispose()
|
|
|
|
|
await rm(storageDir, { recursive: true, force: true })
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
2026-07-14 21:57:52 +08:00
|
|
|
it('rejects a missing non-DeepSeek provider when an LLM service already exists', async () => {
|
2026-07-11 14:08:12 +08:00
|
|
|
const storageDir = await mkdtemp(join(tmpdir(), 'dsh-jsonrpc-new-llm-'))
|
|
|
|
|
const ctx = await makeHarness(storageDir)
|
|
|
|
|
vi.stubEnv('DEEPSEEK_API_KEY', 'test-key')
|
2026-07-14 21:57:52 +08:00
|
|
|
await ctx.plugin(LlmDeepSeek)
|
2026-07-11 14:08:12 +08:00
|
|
|
try {
|
|
|
|
|
const server = new HarnessSdkServer(ctx, new FakeTransport())
|
|
|
|
|
|
2026-07-14 21:57:52 +08:00
|
|
|
await expect(server.initialize({ cwd: storageDir, provider: 'private', model: 'new-model' }))
|
|
|
|
|
.rejects.toThrow('no adapter registered for provider "private"')
|
2026-07-11 14:08:12 +08:00
|
|
|
|
2026-07-15 13:33:42 +08:00
|
|
|
expect(ctx.get('llm')?.listProviders()).toEqual([{ id: 'deepseek', name: 'DeepSeek' }])
|
2026-07-11 14:08:12 +08:00
|
|
|
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')
|
2026-07-13 16:34:03 +08:00
|
|
|
expect(server.finishedStatus({ kind: 'max-tokens' })).toBe('error')
|
2026-07-11 14:08:12 +08:00
|
|
|
expect(server.finishedStatus({ kind: 'error' })).toBe('error')
|
|
|
|
|
await server.shutdown()
|
|
|
|
|
} finally {
|
|
|
|
|
await ctx.fiber.dispose()
|
|
|
|
|
await rm(storageDir, { recursive: true, force: true })
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
2026-07-17 17:29:38 +08:00
|
|
|
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 })
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
2026-07-11 14:08:12 +08:00
|
|
|
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 })
|
|
|
|
|
}
|
|
|
|
|
})
|
2026-07-13 15:57:12 +08:00
|
|
|
|
|
|
|
|
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')
|
|
|
|
|
})
|
2026-07-13 16:34:03 +08:00
|
|
|
|
|
|
|
|
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 },
|
2026-07-15 13:33:42 +08:00
|
|
|
get: () => ({ listProviders: () => [{ id: 'mock', name: 'Mock' }] }),
|
2026-07-13 16:34:03 +08:00
|
|
|
} as unknown as Context
|
|
|
|
|
const server = new HarnessSdkServer(ctx, new FakeTransport()) as unknown as {
|
2026-07-14 21:57:52 +08:00
|
|
|
initialize(params: { cwd: string; provider: string; model: string }): Promise<unknown>
|
2026-07-13 16:34:03 +08:00
|
|
|
getOrCreateSession(sessionId: string): Promise<unknown>
|
|
|
|
|
shutdown(): Promise<Record<string, never>>
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-14 21:57:52 +08:00
|
|
|
await server.initialize({ cwd: '.', provider: 'mock', model: 'model' })
|
2026-07-13 16:34:03 +08:00
|
|
|
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 {
|
fix(sdk): harden runtime lifecycle and JSON-RPC
Keep DeepSeekHarness.run() reusable, but make ownership of its lazy
runtime process explicit. Document the context-manager/close contract and
update every construction example to use a context manager so repeated runs
remain valid without encouraging leaked subprocesses.
Contain notification predicate failures at the subscription boundary. Remove
only the subscriber whose callback raised, deliver that exception through its
queue, and continue dispatching to healthy subscribers so arbitrary callback
code cannot terminate the shared reader thread or strand later requests.
Enforce one in-flight prompt per server session with an atomic activePrompt
guard. Route overlap through the existing -32603 handler-error response and
clear the guard in finally, preserving parallel prompts across sessions and
sequential reuse without changing JSON-RPC request or notification shapes.
Use StringDecoder for line framing so a UTF-8 code point split across Buffer
chunks is not corrupted. Add a queued-write flush barrier, and make memoized
shutdown await it before disposal and exit while retaining exactly-once
cleanup when shutdown calls race or flushing fails.
Cover callback isolation, same-session exclusion, cross-session concurrency,
split multibyte input, delayed writes, racing shutdown, and flush failure with
deterministic tests.
2026-07-13 20:53:20 +08:00
|
|
|
sessions: Map<string, { handle: AgentHandle; lastTurnEnd: undefined; activePrompt: boolean }>
|
2026-07-13 16:34:03 +08:00
|
|
|
shutdown(): Promise<Record<string, never>>
|
|
|
|
|
}
|
fix(sdk): harden runtime lifecycle and JSON-RPC
Keep DeepSeekHarness.run() reusable, but make ownership of its lazy
runtime process explicit. Document the context-manager/close contract and
update every construction example to use a context manager so repeated runs
remain valid without encouraging leaked subprocesses.
Contain notification predicate failures at the subscription boundary. Remove
only the subscriber whose callback raised, deliver that exception through its
queue, and continue dispatching to healthy subscribers so arbitrary callback
code cannot terminate the shared reader thread or strand later requests.
Enforce one in-flight prompt per server session with an atomic activePrompt
guard. Route overlap through the existing -32603 handler-error response and
clear the guard in finally, preserving parallel prompts across sessions and
sequential reuse without changing JSON-RPC request or notification shapes.
Use StringDecoder for line framing so a UTF-8 code point split across Buffer
chunks is not corrupted. Add a queued-write flush barrier, and make memoized
shutdown await it before disposal and exit while retaining exactly-once
cleanup when shutdown calls race or flushing fails.
Cover callback isolation, same-session exclusion, cross-session concurrency,
split multibyte input, delayed writes, racing shutdown, and flush failure with
deterministic tests.
2026-07-13 20:53:20 +08:00
|
|
|
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 })
|
2026-07-13 16:34:03 +08:00
|
|
|
|
|
|
|
|
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)
|
2026-07-18 14:26:14 +08:00
|
|
|
expect(on).toHaveBeenCalledTimes(3)
|
2026-07-13 16:34:03 +08:00
|
|
|
})
|
2026-07-11 14:08:12 +08:00
|
|
|
})
|