Merge branch 'codex/simp-hide-concrete-agent-loop' into codex/simp-hide-subagent-internals
This commit is contained in:
@@ -36,6 +36,7 @@
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-loader-smoke": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-subagent": "workspace:^",
|
||||
"@deepseek-ai/dsh-subagent-subprocess": "workspace:^",
|
||||
|
||||
@@ -294,6 +294,7 @@ export async function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpe
|
||||
let disposal: Promise<void> | undefined
|
||||
return {
|
||||
id,
|
||||
localAgent: undefined,
|
||||
result,
|
||||
dispose(): Promise<void> {
|
||||
if (disposal !== undefined) return disposal
|
||||
|
||||
@@ -36,10 +36,9 @@
|
||||
* grace, before the SIGKILL escalation). Touches
|
||||
* MOCK_READY_FILE once armed.
|
||||
*
|
||||
* It is NOT a test spec (no `describe`/`it`) — it is spawned BY the specs as the
|
||||
* child process the ACP backend drives. Kept as a `.ts` run under tsx by the
|
||||
* spec (which passes its own tsconfig), mirroring how the snapshot harness boots
|
||||
* the real example.
|
||||
* It is not a test spec: the specs launch this protocol-only fixture through
|
||||
* the mode-aware example resolver (tsx in source mode, Node type stripping in
|
||||
* built mode). It imports no harness code or workspace paths.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-subagent-acp/tests/mock-acp-server
|
||||
*/
|
||||
|
||||
@@ -6,6 +6,7 @@ import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import SubagentService from '@deepseek-ai/dsh-subagent'
|
||||
import { resolveExampleLaunch } from '@deepseek-ai/dsh-loader-smoke'
|
||||
import * as acp from '../src/index.ts'
|
||||
|
||||
/**
|
||||
@@ -17,9 +18,22 @@ import * as acp from '../src/index.ts'
|
||||
// The real acp-agent example: its bin + cordis.yml (the live DeepSeek config).
|
||||
const binScript = fileURLToPath(new URL('../../../examples/acp-demo/src/bin.ts', import.meta.url))
|
||||
const exampleConfig = fileURLToPath(new URL('../../../../examples/acp-agent/cordis.yml', import.meta.url))
|
||||
const tsxLoader = fileURLToPath(import.meta.resolve('tsx'))
|
||||
const repoTsconfig = fileURLToPath(new URL('../../../../tsconfig.json', import.meta.url))
|
||||
|
||||
// How to launch the child acp-agent (src via tsx / lib via plain node, per DSH_EXAMPLE_MODE).
|
||||
// buildChildEnv scrubs ambient creds but keeps these extras, so the model key is
|
||||
// forwarded explicitly; TSX_TSCONFIG_PATH is added by the resolver in src mode only.
|
||||
const childLaunch = resolveExampleLaunch({
|
||||
srcBin: binScript,
|
||||
configArgs: ['--config', exampleConfig],
|
||||
tsconfigPath: repoTsconfig,
|
||||
env: {
|
||||
...process.env.DEEPSEEK_API_KEY !== undefined ? { DEEPSEEK_API_KEY: process.env.DEEPSEEK_API_KEY } : {},
|
||||
...process.env.DEEPSEEK_BASE_URL !== undefined ? { DEEPSEEK_BASE_URL: process.env.DEEPSEEK_BASE_URL } : {},
|
||||
DSH_PERMISSION_MODE: 'danger-full-access',
|
||||
},
|
||||
})
|
||||
|
||||
/** The ACP backend ignores the parent, but the seam requires one. */
|
||||
const fakeParent = { id: 'parent', session: { header: {} } } as unknown as Agent
|
||||
|
||||
@@ -40,18 +54,11 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('ACP backend with-key e2e (drive
|
||||
await ctx.plugin(SubagentService)
|
||||
await ctx.plugin(acp, {
|
||||
providerName: 'acp',
|
||||
command: process.execPath,
|
||||
args: ['--import', tsxLoader, binScript, '--config', exampleConfig],
|
||||
command: childLaunch.command,
|
||||
args: childLaunch.args,
|
||||
cwd: workdir,
|
||||
permission: 'reject',
|
||||
// The child harness needs the key to reach the model; forward it
|
||||
// explicitly (buildChildEnv scrubs ambient creds but keeps these extras).
|
||||
env: {
|
||||
...process.env.DEEPSEEK_API_KEY !== undefined ? { DEEPSEEK_API_KEY: process.env.DEEPSEEK_API_KEY } : {},
|
||||
...process.env.DEEPSEEK_BASE_URL !== undefined ? { DEEPSEEK_BASE_URL: process.env.DEEPSEEK_BASE_URL } : {},
|
||||
TSX_TSCONFIG_PATH: repoTsconfig,
|
||||
DSH_PERMISSION_MODE: 'danger-full-access',
|
||||
},
|
||||
env: childLaunch.env as Record<string, string>,
|
||||
})
|
||||
|
||||
const run = await ctx.subagents.start('acp', {
|
||||
@@ -76,17 +83,12 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('ACP backend with-key e2e (drive
|
||||
await ctx.plugin(SubagentService)
|
||||
await ctx.plugin(acp, {
|
||||
providerName: 'acp',
|
||||
command: process.execPath,
|
||||
args: ['--import', tsxLoader, binScript, '--config', exampleConfig],
|
||||
command: childLaunch.command,
|
||||
args: childLaunch.args,
|
||||
cwd: workdir,
|
||||
// The child needs to act (run bash), so approve its permission prompts.
|
||||
permission: 'allow',
|
||||
env: {
|
||||
...process.env.DEEPSEEK_API_KEY !== undefined ? { DEEPSEEK_API_KEY: process.env.DEEPSEEK_API_KEY } : {},
|
||||
...process.env.DEEPSEEK_BASE_URL !== undefined ? { DEEPSEEK_BASE_URL: process.env.DEEPSEEK_BASE_URL } : {},
|
||||
TSX_TSCONFIG_PATH: repoTsconfig,
|
||||
DSH_PERMISSION_MODE: 'danger-full-access',
|
||||
},
|
||||
env: childLaunch.env as Record<string, string>,
|
||||
})
|
||||
|
||||
const run = await ctx.subagents.start('acp', {
|
||||
|
||||
@@ -21,8 +21,6 @@ import { acpStopReason, acpContentText, DEFAULT_DISPOSE_EOF_GRACE_MS, DEFAULT_DI
|
||||
*/
|
||||
|
||||
const mockServer = fileURLToPath(new URL('./mock-acp-server.ts', import.meta.url))
|
||||
const tsxLoader = fileURLToPath(import.meta.resolve('tsx'))
|
||||
const repoTsconfig = fileURLToPath(new URL('../../../../tsconfig.json', import.meta.url))
|
||||
|
||||
/** A throwaway parent Agent — the ACP backend ignores it, but the seam requires one. */
|
||||
const fakeParent = { id: 'parent', session: { header: {} } } as unknown as Agent
|
||||
@@ -46,11 +44,9 @@ async function setup(mockEnv: SetupEnv = {}, permission: 'allow' | 'reject' = 'r
|
||||
await ctx.plugin(acp, {
|
||||
providerName: 'acp',
|
||||
command: process.execPath,
|
||||
args: ['--import', tsxLoader, mockServer],
|
||||
args: [mockServer],
|
||||
permission,
|
||||
// The mock-server scripting vars must reach the child; TSX_TSCONFIG_PATH lets
|
||||
// tsx resolve @deepseek-ai/* from a child cwd outside the repo.
|
||||
env: { ...mockEnv, TSX_TSCONFIG_PATH: repoTsconfig },
|
||||
env: mockEnv,
|
||||
})
|
||||
return ctx
|
||||
}
|
||||
@@ -62,7 +58,7 @@ function text(blocks: { type: string; text?: string }[]): string {
|
||||
/**
|
||||
* Poll until `file` exists (the mock touches it once its prompt is in flight),
|
||||
* so a cancel test waits on a CONDITION rather than an arbitrary timeout — the
|
||||
* subprocess cold-start under tsx is variable, and a fixed sleep both flakes and
|
||||
* subprocess cold-start is variable, and a fixed sleep both flakes and
|
||||
* slows the suite. Fails loud if the child never signals readiness.
|
||||
*/
|
||||
async function waitForFile(file: string, timeoutMs = 5000): Promise<void> {
|
||||
@@ -200,14 +196,13 @@ describe('dsh-subagent-acp', () => {
|
||||
try {
|
||||
await expect(startAcpRun(request(), {
|
||||
command: process.execPath,
|
||||
args: ['--import', tsxLoader, mockServer],
|
||||
args: [mockServer],
|
||||
cwd: process.cwd(),
|
||||
permission: 'reject',
|
||||
env: {
|
||||
MOCK_MISSING_SESSION_ID: '1',
|
||||
MOCK_FLUSH_ON_EOF: flushed,
|
||||
MOCK_FLUSH_DELAY_MS: '20',
|
||||
TSX_TSCONFIG_PATH: repoTsconfig,
|
||||
},
|
||||
disposeEofGraceMs: 1000,
|
||||
disposeGraceMs: 100,
|
||||
@@ -229,10 +224,10 @@ describe('dsh-subagent-acp', () => {
|
||||
try {
|
||||
const spec: AcpRunSpec = {
|
||||
command: process.execPath,
|
||||
args: ['--import', tsxLoader, mockServer],
|
||||
args: [mockServer],
|
||||
cwd: process.cwd(),
|
||||
permission: 'reject',
|
||||
env: { MOCK_TRAP_SIGTERM: '1', MOCK_TEXT: 'x', MOCK_READY_FILE: ready, TSX_TSCONFIG_PATH: repoTsconfig },
|
||||
env: { MOCK_TRAP_SIGTERM: '1', MOCK_TEXT: 'x', MOCK_READY_FILE: ready },
|
||||
// Short on BOTH tiers: the trap ignores EOF and SIGTERM, so dispose must
|
||||
// burn the EOF window, then the SIGTERM window, then SIGKILL — keep each
|
||||
// small so the whole ladder finishes well within the 4000ms bound.
|
||||
@@ -272,7 +267,7 @@ describe('dsh-subagent-acp', () => {
|
||||
try {
|
||||
const spec: AcpRunSpec = {
|
||||
command: process.execPath,
|
||||
args: ['--import', tsxLoader, mockServer],
|
||||
args: [mockServer],
|
||||
cwd: process.cwd(),
|
||||
permission: 'reject',
|
||||
// MOCK_HANG so the prompt never resolves on its own — we tear down a live
|
||||
@@ -281,7 +276,7 @@ describe('dsh-subagent-acp', () => {
|
||||
// wider grace.
|
||||
env: {
|
||||
MOCK_HANG: '1', MOCK_TEXT: 'x', MOCK_READY_FILE: ready,
|
||||
MOCK_FLUSH_ON_EOF: flushed, MOCK_FLUSH_DELAY_MS: '400', TSX_TSCONFIG_PATH: repoTsconfig,
|
||||
MOCK_FLUSH_ON_EOF: flushed, MOCK_FLUSH_DELAY_MS: '400',
|
||||
},
|
||||
disposeEofGraceMs: 2000,
|
||||
disposeGraceMs: 50,
|
||||
@@ -312,12 +307,12 @@ describe('dsh-subagent-acp', () => {
|
||||
try {
|
||||
const spec: AcpRunSpec = {
|
||||
command: process.execPath,
|
||||
args: ['--import', tsxLoader, mockServer],
|
||||
args: [mockServer],
|
||||
cwd: process.cwd(),
|
||||
permission: 'reject',
|
||||
env: {
|
||||
MOCK_HANG: '1', MOCK_IGNORE_EOF: '1', MOCK_TEXT: 'x',
|
||||
MOCK_READY_FILE: ready, MOCK_SIGTERM_FILE: sigterm, TSX_TSCONFIG_PATH: repoTsconfig,
|
||||
MOCK_READY_FILE: ready, MOCK_SIGTERM_FILE: sigterm,
|
||||
},
|
||||
// Tiny EOF grace so the ignored-EOF window elapses fast, then SIGTERM.
|
||||
disposeEofGraceMs: 150,
|
||||
@@ -436,9 +431,9 @@ describe('dsh-subagent-acp', () => {
|
||||
await ctx.plugin(acp, {
|
||||
providerName: 'acp',
|
||||
command: process.execPath,
|
||||
args: ['--import', tsxLoader, mockServer],
|
||||
args: [mockServer],
|
||||
permission: 'reject',
|
||||
env: { MOCK_TRAP_SIGTERM: '1', MOCK_TEXT: 'x', MOCK_READY_FILE: ready, TSX_TSCONFIG_PATH: repoTsconfig },
|
||||
env: { MOCK_TRAP_SIGTERM: '1', MOCK_TEXT: 'x', MOCK_READY_FILE: ready },
|
||||
disposeEofGraceMs: 150,
|
||||
disposeGraceMs: 150,
|
||||
})
|
||||
@@ -487,10 +482,10 @@ describe('dsh-subagent-acp', () => {
|
||||
request(),
|
||||
{
|
||||
command: process.execPath,
|
||||
args: ['--import', tsxLoader, mockServer],
|
||||
args: [mockServer],
|
||||
cwd: process.cwd(),
|
||||
permission: 'reject',
|
||||
env: { MOCK_CRASH_ON_PROMPT: '1', TSX_TSCONFIG_PATH: repoTsconfig },
|
||||
env: { MOCK_CRASH_ON_PROMPT: '1' },
|
||||
disposeEofGraceMs: DEFAULT_DISPOSE_EOF_GRACE_MS,
|
||||
disposeGraceMs: DEFAULT_DISPOSE_GRACE_MS,
|
||||
onError: (error, stopReason) => { errors.push({ message: error.message, stopReason }) },
|
||||
@@ -525,10 +520,10 @@ describe('dsh-subagent-acp', () => {
|
||||
request(),
|
||||
{
|
||||
command: process.execPath,
|
||||
args: ['--import', tsxLoader, mockServer],
|
||||
args: [mockServer],
|
||||
cwd: process.cwd(),
|
||||
permission: 'reject',
|
||||
env: { MOCK_CRASH_ON_PROMPT: '1', TSX_TSCONFIG_PATH: repoTsconfig },
|
||||
env: { MOCK_CRASH_ON_PROMPT: '1' },
|
||||
disposeEofGraceMs: DEFAULT_DISPOSE_EOF_GRACE_MS,
|
||||
disposeGraceMs: DEFAULT_DISPOSE_GRACE_MS,
|
||||
onError: () => { throw new Error('sink boom') },
|
||||
|
||||
@@ -28,6 +28,9 @@
|
||||
},
|
||||
{
|
||||
"path": "../subagent-subprocess"
|
||||
},
|
||||
{
|
||||
"path": "../../support/loader-smoke"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -175,6 +175,7 @@ export async function startInProcessRun(
|
||||
|
||||
return {
|
||||
id: childId,
|
||||
localAgent: child,
|
||||
result,
|
||||
dispose(): Promise<void> {
|
||||
request.signal.removeEventListener('abort', onAbort)
|
||||
|
||||
@@ -50,9 +50,9 @@ Runtime features are optional methods on `SubagentRun`: `sendMessage?` steers a
|
||||
|
||||
`SubagentRun.result` resolves to `{ output, structured?, stopReason }`. Child-level failures resolve with a non-`completed` reason; only an infrastructure fault that the seam cannot represent may reject. `dispose()` is idempotent, cancels remaining work, and waits for the child resources to quiesce.
|
||||
|
||||
A local run publishes an ordinary child agent/session before `start()` fulfills, returns that shared session id as `SubagentRun.id`, and records `request.parent.session.id` in the child's `parentSession` header. The child may be owned by the parent scope or by a provider/root scope; durable lineage is the transport-neutral local-child relation. Remote providers instead mint a parent-scoped lifecycle id without publishing a local child.
|
||||
A local run publishes an ordinary child agent/session before `start()` fulfills, returns that shared session id as `SubagentRun.id`, exposes the exact child as `SubagentRun.localAgent`, and records `request.parent.session.id` in the child's `parentSession` header. Remote providers instead mint a parent-scoped lifecycle id and return `localAgent: undefined`.
|
||||
|
||||
The service emits `subagent/start` only after `start()` has fulfilled. It attaches the result observer before that synchronous notification, so even an already-settled child still produces `subagent/start` before `subagent/end`. In-process start observers can resolve the published child through `ctx.agents.get(info.id)`; remote providers need not publish a local agent.
|
||||
The service emits `subagent/start` only after `start()` has fulfilled. It attaches the result observer before that synchronous notification, so even an already-settled child still produces `subagent/start` before `subagent/end`. The pair shares a service-minted `runId`; its `local` flag is snapshotted from the provider's exact `localAgent`, so observers never infer run identity or locality from reusable provider/session names.
|
||||
|
||||
Run events are scoped to the delegating parent. Every listener is independently contained: a synchronous throw or rejected returned promise is logged without starving peer listeners or changing the run.
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "^0.0.1",
|
||||
"@deepseek-ai/dsh-brand": "^0.0.1",
|
||||
"@deepseek-ai/dsh-llm": "^0.0.1",
|
||||
"@deepseek-ai/dsh-scope": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session": "^0.0.1",
|
||||
@@ -31,6 +32,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-brand": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-scope": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
* @module @deepseek-ai/dsh-subagent
|
||||
*/
|
||||
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { Context, Service } from 'cordis'
|
||||
import { scopeTarget } from '@deepseek-ai/dsh-scope'
|
||||
import type { Scoped } from '@deepseek-ai/dsh-scope'
|
||||
@@ -43,7 +44,9 @@ import type {
|
||||
SubagentRun,
|
||||
SubagentStartRequest,
|
||||
} from './types.ts'
|
||||
import { SubagentRunId } from './types.ts'
|
||||
|
||||
export { SubagentRunId } from './types.ts'
|
||||
export type {
|
||||
SubagentCapabilities,
|
||||
SubagentProvider,
|
||||
@@ -112,18 +115,26 @@ declare module 'cordis' {
|
||||
|
||||
/** Observe-only identifying detail for a ready subagent run. */
|
||||
export interface SubagentRunInfo {
|
||||
/** Unique identity shared with the paired terminal event. */
|
||||
readonly runId: SubagentRunId
|
||||
/** The provider that established the run. */
|
||||
readonly provider: string
|
||||
/** The child agent's id. */
|
||||
readonly id: SessionId
|
||||
/** Whether the provider exposed an exact published in-process child. */
|
||||
readonly local: boolean
|
||||
}
|
||||
|
||||
/** Observe-only outcome detail for a settled subagent run. */
|
||||
export interface SubagentRunEndInfo {
|
||||
/** Unique identity shared with the paired start event. */
|
||||
readonly runId: SubagentRunId
|
||||
/** The provider that ran it. */
|
||||
readonly provider: string
|
||||
/** The child agent's id. */
|
||||
readonly id: SessionId
|
||||
/** Whether the provider exposed an exact published in-process child. */
|
||||
readonly local: boolean
|
||||
/** The terminal stop reason. */
|
||||
readonly stopReason: SubagentResult['stopReason']
|
||||
/** The child's final assistant output, absent on infrastructure rejection. */
|
||||
@@ -208,22 +219,28 @@ export class SubagentService extends Service {
|
||||
|
||||
const parent = request.parent
|
||||
const run = await provider.start(request)
|
||||
const runId = SubagentRunId(randomUUID())
|
||||
const lifecycleIdentity = {
|
||||
runId,
|
||||
provider: name,
|
||||
id: run.id,
|
||||
local: run.localAgent !== undefined,
|
||||
}
|
||||
// Attach the terminal observer before dispatching start. Promise reactions
|
||||
// still run after this synchronous start emission, preserving start → end.
|
||||
void run.result.then(
|
||||
(result) => {
|
||||
this.emitLifecycle('subagent/end', {
|
||||
provider: name,
|
||||
id: run.id,
|
||||
...lifecycleIdentity,
|
||||
stopReason: result.stopReason,
|
||||
lastAssistantMessage: result.output,
|
||||
}, parent)
|
||||
},
|
||||
() => {
|
||||
this.emitLifecycle('subagent/end', { provider: name, id: run.id, stopReason: 'error' }, parent)
|
||||
this.emitLifecycle('subagent/end', { ...lifecycleIdentity, stopReason: 'error' }, parent)
|
||||
},
|
||||
)
|
||||
this.emitLifecycle('subagent/start', { provider: name, id: run.id }, parent)
|
||||
this.emitLifecycle('subagent/start', lifecycleIdentity, parent)
|
||||
return run
|
||||
}
|
||||
|
||||
|
||||
@@ -7,10 +7,23 @@
|
||||
*/
|
||||
|
||||
import type { Agent, AgentOptions } from '@deepseek-ai/dsh-agent'
|
||||
import type { Branded } from '@deepseek-ai/dsh-brand'
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { StructuredOutputSchema, ToolRestriction } from '@deepseek-ai/dsh-tools'
|
||||
|
||||
/** Identifies one accepted subagent run across its lifecycle event pair. */
|
||||
export type SubagentRunId = Branded<'SubagentRunId'>
|
||||
|
||||
/**
|
||||
* Brand a string as a {@link SubagentRunId}.
|
||||
* @param id - the raw id string (the service mints UUIDs; tests may pass fixtures).
|
||||
* @returns the same string, branded.
|
||||
*/
|
||||
export function SubagentRunId(id: string): SubagentRunId {
|
||||
return id as SubagentRunId
|
||||
}
|
||||
|
||||
/**
|
||||
* Which START-TIME features a provider supports. Checked by the service before delegating to
|
||||
* {@link SubagentProvider.start}: a request that needs a capability the chosen provider lacks
|
||||
@@ -139,6 +152,12 @@ export interface SubagentRun {
|
||||
* remote provider mints an id unique in the parent namespace.
|
||||
*/
|
||||
readonly id: SessionId
|
||||
/**
|
||||
* The exact published in-process child, or `undefined` for a remote run.
|
||||
* When present, its id is {@link id}; the provider retains no ownership
|
||||
* implication beyond the run's ordinary {@link dispose} contract.
|
||||
*/
|
||||
readonly localAgent: Agent | undefined
|
||||
/**
|
||||
* Resolves with the child's terminal {@link SubagentResult} when the run
|
||||
* settles. Does NOT reject on a child-level failure — a model/transport
|
||||
|
||||
@@ -48,6 +48,7 @@ class StubProvider implements SubagentProvider {
|
||||
this.startCount += 1
|
||||
return {
|
||||
id: SessionId(`child:${this.name}:${request.parent.id}`),
|
||||
localAgent: undefined,
|
||||
result: Promise.resolve(this.outcome),
|
||||
async dispose() {},
|
||||
}
|
||||
@@ -137,13 +138,14 @@ describe('SubagentService', () => {
|
||||
const parent = fakeParent('delegator')
|
||||
const events: string[] = []
|
||||
const keys: unknown[] = []
|
||||
ctx.on('subagent/start', function () { events.push('start'); keys.push(carrierKeyOf(this)) })
|
||||
ctx.on('subagent/end', function () { events.push('end'); keys.push(carrierKeyOf(this)) })
|
||||
const runIds: string[] = []
|
||||
ctx.on('subagent/start', function (info) { events.push('start'); keys.push(carrierKeyOf(this)); runIds.push(info.runId) })
|
||||
ctx.on('subagent/end', function (info) { events.push('end'); keys.push(carrierKeyOf(this)); runIds.push(info.runId) })
|
||||
|
||||
const starting = subagents.start('deferred', baseRequest({ parent }))
|
||||
await Promise.resolve()
|
||||
expect(events).toEqual([])
|
||||
ready.resolve({ id: SessionId('child'), result: result.promise, async dispose() {} })
|
||||
ready.resolve({ id: SessionId('child'), localAgent: undefined, result: result.promise, async dispose() {} })
|
||||
const run = await starting
|
||||
expect(events).toEqual(['start'])
|
||||
result.resolve({ output: [{ type: 'text', text: 'answer' }], stopReason: 'completed' })
|
||||
@@ -151,6 +153,21 @@ describe('SubagentService', () => {
|
||||
await Promise.resolve()
|
||||
expect(events).toEqual(['start', 'end'])
|
||||
expect(keys).toEqual([parent, parent])
|
||||
expect(runIds[0]).toBe(runIds[1])
|
||||
})
|
||||
|
||||
it('mints distinct lifecycle identities when provider and child ids repeat', async () => {
|
||||
const { ctx, subagents } = await service()
|
||||
subagents.registerProvider(new StubProvider('reused'))
|
||||
const runIds: string[] = []
|
||||
ctx.on('subagent/start', info => void runIds.push(info.runId))
|
||||
|
||||
const first = await subagents.start('reused', baseRequest())
|
||||
const second = await subagents.start('reused', baseRequest())
|
||||
await Promise.all([first.result, second.result])
|
||||
|
||||
expect(runIds).toHaveLength(2)
|
||||
expect(new Set(runIds).size).toBe(2)
|
||||
})
|
||||
|
||||
it('emits no run lifecycle when provider startup rejects', async () => {
|
||||
@@ -192,7 +209,7 @@ describe('SubagentService', () => {
|
||||
capabilities: NO_CAPS,
|
||||
inheritsParentContext: false,
|
||||
async start() {
|
||||
return { id: SessionId('infra-child'), result: failure.promise, async dispose() {} }
|
||||
return { id: SessionId('infra-child'), localAgent: undefined, result: failure.promise, async dispose() {} }
|
||||
},
|
||||
})
|
||||
const failedRun = await subagents.start('infra', baseRequest())
|
||||
|
||||
@@ -144,6 +144,7 @@ describe('dsh-tool-subagent', () => {
|
||||
inheritsParentContext: false,
|
||||
start: async () => ({
|
||||
id: SessionId('weird-child'),
|
||||
localAgent: undefined,
|
||||
result: Promise.resolve({ output: [{ type: 'text', text: 'partial' }], stopReason: 'frobnicated' as never }),
|
||||
dispose: async () => {},
|
||||
}),
|
||||
@@ -171,6 +172,7 @@ describe('dsh-tool-subagent', () => {
|
||||
seen = request
|
||||
return {
|
||||
id: SessionId('capture-child'),
|
||||
localAgent: undefined,
|
||||
result: Promise.resolve({ output: [{ type: 'text', text: 'ok' }], stopReason: 'completed' as const }),
|
||||
dispose: async () => {},
|
||||
}
|
||||
@@ -200,6 +202,7 @@ describe('dsh-tool-subagent', () => {
|
||||
seen = request
|
||||
return {
|
||||
id: SessionId('bare-child'),
|
||||
localAgent: undefined,
|
||||
result: Promise.resolve({ output: [{ type: 'text', text: 'ok' }], stopReason: 'completed' as const }),
|
||||
dispose: async () => {},
|
||||
}
|
||||
@@ -327,6 +330,7 @@ describe('dsh-tool-subagent', () => {
|
||||
inheritsParentContext: false,
|
||||
start: async () => ({
|
||||
id: SessionId('spy-child'),
|
||||
localAgent: undefined,
|
||||
result: Promise.resolve({ output: [{ type: 'text', text: 'ok' }], stopReason: 'completed' as const }),
|
||||
dispose: async () => void disposed(),
|
||||
}),
|
||||
@@ -349,6 +353,7 @@ describe('dsh-tool-subagent', () => {
|
||||
inheritsParentContext: false,
|
||||
start: async () => ({
|
||||
id: SessionId('spy-child'),
|
||||
localAgent: undefined,
|
||||
result: Promise.resolve({ output: [], stopReason: 'error' as const }),
|
||||
dispose: async () => void disposed(),
|
||||
}),
|
||||
@@ -380,6 +385,7 @@ describe('dsh-tool-subagent', () => {
|
||||
}, { once: true })
|
||||
return {
|
||||
id: SessionId('spy-child'),
|
||||
localAgent: undefined,
|
||||
result,
|
||||
dispose: async () => {},
|
||||
}
|
||||
@@ -472,6 +478,7 @@ describe('dsh-tool-subagent', () => {
|
||||
seen = request
|
||||
return {
|
||||
id: SessionId('capture2-child'),
|
||||
localAgent: undefined,
|
||||
result: Promise.resolve({ output: [{ type: 'text', text: 'ok' }], stopReason: 'completed' as const }),
|
||||
dispose: async () => {},
|
||||
}
|
||||
@@ -529,6 +536,7 @@ describe('dsh-tool-subagent', () => {
|
||||
seen = request
|
||||
return {
|
||||
id: SessionId('capture3-child'),
|
||||
localAgent: undefined,
|
||||
result: Promise.resolve({ output: [{ type: 'text', text: 'ok' }], stopReason: 'completed' as const }),
|
||||
dispose: async () => {},
|
||||
}
|
||||
@@ -558,6 +566,7 @@ describe('dsh-tool-subagent', () => {
|
||||
seen = request
|
||||
return {
|
||||
id: SessionId('capture4-child'),
|
||||
localAgent: undefined,
|
||||
result: Promise.resolve({ output: [{ type: 'text', text: 'ok' }], stopReason: 'completed' as const }),
|
||||
dispose: async () => {},
|
||||
}
|
||||
@@ -732,6 +741,7 @@ describe('dsh-tool-subagent background mode', () => {
|
||||
}, { once: true })
|
||||
return {
|
||||
id,
|
||||
localAgent: undefined,
|
||||
result,
|
||||
dispose: () => Promise.resolve(),
|
||||
}
|
||||
@@ -771,6 +781,7 @@ describe('dsh-tool-subagent background mode', () => {
|
||||
const order: string[] = []
|
||||
const completed = await settleRun({
|
||||
id: SessionId('child-1'),
|
||||
localAgent: undefined,
|
||||
result: Promise.resolve({ output: [{ type: 'text' as const, text: 'ok' }], stopReason: 'completed' as const }),
|
||||
dispose() { order.push('dispose'); return Promise.resolve() },
|
||||
})
|
||||
@@ -782,6 +793,7 @@ describe('dsh-tool-subagent background mode', () => {
|
||||
let disposed = false
|
||||
const failed = await settleRun({
|
||||
id: SessionId('child-2'),
|
||||
localAgent: undefined,
|
||||
result: Promise.reject(new Error('transport gone')),
|
||||
dispose() { disposed = true; return Promise.resolve() },
|
||||
})
|
||||
@@ -790,6 +802,7 @@ describe('dsh-tool-subagent background mode', () => {
|
||||
|
||||
const disposeFailed = await settleRun({
|
||||
id: SessionId('child-3'),
|
||||
localAgent: undefined,
|
||||
result: Promise.resolve({ output: [], stopReason: 'completed' }),
|
||||
dispose: () => Promise.reject(new Error('reap failed')),
|
||||
})
|
||||
@@ -797,6 +810,7 @@ describe('dsh-tool-subagent background mode', () => {
|
||||
|
||||
const bothFailed = await settleRun({
|
||||
id: SessionId('child-4'),
|
||||
localAgent: undefined,
|
||||
result: Promise.reject(new Error('result failed')),
|
||||
dispose: () => Promise.reject(new Error('reap failed')),
|
||||
})
|
||||
@@ -832,6 +846,7 @@ describe('background preflight failure (no orphaned child, by construction)', ()
|
||||
starts += 1
|
||||
return {
|
||||
id: SessionId('probe-child'),
|
||||
localAgent: undefined,
|
||||
result: Promise.resolve({ output: [], stopReason: 'completed' as const }),
|
||||
dispose: () => Promise.resolve(),
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user