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 codex/simp-prune-llm-contract
This commit is contained in:
@@ -1,173 +1,62 @@
|
||||
import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'
|
||||
import { Readable, Writable } from 'node:stream'
|
||||
import { mkdtemp, rm, readFile } from 'node:fs/promises'
|
||||
import { mkdtemp, readFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk'
|
||||
import {
|
||||
ClientSideConnection,
|
||||
ndJsonStream,
|
||||
PROTOCOL_VERSION,
|
||||
type Agent as AcpAgent,
|
||||
type Client,
|
||||
type RequestPermissionRequest,
|
||||
type RequestPermissionResponse,
|
||||
type SessionNotification,
|
||||
} from '@agentclientprotocol/sdk'
|
||||
import { resolveExampleLaunch } from '@deepseek-ai/dsh-loader-smoke'
|
||||
launchAcpTestAgent,
|
||||
type AgentUnderTest,
|
||||
type LaunchedAcpTestAgent,
|
||||
} from '@deepseek-ai/dsh-acp-snapshot'
|
||||
import { cleanupAcpExampleTest } from './cleanup.ts'
|
||||
|
||||
/**
|
||||
* Boots examples/acp-agent as an ACP subprocess. The key-gated prompt leg
|
||||
* verifies its filesystem effect; a keyless initialize leg verifies that stdout
|
||||
* contains only framed JSON-RPC. Each subprocess is disposed in `afterEach`.
|
||||
* End-to-end: boot examples/acp-agent as a real subprocess speaking ACP over
|
||||
* its stdio, drive it with a real ClientSideConnection, send a real prompt, and
|
||||
* verify the WORLD (a file the agent wrote), not the agent's self-report. Owns
|
||||
* and disposes the subprocess in afterEach. Key-gated.
|
||||
*
|
||||
* Also asserts stdout purity (only framed JSON-RPC on stdout) — that one runs
|
||||
* WITHOUT a key, since it only needs the server to boot and answer initialize.
|
||||
*/
|
||||
|
||||
// The child runs from a temp cwd, so its bin and config path are absolute.
|
||||
const binScript = fileURLToPath(new URL('../../../packages/examples/acp-demo/src/bin.ts', import.meta.url))
|
||||
const configPath = fileURLToPath(new URL('../cordis.yml', import.meta.url))
|
||||
// The root tsconfig supplies unbuilt workspace `paths`; making it explicit
|
||||
// avoids accidental resolution through stale built output.
|
||||
const repoTsconfig = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url))
|
||||
|
||||
interface Spawned {
|
||||
child: ChildProcessWithoutNullStreams
|
||||
client: ClientSideConnection
|
||||
updates: SessionNotification['update'][]
|
||||
stderr: string[]
|
||||
const AGENT: AgentUnderTest = {
|
||||
binScript: fileURLToPath(new URL('../../../packages/examples/acp-demo/src/bin.ts', import.meta.url)),
|
||||
configPath: fileURLToPath(new URL('../cordis.yml', import.meta.url)),
|
||||
tsconfigPath: fileURLToPath(new URL('../../../tsconfig.json', import.meta.url)),
|
||||
}
|
||||
const DANGER_FULL_ACCESS_ENV = { DSH_PERMISSION_MODE: 'danger-full-access' }
|
||||
|
||||
function spawnAcpAgent(cwd: string, env: NodeJS.ProcessEnv = process.env): Spawned {
|
||||
const launch = resolveExampleLaunch({
|
||||
srcBin: binScript,
|
||||
configArgs: ['--config', configPath],
|
||||
tsconfigPath: repoTsconfig,
|
||||
env: {
|
||||
DSH_PERMISSION_MODE: 'danger-full-access',
|
||||
DSH_HOME: join(cwd, '.dsh'),
|
||||
DSH_AGENTS_HOME: join(cwd, '.agents'),
|
||||
},
|
||||
})
|
||||
const child = spawn(
|
||||
launch.command,
|
||||
launch.args,
|
||||
{ cwd, env: { ...env, ...launch.env }, stdio: ['pipe', 'pipe', 'pipe'] },
|
||||
)
|
||||
const stderr: string[] = []
|
||||
child.stderr.setEncoding('utf8')
|
||||
child.stderr.on('data', (chunk: string) => stderr.push(chunk))
|
||||
|
||||
const updates: SessionNotification['update'][] = []
|
||||
const stream = ndJsonStream(
|
||||
Writable.toWeb(child.stdin) as WritableStream<Uint8Array>,
|
||||
Readable.toWeb(child.stdout) as ReadableStream<Uint8Array>,
|
||||
)
|
||||
const makeClient = (_agent: AcpAgent): Client => ({
|
||||
sessionUpdate(params: SessionNotification): Promise<void> {
|
||||
updates.push(params.update)
|
||||
return Promise.resolve()
|
||||
},
|
||||
requestPermission(_params: RequestPermissionRequest): Promise<RequestPermissionResponse> {
|
||||
// This suite selects danger-full-access (approval never), so the bridge
|
||||
// never prompts here; answer cancelled if an unexpected ask arrives.
|
||||
return Promise.resolve({ outcome: { outcome: 'cancelled' } })
|
||||
},
|
||||
})
|
||||
const client = new ClientSideConnection(makeClient, stream)
|
||||
return { child, client, updates, stderr }
|
||||
}
|
||||
|
||||
let spawned: Spawned | undefined
|
||||
let spawned: LaunchedAcpTestAgent | undefined
|
||||
let workdir: string | undefined
|
||||
|
||||
function hasStdoutLine(out: string[]): boolean {
|
||||
return out.join('').split('\n').some(line => line.trim().length > 0)
|
||||
}
|
||||
|
||||
async function waitForStdoutLine(child: ChildProcessWithoutNullStreams, out: string[], stderr: string[], timeoutMs: number): Promise<void> {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const cleanup = () => {
|
||||
clearTimeout(timeout)
|
||||
child.stdout.off('data', onData)
|
||||
child.off('exit', onExit)
|
||||
child.off('error', onError)
|
||||
}
|
||||
const pass = () => {
|
||||
cleanup()
|
||||
resolve()
|
||||
}
|
||||
const fail = (reason: string) => {
|
||||
cleanup()
|
||||
reject(new Error(`${reason}; stderr: ${stderr.join('')}`))
|
||||
}
|
||||
const onData = () => {
|
||||
if (hasStdoutLine(out)) pass()
|
||||
}
|
||||
const onExit = (code: number | null, signal: NodeJS.Signals | null) => {
|
||||
fail(`ACP child exited before emitting a stdout frame (code ${code ?? 'null'}, signal ${signal ?? 'null'})`)
|
||||
}
|
||||
const onError = (error: Error) => {
|
||||
fail(`ACP child failed before emitting a stdout frame: ${error.message}`)
|
||||
}
|
||||
const timeout = setTimeout(() => {
|
||||
fail(`ACP child did not emit a stdout frame within ${timeoutMs}ms`)
|
||||
}, timeoutMs)
|
||||
|
||||
child.stdout.on('data', onData)
|
||||
child.on('exit', onExit)
|
||||
child.on('error', onError)
|
||||
onData()
|
||||
})
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
if (spawned) {
|
||||
spawned.child.kill('SIGKILL')
|
||||
spawned = undefined
|
||||
}
|
||||
if (workdir !== undefined) await rm(workdir, { recursive: true, force: true })
|
||||
const ownedSpawned = spawned
|
||||
const ownedWorkdir = workdir
|
||||
spawned = undefined
|
||||
workdir = undefined
|
||||
await cleanupAcpExampleTest(ownedSpawned, ownedWorkdir)
|
||||
})
|
||||
|
||||
describe('acp-agent over real stdio (no key required)', () => {
|
||||
it('emits only framed JSON-RPC on stdout', async () => {
|
||||
workdir = await mkdtemp(join(tmpdir(), 'acp-e2e-'))
|
||||
// Collect raw stdout bytes directly (bypass the SDK framing) to inspect.
|
||||
// A dummy key boots the adapter; this purity test sends no prompt and makes no model call.
|
||||
const launch = resolveExampleLaunch({
|
||||
srcBin: binScript,
|
||||
configArgs: ['--config', configPath],
|
||||
tsconfigPath: repoTsconfig,
|
||||
// Inspect the launcher's raw-byte tee in addition to driving its SDK client.
|
||||
// A dummy key lets the deepseek adapter APPLY (it only checks the key is
|
||||
// present at boot, not valid — the key is used only on a real model call,
|
||||
// which this purity test never triggers). So this runs WITHOUT real creds.
|
||||
spawned = launchAcpTestAgent({
|
||||
agent: AGENT,
|
||||
cwd: workdir,
|
||||
env: {
|
||||
DEEPSEEK_API_KEY: process.env.DEEPSEEK_API_KEY ?? 'sk-dummy-for-boot',
|
||||
DSH_PERMISSION_MODE: 'danger-full-access',
|
||||
DSH_HOME: join(workdir, '.dsh'),
|
||||
DSH_AGENTS_HOME: join(workdir, '.agents'),
|
||||
...DANGER_FULL_ACCESS_ENV,
|
||||
},
|
||||
})
|
||||
const child = spawn(launch.command, launch.args, {
|
||||
cwd: workdir,
|
||||
env: { ...process.env, ...launch.env },
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
})
|
||||
const out: string[] = []
|
||||
const stderr: string[] = []
|
||||
child.stdout.setEncoding('utf8')
|
||||
child.stderr.setEncoding('utf8')
|
||||
child.stdout.on('data', (c: string) => out.push(c))
|
||||
child.stderr.on('data', (c: string) => stderr.push(c))
|
||||
await spawned.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
|
||||
// Send a single initialize request as a newline-delimited JSON-RPC frame.
|
||||
const req = JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize', params: { protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} } })
|
||||
child.stdin.write(req + '\n')
|
||||
|
||||
try {
|
||||
await waitForStdoutLine(child, out, stderr, 15_000)
|
||||
} finally {
|
||||
child.kill('SIGKILL')
|
||||
}
|
||||
|
||||
const lines = out.join('').split('\n').filter(l => l.trim().length > 0)
|
||||
const lines = spawned.rawStdout().split('\n').filter(line => line.trim().length > 0)
|
||||
expect(lines.length).toBeGreaterThan(0)
|
||||
for (const line of lines) {
|
||||
// Every stdout line MUST parse as JSON (a JSON-RPC frame). A non-JSON
|
||||
@@ -177,14 +66,28 @@ describe('acp-agent over real stdio (no key required)', () => {
|
||||
}, 30_000)
|
||||
|
||||
it('session/new succeeds over real stdio (no model call)', async () => {
|
||||
// Regression guard (this exact RPC crashed a real Zed session with "cannot get property
|
||||
// \"agents\" without inject"): `session/new` drives the full bridge →
|
||||
// `ctx.agents.create({sessionId, meta:{cwd}})` → AgentLoop → registry/persistence path, ALL
|
||||
// of which run from the JSON-RPC read loop outside the bridge plugin's injection scope.
|
||||
// REGRESSION GUARD (this exact RPC crashed a real Zed session with
|
||||
// "cannot get property \"agents\" without inject"): `session/new` drives the
|
||||
// full bridge → `ctx.agents.create({sessionId, meta:{cwd}})` → AgentLoop →
|
||||
// registry/persistence path, ALL of which run from the JSON-RPC read loop
|
||||
// OUTSIDE the bridge plugin's injection scope. A lazy `ctx.<service>` read
|
||||
// on that path throws and the RPC fails with an Internal error — yet the
|
||||
// call never touches the model, so this reproduces WITHOUT a key. The
|
||||
// key-gated prompt test below never caught it (it needs real creds); the
|
||||
// initialize-only purity test never caught it (initialize does not reach
|
||||
// the factory). This closes that gap: boot the real subprocess and create a
|
||||
// session, asserting the RPC RESOLVES (not rejects with an inject error).
|
||||
workdir = await mkdtemp(join(tmpdir(), 'acp-e2e-'))
|
||||
// A dummy key lets the deepseek adapter boot (it only checks presence, not
|
||||
// validity, at apply time); no model call is made, so the key is never used.
|
||||
spawned = spawnAcpAgent(workdir, { ...process.env, DEEPSEEK_API_KEY: process.env.DEEPSEEK_API_KEY ?? 'sk-dummy-for-boot' })
|
||||
spawned = launchAcpTestAgent({
|
||||
agent: AGENT,
|
||||
cwd: workdir,
|
||||
env: {
|
||||
DEEPSEEK_API_KEY: process.env.DEEPSEEK_API_KEY ?? 'sk-dummy-for-boot',
|
||||
...DANGER_FULL_ACCESS_ENV,
|
||||
},
|
||||
})
|
||||
const { client } = spawned
|
||||
|
||||
await client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
@@ -197,7 +100,7 @@ describe('acp-agent over real stdio (no key required)', () => {
|
||||
describe.skipIf(!process.env.DEEPSEEK_API_KEY)('acp-agent e2e: real prompt over ACP', () => {
|
||||
it('runs a real turn and the agent writes the requested file (verified on disk)', async () => {
|
||||
workdir = await mkdtemp(join(tmpdir(), 'acp-e2e-'))
|
||||
spawned = spawnAcpAgent(workdir)
|
||||
spawned = launchAcpTestAgent({ agent: AGENT, cwd: workdir, env: DANGER_FULL_ACCESS_ENV })
|
||||
const { client, updates } = spawned
|
||||
|
||||
await client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
@@ -211,29 +114,34 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('acp-agent e2e: real prompt over
|
||||
})
|
||||
expect(['end_turn', 'max_tokens']).toContain(res.stopReason)
|
||||
|
||||
// Verify the filesystem effect rather than the agent's report.
|
||||
// Verify the WORLD, not the agent's self-report: read the file from disk.
|
||||
const proof = await readFile(join(workdir, 'proof.txt'), 'utf8')
|
||||
expect(proof).toContain('ACP_OK')
|
||||
|
||||
// And the client saw tool-call activity stream through.
|
||||
const toolCalls = updates.filter(u => u.sessionUpdate === 'tool_call')
|
||||
expect(toolCalls.length).toBeGreaterThan(0)
|
||||
|
||||
// Bash execute cards hide rawInput, so `presentCall` uses the exact command
|
||||
// as the title rather than the bare tool name "bash".
|
||||
// Tool-call UI quality (the tool owns its presentation): the bash tool's
|
||||
// `presentCall` sets the title to the exact command (an execute card hides
|
||||
// rawInput, so the command IS the title) — NOT the bare tool name "bash".
|
||||
// A `bash` call must therefore carry an execute kind, a non-"bash" title,
|
||||
// and a string rawInput (the command). `toolCalls` is already narrowed to
|
||||
// the `tool_call` shape by the filter above, so these fields are reachable.
|
||||
const bashCall = toolCalls.find(u => u.kind === 'execute')
|
||||
expect(bashCall).toBeDefined()
|
||||
if (bashCall === undefined) throw new Error('expected an execute tool_call')
|
||||
expect(typeof bashCall.title).toBe('string')
|
||||
expect(bashCall.title.length).toBeGreaterThan(0)
|
||||
expect(bashCall.title).not.toBe('bash')
|
||||
expect(typeof bashCall.rawInput).toBe('string')
|
||||
// Without the terminal capability, output uses the console-text path.
|
||||
expect(bashCall.title).not.toBe('bash') // the old, unhelpful title
|
||||
expect(typeof bashCall.rawInput).toBe('string') // the exact command
|
||||
// Capability OFF: no terminal _meta — the ```console text path renders.
|
||||
expect((bashCall as { _meta?: unknown })._meta).toBeUndefined()
|
||||
}, 180_000)
|
||||
|
||||
it('with the terminal_output capability, a real bash call renders as a terminal card (content + _meta + exit)', async () => {
|
||||
workdir = await mkdtemp(join(tmpdir(), 'acp-e2e-'))
|
||||
spawned = spawnAcpAgent(workdir)
|
||||
spawned = launchAcpTestAgent({ agent: AGENT, cwd: workdir, env: DANGER_FULL_ACCESS_ENV })
|
||||
const { client, updates } = spawned
|
||||
|
||||
// Advertise the Zed `_meta.terminal_output` capability so the bridge emits
|
||||
|
||||
@@ -53,6 +53,13 @@ const SCENARIOS: Scenario[] = [
|
||||
// Its prompt and tool-schema sidecars pin the composed header.
|
||||
{ name: 'text-turn', hasModelTurn: true, recorded: true, pinsHeader: true },
|
||||
{ name: 'tool-call-turn', hasModelTurn: true, recorded: true },
|
||||
{
|
||||
name: 'parallel-tool-calls',
|
||||
hasModelTurn: true,
|
||||
recorded: false,
|
||||
headerClass: 'fs',
|
||||
configPath: FS_CONFIG,
|
||||
},
|
||||
{ name: 'bash-spill', hasModelTurn: true, recorded: false, headerClass: 'fs', configPath: FS_CONFIG },
|
||||
{ name: 'fs-terminal-card', hasModelTurn: true, recorded: true },
|
||||
{ name: 'todo-plan', hasModelTurn: true, recorded: true },
|
||||
@@ -96,14 +103,14 @@ const SCENARIOS: Scenario[] = [
|
||||
configPath: WORKSPACE_CONTEXT_CONFIG,
|
||||
},
|
||||
{ name: 'cancel', hasModelTurn: true, recorded: false, overridden: true },
|
||||
{ name: 'subagent-spawn', hasModelTurn: true, recorded: true, childSessions: 1 },
|
||||
{ name: 'subagent-multi', hasModelTurn: true, recorded: true, childSessions: 2 },
|
||||
{ name: 'subagent-fork', hasModelTurn: true, recorded: true, childSessions: 1 },
|
||||
{ name: 'subagent-mixed', hasModelTurn: true, recorded: true, childSessions: 2 },
|
||||
{ name: 'subagent-spawn', hasModelTurn: true, recorded: true },
|
||||
{ name: 'subagent-multi', hasModelTurn: true, recorded: true },
|
||||
{ name: 'subagent-fork', hasModelTurn: true, recorded: true },
|
||||
{ name: 'subagent-mixed', hasModelTurn: true, recorded: true },
|
||||
// The workflow tool: the model writes a one-child orchestration script; the
|
||||
// child runs as a spawn subagent under the worker-thread engine (its session is the
|
||||
// child fixture), and the tool result carries the script's return value.
|
||||
{ name: 'workflow-run', hasModelTurn: true, recorded: true, childSessions: 1 },
|
||||
{ name: 'workflow-run', hasModelTurn: true, recorded: true },
|
||||
// Authored counterpart to the packaged Python SDK snapshot: mount a live marker, inspect it
|
||||
// through Code Mode, run direct and workflow children, then unmount it. The extra Code Mode and
|
||||
// Cordis plugins require their own request-header pin; the fixture tests deterministic composition.
|
||||
@@ -111,7 +118,6 @@ const SCENARIOS: Scenario[] = [
|
||||
name: 'advanced-toolchain',
|
||||
hasModelTurn: true,
|
||||
recorded: false,
|
||||
childSessions: 2,
|
||||
pinsHeader: true,
|
||||
headerClass: 'advanced',
|
||||
configPath: ADVANCED_CONFIG,
|
||||
|
||||
38
examples/acp-agent/tests/cleanup.e2e.ts
Normal file
38
examples/acp-agent/tests/cleanup.e2e.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
/** Regression coverage for ACP example teardown. */
|
||||
|
||||
import { access, mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { cleanupAcpExampleTest } from './cleanup.ts'
|
||||
|
||||
let fallbackWorkdir: string | undefined
|
||||
|
||||
afterEach(async () => {
|
||||
if (fallbackWorkdir !== undefined) await rm(fallbackWorkdir, { recursive: true, force: true })
|
||||
fallbackWorkdir = undefined
|
||||
})
|
||||
|
||||
describe('cleanupAcpExampleTest', () => {
|
||||
it('removes the workspace after process shutdown fails', async () => {
|
||||
fallbackWorkdir = await mkdtemp(join(tmpdir(), 'acp-cleanup-'))
|
||||
const closeFailure = new Error('close failed')
|
||||
const spawned = { close: vi.fn().mockRejectedValue(closeFailure) }
|
||||
|
||||
await expect(cleanupAcpExampleTest(spawned, fallbackWorkdir))
|
||||
.rejects.toMatchObject({ errors: [closeFailure] })
|
||||
await expect(access(fallbackWorkdir)).rejects.toThrow()
|
||||
fallbackWorkdir = undefined
|
||||
})
|
||||
|
||||
it('reports process and workspace failures together', async () => {
|
||||
const closeFailure = new Error('close failed')
|
||||
const spawned = { close: vi.fn().mockRejectedValue(closeFailure) }
|
||||
|
||||
const failure = await cleanupAcpExampleTest(spawned, '\0').catch((error: unknown) => error)
|
||||
|
||||
expect(failure).toBeInstanceOf(AggregateError)
|
||||
expect((failure as AggregateError).errors).toHaveLength(2)
|
||||
expect((failure as AggregateError).errors[0]).toBe(closeFailure)
|
||||
})
|
||||
})
|
||||
23
examples/acp-agent/tests/cleanup.ts
Normal file
23
examples/acp-agent/tests/cleanup.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
/** Shared teardown for ACP example tests. */
|
||||
|
||||
import { rm } from 'node:fs/promises'
|
||||
import type { LaunchedAcpTestAgent } from '@deepseek-ai/dsh-acp-snapshot'
|
||||
|
||||
/**
|
||||
* Close the test agent, then remove its workspace, attempting both operations
|
||||
* and reporting every failure instead of allowing the later one to mask the
|
||||
* earlier one.
|
||||
*/
|
||||
export async function cleanupAcpExampleTest(
|
||||
spawned: Pick<LaunchedAcpTestAgent, 'close'> | undefined,
|
||||
workdir: string | undefined,
|
||||
): Promise<void> {
|
||||
const results: PromiseSettledResult<unknown>[] = []
|
||||
if (spawned !== undefined) results.push(...await Promise.allSettled([spawned.close('SIGKILL')]))
|
||||
if (workdir !== undefined) results.push(...await Promise.allSettled([rm(workdir, { recursive: true, force: true })]))
|
||||
|
||||
const failures = results
|
||||
.filter((result): result is PromiseRejectedResult => result.status === 'rejected')
|
||||
.map(result => result.reason as unknown)
|
||||
if (failures.length > 0) throw new AggregateError(failures, 'ACP example cleanup failed')
|
||||
}
|
||||
@@ -1,40 +1,49 @@
|
||||
import { spawn, spawnSync, type ChildProcessWithoutNullStreams } from 'node:child_process'
|
||||
import { Readable, Writable } from 'node:stream'
|
||||
import { mkdtemp, readFile, rm } from 'node:fs/promises'
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { mkdtemp, readFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
ClientSideConnection,
|
||||
ndJsonStream,
|
||||
PROTOCOL_VERSION,
|
||||
type Agent as AcpAgent,
|
||||
type Client,
|
||||
type RequestPermissionRequest,
|
||||
type RequestPermissionResponse,
|
||||
type SessionNotification,
|
||||
} from '@agentclientprotocol/sdk'
|
||||
import { resolveExampleLaunch } from '@deepseek-ai/dsh-loader-smoke'
|
||||
import {
|
||||
launchAcpTestAgent,
|
||||
type AgentUnderTest,
|
||||
type LaunchedAcpTestAgent,
|
||||
} from '@deepseek-ai/dsh-acp-snapshot'
|
||||
import { cleanupAcpExampleTest } from './cleanup.ts'
|
||||
|
||||
/**
|
||||
* Exercises the default ACP composition through the real bin and Loader. The
|
||||
* keyless leg boots sandbox, approval, permission, and bridge services, then
|
||||
* initializes and opens a session without a model call or runner probe. With a
|
||||
* key and usable runner, the prompt asserts a prior denial; the model requests
|
||||
* a wider retry with justification, and a scripted client grants or rejects it.
|
||||
* The filesystem must show that only the granted retry ran. Missing credentials
|
||||
* or runner support self-skip; real denial markers remain on sandbox e2e tiers.
|
||||
* The default ACP composition (`cordis.yml`) end to end.
|
||||
*
|
||||
* Keyless smoke: boot the REAL `cordis.yml` through the `dsh-acp-agent` bin as
|
||||
* an ACP subprocess and drive initialize + session/new — the real-Loader-path
|
||||
* guard (postmortem 0001) for THIS tree's export shapes, which now include the
|
||||
* sandbox executor AND the approval service. No prompt is sent, so neither the
|
||||
* model nor a sandbox runner is ever exercised.
|
||||
*
|
||||
* With-key escalation flow (self-skips without DEEPSEEK_API_KEY or a usable
|
||||
* platform runner): a scripted ACP client plays the human. The prompt asserts
|
||||
* a prior denial (the organic denial→marker path lives on the sandbox e2e
|
||||
* legs and unit tiers), the real model escalates with `sandbox_permissions` +
|
||||
* `justification`, the bridge prompts THIS client over
|
||||
* `session/request_permission`, the client answers `allow-once`, and the
|
||||
* retried write must land ON DISK (world-verified) — under the granted mode,
|
||||
* a temp-dir session cwd is writable either way.
|
||||
*/
|
||||
|
||||
const binScript = fileURLToPath(new URL('../../../packages/examples/acp-demo/src/bin.ts', import.meta.url))
|
||||
const configPath = fileURLToPath(new URL('../cordis.yml', import.meta.url))
|
||||
// The subprocess runs from a temp cwd outside the repo; point tsx at the repo
|
||||
// tsconfig so the unbuilt `paths` map resolves in src mode (see examples/AGENTS.md).
|
||||
const repoTsconfig = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url))
|
||||
const AGENT: AgentUnderTest = {
|
||||
binScript: fileURLToPath(new URL('../../../packages/examples/acp-demo/src/bin.ts', import.meta.url)),
|
||||
configPath: fileURLToPath(new URL('../cordis.yml', import.meta.url)),
|
||||
tsconfigPath: fileURLToPath(new URL('../../../tsconfig.json', import.meta.url)),
|
||||
}
|
||||
|
||||
// Without a usable bwrap/Seatbelt runner, the strict attempt fails closed with
|
||||
// SANDBOX_UNAVAILABLE instead of producing the denial this flow requires.
|
||||
// A usable confining runner, probed the same way the executor suites do:
|
||||
// bwrap on Linux, Seatbelt's sandbox-exec on macOS. Without one the strict
|
||||
// attempt would fail closed (SANDBOX_UNAVAILABLE) instead of producing the
|
||||
// denial this flow starts from.
|
||||
const hasBwrap = spawnSync('bwrap', ['--ro-bind', '/', '/', '--dev', '/dev', '--proc', '/proc', '--die-with-parent', '--', 'true'], {
|
||||
timeout: 5_000,
|
||||
stdio: 'ignore',
|
||||
@@ -45,72 +54,49 @@ const hasSeatbelt = process.platform === 'darwin' && spawnSync('sandbox-exec', [
|
||||
}).status === 0
|
||||
const hasRunner = hasBwrap || hasSeatbelt
|
||||
|
||||
interface Spawned {
|
||||
child: ChildProcessWithoutNullStreams
|
||||
client: ClientSideConnection
|
||||
updates: SessionNotification['update'][]
|
||||
interface Spawned extends LaunchedAcpTestAgent {
|
||||
permissionRequests: RequestPermissionRequest[]
|
||||
stderr: string[]
|
||||
}
|
||||
|
||||
/** Boot the example as an ACP subprocess; the scripted client answers every permission prompt with `answer`. */
|
||||
function spawnAcpAgent(cwd: string, answer: 'allow-once' | 'reject-once'): Spawned {
|
||||
const launch = resolveExampleLaunch({
|
||||
srcBin: binScript,
|
||||
configArgs: ['--config', configPath],
|
||||
tsconfigPath: repoTsconfig,
|
||||
// A dummy key lets the deepseek adapter boot keyless (presence-checked at
|
||||
// apply, used only on a real model call); the with-key tests carry the
|
||||
// real key, so the fallback is inert there.
|
||||
env: { DEEPSEEK_API_KEY: process.env.DEEPSEEK_API_KEY || 'sk-dummy-for-boot' },
|
||||
})
|
||||
const child = spawn(
|
||||
launch.command,
|
||||
launch.args,
|
||||
{ cwd, env: { ...process.env, ...launch.env }, stdio: ['pipe', 'pipe', 'pipe'] },
|
||||
)
|
||||
const stderr: string[] = []
|
||||
child.stderr.setEncoding('utf8')
|
||||
child.stderr.on('data', (chunk: string) => stderr.push(chunk))
|
||||
|
||||
const updates: SessionNotification['update'][] = []
|
||||
function launchExampleAcpAgent(cwd: string, answer: 'allow-once' | 'reject-once'): Spawned {
|
||||
const permissionRequests: RequestPermissionRequest[] = []
|
||||
const stream = ndJsonStream(
|
||||
Writable.toWeb(child.stdin) as WritableStream<Uint8Array>,
|
||||
Readable.toWeb(child.stdout) as ReadableStream<Uint8Array>,
|
||||
)
|
||||
const makeClient = (_agent: AcpAgent): Client => ({
|
||||
sessionUpdate(params: SessionNotification): Promise<void> {
|
||||
updates.push(params.update)
|
||||
return Promise.resolve()
|
||||
},
|
||||
requestPermission(params: RequestPermissionRequest): Promise<RequestPermissionResponse> {
|
||||
const launched = launchAcpTestAgent({
|
||||
agent: AGENT,
|
||||
cwd,
|
||||
// A dummy key lets the adapter boot keylessly; live tests carry the real key.
|
||||
env: { DEEPSEEK_API_KEY: process.env.DEEPSEEK_API_KEY ?? 'sk-dummy-for-boot' },
|
||||
requestPermission(params) {
|
||||
permissionRequests.push(params)
|
||||
const option = params.options.find(o => o.optionId === answer)
|
||||
// An unexpected prompt shape cancels without granting.
|
||||
// The scripted human: pick the requested option when the prompt offers
|
||||
// it; an unexpected prompt shape cancels (fail closed, never grants).
|
||||
if (option === undefined) return Promise.resolve({ outcome: { outcome: 'cancelled' } })
|
||||
return Promise.resolve({ outcome: { outcome: 'selected', optionId: option.optionId } })
|
||||
},
|
||||
})
|
||||
const client = new ClientSideConnection(makeClient, stream)
|
||||
return { child, client, updates, permissionRequests, stderr }
|
||||
return Object.assign(launched, { permissionRequests })
|
||||
}
|
||||
|
||||
let spawned: Spawned | undefined
|
||||
let workdir: string | undefined
|
||||
|
||||
afterEach(async () => {
|
||||
if (spawned !== undefined && spawned.child.exitCode === null) spawned.child.kill('SIGKILL')
|
||||
const ownedSpawned = spawned
|
||||
const ownedWorkdir = workdir
|
||||
spawned = undefined
|
||||
if (workdir !== undefined) await rm(workdir, { recursive: true, force: true })
|
||||
workdir = undefined
|
||||
await cleanupAcpExampleTest(ownedSpawned, ownedWorkdir)
|
||||
})
|
||||
|
||||
describe('default sandbox composition keyless smoke (real cordis.yml via the Loader)', () => {
|
||||
it('boots the tree — sandbox executor + approval service + bridge — and opens a session', async () => {
|
||||
workdir = await mkdtemp(join(tmpdir(), 'sandbox-acp-smoke-'))
|
||||
spawned = spawnAcpAgent(workdir, 'reject-once')
|
||||
spawned = launchExampleAcpAgent(workdir, 'reject-once')
|
||||
const { client } = spawned
|
||||
// A dummy key boots the adapter; no prompt is ever sent, so no model call
|
||||
// and no sandbox runner probe happen. This drives the fiber tree the same
|
||||
// way an editor would, which is what catches a broken export/inject shape.
|
||||
const init = await client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
expect(init.protocolVersion).toBe(PROTOCOL_VERSION)
|
||||
const { sessionId } = await client.newSession({ cwd: workdir, mcpServers: [] })
|
||||
@@ -119,14 +105,18 @@ describe('default sandbox composition keyless smoke (real cordis.yml via the Loa
|
||||
|
||||
it('advertises model and Permissions selects and honors a permission switch without a model call', async () => {
|
||||
workdir = await mkdtemp(join(tmpdir(), 'sandbox-acp-config-'))
|
||||
spawned = spawnAcpAgent(workdir, 'reject-once')
|
||||
spawned = launchExampleAcpAgent(workdir, 'reject-once')
|
||||
const { client } = spawned
|
||||
await client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
// This tree composes the permission presets over bash-sandbox + approval →
|
||||
// ONE select advertises, current from the configured default preset.
|
||||
const created = await client.newSession({ cwd: workdir, mcpServers: [] })
|
||||
const advertised = created.configOptions ?? []
|
||||
const modelValue = JSON.stringify(['deepseek', 'deepseek-v4-flash'])
|
||||
expect(advertised.map(option => [option.id, 'currentValue' in option ? option.currentValue : undefined]))
|
||||
.toEqual([['model', modelValue], ['permission', 'workspace-write']])
|
||||
// A switch responds with the COMPLETE refreshed state (the spec contract),
|
||||
// and the new current survives in the response of a second switch.
|
||||
const afterFullAccess = await client.setSessionConfigOption({
|
||||
sessionId: created.sessionId, configId: 'permission', value: 'danger-full-access',
|
||||
})
|
||||
@@ -137,6 +127,7 @@ describe('default sandbox composition keyless smoke (real cordis.yml via the Loa
|
||||
})
|
||||
expect((again.configOptions ?? []).map(option => [option.id, 'currentValue' in option ? option.currentValue : undefined]))
|
||||
.toEqual([['model', modelValue], ['permission', 'danger-full-access']])
|
||||
// An out-of-vocabulary value is a protocol error, never a silent default.
|
||||
await expect(client.setSessionConfigOption({
|
||||
sessionId: created.sessionId, configId: 'permission', value: 'plan',
|
||||
})).rejects.toThrow(/unknown permission value/)
|
||||
@@ -146,7 +137,7 @@ describe('default sandbox composition keyless smoke (real cordis.yml via the Loa
|
||||
describe.skipIf(!process.env.DEEPSEEK_API_KEY || !hasRunner)('default sandbox composition e2e: the live approval loop', () => {
|
||||
it('denial → model escalation → editor prompt → allow-once → the retried write lands on disk', async () => {
|
||||
workdir = await mkdtemp(join(tmpdir(), 'sandbox-acp-e2e-'))
|
||||
spawned = spawnAcpAgent(workdir, 'allow-once')
|
||||
spawned = launchExampleAcpAgent(workdir, 'allow-once')
|
||||
const { client, permissionRequests } = spawned
|
||||
|
||||
await client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
@@ -158,11 +149,13 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY || !hasRunner)('default sandbox co
|
||||
})
|
||||
expect(['end_turn', 'max_tokens']).toContain(res.stopReason)
|
||||
|
||||
// Verify the filesystem, not the model's report.
|
||||
// The WORLD: the approved escalated retry landed the write.
|
||||
const proof = await readFile(join(workdir, 'escalated.txt'), 'utf8')
|
||||
expect(proof).toContain('ACP_ESCALATION_OK')
|
||||
|
||||
// Verify that ACP carried the grant with only one-shot choices.
|
||||
// The CHANNEL: the grant came through a real session/request_permission
|
||||
// prompt attached to the escalating tool call, offering exactly the
|
||||
// one-shot options.
|
||||
expect(permissionRequests.length).toBeGreaterThan(0)
|
||||
const prompt = permissionRequests[0]
|
||||
if (prompt === undefined) throw new Error('expected a permission request')
|
||||
@@ -173,7 +166,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY || !hasRunner)('default sandbox co
|
||||
|
||||
it('a rejected escalation stays denied: no write lands, the turn still ends', async () => {
|
||||
workdir = await mkdtemp(join(tmpdir(), 'sandbox-acp-e2e-'))
|
||||
spawned = spawnAcpAgent(workdir, 'reject-once')
|
||||
spawned = launchExampleAcpAgent(workdir, 'reject-once')
|
||||
const { client, permissionRequests } = spawned
|
||||
|
||||
await client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
@@ -185,8 +178,9 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY || !hasRunner)('default sandbox co
|
||||
})
|
||||
expect(['end_turn', 'max_tokens']).toContain(res.stopReason)
|
||||
|
||||
// The WORLD: rejected means the file never appeared.
|
||||
await expect(readFile(join(workdir, 'refused.txt'), 'utf8')).rejects.toThrow()
|
||||
// Distinguish a user rejection from a missing approval channel.
|
||||
// And the rejection really flowed through a prompt (not a missing channel).
|
||||
expect(permissionRequests.length).toBeGreaterThan(0)
|
||||
}, 240_000)
|
||||
})
|
||||
|
||||
@@ -1,21 +1,15 @@
|
||||
import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'
|
||||
import { Readable, Writable } from 'node:stream'
|
||||
import { mkdtemp, rm, writeFile, access } from 'node:fs/promises'
|
||||
import { mkdtemp, writeFile, access } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk'
|
||||
import {
|
||||
ClientSideConnection,
|
||||
ndJsonStream,
|
||||
PROTOCOL_VERSION,
|
||||
type Agent as AcpAgent,
|
||||
type Client,
|
||||
type RequestPermissionRequest,
|
||||
type RequestPermissionResponse,
|
||||
type SessionNotification,
|
||||
} from '@agentclientprotocol/sdk'
|
||||
import { resolveExampleLaunch } from '@deepseek-ai/dsh-loader-smoke'
|
||||
launchAcpTestAgent,
|
||||
type AgentUnderTest,
|
||||
type LaunchedAcpTestAgent,
|
||||
} from '@deepseek-ai/dsh-acp-snapshot'
|
||||
import { cleanupAcpExampleTest } from './cleanup.ts'
|
||||
|
||||
/**
|
||||
* With-key e2e for the Claude hook bridge. The process-level `./hooks.json` is
|
||||
@@ -24,61 +18,21 @@ import { resolveExampleLaunch } from '@deepseek-ai/dsh-loader-smoke'
|
||||
* The test owns and disposes the ACP subprocess.
|
||||
*/
|
||||
|
||||
const binScript = fileURLToPath(new URL('../../../packages/examples/acp-demo/src/bin.ts', import.meta.url))
|
||||
const configPath = fileURLToPath(new URL('../cordis.yml', import.meta.url))
|
||||
const repoTsconfig = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url))
|
||||
|
||||
interface Spawned {
|
||||
child: ChildProcessWithoutNullStreams
|
||||
client: ClientSideConnection
|
||||
updates: SessionNotification['update'][]
|
||||
stderr: string[]
|
||||
const AGENT: AgentUnderTest = {
|
||||
binScript: fileURLToPath(new URL('../../../packages/examples/acp-demo/src/bin.ts', import.meta.url)),
|
||||
configPath: fileURLToPath(new URL('../cordis.yml', import.meta.url)),
|
||||
tsconfigPath: fileURLToPath(new URL('../../../tsconfig.json', import.meta.url)),
|
||||
}
|
||||
|
||||
function spawnAcpAgent(cwd: string): Spawned {
|
||||
const launch = resolveExampleLaunch({
|
||||
srcBin: binScript,
|
||||
configArgs: ['--config', configPath],
|
||||
tsconfigPath: repoTsconfig,
|
||||
env: { DSH_PERMISSION_MODE: 'danger-full-access' },
|
||||
})
|
||||
const child = spawn(
|
||||
launch.command,
|
||||
launch.args,
|
||||
{ cwd, env: { ...process.env, ...launch.env }, stdio: ['pipe', 'pipe', 'pipe'] },
|
||||
)
|
||||
const stderr: string[] = []
|
||||
child.stderr.setEncoding('utf8')
|
||||
child.stderr.on('data', (chunk: string) => stderr.push(chunk))
|
||||
|
||||
const updates: SessionNotification['update'][] = []
|
||||
const stream = ndJsonStream(
|
||||
Writable.toWeb(child.stdin) as WritableStream<Uint8Array>,
|
||||
Readable.toWeb(child.stdout) as ReadableStream<Uint8Array>,
|
||||
)
|
||||
const makeClient = (_agent: AcpAgent): Client => ({
|
||||
sessionUpdate(params: SessionNotification): Promise<void> {
|
||||
updates.push(params.update)
|
||||
return Promise.resolve()
|
||||
},
|
||||
requestPermission(_params: RequestPermissionRequest): Promise<RequestPermissionResponse> {
|
||||
return Promise.resolve({ outcome: { outcome: 'cancelled' } })
|
||||
},
|
||||
})
|
||||
const client = new ClientSideConnection(makeClient, stream)
|
||||
return { child, client, updates, stderr }
|
||||
}
|
||||
|
||||
let spawned: Spawned | undefined
|
||||
let spawned: LaunchedAcpTestAgent | undefined
|
||||
let workdir: string | undefined
|
||||
|
||||
afterEach(async () => {
|
||||
if (spawned) {
|
||||
spawned.child.kill('SIGKILL')
|
||||
spawned = undefined
|
||||
}
|
||||
if (workdir !== undefined) await rm(workdir, { recursive: true, force: true })
|
||||
const ownedSpawned = spawned
|
||||
const ownedWorkdir = workdir
|
||||
spawned = undefined
|
||||
workdir = undefined
|
||||
await cleanupAcpExampleTest(ownedSpawned, ownedWorkdir)
|
||||
})
|
||||
|
||||
describe.skipIf(!process.env.DEEPSEEK_API_KEY)('acp-agent e2e: a PreToolUse hook blocks bash (real model)', () => {
|
||||
@@ -90,7 +44,11 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('acp-agent e2e: a PreToolUse hook
|
||||
hooks: { PreToolUse: [{ hooks: [{ type: 'command', command: 'echo "bash blocked by policy" >&2; exit 2' }] }] },
|
||||
}))
|
||||
|
||||
spawned = spawnAcpAgent(workdir)
|
||||
spawned = launchAcpTestAgent({
|
||||
agent: AGENT,
|
||||
cwd: workdir,
|
||||
env: { DSH_PERMISSION_MODE: 'danger-full-access' },
|
||||
})
|
||||
const { client, updates } = spawned
|
||||
|
||||
await client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"steps": [
|
||||
{ "op": "initialize" },
|
||||
{ "op": "newSession" },
|
||||
{ "op": "prompt", "text": "Use the read tool twice in the same assistant message: read a.txt and b.txt. Then reply DONE." }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
{"type":"session","version":0,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}"}
|
||||
{"type":"turn/start","seq":0,"time":0,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}}
|
||||
{"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"Use the read tool twice in the same assistant message: read a.txt and b.txt. Then reply DONE."}],"source":{"kind":"user"}},"surfaceOp":"append"}
|
||||
{"type":"step/start","seq":2,"time":0,"data":{"turn":1,"step":1}}
|
||||
{"type":"request/header","seq":3,"time":0,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}
|
||||
{"type":"assistant/chunk","seq":4,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}
|
||||
{"type":"assistant/chunk","seq":5,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_read_a","name":"read","argumentsDelta":"{\"file_path\":\"a.txt\"}"}}}
|
||||
{"type":"assistant/chunk","seq":6,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_read_a","name":"read","arguments":"{\"file_path\":\"a.txt\"}"}}}}
|
||||
{"type":"assistant/chunk","seq":7,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}}
|
||||
{"type":"assistant/chunk","seq":8,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_read_b","name":"read","argumentsDelta":"{\"file_path\":\"b.txt\"}"}}}
|
||||
{"type":"assistant/chunk","seq":9,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_read_b","name":"read","arguments":"{\"file_path\":\"b.txt\"}"}}}}
|
||||
{"type":"assistant/chunk","seq":10,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}}
|
||||
{"type":"assistant/chunk","seq":11,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
|
||||
{"type":"assistant/message","seq":12,"time":0,"data":{"turn":1,"step":1,"content":[{"type":"tool-call","id":"call_read_a","name":"read","arguments":"{\"file_path\":\"a.txt\"}"},{"type":"tool-call","id":"call_read_b","name":"read","arguments":"{\"file_path\":\"b.txt\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[4,5,6,7,8,9,10,11],"surfaceOp":"append"}
|
||||
{"type":"tool/call","seq":13,"time":0,"data":{"turn":1,"step":1,"callId":"call_read_a","name":"read","arguments":"{\"file_path\":\"a.txt\"}"}}
|
||||
{"type":"tool/call","seq":14,"time":0,"data":{"turn":1,"step":1,"callId":"call_read_b","name":"read","arguments":"{\"file_path\":\"b.txt\"}"}}
|
||||
{"type":"tool/result","seq":15,"time":0,"data":{"turn":1,"step":1,"callId":"call_read_a","content":[{"type":"text","text":"<path>{{cwd}}/a.txt</path>\n<type>file</type>\n<content>\n1: alpha\n\n(End of file - total 1 lines)\n</content>"}],"isError":false},"sourceEventSeqs":[13],"surfaceOp":"append"}
|
||||
{"type":"tool/result","seq":16,"time":0,"data":{"turn":1,"step":1,"callId":"call_read_b","content":[{"type":"text","text":"<path>{{cwd}}/b.txt</path>\n<type>file</type>\n<content>\n1: beta\n\n(End of file - total 1 lines)\n</content>"}],"isError":false},"sourceEventSeqs":[14],"surfaceOp":"append"}
|
||||
{"type":"step/end","seq":17,"time":0,"data":{"turn":1,"step":1}}
|
||||
{"type":"step/start","seq":18,"time":0,"data":{"turn":1,"step":2}}
|
||||
{"type":"assistant/chunk","seq":19,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}
|
||||
{"type":"assistant/chunk","seq":20,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":0,"text":"DONE"}}}
|
||||
{"type":"assistant/chunk","seq":21,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DONE"}}}}
|
||||
{"type":"assistant/chunk","seq":22,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":1}}}}
|
||||
{"type":"assistant/chunk","seq":23,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
|
||||
{"type":"assistant/message","seq":24,"time":0,"data":{"turn":1,"step":2,"content":[{"type":"text","text":"DONE"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":10,"outputTokens":1}},"sourceEventSeqs":[19,20,21,22,23],"surfaceOp":"append"}
|
||||
{"type":"step/end","seq":25,"time":0,"data":{"turn":1,"step":2}}
|
||||
{"type":"turn/end","seq":26,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}}
|
||||
@@ -0,0 +1,8 @@
|
||||
{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}}
|
||||
{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"Sets this session's sandbox and approval behavior.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_read_a","title":"Read a.txt","kind":"read","status":"in_progress","locations":[{"path":"a.txt","line":1}]}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_read_b","title":"Read b.txt","kind":"read","status":"in_progress","locations":[{"path":"b.txt","line":1}]}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_read_a","status":"completed","content":[{"type":"content","content":{"type":"text","text":"<path>{{cwd}}/a.txt</path>\n<type>file</type>\n<content>\n1: alpha\n\n(End of file - total 1 lines)\n</content>"}}]}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_read_b","status":"completed","content":[{"type":"content","content":{"type":"text","text":"<path>{{cwd}}/b.txt</path>\n<type>file</type>\n<content>\n1: beta\n\n(End of file - total 1 lines)\n</content>"}}]}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"DONE"}}}}
|
||||
{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}}
|
||||
@@ -0,0 +1 @@
|
||||
alpha
|
||||
@@ -0,0 +1 @@
|
||||
beta
|
||||
Reference in New Issue
Block a user