code-mode.e2e.ts 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390
  1. import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'
  2. import { tmpdir } from 'node:os'
  3. import { join } from 'node:path'
  4. import { afterEach, describe, expect, it } from 'vitest'
  5. import { Context } from 'cordis'
  6. import LlmService, { CallId, HarnessError } from '@deepseek-ai/dsh-llm'
  7. import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
  8. import type { SessionEvent } from '@deepseek-ai/dsh-session'
  9. import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
  10. import ToolRegistry, { RUN_CODE_NAME, defineTool } from '@deepseek-ai/dsh-tools'
  11. import type { ToolExecutionResult } from '@deepseek-ai/dsh-tools'
  12. import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
  13. import AgentLoop from '@deepseek-ai/dsh-agent-loop'
  14. import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
  15. import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local'
  16. import * as ToolBash from '@deepseek-ai/dsh-tool-bash'
  17. import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek'
  18. import { WorkerCodeRuntime } from '@deepseek-ai/dsh-code-runtime-worker'
  19. import LocalFileSystem from '@deepseek-ai/dsh-fs-local'
  20. import * as ToolFs from '@deepseek-ai/dsh-tool-fs'
  21. import * as WorkspaceContext from '@deepseek-ai/dsh-workspace-context'
  22. import LocalTaskService from '@deepseek-ai/dsh-tasks-local'
  23. import * as ToolTasks from '@deepseek-ai/dsh-tool-tasks'
  24. import * as ToolCordis from '@deepseek-ai/dsh-tool-cordis'
  25. /**
  26. * With-key Code Mode proof: a real model receives only `run_code`, composes two
  27. * sub-calls, writes a file, and returns curated output while the log records
  28. * each `tool/code-dispatch`. The keyless Loader smoke is in the sibling test.
  29. */
  30. const PERSONA = 'You are a coding agent. You work by writing TypeScript programs for run_code: '
  31. + 'batch related tool work into one program and print or return ONLY the findings that matter.'
  32. const WORKSPACE_PROBE = 'dragonfruit-8675309'
  33. let ctx: Context | undefined
  34. let workdir: string | undefined
  35. afterEach(async () => {
  36. // Always dispose, even on failure/retry/timeout: agent-loop teardown stops
  37. // the loop, the executor kills stray processes, and the code runtime's
  38. // dispose awaits worker exits.
  39. await ctx?.fiber.dispose()
  40. ctx = undefined
  41. if (workdir !== undefined) await rm(workdir, { recursive: true, force: true })
  42. workdir = undefined
  43. })
  44. async function codeModeHarness(cwd: string): Promise<Context> {
  45. const harness = new Context()
  46. await harness.plugin(LlmService)
  47. await harness.plugin(SessionStore)
  48. await harness.plugin(SystemPrompt, { persona: PERSONA })
  49. await harness.plugin(ToolRegistry, { mode: 'code' })
  50. await harness.plugin(AgentRegistry)
  51. await harness.plugin(AgentLoop, { agents: [] })
  52. await harness.plugin(LlmDeepSeek)
  53. await harness.plugin(LocalSubprocessService)
  54. await harness.plugin(LocalBashExecutor, { cwd, timeoutMs: 30_000 })
  55. await harness.plugin(ToolBash)
  56. await harness.plugin(WorkerCodeRuntime, {})
  57. return harness
  58. }
  59. async function workspaceCodeModeHarness(): Promise<Context> {
  60. const harness = new Context()
  61. await harness.plugin(LlmService)
  62. await harness.plugin(SessionStore)
  63. await harness.plugin(SystemPrompt, { persona: PERSONA })
  64. await harness.plugin(ToolRegistry, { mode: 'code' })
  65. await harness.plugin(AgentRegistry)
  66. await harness.plugin(LocalFileSystem, { cwd: '/' })
  67. await harness.plugin(ToolFs)
  68. await harness.plugin(WorkspaceContext, { maxBytes: 65536 })
  69. await harness.plugin(AgentLoop, { agents: [] })
  70. await harness.plugin(LlmDeepSeek, { models: [{ id: 'deepseek-v4-flash' }] })
  71. await harness.plugin(WorkerCodeRuntime, {})
  72. return harness
  73. }
  74. let keylessCall = 0
  75. const testToolSignal = new AbortController().signal
  76. /** Execute one outer Code Mode call through the real registry and worker. */
  77. function runCode(harness: Context, code: string, signal: AbortSignal = testToolSignal): Promise<ToolExecutionResult> {
  78. return harness.tools.execute({
  79. callId: CallId(`keyless-code-${++keylessCall}`),
  80. name: RUN_CODE_NAME,
  81. arguments: { code, description: 'Run the e2e program' },
  82. signal,
  83. })
  84. }
  85. /** Read the optional completion from a successful canonical `run_code` value. */
  86. function completion(result: ToolExecutionResult): unknown {
  87. if (result.isError) {
  88. throw new Error(result.content.filter(block => block.type === 'text').map(block => block.text).join('\n'))
  89. }
  90. const value = result.value
  91. if (typeof value !== 'object' || value === null || Array.isArray(value)) throw new Error('invalid run_code result')
  92. return value.result
  93. }
  94. /** Keyless real-worker harness for direct typed-binding acceptance tests. */
  95. async function typedCodeModeHarness(): Promise<Context> {
  96. const harness = new Context()
  97. await harness.plugin(SystemPrompt)
  98. await harness.plugin(ToolRegistry, { mode: 'code' })
  99. await harness.plugin(WorkerCodeRuntime, {})
  100. return harness
  101. }
  102. /** Keyless real-worker harness with the task-owned bash lifecycle. */
  103. async function backgroundCodeModeHarness(cwd: string): Promise<Context> {
  104. const harness = await typedCodeModeHarness()
  105. await harness.plugin(LocalTaskService)
  106. await harness.plugin(ToolTasks, {})
  107. await harness.plugin(LocalSubprocessService)
  108. await harness.plugin(LocalBashExecutor, { cwd, timeoutMs: 30_000 })
  109. await harness.plugin(ToolBash)
  110. return harness
  111. }
  112. describe('Code Mode typed values: keyless real-worker contracts', () => {
  113. it('crosses a large intermediate value intact and exposes only typed tool failure fields', async () => {
  114. ctx = await typedCodeModeHarness()
  115. ctx.tools.register(defineTool({
  116. name: 'large_value',
  117. description: 'Return a large canonical string.',
  118. parameters: {},
  119. output: {
  120. schema: { type: 'string' },
  121. render: (_args, value) => [{ type: 'text', text: value }],
  122. },
  123. execute: () => Promise.resolve('x'.repeat(100_000)),
  124. }))
  125. ctx.tools.register(defineTool({
  126. name: 'always_fail',
  127. description: 'Fail for ToolCallError coverage.',
  128. parameters: {},
  129. output: { schema: { type: 'null' }, render: () => [] },
  130. execute: () => Promise.reject(new HarnessError('expected failure', 'EXPECTED_INTERNAL_CODE')),
  131. }))
  132. const value = completion(await runCode(ctx, `
  133. const large = await tools.large_value({});
  134. let failure;
  135. try {
  136. await tools.always_fail({});
  137. } catch (error) {
  138. failure = {
  139. typed: error instanceof ToolCallError,
  140. name: error.name,
  141. toolName: error.toolName,
  142. message: error.message,
  143. exposesCode: 'code' in error,
  144. exposesContent: 'content' in error,
  145. exposesInfo: 'info' in error,
  146. };
  147. }
  148. return { length: large.length, failure };
  149. `))
  150. expect(value).toEqual({
  151. length: 100_000,
  152. failure: {
  153. typed: true,
  154. name: 'ToolCallError',
  155. toolName: 'always_fail',
  156. message: 'expected failure',
  157. exposesCode: false,
  158. exposesContent: false,
  159. exposesInfo: false,
  160. },
  161. })
  162. })
  163. it('returns a background task id, settles the outer run, and polls that id to completion', async () => {
  164. workdir = await mkdtemp(join(tmpdir(), 'dsh-code-mode-background-'))
  165. ctx = await backgroundCodeModeHarness(workdir)
  166. const taskId = completion(await runCode(ctx, `
  167. const started = await tools.bash({
  168. command: "sleep 0.2; printf 'background-complete\\n'",
  169. description: 'Run completion marker in background',
  170. run_in_background: true,
  171. });
  172. return started.taskId;
  173. `))
  174. expect(taskId).toBe('bash-1')
  175. const polled = completion(await runCode(ctx, `
  176. return await tools.task_output({ task_id: ${JSON.stringify(taskId)}, wait: true, timeout_ms: 5000 });
  177. `))
  178. if (typeof polled !== 'object' || polled === null || Array.isArray(polled)) throw new Error('invalid task_output completion')
  179. const taskOutput = polled as Record<string, unknown>
  180. expect(taskOutput.text).toContain('background-complete')
  181. expect(taskOutput.task).toMatchObject({ id: taskId, kind: 'bash', status: 'completed' })
  182. }, 15_000)
  183. it('pre-abort spawns nothing; post-publication abort leaves task_kill as the cancellation owner', async () => {
  184. workdir = await mkdtemp(join(tmpdir(), 'dsh-code-mode-task-cancel-'))
  185. ctx = await backgroundCodeModeHarness(workdir)
  186. const pre = new AbortController()
  187. pre.abort('pre-aborted')
  188. const preResult = await runCode(ctx, `
  189. return await tools.bash({ command: 'sleep 10', description: 'Must never start', run_in_background: true });
  190. `, pre.signal)
  191. expect(preResult.isError).toBe(true)
  192. expect(ctx.tasks.list()).toEqual([])
  193. const afterPublication = new AbortController()
  194. const running = runCode(ctx, `
  195. const started = await tools.bash({ command: 'sleep 10', description: 'Wait for explicit task kill', run_in_background: true });
  196. console.log(started.taskId);
  197. await new Promise(() => {});
  198. `, afterPublication.signal)
  199. for (let attempt = 0; attempt < 100 && ctx.tasks.list().length === 0; attempt++) {
  200. await new Promise(resolve => setTimeout(resolve, 10))
  201. }
  202. const task = ctx.tasks.list()[0]
  203. expect(task).toMatchObject({ id: 'bash-1', status: 'running' })
  204. afterPublication.abort('outer-call-cancelled')
  205. expect((await running).isError).toBe(true)
  206. expect(ctx.tasks.list()[0]).toMatchObject({ id: task!.id, status: 'running' })
  207. const killed = completion(await runCode(ctx, `
  208. return await tools.task_kill({ task_id: ${JSON.stringify(task!.id)}, reason: 'test owns cancellation' });
  209. `))
  210. expect(killed).toMatchObject({ outcome: 'cancellation-requested', task: { id: task!.id } })
  211. const settled = completion(await runCode(ctx, `
  212. return await tools.task_output({ task_id: ${JSON.stringify(task!.id)}, wait: true, timeout_ms: 5000 });
  213. `))
  214. expect(settled).toMatchObject({ task: { id: task!.id, status: 'killed' } })
  215. }, 15_000)
  216. it('keeps foreground bash coupled to the outer signal', async () => {
  217. workdir = await mkdtemp(join(tmpdir(), 'dsh-code-mode-foreground-cancel-'))
  218. ctx = await backgroundCodeModeHarness(workdir)
  219. const controller = new AbortController()
  220. const startedAt = Date.now()
  221. const pending = runCode(ctx, `
  222. return await tools.bash({ command: 'sleep 10', description: 'Run cancellable foreground command' });
  223. `, controller.signal)
  224. setTimeout(() => { controller.abort('stop-foreground') }, 200)
  225. const result = await pending
  226. expect(result.isError).toBe(true)
  227. expect(Date.now() - startedAt).toBeLessThan(5_000)
  228. expect(ctx.tasks.list()).toEqual([])
  229. }, 15_000)
  230. it('uses cordis_mount DTO ids directly for running and pending temporary Plugins, then confirms removal', async () => {
  231. ctx = await typedCodeModeHarness()
  232. await ctx.plugin(ToolCordis)
  233. const value = completion(await runCode(ctx, `
  234. const active = await tools.cordis_mount({
  235. code: "return { name: 'active-code-mode-plugin', apply(ctx) {} }",
  236. });
  237. const pending = await tools.cordis_mount({
  238. code: "return { name: 'pending-code-mode-plugin', inject: ['missing-code-mode-service'], apply(ctx) {} }",
  239. });
  240. const before = await tools.cordis_inspect({ what: 'temporary' });
  241. const stopped = await tools.cordis_unmount({ id: active.id });
  242. const after = await tools.cordis_inspect({ what: 'temporary' });
  243. await tools.cordis_unmount({ id: pending.id });
  244. return {
  245. active,
  246. pending,
  247. stopped,
  248. beforeContainsId: before.includes(active.id),
  249. afterContainsId: after.includes(active.id),
  250. };
  251. `))
  252. expect(value).toEqual({
  253. active: {
  254. id: 'dyn-1',
  255. pluginName: 'active-code-mode-plugin',
  256. state: 'active',
  257. provides: [],
  258. waitingFor: [],
  259. },
  260. pending: {
  261. id: 'dyn-2',
  262. pluginName: 'pending-code-mode-plugin',
  263. state: 'pending',
  264. provides: [],
  265. waitingFor: ['missing-code-mode-service'],
  266. },
  267. stopped: { id: 'dyn-1', pluginName: 'active-code-mode-plugin' },
  268. beforeContainsId: true,
  269. afterContainsId: false,
  270. })
  271. })
  272. })
  273. function waitForIdle(harness: Context, agent: Agent): Promise<void> {
  274. return new Promise((resolve) => {
  275. const dispose = harness.on('agent/status', (subject, status) => {
  276. if (subject === agent && status === 'idle') {
  277. dispose()
  278. resolve()
  279. }
  280. })
  281. })
  282. }
  283. describe.skipIf(!process.env.DEEPSEEK_API_KEY)('Code Mode: real model writes a program over real tools', () => {
  284. it('collapses the wire tool list to [run_code], bridges sub-calls, and returns curated output', async () => {
  285. workdir = await mkdtemp(join(tmpdir(), 'dsh-code-mode-e2e-'))
  286. ctx = await codeModeHarness(workdir)
  287. const agent = ctx.agentLoop.create(SessionId('e2e-code-mode'), { provider: 'deepseek', model: 'deepseek-v4-flash' })
  288. agent.followup({ content: [{
  289. type: 'text',
  290. text: 'Using one run_code program: run `echo alpha-7` with the bash tool, run `echo beta-9` with the bash tool, '
  291. + 'then write both outputs joined by a plus sign into combined.txt (bash heredoc or redirect), '
  292. + 'and return only the joined string.',
  293. }], source: { kind: 'user' } })
  294. await waitForIdle(ctx, agent)
  295. const events: SessionEvent[] = [...agent.session.events]
  296. // The wire contract: every request this session made offered EXACTLY ONE
  297. // tool — run_code (the logged header snapshots the assembled list).
  298. const headers = events.filter(event => event.type === 'request/header')
  299. expect(headers.length).toBeGreaterThan(0)
  300. for (const header of headers) {
  301. expect(header.data.header.tools?.map(tool => tool.name)).toEqual([RUN_CODE_NAME])
  302. }
  303. // The model actually went through run_code…
  304. const calls = events.filter(event => event.type === 'tool/call')
  305. expect(calls.length).toBeGreaterThan(0)
  306. expect(calls.every(event => event.data.name === RUN_CODE_NAME)).toBe(true)
  307. // …and the program's tool calls landed as dispatch events under it.
  308. const dispatches = events.filter(event => event.type === 'tool/code-dispatch')
  309. expect(dispatches.length).toBeGreaterThanOrEqual(2)
  310. expect(dispatches.every(event => event.data.name === 'bash')).toBe(true)
  311. const parents = new Set(calls.map(event => event.data.callId))
  312. expect(dispatches.every(event => parents.has(event.data.parentCallId))).toBe(true)
  313. // World verification: the file the program wrote, and the curated answer.
  314. const combined = await readFile(join(workdir, 'combined.txt'), 'utf8')
  315. expect(combined).toContain('alpha-7')
  316. expect(combined).toContain('beta-9')
  317. const finalMessage = events.findLast(event => event.type === 'assistant/message')
  318. const finalText = finalMessage !== undefined
  319. ? finalMessage.data.content.filter(block => block.type === 'text').map(block => block.text).join('')
  320. : ''
  321. expect(finalText).toContain('alpha-7')
  322. expect(finalText).toContain('beta-9')
  323. }, 180_000)
  324. it('delivers nested workspace instructions discovered by an fs sub-call after the outer result', async () => {
  325. workdir = await mkdtemp(join(tmpdir(), 'dsh-code-mode-workspace-e2e-'))
  326. await mkdir(join(workdir, '.git'), { recursive: true })
  327. await mkdir(join(workdir, 'pkg/deep'), { recursive: true })
  328. await writeFile(join(workdir, 'pkg/AGENTS.md'), `If asked for the Code Mode workspace handshake, reply with exactly ${WORKSPACE_PROBE} and nothing else.\n`)
  329. await writeFile(join(workdir, 'pkg/deep/task.txt'), 'Touch this file to discover the nested instructions.\n')
  330. ctx = await workspaceCodeModeHarness()
  331. const handle = await ctx.agents.create({
  332. sessionId: SessionId('e2e-code-mode-workspace-session'),
  333. meta: { cwd: workdir },
  334. agentOptions: { provider: 'deepseek', model: 'deepseek-v4-flash' },
  335. })
  336. handle.agent.followup({ content: [{
  337. type: 'text',
  338. text: 'Use one run_code program to call tools.read on pkg/deep/task.txt. After it finishes, answer: Code Mode workspace handshake?',
  339. }], source: { kind: 'user' } })
  340. await waitForIdle(ctx, handle.agent)
  341. const events: SessionEvent[] = [...handle.agent.session.events]
  342. const dispatch = events.find(event => event.type === 'tool/code-dispatch' && event.data.name === 'read')
  343. const outerResult = events.find(event => event.type === 'tool/result')
  344. const workspaceContext = events.find(event => event.type === 'user/message'
  345. && event.data.source.kind === 'workspace-instructions')
  346. expect(dispatch).toBeDefined()
  347. expect(outerResult).toBeDefined()
  348. expect(workspaceContext).toBeDefined()
  349. expect(workspaceContext!.seq).toBeGreaterThan(outerResult!.seq)
  350. const finalMessage = events.findLast(event => event.type === 'assistant/message')
  351. const answer = finalMessage?.type === 'assistant/message'
  352. ? finalMessage.data.content.filter(block => block.type === 'text').map(block => block.text).join('')
  353. : ''
  354. expect(answer).toContain(WORKSPACE_PROBE)
  355. }, 180_000)
  356. })