Merge branch 'codex/goal-domain' into codex/goal-tools
# Conflicts: # docs/event-producer-consumer.md
This commit is contained in:
169
examples/headless-agent/tests/code-mode.e2e.ts
Normal file
169
examples/headless-agent/tests/code-mode.e2e.ts
Normal file
@@ -0,0 +1,169 @@
|
||||
import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import LlmService from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry, { RUN_CODE_NAME } from '@deepseek-ai/dsh-tools'
|
||||
import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
|
||||
|
||||
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
|
||||
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
|
||||
import * as ToolBash from '@deepseek-ai/dsh-tool-bash'
|
||||
import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek'
|
||||
import { WorkerCodeRuntime } from '@deepseek-ai/dsh-code-runtime-worker'
|
||||
import LocalFileSystem from '@deepseek-ai/dsh-fs-local'
|
||||
import * as ToolFs from '@deepseek-ai/dsh-tool-fs'
|
||||
import * as WorkspaceContext from '@deepseek-ai/dsh-workspace-context'
|
||||
|
||||
/**
|
||||
* With-key Code Mode proof: a real model receives only `run_code`, composes two
|
||||
* sub-calls, writes a file, and returns curated output while the log records
|
||||
* each `tool/code-dispatch`. The keyless Loader smoke is in the sibling test.
|
||||
*/
|
||||
|
||||
const PERSONA = 'You are a coding agent. You work by writing TypeScript programs for run_code: '
|
||||
+ 'batch related tool work into one program and print or return ONLY the findings that matter.'
|
||||
const WORKSPACE_PROBE = 'dragonfruit-8675309'
|
||||
|
||||
let ctx: Context | undefined
|
||||
let workdir: string | undefined
|
||||
|
||||
afterEach(async () => {
|
||||
// Always dispose, even on failure/retry/timeout: agent-loop teardown stops
|
||||
// the loop, the executor kills stray processes, and the code runtime's
|
||||
// dispose awaits worker exits.
|
||||
await ctx?.fiber.dispose()
|
||||
ctx = undefined
|
||||
if (workdir !== undefined) await rm(workdir, { recursive: true, force: true })
|
||||
workdir = undefined
|
||||
})
|
||||
|
||||
async function codeModeHarness(cwd: string): Promise<Context> {
|
||||
const harness = new Context()
|
||||
await harness.plugin(LlmService)
|
||||
await harness.plugin(SessionStore)
|
||||
await harness.plugin(SystemPrompt, { persona: PERSONA })
|
||||
await harness.plugin(ToolRegistry, { mode: 'code' })
|
||||
await harness.plugin(AgentRegistry)
|
||||
await harness.plugin(AgentLoop, { agents: [] })
|
||||
await harness.plugin(LlmDeepSeek)
|
||||
await harness.plugin(LocalBashExecutor, { cwd, timeoutMs: 30_000 })
|
||||
await harness.plugin(ToolBash)
|
||||
await harness.plugin(WorkerCodeRuntime, {})
|
||||
return harness
|
||||
}
|
||||
|
||||
async function workspaceCodeModeHarness(): Promise<Context> {
|
||||
const harness = new Context()
|
||||
await harness.plugin(LlmService)
|
||||
await harness.plugin(SessionStore)
|
||||
await harness.plugin(SystemPrompt, { persona: PERSONA })
|
||||
await harness.plugin(ToolRegistry, { mode: 'code' })
|
||||
await harness.plugin(AgentRegistry)
|
||||
await harness.plugin(LocalFileSystem, { cwd: '/' })
|
||||
await harness.plugin(ToolFs)
|
||||
await harness.plugin(WorkspaceContext, { maxBytes: 65536 })
|
||||
await harness.plugin(AgentLoop, { agents: [] })
|
||||
await harness.plugin(LlmDeepSeek, { models: [{ id: 'deepseek-v4-flash' }] })
|
||||
await harness.plugin(WorkerCodeRuntime, {})
|
||||
return harness
|
||||
}
|
||||
|
||||
function waitForIdle(harness: Context, agent: Agent): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
const dispose = harness.on('agent/status', (subject, status) => {
|
||||
if (subject === agent && status === 'idle') {
|
||||
dispose()
|
||||
resolve()
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
describe.skipIf(!process.env.DEEPSEEK_API_KEY)('Code Mode: real model writes a program over real tools', () => {
|
||||
it('collapses the wire tool list to [run_code], bridges sub-calls, and returns curated output', async () => {
|
||||
workdir = await mkdtemp(join(tmpdir(), 'dsh-code-mode-e2e-'))
|
||||
ctx = await codeModeHarness(workdir)
|
||||
const agent = ctx.agentLoop.create(SessionId('e2e-code-mode'), { provider: 'deepseek', model: 'deepseek-v4-flash' })
|
||||
|
||||
agent.send([{
|
||||
type: 'text',
|
||||
text: 'Using one run_code program: run `echo alpha-7` with the bash tool, run `echo beta-9` with the bash tool, '
|
||||
+ 'then write both outputs joined by a plus sign into combined.txt (bash heredoc or redirect), '
|
||||
+ 'and return only the joined string.',
|
||||
}])
|
||||
await waitForIdle(ctx, agent)
|
||||
const events: SessionEvent[] = [...agent.session.events]
|
||||
|
||||
// The wire contract: every request this session made offered EXACTLY ONE
|
||||
// tool — run_code (the logged header snapshots the assembled list).
|
||||
const headers = events.filter(event => event.type === 'request/header')
|
||||
expect(headers.length).toBeGreaterThan(0)
|
||||
for (const header of headers) {
|
||||
expect(header.data.header.tools?.map(tool => tool.name)).toEqual([RUN_CODE_NAME])
|
||||
}
|
||||
// The model actually went through run_code…
|
||||
const calls = events.filter(event => event.type === 'tool/call')
|
||||
expect(calls.length).toBeGreaterThan(0)
|
||||
expect(calls.every(event => event.data.name === RUN_CODE_NAME)).toBe(true)
|
||||
// …and the program's tool calls landed as dispatch events under it.
|
||||
const dispatches = events.filter(event => event.type === 'tool/code-dispatch')
|
||||
expect(dispatches.length).toBeGreaterThanOrEqual(2)
|
||||
expect(dispatches.every(event => event.data.name === 'bash')).toBe(true)
|
||||
const parents = new Set(calls.map(event => event.data.callId))
|
||||
expect(dispatches.every(event => parents.has(event.data.parentCallId))).toBe(true)
|
||||
|
||||
// World verification: the file the program wrote, and the curated answer.
|
||||
const combined = await readFile(join(workdir, 'combined.txt'), 'utf8')
|
||||
expect(combined).toContain('alpha-7')
|
||||
expect(combined).toContain('beta-9')
|
||||
const finalMessage = events.findLast(event => event.type === 'assistant/message')
|
||||
const finalText = finalMessage !== undefined
|
||||
? finalMessage.data.content.filter(block => block.type === 'text').map(block => block.text).join('')
|
||||
: ''
|
||||
expect(finalText).toContain('alpha-7')
|
||||
expect(finalText).toContain('beta-9')
|
||||
}, 180_000)
|
||||
|
||||
it('delivers nested workspace instructions discovered by an fs sub-call after the outer result', async () => {
|
||||
workdir = await mkdtemp(join(tmpdir(), 'dsh-code-mode-workspace-e2e-'))
|
||||
await mkdir(join(workdir, '.git'), { recursive: true })
|
||||
await mkdir(join(workdir, 'pkg/deep'), { recursive: true })
|
||||
await writeFile(join(workdir, 'pkg/AGENTS.md'), `If asked for the Code Mode workspace handshake, reply with exactly ${WORKSPACE_PROBE} and nothing else.\n`)
|
||||
await writeFile(join(workdir, 'pkg/deep/task.txt'), 'Touch this file to discover the nested instructions.\n')
|
||||
ctx = await workspaceCodeModeHarness()
|
||||
const handle = await ctx.agents.create({
|
||||
sessionId: SessionId('e2e-code-mode-workspace-session'),
|
||||
meta: { cwd: workdir },
|
||||
agentOptions: { provider: 'deepseek', model: 'deepseek-v4-flash' },
|
||||
})
|
||||
|
||||
handle.agent.send([{
|
||||
type: 'text',
|
||||
text: 'Use one run_code program to call tools.read on pkg/deep/task.txt. After it finishes, answer: Code Mode workspace handshake?',
|
||||
}])
|
||||
await waitForIdle(ctx, handle.agent)
|
||||
|
||||
const events: SessionEvent[] = [...handle.agent.session.events]
|
||||
const dispatch = events.find(event => event.type === 'tool/code-dispatch' && event.data.name === 'read')
|
||||
const outerResult = events.find(event => event.type === 'tool/result')
|
||||
const workspaceContext = events.find(event => event.type === 'context/message'
|
||||
&& typeof event.data.meta === 'object'
|
||||
&& event.data.meta !== null
|
||||
&& !Array.isArray(event.data.meta)
|
||||
&& event.data.meta.kind === 'workspace-instructions')
|
||||
expect(dispatch).toBeDefined()
|
||||
expect(outerResult).toBeDefined()
|
||||
expect(workspaceContext).toBeDefined()
|
||||
expect(workspaceContext!.seq).toBeGreaterThan(outerResult!.seq)
|
||||
const finalMessage = events.findLast(event => event.type === 'assistant/message')
|
||||
const answer = finalMessage?.type === 'assistant/message'
|
||||
? finalMessage.data.content.filter(block => block.type === 'text').map(block => block.text).join('')
|
||||
: ''
|
||||
expect(answer).toContain(WORKSPACE_PROBE)
|
||||
}, 180_000)
|
||||
})
|
||||
84
examples/headless-agent/tests/coding-task.e2e.ts
Normal file
84
examples/headless-agent/tests/coding-task.e2e.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import type { Context } from 'cordis'
|
||||
import { codingHarness, finalText, SYSTEM_PROMPT, waitForIdle } from './harness.ts'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
|
||||
/**
|
||||
* The swebench-style smoke test: a real model fixes a real bug in a temp
|
||||
* directory using only the bash tool, and the fix is verified OUTSIDE the
|
||||
* agent by re-running the test script. Key-gated.
|
||||
*/
|
||||
|
||||
const TEST_FILE = [
|
||||
"const assert = require('node:assert');",
|
||||
"const { add } = require('./add.js');",
|
||||
'assert.strictEqual(add(2, 3), 5);',
|
||||
'assert.strictEqual(add(-1, 1), 0);',
|
||||
"console.log('PASS');",
|
||||
'',
|
||||
].join('\n')
|
||||
|
||||
const BUGGY_ADD = [
|
||||
'// A tiny module with an obvious bug.',
|
||||
'function add(a, b) {',
|
||||
' return a - b;',
|
||||
'}',
|
||||
'module.exports = { add };',
|
||||
'',
|
||||
].join('\n')
|
||||
|
||||
let workdir: string | undefined
|
||||
let ctx: Context | undefined
|
||||
|
||||
afterEach(async () => {
|
||||
// Dispose the harness even on failure/retry: agent-loop teardown stops the
|
||||
// loop and LocalBashExecutor teardown kills anything the model left running.
|
||||
await ctx?.fiber.dispose()
|
||||
ctx = undefined
|
||||
if (workdir !== undefined) await rm(workdir, { recursive: true, force: true })
|
||||
workdir = undefined
|
||||
})
|
||||
|
||||
describe.skipIf(!process.env.DEEPSEEK_API_KEY)('coding task: fix a failing test via bash', () => {
|
||||
it('repairs add.js so node add.test.js passes', async () => {
|
||||
workdir = await mkdtemp(join(tmpdir(), 'dsh-coding-task-'))
|
||||
await writeFile(join(workdir, 'add.js'), BUGGY_ADD)
|
||||
await writeFile(join(workdir, 'add.test.js'), TEST_FILE)
|
||||
|
||||
// Confirm the fixture actually fails before the agent touches it.
|
||||
const before = spawnSync('node', ['add.test.js'], { cwd: workdir })
|
||||
expect(before.status).not.toBe(0)
|
||||
|
||||
ctx = await codingHarness(workdir, { persona: SYSTEM_PROMPT })
|
||||
const agent = ctx.agentLoop.create(SessionId('e2e-task'), { provider: 'deepseek', model: 'deepseek-v4-flash' })
|
||||
|
||||
agent.send([{
|
||||
type: 'text',
|
||||
text: 'In the current directory, `node add.test.js` fails because add.js has a bug. '
|
||||
+ 'Fix add.js so the test passes, run `node add.test.js` to verify, and report the result. '
|
||||
+ 'Do not modify add.test.js.',
|
||||
}])
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
// The agent claims success…
|
||||
const summary = finalText([...agent.session.events]).toLowerCase()
|
||||
expect(summary.length).toBeGreaterThan(0)
|
||||
|
||||
// …and the world agrees: the test passes when WE run it, and the test
|
||||
// file is byte-identical (an agent that neutered the test instead of
|
||||
// fixing the bug fails here, not just on a keyword probe).
|
||||
const untouchedTest = await readFile(join(workdir, 'add.test.js'), 'utf8')
|
||||
expect(untouchedTest).toBe(TEST_FILE)
|
||||
|
||||
const after = spawnSync('node', ['add.test.js'], { cwd: workdir, encoding: 'utf8' })
|
||||
expect(after.stdout).toContain('PASS')
|
||||
expect(after.status).toBe(0)
|
||||
|
||||
const fixed = await readFile(join(workdir, 'add.js'), 'utf8')
|
||||
expect(fixed).not.toMatch(/a\s*-\s*b/)
|
||||
}, 180_000)
|
||||
})
|
||||
88
examples/headless-agent/tests/compaction.e2e.ts
Normal file
88
examples/headless-agent/tests/compaction.e2e.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import type { Context } from 'cordis'
|
||||
import { codingHarness, finalText, SYSTEM_PROMPT, waitForIdle } from './harness.ts'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
|
||||
/**
|
||||
* Key-gated smoke for mid-session compaction. It verifies the compact event
|
||||
* pair, replacement of older surface nodes, and a final answer after compaction.
|
||||
*/
|
||||
// FIXME(compaction-snapshot): this is the only full compaction coverage because
|
||||
// replay cannot serve the summarizer's unlogged model call.
|
||||
|
||||
let workdir: string | undefined
|
||||
let ctx: Context | undefined
|
||||
|
||||
afterEach(async () => {
|
||||
await ctx?.fiber.dispose()
|
||||
ctx = undefined
|
||||
if (workdir !== undefined) await rm(workdir, { recursive: true, force: true })
|
||||
workdir = undefined
|
||||
})
|
||||
|
||||
describe.skipIf(!process.env.DEEPSEEK_API_KEY)('compaction: a long session compacts mid-flight and keeps running', () => {
|
||||
it('summarizes older history into a checkpoint without breaking the task', async () => {
|
||||
workdir = await mkdtemp(join(tmpdir(), 'dsh-compaction-'))
|
||||
for (let i = 1; i <= 4; i++) {
|
||||
await writeFile(join(workdir, `file${i}.txt`), `This is file number ${i}. `.repeat(50))
|
||||
}
|
||||
|
||||
// Reasoning tokens require a larger generation cap than the retained checkpoint.
|
||||
ctx = await codingHarness(workdir, {
|
||||
persona: SYSTEM_PROMPT,
|
||||
tokenMeter: {
|
||||
contextWindow: 2000,
|
||||
},
|
||||
compact: {
|
||||
thresholdRatio: 0.5,
|
||||
retainTokens: 400,
|
||||
summarizationProvider: '',
|
||||
summarizationModel: '',
|
||||
maxTokens: 1024,
|
||||
compactionRetries: 1,
|
||||
},
|
||||
persistenceRoot: join(workdir, '.sessions'),
|
||||
})
|
||||
const agent = ctx.agentLoop.create(SessionId('e2e-compaction'), { provider: 'deepseek', model: 'deepseek-v4-flash' })
|
||||
|
||||
agent.send([{
|
||||
type: 'text',
|
||||
text: 'Read file1.txt, file2.txt, file3.txt, and file4.txt one at a '
|
||||
+ 'time using cat (a separate bash command for each). After reading all four, tell me how '
|
||||
+ 'many files you read and the number mentioned in file1.txt.',
|
||||
}])
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
const events = [...agent.session.events]
|
||||
|
||||
// A compaction ran: the start…end bracket landed in the real log.
|
||||
const starts = events.filter(e => e.type === 'compact/start')
|
||||
const ends = events.filter(e => e.type === 'compact/end')
|
||||
expect(starts.length).toBeGreaterThan(0)
|
||||
expect(ends.length).toBe(starts.length) // every start was released
|
||||
|
||||
// It succeeded at least once: a compact/summary provenance event and a
|
||||
// replace-op user/message (the surface mutation) both landed.
|
||||
const summaries = events.filter(e => e.type === 'compact/summary')
|
||||
expect(summaries.length).toBeGreaterThan(0)
|
||||
const replaceNode = events.find((e) => {
|
||||
const se = e as unknown as { type: string; surfaceOp?: unknown }
|
||||
return se.type === 'user/message' && typeof se.surfaceOp === 'object' && se.surfaceOp !== null
|
||||
})
|
||||
expect(replaceNode).toBeDefined()
|
||||
|
||||
// The summary shadowed real older nodes (the surface shrank vs. the raw
|
||||
// message-producing event count).
|
||||
const summaryData = summaries[0]!.data as { shadowedSeqs: number[] }
|
||||
expect(summaryData.shadowedSeqs.length).toBeGreaterThan(0)
|
||||
|
||||
// The conversation survived compaction: the agent produced a final answer
|
||||
// that reflects the work (it read four files).
|
||||
const answer = finalText(events).toLowerCase()
|
||||
expect(answer.length).toBeGreaterThan(0)
|
||||
expect(answer).toMatch(/\b(4|four)\b/)
|
||||
}, 240_000)
|
||||
})
|
||||
24
examples/headless-agent/tests/fixtures/goal-domain/cordis.yml
vendored
Normal file
24
examples/headless-agent/tests/fixtures/goal-domain/cordis.yml
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
# Test-only composition: create one goal through a Loader-mounted step consumer.
|
||||
- id: cli-mock-llm
|
||||
name: '../cli-mock-llm.ts'
|
||||
|
||||
- id: bash
|
||||
name: '@deepseek-ai/dsh-bash-local'
|
||||
|
||||
- id: goal
|
||||
name: '@deepseek-ai/dsh-goal'
|
||||
config:
|
||||
defaultMaxGoalRounds: 11
|
||||
|
||||
- id: seed-goal
|
||||
name: './seed-goal.ts'
|
||||
|
||||
- id: cli-agent
|
||||
name: '@deepseek-ai/dsh-cli-demo'
|
||||
config:
|
||||
provider: cli-mock
|
||||
model: cli-mock
|
||||
persona: 'Test the persisted goal domain.'
|
||||
persistenceRoot: './.sessions'
|
||||
persistenceCompression: none
|
||||
workspaceContext: false
|
||||
17
examples/headless-agent/tests/fixtures/goal-domain/seed-goal.ts
vendored
Normal file
17
examples/headless-agent/tests/fixtures/goal-domain/seed-goal.ts
vendored
Normal file
@@ -0,0 +1,17 @@
|
||||
/** Test-only Loader plugin that creates a goal at the first real step edge. */
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import type {} from '@deepseek-ai/dsh-goal'
|
||||
|
||||
export const name = 'seed-goal'
|
||||
export const inject = ['goals']
|
||||
|
||||
export function apply(ctx: Context): void {
|
||||
ctx.on('agent/pre-step', (agent) => {
|
||||
if (ctx.goals.get(agent) !== undefined) return
|
||||
ctx.goals.create(agent, {
|
||||
objective: 'Prove the composed goal survives in the session log',
|
||||
maxGoalRounds: 7,
|
||||
})
|
||||
})
|
||||
}
|
||||
16
examples/headless-agent/tests/fixtures/time-context-driver.ts
vendored
Normal file
16
examples/headless-agent/tests/fixtures/time-context-driver.ts
vendored
Normal file
@@ -0,0 +1,16 @@
|
||||
#!/usr/bin/env node
|
||||
/** Test driver that sends two turns through one Headless Loader composition. */
|
||||
|
||||
import { boot, resolveConfigPath } from '@deepseek-ai/dsh-app-boot'
|
||||
import { runOneShot } from '@deepseek-ai/dsh-cli-demo/src/cli.ts'
|
||||
|
||||
const configPath = process.argv[2]
|
||||
if (configPath === undefined) throw new Error('time-context driver requires a config path')
|
||||
|
||||
const ctx = await boot('time-context-e2e', resolveConfigPath(configPath, undefined))
|
||||
try {
|
||||
await runOneShot(ctx, { task: 'first' })
|
||||
await runOneShot(ctx, { task: 'second' })
|
||||
} finally {
|
||||
await ctx.fiber.dispose()
|
||||
}
|
||||
22
examples/headless-agent/tests/fixtures/time-context-mock-llm.ts
vendored
Normal file
22
examples/headless-agent/tests/fixtures/time-context-mock-llm.ts
vendored
Normal file
@@ -0,0 +1,22 @@
|
||||
import type { Context } from 'cordis'
|
||||
import { LlmAdapter, type StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
|
||||
/** Deterministic one-step adapter for the time-context Loader fixture. */
|
||||
class TimeContextMockAdapter extends LlmAdapter {
|
||||
async * stream(): AsyncIterable<StreamChunk> {
|
||||
const text = 'time context sampled'
|
||||
yield { type: 'block-start', index: 0, blockType: 'text' }
|
||||
yield { type: 'text-delta', index: 0, text }
|
||||
yield { type: 'block-end', index: 0, block: { type: 'text', text } }
|
||||
yield { type: 'usage', usage: { inputTokens: 1, outputTokens: 1 } }
|
||||
yield { type: 'finish', reason: { kind: 'stop' } }
|
||||
}
|
||||
}
|
||||
|
||||
export const name = 'time-context-mock-llm'
|
||||
export const inject = ['llm']
|
||||
|
||||
/** Register the test-only `time-context-mock` adapter. */
|
||||
export function apply(ctx: Context): void {
|
||||
ctx.llm.registerAdapter(['time-context-mock'], new TimeContextMockAdapter())
|
||||
}
|
||||
19
examples/headless-agent/tests/fixtures/time-context.cordis.yml
vendored
Normal file
19
examples/headless-agent/tests/fixtures/time-context.cordis.yml
vendored
Normal file
@@ -0,0 +1,19 @@
|
||||
# Test-only composition: keep time-context opt-in while exercising its real Loader/app path.
|
||||
- id: time-context-mock-llm
|
||||
name: './time-context-mock-llm.ts'
|
||||
|
||||
- id: bash
|
||||
name: '@deepseek-ai/dsh-bash-local'
|
||||
|
||||
- id: time-context
|
||||
name: '@deepseek-ai/dsh-time-context'
|
||||
|
||||
- id: cli-agent
|
||||
name: '@deepseek-ai/dsh-cli-demo'
|
||||
config:
|
||||
provider: time-context-mock
|
||||
model: time-context-mock
|
||||
persona: 'Test the time-context plugin.'
|
||||
persistenceRoot: './.sessions'
|
||||
persistenceCompression: 'none'
|
||||
workspaceContext: false
|
||||
48
examples/headless-agent/tests/full-loop.e2e.ts
Normal file
48
examples/headless-agent/tests/full-loop.e2e.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import type { Context } from 'cordis'
|
||||
import { codingHarness, finalText, SYSTEM_PROMPT, waitForIdle } from './harness.ts'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
|
||||
/**
|
||||
* The first place a REAL model meets the REAL bash tool: the cheap canary
|
||||
* before the coding-task e2e. Key-gated (see vitest.e2e.config.ts).
|
||||
*/
|
||||
|
||||
let ctx: Context | undefined
|
||||
let workdir: string | undefined
|
||||
|
||||
afterEach(async () => {
|
||||
// Always dispose the harness, even on failure/retry/timeout: agent-loop
|
||||
// teardown stops the loop and LocalBashExecutor teardown kills any
|
||||
// process the model left behind.
|
||||
await ctx?.fiber.dispose()
|
||||
ctx = undefined
|
||||
if (workdir !== undefined) await rm(workdir, { recursive: true, force: true })
|
||||
workdir = undefined
|
||||
})
|
||||
|
||||
describe.skipIf(!process.env.DEEPSEEK_API_KEY)('full loop: real model + real bash tool', () => {
|
||||
it('runs a bash command on request and reports its output', async () => {
|
||||
workdir = await mkdtemp(join(tmpdir(), 'dsh-full-loop-e2e-'))
|
||||
ctx = await codingHarness(workdir, { persona: SYSTEM_PROMPT })
|
||||
const agent = ctx.agentLoop.create(SessionId('e2e-loop'), { provider: 'deepseek', model: 'deepseek-v4-flash' })
|
||||
|
||||
agent.send([{ type: 'text', text: 'Run `echo e2e-ok` with the bash tool and tell me its exact output.' }])
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
const events = [...agent.session.events]
|
||||
const calls = events.filter(event => event.type === 'tool/call')
|
||||
expect(calls.length).toBeGreaterThan(0)
|
||||
expect(calls.some(event => event.data.name === 'bash')).toBe(true)
|
||||
|
||||
const results = events.filter(event => event.type === 'tool/result')
|
||||
const resultTexts = results.flatMap(event =>
|
||||
event.data.content.filter(block => block.type === 'text').map(block => block.text))
|
||||
expect(resultTexts.some(text => text.includes('e2e-ok'))).toBe(true)
|
||||
|
||||
expect(finalText(events)).toContain('e2e-ok')
|
||||
}, 120_000)
|
||||
})
|
||||
95
examples/headless-agent/tests/harness.ts
Normal file
95
examples/headless-agent/tests/harness.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { Context } from 'cordis'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
|
||||
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
|
||||
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
|
||||
import * as ToolBash from '@deepseek-ai/dsh-tool-bash'
|
||||
import * as ToolTodo from '@deepseek-ai/dsh-tool-todo'
|
||||
import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek'
|
||||
import TokenMeterService from '@deepseek-ai/dsh-token-meter'
|
||||
import type { TokenMeterConfig } from '@deepseek-ai/dsh-token-meter'
|
||||
import ToolResultPruneService from '@deepseek-ai/dsh-compact-tool-result-prune'
|
||||
import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl'
|
||||
import { BasicCompactService } from '@deepseek-ai/dsh-compact-basic'
|
||||
import type { BasicCompactConfig } from '@deepseek-ai/dsh-compact-basic'
|
||||
|
||||
/**
|
||||
* Shared harness for the headless-agent e2e suites: the full plugin stack
|
||||
* with the real DeepSeek adapter and the real bash + todo_write tools. Lives
|
||||
* outside the *.e2e.ts pattern so importing it never re-registers another
|
||||
* file's tests.
|
||||
*/
|
||||
|
||||
export const SYSTEM_PROMPT = 'You are a coding agent. Use bash for file operations '
|
||||
+ 'with cat/grep/heredocs; check [exit code: N] markers, '
|
||||
+ 'and report results briefly.'
|
||||
|
||||
/** System prompt for the todo_write e2e: nudges the model to plan with the tool. */
|
||||
export const TODO_SYSTEM_PROMPT = 'You are a coding agent. For multi-step work, '
|
||||
+ 'use the todo_write tool to track a task list: send the WHOLE list each call, '
|
||||
+ 'keep at most one task in_progress (exactly one while work remains), and mark '
|
||||
+ 'a task completed as soon as it is done.'
|
||||
|
||||
/** Options for {@link codingHarness}. */
|
||||
export interface CodingHarnessOptions {
|
||||
/**
|
||||
* Deployment persona for the tree (the system-prompt plugin's `persona`
|
||||
* config — per-context, not per-agent). Omitted ⇒ no persona section.
|
||||
*/
|
||||
persona?: string
|
||||
/** Durable JSONL persistence root (the resume suite needs it; others stay file-free). */
|
||||
persistenceRoot?: string
|
||||
/**
|
||||
* Load {@link BasicCompactService} with this config so the compaction e2e can
|
||||
* trigger compaction at a small, controlled history size. Omitted ⇒ no
|
||||
* compaction plugin (the default suites run without it).
|
||||
*/
|
||||
compact?: BasicCompactConfig
|
||||
/** Optional token-meter capacity loaded before compact-basic. */
|
||||
tokenMeter?: TokenMeterConfig
|
||||
}
|
||||
|
||||
export async function codingHarness(workdir: string, options: CodingHarnessOptions = {}): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
await mountAgentLoopTestDependencies(ctx, {
|
||||
systemPrompt: { persona: options.persona ?? '' },
|
||||
})
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
await ctx.plugin(LlmDeepSeek)
|
||||
await ctx.plugin(LocalBashExecutor, { cwd: workdir, timeoutMs: 30_000 })
|
||||
await ctx.plugin(ToolBash)
|
||||
await ctx.plugin(ToolTodo)
|
||||
// Compaction is opt-in: only the compaction e2e loads the reusable meter and
|
||||
// backend, with a lower context window so a short real session crosses the threshold.
|
||||
if (options.compact !== undefined) {
|
||||
await ctx.plugin(TokenMeterService, options.tokenMeter)
|
||||
await ctx.plugin(ToolResultPruneService)
|
||||
await ctx.plugin(BasicCompactService, options.compact)
|
||||
}
|
||||
// Durable JSONL persistence is opt-in: only the resume e2e needs it, and the
|
||||
// other suites stay file-free. Loaded last so a resume's deferred
|
||||
// `ctx.inject(['sessionPersistence'])` resolves once this is present.
|
||||
if (options.persistenceRoot !== undefined) await ctx.plugin(SessionPersistenceJsonl, { root: options.persistenceRoot })
|
||||
return ctx
|
||||
}
|
||||
|
||||
export function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
const dispose = ctx.on('agent/status', (subject, status) => {
|
||||
if (subject === agent && status === 'idle') {
|
||||
dispose()
|
||||
resolve()
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
export function finalText(events: SessionEvent[]): string {
|
||||
const message = events.findLast(event => event.type === 'assistant/message')
|
||||
if (message?.type !== 'assistant/message') return ''
|
||||
return message.data.content
|
||||
.filter(block => block.type === 'text')
|
||||
.map(block => block.text)
|
||||
.join('')
|
||||
}
|
||||
@@ -1,4 +1,7 @@
|
||||
import { readdir } from 'node:fs/promises'
|
||||
import { readFile, readdir } from 'node:fs/promises'
|
||||
import { zstdDecompress } from 'node:zlib'
|
||||
import { promisify } from 'node:util'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { LOADER_SMOKE_TEST_TIMEOUT_MS, runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke'
|
||||
@@ -7,10 +10,11 @@ import type { SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
const binScript = fileURLToPath(new URL('../../../packages/examples/cli-demo/src/bin.ts', import.meta.url))
|
||||
const configPath = fileURLToPath(new URL('./fixtures/cli.cordis.yml', import.meta.url))
|
||||
const tsconfigPath = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url))
|
||||
const decompress = promisify(zstdDecompress)
|
||||
|
||||
describe('headless-agent keyless smoke', () => {
|
||||
it('boots the real Loader tree, runs a real bash tool round trip, and persists the turn', async () => {
|
||||
let persisted = false
|
||||
let persistedHeader: Record<string, unknown> | undefined
|
||||
const { stdout, stderr } = await runLoaderSmoke({
|
||||
label: 'headless-agent',
|
||||
tempDirPrefix: 'headless-agent-smoke-',
|
||||
@@ -20,7 +24,11 @@ describe('headless-agent keyless smoke', () => {
|
||||
tsconfigPath,
|
||||
inspect: async (cwd) => {
|
||||
const files = await readdir(cwd, { recursive: true })
|
||||
persisted = files.some(file => file.endsWith('.jsonl'))
|
||||
const relativePath = files.find(file => file.endsWith('.jsonl.zstd'))
|
||||
if (relativePath === undefined) return
|
||||
const compressed = await readFile(join(cwd, relativePath))
|
||||
expect(compressed.subarray(0, 4).toString('hex')).toBe('28b52ffd')
|
||||
persistedHeader = JSON.parse((await decompress(compressed)).toString()) as Record<string, unknown>
|
||||
},
|
||||
})
|
||||
const lines = stdout.trimEnd().split('\n').map(line => JSON.parse(line) as Record<string, unknown>)
|
||||
@@ -38,6 +46,6 @@ describe('headless-agent keyless smoke', () => {
|
||||
usage: { inputTokens: 18, outputTokens: 8, cacheReadTokens: 2, reasoningTokens: 1 },
|
||||
})
|
||||
expect(String(result?.['result'])).toContain('CLI_TOOL_ROUND_TRIP')
|
||||
expect(persisted).toBe(true)
|
||||
expect(persistedHeader).toMatchObject({ type: 'session' })
|
||||
}, LOADER_SMOKE_TEST_TIMEOUT_MS)
|
||||
})
|
||||
|
||||
67
examples/headless-agent/tests/resume.e2e.ts
Normal file
67
examples/headless-agent/tests/resume.e2e.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import type { Context } from 'cordis'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import { codingHarness, finalText, SYSTEM_PROMPT, waitForIdle } from './harness.ts'
|
||||
|
||||
/**
|
||||
* Proves durable conversation continuity end-to-end: run 1 tells the REAL model
|
||||
* a fact and persists the turn to JSONL; run 2 is a fresh harness (new Context,
|
||||
* same `.sessions` root) that RESUMES the persisted session id and asks the
|
||||
* model to recall the fact. The recall can only come from the rehydrated event
|
||||
* log — a fresh session would have no idea. Key-gated like the other e2es.
|
||||
*/
|
||||
|
||||
const SECRET = 'plum-galaxy-1791'
|
||||
const SESSION_ID = SessionId('resume-e2e-session')
|
||||
|
||||
let ctx: Context | undefined
|
||||
let root: string | undefined
|
||||
|
||||
afterEach(async () => {
|
||||
// Dispose even on failure/retry: agent-loop teardown stops the loop and the
|
||||
// JSONL backend flushes; then drop the on-disk session log.
|
||||
await ctx?.fiber.dispose()
|
||||
ctx = undefined
|
||||
if (root !== undefined) await rm(root, { recursive: true, force: true })
|
||||
root = undefined
|
||||
})
|
||||
|
||||
describe.skipIf(!process.env.DEEPSEEK_API_KEY)('resume: continue a persisted session across processes', () => {
|
||||
it('recalls a fact stored in a prior, separately-disposed session', async () => {
|
||||
root = await mkdtemp(join(tmpdir(), 'dsh-resume-e2e-'))
|
||||
|
||||
// Run 1: a fresh agent on a KNOWN session id learns a secret, then we
|
||||
// dispose the whole context (simulating process exit) so only the JSONL
|
||||
// log on disk survives.
|
||||
ctx = await codingHarness(process.cwd(), { persona: SYSTEM_PROMPT, persistenceRoot: root })
|
||||
const first = (await ctx.agents.create({
|
||||
sessionId: SESSION_ID,
|
||||
agentOptions: { provider: 'deepseek', model: 'deepseek-v4-flash' },
|
||||
})).agent
|
||||
first.send([{ type: 'text', text: `Remember this code for later: ${SECRET}. Just acknowledge it.` }])
|
||||
await waitForIdle(ctx, first)
|
||||
await ctx.fiber.dispose()
|
||||
ctx = undefined
|
||||
|
||||
// Run 2: a brand-new context over the SAME root resumes the persisted
|
||||
// session. The loaded event log seeds the live session, so the model sees
|
||||
// run 1's exchange as conversation history.
|
||||
ctx = await codingHarness(process.cwd(), { persona: SYSTEM_PROMPT, persistenceRoot: root })
|
||||
const resumed = (await ctx.agents.resume({
|
||||
resumeSessionId: SESSION_ID,
|
||||
agentOptions: { provider: 'deepseek', model: 'deepseek-v4-flash' },
|
||||
})).agent
|
||||
expect(resumed.session.id).toBe(SESSION_ID)
|
||||
// The prior user turn is in the rehydrated log before the model is asked.
|
||||
expect(JSON.stringify(resumed.session.deriveMessages())).toContain(SECRET)
|
||||
|
||||
resumed.send([{ type: 'text', text: 'What was the code I asked you to remember? Reply with just the code.' }])
|
||||
await waitForIdle(ctx, resumed)
|
||||
|
||||
// The model recalls it — only possible from the resumed history.
|
||||
expect(finalText([...resumed.session.events])).toContain(SECRET)
|
||||
}, 180_000)
|
||||
})
|
||||
53
examples/headless-agent/tests/todo-write.e2e.ts
Normal file
53
examples/headless-agent/tests/todo-write.e2e.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import type { Context } from 'cordis'
|
||||
import { codingHarness, TODO_SYSTEM_PROMPT, waitForIdle } from './harness.ts'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
|
||||
/**
|
||||
* A REAL model drives the REAL todo_write tool: verify the WORLD (the session
|
||||
* log gains a todo/write event whose snapshot the model actually produced), not
|
||||
* the agent's self-report. Key-gated (see vitest.e2e.config.ts).
|
||||
*/
|
||||
|
||||
let ctx: Context | undefined
|
||||
let workdir: string | undefined
|
||||
|
||||
afterEach(async () => {
|
||||
await ctx?.fiber.dispose()
|
||||
ctx = undefined
|
||||
if (workdir !== undefined) await rm(workdir, { recursive: true, force: true })
|
||||
workdir = undefined
|
||||
})
|
||||
|
||||
describe.skipIf(!process.env.DEEPSEEK_API_KEY)('todo_write: real model records a plan', () => {
|
||||
it('appends a todo/write event with the model-produced task list', async () => {
|
||||
workdir = await mkdtemp(join(tmpdir(), 'dsh-todo-write-e2e-'))
|
||||
ctx = await codingHarness(workdir, { persona: TODO_SYSTEM_PROMPT })
|
||||
const agent = ctx.agentLoop.create(SessionId('e2e-todo'), { provider: 'deepseek', model: 'deepseek-v4-flash' })
|
||||
|
||||
agent.send([{ type: 'text', text:
|
||||
'Use the todo_write tool to record a plan of exactly two steps: first '
|
||||
+ '"inspect the failing test" (in_progress), then "apply the fix" (pending). '
|
||||
+ 'Send both in one todo_write call, then reply with the single word DONE.' }])
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
const events = [...agent.session.events]
|
||||
|
||||
// The model actually called the tool.
|
||||
const calls = events.filter(event => event.type === 'tool/call')
|
||||
expect(calls.some(event => event.data.name === 'todo_write')).toBe(true)
|
||||
|
||||
// And the tool wrote a todo/write event to the log — verify the WORLD.
|
||||
const todoEvents = events.filter(event => event.type === 'todo/write')
|
||||
expect(todoEvents.length).toBeGreaterThan(0)
|
||||
|
||||
const todos = (todoEvents.at(-1)!).data.todos
|
||||
expect(todos).toEqual([
|
||||
{ content: 'inspect the failing test', status: 'in_progress' },
|
||||
{ content: 'apply the fix', status: 'pending' },
|
||||
])
|
||||
}, 120_000)
|
||||
})
|
||||
Reference in New Issue
Block a user