mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
# Conflicts: # apps/web/tests/snapshots/queue-actions/layout.expected.md # docs/cordis-catalog/events.md # docs/cordis-catalog/services.md # docs/core-data-structures/core.i18n.yaml # docs/core-data-structures/goal.i18n.yaml # docs/core-data-structures/goal.md # docs/core-data-structures/goal.zh.md # docs/event-producer-consumer.md # examples/acp-agent/tests/goal-snapshots/goal-session/session.expected.jsonl # examples/acp-agent/tests/goal-snapshots/goal-wrapup/session.expected.jsonl # examples/acp-agent/tests/snapshots/advanced-toolchain/session.1.jsonl # examples/acp-agent/tests/snapshots/advanced-toolchain/session.2.jsonl # examples/acp-agent/tests/snapshots/advanced-toolchain/session.jsonl # examples/acp-agent/tests/snapshots/bash-spill/session.jsonl # examples/acp-agent/tests/snapshots/bash-tool-turn/session.jsonl # examples/acp-agent/tests/snapshots/both-mode-turn/session.jsonl # examples/acp-agent/tests/snapshots/cancel-tool-calls/session.jsonl # examples/acp-agent/tests/snapshots/cancel/session.jsonl # examples/acp-agent/tests/snapshots/code-mode-turn/session.jsonl # examples/acp-agent/tests/snapshots/code-mode-workspace-context/session.jsonl # examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl # examples/acp-agent/tests/snapshots/empty-response-retry/session.jsonl # examples/acp-agent/tests/snapshots/error-finish/session.jsonl # examples/acp-agent/tests/snapshots/escalation-approved/session.jsonl # examples/acp-agent/tests/snapshots/escalation-rejected/session.jsonl # examples/acp-agent/tests/snapshots/fs-edit/session.jsonl # examples/acp-agent/tests/snapshots/fs-escalation-approved/session.jsonl # examples/acp-agent/tests/snapshots/fs-policy-reject/session.jsonl # examples/acp-agent/tests/snapshots/fs-read-window/session.jsonl # examples/acp-agent/tests/snapshots/fs-read/session.jsonl # examples/acp-agent/tests/snapshots/fs-write-overwrite/session.jsonl # examples/acp-agent/tests/snapshots/fs-write/session.jsonl # examples/acp-agent/tests/snapshots/hook-cc-invalid-matcher/session.jsonl # examples/acp-agent/tests/snapshots/hook-cc-posttool-block/session.jsonl # examples/acp-agent/tests/snapshots/hook-cc-posttool-context/session.jsonl # examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/session.jsonl # examples/acp-agent/tests/snapshots/hook-cc-pretool-deny/session.jsonl # examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-context/session.jsonl # examples/acp-agent/tests/snapshots/hook-cc-stop-continue/session.jsonl # examples/acp-agent/tests/snapshots/hook-codex-invalid-matcher/session.jsonl # examples/acp-agent/tests/snapshots/hook-codex-posttool-block/session.jsonl # examples/acp-agent/tests/snapshots/hook-codex-posttool-context/session.jsonl # examples/acp-agent/tests/snapshots/hook-codex-pretool-block/session.jsonl # examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-context/session.jsonl # examples/acp-agent/tests/snapshots/hook-codex-stop-continue/session.jsonl # examples/acp-agent/tests/snapshots/lsp-definition/session.jsonl # examples/acp-agent/tests/snapshots/missing-sandbox-runner/session.jsonl # examples/acp-agent/tests/snapshots/multi-turn/session.jsonl # examples/acp-agent/tests/snapshots/packed-chunks/session.jsonl # examples/acp-agent/tests/snapshots/parallel-tool-calls/session.jsonl # examples/acp-agent/tests/snapshots/partial-landlock-child-failure/session.jsonl # examples/acp-agent/tests/snapshots/pty-tools/session.jsonl # examples/acp-agent/tests/snapshots/repeat-tool-guard/session.jsonl # examples/acp-agent/tests/snapshots/session-query-spill/session.jsonl # examples/acp-agent/tests/snapshots/session-sandbox-root/session.jsonl # examples/acp-agent/tests/snapshots/session-title-after-turn/session.jsonl # examples/acp-agent/tests/snapshots/skill-load/session.jsonl # examples/acp-agent/tests/snapshots/subagent-continuable/session.1.jsonl # examples/acp-agent/tests/snapshots/subagent-continuable/session.jsonl # examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/session.1.jsonl # examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/session.2.jsonl # examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/session.jsonl # examples/acp-agent/tests/snapshots/subagent-fork/session.1.jsonl # examples/acp-agent/tests/snapshots/subagent-fork/session.jsonl # examples/acp-agent/tests/snapshots/subagent-list-agents/session.1.jsonl # examples/acp-agent/tests/snapshots/subagent-list-agents/session.jsonl # examples/acp-agent/tests/snapshots/subagent-mixed/session.1.jsonl # examples/acp-agent/tests/snapshots/subagent-mixed/session.2.jsonl # examples/acp-agent/tests/snapshots/subagent-mixed/session.jsonl # examples/acp-agent/tests/snapshots/subagent-multi/session.1.jsonl # examples/acp-agent/tests/snapshots/subagent-multi/session.2.jsonl # examples/acp-agent/tests/snapshots/subagent-multi/session.jsonl # examples/acp-agent/tests/snapshots/subagent-published-run-failure/session.jsonl # examples/acp-agent/tests/snapshots/subagent-report/session.1.jsonl # examples/acp-agent/tests/snapshots/subagent-report/session.jsonl # examples/acp-agent/tests/snapshots/subagent-spawn/session.1.jsonl # examples/acp-agent/tests/snapshots/subagent-spawn/session.jsonl # examples/acp-agent/tests/snapshots/text-turn/session.jsonl # examples/acp-agent/tests/snapshots/todo-write/session.jsonl # examples/acp-agent/tests/snapshots/tool-call-turn/session.jsonl # examples/acp-agent/tests/snapshots/web-fetch/session.jsonl # examples/acp-agent/tests/snapshots/workflow-run/session.1.jsonl # examples/acp-agent/tests/snapshots/workflow-run/session.jsonl # examples/acp-agent/tests/snapshots/workspace-context/session.jsonl # examples/acp-agent/tests/snapshots/workspace-edit/session.jsonl # examples/headless-agent/tests/snapshots/goal-tools/stream-json.expected.jsonl # examples/headless-agent/tests/snapshots/pty-tools/session.jsonl # examples/headless-agent/tests/snapshots/pty-tools/stream-json.expected.jsonl # examples/headless-agent/tests/subagent-inheritance-snapshots/parent-override/child.expected.jsonl # examples/headless-agent/tests/subagent-inheritance-snapshots/parent-override/parent.expected.jsonl # examples/jsonrpc-agent/tests/snapshots/persistent-tools/notifications.expected.jsonl # examples/jsonrpc-agent/tests/snapshots/persistent-tools/session.jsonl # packages/bash/tool-bash/tests/integration.spec.ts # packages/context/time-context/src/index.ts # packages/context/tmux-context/src/index.ts # packages/core/agent-loop/src/agent.ts # packages/core/system-prompt/src/index.ts # packages/goal/goal/src/domain.ts # packages/goal/goal/src/index.ts # packages/goal/goal/src/render.ts # packages/plan/plan-mode/src/index.ts
189 lines
7.5 KiB
TypeScript
189 lines
7.5 KiB
TypeScript
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
import { mkdtempSync, rmSync } from 'node:fs'
|
|
import { tmpdir } from 'node:os'
|
|
import { join } from 'node:path'
|
|
import { Context } from 'cordis'
|
|
import { CallId } from '@deepseek-ai/dsh-llm'
|
|
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
|
|
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
|
|
import { SessionId } from '@deepseek-ai/dsh-session'
|
|
import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
|
|
import SubagentService from '@deepseek-ai/dsh-subagent'
|
|
import * as SubagentSpawn from '@deepseek-ai/dsh-subagent-spawn'
|
|
import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
|
|
import * as tool from '../src/index.ts'
|
|
|
|
const testToolSignal = new AbortController().signal
|
|
|
|
const roots: string[] = []
|
|
afterEach(() => {
|
|
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
|
|
})
|
|
|
|
async function setup(script: ConstructorParameters<typeof MockAdapter>[0]) {
|
|
const ctx = new Context()
|
|
await mountAgentLoopTestDependencies(ctx)
|
|
const root = mkdtempSync(join(tmpdir(), 'dsh-tool-subagent-control-'))
|
|
roots.push(root)
|
|
await ctx.plugin(JsonlSessionPersistence, { root })
|
|
await ctx.plugin(AgentLoop, { agents: [] })
|
|
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 }
|
|
}
|
|
|
|
function text(result: { content: { type: string; text?: string }[] }): string {
|
|
return result.content.filter(block => block.type === 'text').map(block => block.text).join('')
|
|
}
|
|
|
|
let calls = 0
|
|
function callTool(
|
|
ctx: Context,
|
|
name: string,
|
|
args: unknown,
|
|
agent?: unknown,
|
|
signal: AbortSignal = testToolSignal,
|
|
) {
|
|
return ctx.tools.execute({
|
|
signal,
|
|
callId: CallId(`call-${++calls}`),
|
|
name,
|
|
arguments: args,
|
|
...agent !== undefined ? { agent: agent as never } : {},
|
|
})
|
|
}
|
|
|
|
/** Wait until a child's Activation released its handle. */
|
|
async function waitNoActivation(ctx: Context, childId: SessionId): Promise<void> {
|
|
await vi.waitFor(() => {
|
|
expect(ctx.agents.get(childId)).toBeUndefined()
|
|
}, { timeout: 5_000 })
|
|
}
|
|
|
|
describe('dsh-tool-subagent-control', () => {
|
|
it('registers send_message once, globally, with the two required parameters', async () => {
|
|
const { ctx } = await setup([])
|
|
const schemas = ctx.tools.schemas().filter(schema => schema.name === 'send_message')
|
|
expect(schemas).toHaveLength(1)
|
|
const props = (schemas[0]!.parameters as { properties?: Record<string, unknown> }).properties ?? {}
|
|
expect(Object.keys(props).sort()).toEqual(['message', 'subagent_id'])
|
|
// The continuable path has no Task, so the schema must not promise one.
|
|
expect(schemas[0]!.description).not.toContain('task_output')
|
|
expect(schemas[0]!.description).not.toContain('task id')
|
|
// Follow-up ordering is model-visible: it cannot redirect the open turn.
|
|
expect(schemas[0]!.description).toContain('next turn')
|
|
})
|
|
|
|
it('cold-resumes a settled child and reports the queued next turn', async () => {
|
|
const { ctx, parent } = await setup([textResponse('first answer'), textResponse('second answer')])
|
|
const started = await ctx.subagents.startContinuable({
|
|
provider: 'spawn',
|
|
label: 'child task',
|
|
request: { prompt: [{ type: 'text', text: 'child task' }], parent },
|
|
signal: testToolSignal,
|
|
})
|
|
await waitNoActivation(ctx, started.childId)
|
|
|
|
const result = await callTool(ctx, 'send_message', {
|
|
subagent_id: started.childId,
|
|
message: 'and then?',
|
|
}, parent)
|
|
|
|
expect(result.isError).toBe(false)
|
|
expect(text(result)).toBe(`message queued as the next turn for subagent ${started.childId}`)
|
|
await waitNoActivation(ctx, started.childId)
|
|
|
|
const loaded = await ctx.sessionPersistence.load(started.childId)
|
|
const followUp = loaded.events.findLast(event => event.type === 'user/message')
|
|
// Durable provenance records the calling agent without granting authority.
|
|
expect(followUp?.type === 'user/message' && followUp.data.source).toEqual({
|
|
kind: 'coordinator',
|
|
form: 'relay',
|
|
senderSessionId: parent.id,
|
|
})
|
|
})
|
|
|
|
it('queues behind an open turn instead of joining it', async () => {
|
|
const { ctx, parent, adapter } = await setup([textResponse('first'), textResponse('second')])
|
|
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 result = await callTool(ctx, 'send_message', {
|
|
subagent_id: started.childId,
|
|
message: 'also consider Y',
|
|
}, parent)
|
|
expect(result.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] : [])
|
|
: [])
|
|
// A follow-up is its own later turn, never steering inside the first one.
|
|
expect(prompts).toEqual(['long work', 'also consider Y'])
|
|
})
|
|
|
|
it('reports a delivery failure as an errored, not-delivered result', async () => {
|
|
const { ctx, parent } = await setup([])
|
|
const result = await callTool(ctx, 'send_message', {
|
|
subagent_id: 'no-such-child',
|
|
message: 'hello?',
|
|
}, parent)
|
|
expect(result.isError).toBe(true)
|
|
expect(text(result)).toContain('unavailable')
|
|
})
|
|
|
|
it('rejects a caller that is not the child\'s durable direct parent', async () => {
|
|
const { ctx, parent } = await setup([textResponse('first')])
|
|
const started = await ctx.subagents.startContinuable({
|
|
provider: 'spawn',
|
|
label: 'child task',
|
|
request: { prompt: [{ type: 'text', text: 'child task' }], parent },
|
|
signal: testToolSignal,
|
|
})
|
|
await waitNoActivation(ctx, started.childId)
|
|
const stranger = ctx.agentLoop.create(SessionId('stranger'), { provider: 'mock', model: 'mock' })
|
|
|
|
const result = await callTool(ctx, 'send_message', {
|
|
subagent_id: started.childId,
|
|
message: 'mine now',
|
|
}, stranger)
|
|
expect(result.isError).toBe(true)
|
|
expect(text(result)).toContain('another parent session')
|
|
})
|
|
|
|
it('fails loud when invoked without a calling agent', async () => {
|
|
const { ctx } = await setup([])
|
|
const result = await callTool(ctx, 'send_message', { subagent_id: 'x', message: 'y' })
|
|
expect(result.isError).toBe(true)
|
|
expect(text(result)).toContain('requires a calling agent')
|
|
})
|
|
|
|
it('unregisters with its plugin fiber (HMR safety)', async () => {
|
|
const ctx = new Context()
|
|
await mountAgentLoopTestDependencies(ctx)
|
|
await ctx.plugin(AgentLoop, { agents: [] })
|
|
await ctx.plugin(SubagentService)
|
|
const fiber = await ctx.plugin(tool)
|
|
expect(ctx.tools.schemas().some(schema => schema.name === 'send_message')).toBe(true)
|
|
await fiber.dispose()
|
|
expect(ctx.tools.schemas().some(schema => schema.name === 'send_message')).toBe(false)
|
|
})
|
|
|
|
it('has the namespace-plugin export shape (no stray default)', () => {
|
|
expect('default' in tool).toBe(false)
|
|
expect(tool.name).toBe('tool-subagent-control')
|
|
expect(tool.inject).toEqual(['tools', 'subagents'])
|
|
expect(typeof tool.apply).toBe('function')
|
|
})
|
|
})
|