code-mode.e2e.ts 19 KB

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