|
|
@@ -11,9 +11,37 @@ import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
|
|
|
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
|
|
|
import SubagentService from '@deepseek-ai/dsh-subagent'
|
|
|
import * as SubagentSpawn from '@deepseek-ai/dsh-subagent-spawn'
|
|
|
+import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm'
|
|
|
+import { LlmAdapter } from '@deepseek-ai/dsh-llm'
|
|
|
import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
|
|
|
import * as tool from '../src/index.ts'
|
|
|
|
|
|
+/** One scripted response that may wait on a caller-released gate before streaming. */
|
|
|
+interface GatedEntry {
|
|
|
+ chunks: StreamChunk[]
|
|
|
+ gate?: Promise<undefined>
|
|
|
+}
|
|
|
+
|
|
|
+/** Adapter whose entries can hold a model call open until the test releases it. */
|
|
|
+class GatedAdapter extends LlmAdapter {
|
|
|
+ readonly requests: GenerateOptions[] = []
|
|
|
+
|
|
|
+ constructor(private script: GatedEntry[]) {
|
|
|
+ super()
|
|
|
+ }
|
|
|
+
|
|
|
+ async * stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
|
|
|
+ this.requests.push(options)
|
|
|
+ const entry = this.script.shift()
|
|
|
+ if (!entry) throw new Error('GatedAdapter: script exhausted')
|
|
|
+ if (entry.gate) await entry.gate
|
|
|
+ for (const chunk of entry.chunks) {
|
|
|
+ if (options.signal?.aborted) throw new Error('aborted')
|
|
|
+ yield chunk
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
const testToolSignal = new AbortController().signal
|
|
|
|
|
|
const roots: string[] = []
|
|
|
@@ -21,7 +49,7 @@ afterEach(() => {
|
|
|
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
|
|
|
})
|
|
|
|
|
|
-async function setup(script: ConstructorParameters<typeof MockAdapter>[0]) {
|
|
|
+async function setupWith(adapter: MockAdapter | GatedAdapter) {
|
|
|
const ctx = new Context()
|
|
|
await mountAgentLoopTestDependencies(ctx)
|
|
|
const root = mkdtempSync(join(tmpdir(), 'dsh-tool-subagent-control-'))
|
|
|
@@ -32,12 +60,15 @@ async function setup(script: ConstructorParameters<typeof MockAdapter>[0]) {
|
|
|
await ctx.plugin(SubagentService)
|
|
|
await ctx.plugin(SubagentSpawn, { providerName: 'spawn' })
|
|
|
await ctx.plugin(tool)
|
|
|
- const adapter = new MockAdapter(script)
|
|
|
ctx.llm.registerAdapter(['mock'], adapter)
|
|
|
const parent = ctx.agentLoop.create(SessionId('parent'), { provider: 'mock', model: 'mock' })
|
|
|
return { ctx, parent, adapter }
|
|
|
}
|
|
|
|
|
|
+async function setup(script: ConstructorParameters<typeof MockAdapter>[0]) {
|
|
|
+ return setupWith(new MockAdapter(script))
|
|
|
+}
|
|
|
+
|
|
|
function text(result: { content: { type: string; text?: string }[] }): string {
|
|
|
return result.content.filter(block => block.type === 'text').map(block => block.text).join('')
|
|
|
}
|
|
|
@@ -177,8 +208,10 @@ describe('dsh-tool-subagent-control', () => {
|
|
|
await ctx.plugin(SubagentService)
|
|
|
const fiber = await ctx.plugin(tool)
|
|
|
expect(ctx.tools.schemas().some(schema => schema.name === 'send_message')).toBe(true)
|
|
|
+ expect(ctx.tools.schemas().some(schema => schema.name === 'interrupt_agent')).toBe(true)
|
|
|
await fiber.dispose()
|
|
|
expect(ctx.tools.schemas().some(schema => schema.name === 'send_message')).toBe(false)
|
|
|
+ expect(ctx.tools.schemas().some(schema => schema.name === 'interrupt_agent')).toBe(false)
|
|
|
})
|
|
|
|
|
|
it('has the namespace-plugin export shape (no stray default)', () => {
|
|
|
@@ -188,3 +221,168 @@ describe('dsh-tool-subagent-control', () => {
|
|
|
expect(typeof tool.apply).toBe('function')
|
|
|
})
|
|
|
})
|
|
|
+
|
|
|
+describe('dsh-tool-subagent-control interrupt_agent', () => {
|
|
|
+ it('registers interrupt_agent with the single agent_id parameter and current-turn wording', async () => {
|
|
|
+ const { ctx } = await setup([])
|
|
|
+ const schemas = ctx.tools.schemas().filter(schema => schema.name === 'interrupt_agent')
|
|
|
+ expect(schemas).toHaveLength(1)
|
|
|
+ const props = (schemas[0]!.parameters as { properties?: Record<string, unknown> }).properties ?? {}
|
|
|
+ expect(Object.keys(props)).toEqual(['agent_id'])
|
|
|
+ expect(schemas[0]!.description).toContain('current turn')
|
|
|
+ expect(schemas[0]!.description).toContain('send_message')
|
|
|
+ })
|
|
|
+
|
|
|
+ it('interrupts a running direct child with the parent cause, parking its queue', async () => {
|
|
|
+ const releaseFirst = Promise.withResolvers<undefined>()
|
|
|
+ const adapter = new GatedAdapter([
|
|
|
+ { chunks: textResponse('held'), gate: releaseFirst.promise },
|
|
|
+ { chunks: textResponse('parked answer') },
|
|
|
+ { chunks: textResponse('waking answer') },
|
|
|
+ ])
|
|
|
+ const { ctx, parent } = await setupWith(adapter)
|
|
|
+ const started = await ctx.subagents.startContinuable({
|
|
|
+ provider: 'spawn',
|
|
|
+ label: 'long work',
|
|
|
+ request: { prompt: [{ type: 'text', text: 'long work' }], parent },
|
|
|
+ signal: testToolSignal,
|
|
|
+ })
|
|
|
+ await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) })
|
|
|
+ const child = ctx.agents.get(started.childId)!
|
|
|
+ const queued = await callTool(ctx, 'send_message', {
|
|
|
+ subagent_id: started.childId,
|
|
|
+ message: 'parked follow-up',
|
|
|
+ }, parent)
|
|
|
+ expect(queued.isError).toBe(false)
|
|
|
+ const cancelSpy = vi.spyOn(child, 'cancel')
|
|
|
+
|
|
|
+ const result = await callTool(ctx, 'interrupt_agent', { agent_id: started.childId }, parent)
|
|
|
+
|
|
|
+ expect(result.isError).toBe(false)
|
|
|
+ expect(text(result)).toBe(`interrupt requested for agent ${started.childId}`)
|
|
|
+ expect(cancelSpy).toHaveBeenCalledExactlyOnceWith({ kind: 'parent' }, { keepInbox: true })
|
|
|
+ releaseFirst.resolve(undefined)
|
|
|
+ await child.whenIdle()
|
|
|
+ // Parked, not resumed: the queued follow-up waits for a waking send.
|
|
|
+ expect(adapter.requests).toHaveLength(1)
|
|
|
+ expect(child.inbox.nextTurn).toHaveLength(1)
|
|
|
+
|
|
|
+ const waking = await callTool(ctx, 'send_message', {
|
|
|
+ subagent_id: started.childId,
|
|
|
+ message: 'wake up',
|
|
|
+ }, parent)
|
|
|
+ expect(waking.isError).toBe(false)
|
|
|
+ await waitNoActivation(ctx, started.childId)
|
|
|
+ const loaded = await ctx.sessionPersistence.load(started.childId)
|
|
|
+ const prompts = loaded.events.flatMap(event => event.type === 'user/message'
|
|
|
+ ? event.data.content.flatMap(block => block.type === 'text' ? [block.text] : [])
|
|
|
+ : [])
|
|
|
+ expect(prompts).toEqual(['long work', 'parked follow-up', 'wake up'])
|
|
|
+ })
|
|
|
+
|
|
|
+ it('lets a deep live ancestor interrupt a descendant it did not directly create', async () => {
|
|
|
+ const releaseChild = Promise.withResolvers<undefined>()
|
|
|
+ const releaseGrandchild = Promise.withResolvers<undefined>()
|
|
|
+ const adapter = new GatedAdapter([
|
|
|
+ { chunks: textResponse('child'), gate: releaseChild.promise },
|
|
|
+ { chunks: textResponse('grandchild'), gate: releaseGrandchild.promise },
|
|
|
+ ])
|
|
|
+ const { ctx, parent } = await setupWith(adapter)
|
|
|
+ const started = await ctx.subagents.startContinuable({
|
|
|
+ provider: 'spawn',
|
|
|
+ label: 'child',
|
|
|
+ request: { prompt: [{ type: 'text', text: 'child work' }], parent },
|
|
|
+ signal: testToolSignal,
|
|
|
+ })
|
|
|
+ await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) })
|
|
|
+ const child = ctx.agents.get(started.childId)!
|
|
|
+ const grandchild = await ctx.subagents.startContinuable({
|
|
|
+ provider: 'spawn',
|
|
|
+ label: 'grandchild',
|
|
|
+ request: { prompt: [{ type: 'text', text: 'grandchild work' }], parent: child },
|
|
|
+ signal: testToolSignal,
|
|
|
+ })
|
|
|
+ await vi.waitFor(() => { expect(adapter.requests).toHaveLength(2) })
|
|
|
+ const grandchildAgent = ctx.agents.get(grandchild.childId)!
|
|
|
+ const cancelSpy = vi.spyOn(grandchildAgent, 'cancel')
|
|
|
+
|
|
|
+ const result = await callTool(ctx, 'interrupt_agent', { agent_id: grandchild.childId }, parent)
|
|
|
+
|
|
|
+ expect(result.isError).toBe(false)
|
|
|
+ expect(cancelSpy).toHaveBeenCalledExactlyOnceWith({ kind: 'parent' }, { keepInbox: true })
|
|
|
+ releaseChild.resolve(undefined)
|
|
|
+ releaseGrandchild.resolve(undefined)
|
|
|
+ await waitNoActivation(ctx, grandchild.childId)
|
|
|
+ await waitNoActivation(ctx, started.childId)
|
|
|
+ })
|
|
|
+
|
|
|
+ it('rejects self, sibling, and unrelated callers without touching the target', async () => {
|
|
|
+ const releaseA = Promise.withResolvers<undefined>()
|
|
|
+ const releaseB = Promise.withResolvers<undefined>()
|
|
|
+ const adapter = new GatedAdapter([
|
|
|
+ { chunks: textResponse('a'), gate: releaseA.promise },
|
|
|
+ { chunks: textResponse('b'), gate: releaseB.promise },
|
|
|
+ ])
|
|
|
+ const { ctx, parent } = await setupWith(adapter)
|
|
|
+ const target = await ctx.subagents.startContinuable({
|
|
|
+ provider: 'spawn',
|
|
|
+ label: 'target',
|
|
|
+ request: { prompt: [{ type: 'text', text: 'a' }], parent },
|
|
|
+ signal: testToolSignal,
|
|
|
+ })
|
|
|
+ await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) })
|
|
|
+ const sibling = await ctx.subagents.startContinuable({
|
|
|
+ provider: 'spawn',
|
|
|
+ label: 'sibling',
|
|
|
+ request: { prompt: [{ type: 'text', text: 'b' }], parent },
|
|
|
+ signal: testToolSignal,
|
|
|
+ })
|
|
|
+ await vi.waitFor(() => { expect(adapter.requests).toHaveLength(2) })
|
|
|
+ const targetAgent = ctx.agents.get(target.childId)!
|
|
|
+ const siblingAgent = ctx.agents.get(sibling.childId)!
|
|
|
+ const stranger = ctx.agentLoop.create(SessionId('stranger'), { provider: 'mock', model: 'mock' })
|
|
|
+ const cancelSpy = vi.spyOn(targetAgent, 'cancel')
|
|
|
+
|
|
|
+ const self = await callTool(ctx, 'interrupt_agent', { agent_id: target.childId }, targetAgent)
|
|
|
+ expect(self.isError).toBe(true)
|
|
|
+ expect(text(self)).toContain('cannot interrupt itself')
|
|
|
+ const fromSibling = await callTool(ctx, 'interrupt_agent', { agent_id: target.childId }, siblingAgent)
|
|
|
+ expect(fromSibling.isError).toBe(true)
|
|
|
+ expect(text(fromSibling)).toContain('not a live descendant')
|
|
|
+ const fromStranger = await callTool(ctx, 'interrupt_agent', { agent_id: target.childId }, stranger)
|
|
|
+ expect(fromStranger.isError).toBe(true)
|
|
|
+ expect(text(fromStranger)).toContain('not a live descendant')
|
|
|
+ expect(cancelSpy).not.toHaveBeenCalled()
|
|
|
+
|
|
|
+ releaseA.resolve(undefined)
|
|
|
+ releaseB.resolve(undefined)
|
|
|
+ await waitNoActivation(ctx, target.childId)
|
|
|
+ await waitNoActivation(ctx, sibling.childId)
|
|
|
+ })
|
|
|
+
|
|
|
+ it('accepts an absent target as a no-op without cold-resuming it', async () => {
|
|
|
+ const { ctx, parent } = await setup([textResponse('done')])
|
|
|
+ const started = await ctx.subagents.startContinuable({
|
|
|
+ provider: 'spawn',
|
|
|
+ label: 'settled child',
|
|
|
+ request: { prompt: [{ type: 'text', text: 'child work' }], parent },
|
|
|
+ signal: testToolSignal,
|
|
|
+ })
|
|
|
+ await waitNoActivation(ctx, started.childId)
|
|
|
+
|
|
|
+ const settled = await callTool(ctx, 'interrupt_agent', { agent_id: started.childId }, parent)
|
|
|
+ expect(settled.isError).toBe(false)
|
|
|
+ expect(text(settled)).toBe(`interrupt requested for agent ${started.childId}`)
|
|
|
+ const unknown = await callTool(ctx, 'interrupt_agent', { agent_id: 'no-such-agent' }, parent)
|
|
|
+ expect(unknown.isError).toBe(false)
|
|
|
+ // No cold resume: the settled target never rematerialized.
|
|
|
+ expect(ctx.agents.get(started.childId)).toBeUndefined()
|
|
|
+ })
|
|
|
+
|
|
|
+ it('fails loud when invoked without a calling agent', async () => {
|
|
|
+ const { ctx } = await setup([])
|
|
|
+ const result = await callTool(ctx, 'interrupt_agent', { agent_id: 'x' })
|
|
|
+ expect(result.isError).toBe(true)
|
|
|
+ expect(text(result)).toContain('requires a calling agent')
|
|
|
+ })
|
|
|
+})
|