mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
Merge remote-tracking branch 'origin/master' into xtr/react-loop-simplification
# Conflicts: # examples/acp-agent/tests/snapshots/cancel-tool-calls/session.jsonl
This commit is contained in:
@@ -1,193 +0,0 @@
|
||||
import { homedir } from 'node:os'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { CallId } from '@deepseek-ai/dsh-llm'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import type { ToolExecution } from '@deepseek-ai/dsh-tools'
|
||||
import { BashEnvRegistry } from '@deepseek-ai/dsh-tool-bash'
|
||||
|
||||
const testToolSignal = new AbortController().signal
|
||||
|
||||
afterEach(() => vi.unstubAllEnvs())
|
||||
|
||||
function execution(sessionId?: string): ToolExecution {
|
||||
return {
|
||||
signal: testToolSignal,
|
||||
token: Symbol('bash-env-test') as ToolExecution['token'],
|
||||
callId: CallId('bash-env-call'),
|
||||
name: 'bash',
|
||||
arguments: { command: 'true' },
|
||||
...(sessionId === undefined
|
||||
? {}
|
||||
: { agent: { session: { header: { version: 0, id: sessionId, createdAt: 0 } } } as Agent }),
|
||||
}
|
||||
}
|
||||
|
||||
describe('BashEnvRegistry', () => {
|
||||
it('collects unconditional shell facts and the current agent session id', () => {
|
||||
const ctx = new Context()
|
||||
const registry = new BashEnvRegistry(ctx, { dshHome: './test-dsh-home' })
|
||||
|
||||
expect(registry.collect(execution())).toEqual({
|
||||
DSH_HOME: resolve('./test-dsh-home'),
|
||||
DSH_SHELL: '1',
|
||||
})
|
||||
expect(registry.collect(execution('session-a'))).toEqual({
|
||||
DSH_HOME: resolve('./test-dsh-home'),
|
||||
DSH_SESSION_ID: 'session-a',
|
||||
DSH_SHELL: '1',
|
||||
})
|
||||
})
|
||||
|
||||
it('resolves DSH_HOME from the ambient override or the user-home default', () => {
|
||||
vi.stubEnv('DSH_HOME', './ambient-dsh-home')
|
||||
const fromEnvironment = new BashEnvRegistry(new Context())
|
||||
expect(fromEnvironment.collect(execution()).DSH_HOME).toBe(resolve('./ambient-dsh-home'))
|
||||
|
||||
vi.stubEnv('DSH_HOME', undefined)
|
||||
const fromDefault = new BashEnvRegistry(new Context())
|
||||
expect(fromDefault.collect(execution()).DSH_HOME).toBe(join(homedir(), '.dsh'))
|
||||
})
|
||||
|
||||
it('collects declared contributor variables and omits unavailable values', () => {
|
||||
const ctx = new Context()
|
||||
const registry = new BashEnvRegistry(ctx, { dshHome: './test-dsh-home' })
|
||||
registry.register({
|
||||
name: 'optional-session-fact',
|
||||
variables: {
|
||||
DSH_SESSION_OPTIONAL: { description: 'Optional session-scoped test fact.' },
|
||||
},
|
||||
resolve: exec => exec.agent === undefined ? {} : { DSH_SESSION_OPTIONAL: exec.agent.session.header.id },
|
||||
})
|
||||
registry.register({
|
||||
name: 'always-available-fact',
|
||||
variables: {
|
||||
DSH_ALWAYS_AVAILABLE: { description: 'Always-available test fact.' },
|
||||
},
|
||||
resolve: () => ({ DSH_ALWAYS_AVAILABLE: 'yes' }),
|
||||
})
|
||||
|
||||
expect(registry.collect(execution())).not.toHaveProperty('DSH_SESSION_OPTIONAL')
|
||||
expect(registry.collect(execution()).DSH_ALWAYS_AVAILABLE).toBe('yes')
|
||||
expect(registry.collect(execution('session-b')).DSH_SESSION_OPTIONAL).toBe('session-b')
|
||||
expect(registry.list()).toEqual([
|
||||
{
|
||||
contributor: 'always-available-fact',
|
||||
description: 'Always-available test fact.',
|
||||
key: 'DSH_ALWAYS_AVAILABLE',
|
||||
},
|
||||
{
|
||||
contributor: 'optional-session-fact',
|
||||
description: 'Optional session-scoped test fact.',
|
||||
key: 'DSH_SESSION_OPTIONAL',
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('rejects duplicate variable ownership at registration time', () => {
|
||||
const ctx = new Context()
|
||||
const registry = new BashEnvRegistry(ctx, { dshHome: './test-dsh-home' })
|
||||
registry.register({
|
||||
name: 'first',
|
||||
variables: { DSH_SHARED: { description: 'First owner.' } },
|
||||
resolve: () => ({ DSH_SHARED: 'first' }),
|
||||
})
|
||||
|
||||
expect(() => registry.register({
|
||||
name: 'second',
|
||||
variables: { DSH_SHARED: { description: 'Second owner.' } },
|
||||
resolve: () => ({ DSH_SHARED: 'second' }),
|
||||
})).toThrow(/DSH_SHARED.*first.*second|DSH_SHARED.*second.*first/)
|
||||
})
|
||||
|
||||
it('rejects duplicate contributor names and malformed declarations', () => {
|
||||
const registry = new BashEnvRegistry(new Context(), { dshHome: './test-dsh-home' })
|
||||
registry.register({
|
||||
name: 'declared',
|
||||
variables: { DSH_DECLARED: { description: 'Declared fact.' } },
|
||||
resolve: () => ({}),
|
||||
})
|
||||
|
||||
expect(() => registry.register({
|
||||
name: 'declared',
|
||||
variables: { DSH_ANOTHER: { description: 'Another fact.' } },
|
||||
resolve: () => ({}),
|
||||
})).toThrow(/already registered/)
|
||||
expect(() => registry.register({
|
||||
name: ' ',
|
||||
variables: { DSH_BLANK_NAME: { description: 'Blank owner.' } },
|
||||
resolve: () => ({}),
|
||||
})).toThrow(/name must be non-empty/)
|
||||
expect(() => registry.register({
|
||||
name: 'invalid-key',
|
||||
variables: { dsh_invalid: { description: 'Invalid key.' } } as unknown as Record<'DSH_INVALID', { description: string }>,
|
||||
resolve: () => ({}),
|
||||
})).toThrow(/invalid key/)
|
||||
expect(() => registry.register({
|
||||
name: 'reserved-key',
|
||||
variables: { DSH_HOME: { description: 'Reserved key.' } },
|
||||
resolve: () => ({}),
|
||||
})).toThrow(/reserved key/)
|
||||
expect(() => registry.register({
|
||||
name: 'blank-description',
|
||||
variables: { DSH_BLANK_DESCRIPTION: { description: ' ' } },
|
||||
resolve: () => ({}),
|
||||
})).toThrow(/must describe/)
|
||||
})
|
||||
|
||||
it('rejects undeclared variables returned by a contributor', () => {
|
||||
const ctx = new Context()
|
||||
const registry = new BashEnvRegistry(ctx, { dshHome: './test-dsh-home' })
|
||||
registry.register({
|
||||
name: 'drifted-provider',
|
||||
variables: { DSH_DECLARED: { description: 'Declared fact.' } },
|
||||
resolve: () => ({ DSH_UNDECLARED: 'bad' }),
|
||||
})
|
||||
|
||||
expect(() => registry.collect(execution())).toThrow(/drifted-provider.*DSH_UNDECLARED/)
|
||||
})
|
||||
|
||||
it('rejects non-string values returned by a contributor', () => {
|
||||
const registry = new BashEnvRegistry(new Context(), { dshHome: './test-dsh-home' })
|
||||
registry.register({
|
||||
name: 'wrong-value-type',
|
||||
variables: { DSH_STRING: { description: 'String fact.' } },
|
||||
resolve: () => ({ DSH_STRING: 42 }) as unknown as Record<'DSH_STRING', string>,
|
||||
})
|
||||
|
||||
expect(() => registry.collect(execution())).toThrow(/wrong-value-type.*non-string.*DSH_STRING/)
|
||||
})
|
||||
|
||||
it('removes an effect-scoped contributor when its plugin is disposed', async () => {
|
||||
const ctx = new Context()
|
||||
const registry = new BashEnvRegistry(ctx, { dshHome: './test-dsh-home' })
|
||||
const fiber = await ctx.plugin({
|
||||
inject: ['bashEnv'],
|
||||
apply(inner: Context) {
|
||||
inner.bashEnv.register({
|
||||
name: 'temporary',
|
||||
variables: { DSH_TEMPORARY: { description: 'Temporary fact.' } },
|
||||
resolve: () => ({ DSH_TEMPORARY: 'present' }),
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
expect(registry.collect(execution()).DSH_TEMPORARY).toBe('present')
|
||||
await fiber.dispose()
|
||||
expect(registry.collect(execution())).not.toHaveProperty('DSH_TEMPORARY')
|
||||
})
|
||||
|
||||
it('returns an explicit contributor disposer', () => {
|
||||
const registry = new BashEnvRegistry(new Context(), { dshHome: './test-dsh-home' })
|
||||
const dispose = registry.register({
|
||||
name: 'explicit-disposal',
|
||||
variables: { DSH_EXPLICIT_DISPOSAL: { description: 'Explicitly disposed fact.' } },
|
||||
resolve: () => ({ DSH_EXPLICIT_DISPOSAL: 'present' }),
|
||||
})
|
||||
|
||||
expect(registry.collect(execution()).DSH_EXPLICIT_DISPOSAL).toBe('present')
|
||||
dispose()
|
||||
expect(registry.collect(execution())).not.toHaveProperty('DSH_EXPLICIT_DISPOSAL')
|
||||
})
|
||||
})
|
||||
@@ -14,6 +14,7 @@ import * as ToolTasks from '@deepseek-ai/dsh-tool-tasks'
|
||||
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
|
||||
import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local'
|
||||
import * as ToolBash from '@deepseek-ai/dsh-tool-bash'
|
||||
import * as BashEnvPlugin from '@deepseek-ai/dsh-bash-env'
|
||||
import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
|
||||
|
||||
/**
|
||||
@@ -32,8 +33,9 @@ async function harness(adapter: MockAdapter, sessionRoot?: string, dshHome?: str
|
||||
await ctx.plugin(LocalTaskService)
|
||||
await ctx.plugin(ToolTasks)
|
||||
await ctx.plugin(LocalSubprocessService)
|
||||
await ctx.plugin(BashEnvPlugin, dshHome === undefined ? {} : { dshHome })
|
||||
await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 })
|
||||
await ctx.plugin(ToolBash, dshHome === undefined ? {} : { dshHome })
|
||||
await ctx.plugin(ToolBash)
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
return ctx
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
|
||||
import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local'
|
||||
import SandboxPolicyService from '@deepseek-ai/dsh-sandbox-policy'
|
||||
import * as ToolBash from '@deepseek-ai/dsh-tool-bash'
|
||||
import * as BashEnvPlugin from '@deepseek-ai/dsh-bash-env'
|
||||
import { processOutcome } from '../src/background.ts'
|
||||
import { renderProcessRead, renderResult } from '../src/render.ts'
|
||||
|
||||
@@ -35,6 +36,7 @@ async function setup() {
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(LocalSubprocessService)
|
||||
;(ctx.subprocess as LocalSubprocessService).internals = { spillDir }
|
||||
await ctx.plugin(BashEnvPlugin)
|
||||
await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000, graceMs: 200 })
|
||||
await ctx.plugin(ToolBash)
|
||||
return ctx
|
||||
@@ -50,6 +52,7 @@ async function setupWithTasks() {
|
||||
await ctx.plugin(ToolTasks)
|
||||
await ctx.plugin(LocalSubprocessService)
|
||||
;(ctx.subprocess as LocalSubprocessService).internals = { spillDir }
|
||||
await ctx.plugin(BashEnvPlugin)
|
||||
await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000, graceMs: 200 })
|
||||
await ctx.plugin(ToolBash)
|
||||
return ctx
|
||||
@@ -188,6 +191,7 @@ async function setupSandboxed(withApproval = false) {
|
||||
await ctx.plugin(SandboxPolicyService, {})
|
||||
await ctx.plugin(RecordingSandboxExecutor)
|
||||
if (withApproval) await ctx.plugin(ApprovalService)
|
||||
await ctx.plugin(BashEnvPlugin)
|
||||
await ctx.plugin(ToolBash)
|
||||
return { ctx, bash: ctx.bash as RecordingSandboxExecutor }
|
||||
}
|
||||
@@ -281,6 +285,7 @@ describe('bash tool', () => {
|
||||
await ctx.plugin(LocalSubprocessService)
|
||||
;(ctx.subprocess as LocalSubprocessService).internals = { spillDir }
|
||||
await ctx.plugin(LocalBashExecutor, { maxOutputBytes: 100, graceMs: 200 })
|
||||
await ctx.plugin(BashEnvPlugin)
|
||||
await ctx.plugin(ToolBash)
|
||||
const result = await call(ctx, 'bash', { command: 'for i in $(seq 1 100); do printf "line-%04d\\n" $i; done', description: 'test command' })
|
||||
expect(text(result)).toContain('[output truncated; full output: ')
|
||||
@@ -300,7 +305,7 @@ describe('bash tool', () => {
|
||||
expect(text(result)).toMatch(/ENOENT/)
|
||||
})
|
||||
|
||||
it('surfaces foreground aborts as isError', async () => {
|
||||
it('surfaces foreground aborts as the structured TOOL_ABORTED error', async () => {
|
||||
const ctx = await setup()
|
||||
const controller = new AbortController()
|
||||
const pending = ctx.tools.execute({
|
||||
@@ -312,7 +317,10 @@ describe('bash tool', () => {
|
||||
setTimeout(() => { controller.abort() }, 50)
|
||||
const result = await pending
|
||||
expect(result.isError).toBe(true)
|
||||
expect(text(result)).toMatch(/aborted/)
|
||||
expect(result.error).toMatchObject({
|
||||
message: 'tool call aborted',
|
||||
info: { name: 'AbortError', code: TOOL_ABORTED },
|
||||
})
|
||||
})
|
||||
|
||||
// Type and required-key violations are rejected by the harness
|
||||
@@ -389,6 +397,7 @@ describe('bash tool', () => {
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(LocalSubprocessService)
|
||||
await ctx.plugin(LocalBashExecutor, {})
|
||||
await ctx.plugin(BashEnvPlugin)
|
||||
const fiber = await ctx.plugin(ToolBash)
|
||||
expect(ctx.tools.schemas()).toHaveLength(1)
|
||||
expect((await ctx.systemPrompt.assemble()).sections.map(s => s.name)).toEqual(['harness:identity', 'deployment:persona', 'tool:bash'])
|
||||
@@ -403,6 +412,7 @@ describe('bash tool', () => {
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
// inject: ['tools', 'bash'] keeps the plugin pending until bash exists.
|
||||
await ctx.plugin(BashEnvPlugin)
|
||||
await ctx.plugin(ToolBash)
|
||||
expect(ctx.tools.schemas()).toHaveLength(0)
|
||||
await ctx.plugin(LocalSubprocessService)
|
||||
@@ -493,6 +503,7 @@ describe('background execution through the task runtime', () => {
|
||||
await ctx.plugin(LocalTaskService)
|
||||
await ctx.plugin(ToolTasks)
|
||||
await ctx.plugin(CountingStartExecutor)
|
||||
await ctx.plugin(BashEnvPlugin)
|
||||
await ctx.plugin(ToolBash)
|
||||
|
||||
const controller = new AbortController()
|
||||
@@ -520,6 +531,7 @@ describe('background execution through the task runtime', () => {
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(LocalTaskService)
|
||||
await ctx.plugin(CountingStartExecutor)
|
||||
await ctx.plugin(BashEnvPlugin)
|
||||
await ctx.plugin(ToolBash)
|
||||
|
||||
const result = await call(ctx, 'bash', { command: 'sleep 60', description: 'test command', run_in_background: true })
|
||||
@@ -534,6 +546,7 @@ describe('background execution through the task runtime', () => {
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(LocalSubprocessService)
|
||||
await ctx.plugin(BashEnvPlugin)
|
||||
await ctx.plugin(LocalBashExecutor, {})
|
||||
await ctx.plugin(ToolBash, { enableRunInBackground: false })
|
||||
|
||||
@@ -568,6 +581,7 @@ describe('sandbox escalation through the generic task producer', () => {
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(RecordingSandboxExecutor)
|
||||
await ctx.plugin(BashEnvPlugin)
|
||||
await expect(ctx.plugin(ToolBash)).rejects.toThrow('tool-bash: the mounted bash executor confines but ctx.sandboxPolicy is missing')
|
||||
})
|
||||
|
||||
@@ -1003,9 +1017,9 @@ describe('tool-owned UI presentation (presentCall / presentResult)', () => {
|
||||
// not renderResult output, so a generic fenced card, no terminal output/exit.
|
||||
const out = ctx.tools.get('bash')!.presentResult!(
|
||||
{ command: 'x', description: 'x' },
|
||||
{ content: [{ type: 'text', text: 'command aborted' }], isError: true },
|
||||
{ content: [{ type: 'text', text: 'tool call aborted' }], isError: true },
|
||||
)
|
||||
expect(out).toEqual({ card: 'generic', content: [{ type: 'text', text: '```console\ncommand aborted\n```' }] })
|
||||
expect(out).toEqual({ card: 'generic', content: [{ type: 'text', text: '```console\ntool call aborted\n```' }] })
|
||||
})
|
||||
|
||||
it('bash presentResult: leaves a non-text (unexpected) result untouched → undefined (UI keeps raw content)', async () => {
|
||||
@@ -1097,8 +1111,9 @@ describe('the model-facing bash tool builds its request from named args only (no
|
||||
}
|
||||
await ctx.plugin(LocalTaskService)
|
||||
await ctx.plugin(ToolTasks)
|
||||
await ctx.plugin(BashEnvPlugin, { dshHome: recordingDshHome })
|
||||
await ctx.plugin(RecordingBashExecutor)
|
||||
await ctx.plugin(ToolBash, { dshHome: recordingDshHome })
|
||||
await ctx.plugin(ToolBash)
|
||||
return { ctx, bash: ctx.bash as RecordingBashExecutor }
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user