From 4e47a7c1bf4221bcf0cbfc6f0a6a598c5dfa673b Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Mon, 13 Jul 2026 14:18:22 +0800 Subject: [PATCH] Fix path-dependent Code Mode snapshots --- docs/persistence-catalog.md | 4 +- .../feature/2026-06-15-code-mode.md | 2 +- .../code-mode-workspace-context/session.jsonl | 2 +- packages/core/tools/src/code-mode.ts | 17 +++++-- packages/core/tools/tests/code-mode.spec.ts | 49 ++++++++++++++++++- 5 files changed, 64 insertions(+), 10 deletions(-) diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index c93a21c87c..6dccef83d4 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -257,7 +257,7 @@ Source: [`packages/core/session/src/types.ts:337`](../packages/core/session/src/ #### `tool/code-dispatch` — log-only -One bridged sub-dispatch from a `run_code` program: the parent `run_code` call id, the deterministic sub-call id (`:code:`), the tool `name` with its JSON-normalized `arguments` — the exact value dispatched, normalized BEFORE dispatch, so this append can never fail on payload shape — whether the sub-call errored, and a bounded `resultSummary` of its model-facing text. Log-only: `deriveMessages()` ignores it, so sub-calls never re-enter model context; persistence and UIs get every call. Appended inside the parent `run_code`'s execution (the bridge drains its queue before returning), so the turn-enclosure invariant holds by construction. +One bridged sub-dispatch from a `run_code` program: the parent `run_code` call id, the deterministic sub-call id (`:code:`), the tool `name` with its JSON-normalized `arguments` — the exact value dispatched, normalized BEFORE dispatch, so this append can never fail on payload shape — whether the sub-call errored, and a bounded `resultSummary` of its model-facing text. Before bounding, occurrences of a non-root session workspace path are normalized to `.` so host-specific absolute path lengths cannot change the summary. Log-only: `deriveMessages()` ignores it, so sub-calls never re-enter model context; persistence and UIs get every call. Appended inside the parent `run_code`'s execution (the bridge drains its queue before returning), so the turn-enclosure invariant holds by construction. ```ts persistence-catalog 'tool/code-dispatch': { parentCallId: CallId; subCallId: CallId; name: string; arguments: unknown; isError: boolean; resultSummary: string } @@ -265,7 +265,7 @@ One bridged sub-dispatch from a `run_code` program: the parent `run_code` call i Types: [CallId](core-data-structures/core.md) -Source: [`packages/core/tools/src/code-mode.ts:36`](../packages/core/tools/src/code-mode.ts) +Source: [`packages/core/tools/src/code-mode.ts:40`](../packages/core/tools/src/code-mode.ts) #### `tool/result` — surface diff --git a/docs/rfc/implemented/feature/2026-06-15-code-mode.md b/docs/rfc/implemented/feature/2026-06-15-code-mode.md index 9a253680bf..194659522e 100644 --- a/docs/rfc/implemented/feature/2026-06-15-code-mode.md +++ b/docs/rfc/implemented/feature/2026-06-15-code-mode.md @@ -48,7 +48,7 @@ Under `'code'` and `'both'` the registry registers `run_code` in itself as an or ### Observability: `tool/code-dispatch` -Each sub-dispatch appends one session event, declared by `dsh-tools` via `SessionEventMap` declaration merging (the map is merge-extensible for exactly this; `todo/write` is the log-only precedent): `tool/code-dispatch` with `{ parentCallId, subCallId, name, arguments, isError, resultSummary }` — `arguments` being the bridge's JSON-normalized value, the very one dispatched, so the append cannot fail on payload shape. It is log-only — `deriveEventMessage()` ignores unknown event types by design, so sub-calls never re-enter model context — but persistence and UIs get every call. As a log event it carries JSDoc prose but **no `@mode` tag** (that vocabulary belongs to cordis bus events; the persistence-catalog generator hard-errors on one) and lands in the regenerated `docs/persistence-catalog.md`; appends happen inside `run_code`'s execution, so the turn-enclosure invariant is satisfied by construction. A `run_code` execution arriving without `exec.agent` (the loop always supplies it; direct programmatic calls may not) still runs and simply skips event logging, exactly as the `ToolExecution` contract allows. +Each sub-dispatch appends one session event, declared by `dsh-tools` via `SessionEventMap` declaration merging (the map is merge-extensible for exactly this; `todo/write` is the log-only precedent): `tool/code-dispatch` with `{ parentCallId, subCallId, name, arguments, isError, resultSummary }` — `arguments` being the bridge's JSON-normalized value, the very one dispatched, so the append cannot fail on payload shape. The bounded summary normalizes occurrences of a non-root session workspace path to `.` before truncation, keeping the durable event stable when equivalent runs use host temp directories of different lengths; the full result returned to the program is unchanged. It is log-only — `deriveEventMessage()` ignores unknown event types by design, so sub-calls never re-enter model context — but persistence and UIs get every call. As a log event it carries JSDoc prose but **no `@mode` tag** (that vocabulary belongs to cordis bus events; the persistence-catalog generator hard-errors on one) and lands in the regenerated `docs/persistence-catalog.md`; appends happen inside `run_code`'s execution, so the turn-enclosure invariant is satisfied by construction. A `run_code` execution arriving without `exec.agent` (the loop always supplies it; direct programmatic calls may not) still runs and simply skips event logging, exactly as the `ToolExecution` contract allows. ### The code-runtime seam diff --git a/examples/acp-agent/tests/snapshots/code-mode-workspace-context/session.jsonl b/examples/acp-agent/tests/snapshots/code-mode-workspace-context/session.jsonl index 0a857fc65e..060c43f028 100644 --- a/examples/acp-agent/tests/snapshots/code-mode-workspace-context/session.jsonl +++ b/examples/acp-agent/tests/snapshots/code-mode-workspace-context/session.jsonl @@ -84,7 +84,7 @@ {"type":"assistant/chunk","seq":82,"time":1783921767205,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":83,"time":1783921767208,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to write a single run_code program that reads a file called nested/task.txt and then answer the handshake question based on what's in that file. Let me start by reading the file."},{"type":"tool-call","id":"call_00_6APApmaKLRDlXKMdIcWL5139","name":"run_code","arguments":"{\"code\": \"const content = await tools.read({ file_path: \\\"nested/task.txt\\\" });\\nreturn content;\"}"}],"usage":{"inputTokens":3195,"outputTokens":106,"cacheReadTokens":0,"reasoningTokens":43}},"sourceEventSeqs":[4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82],"surfaceOp":"append"} {"type":"tool/call","seq":84,"time":1783921767208,"data":{"turn":1,"step":1,"callId":"call_00_6APApmaKLRDlXKMdIcWL5139","name":"run_code","arguments":"{\"code\": \"const content = await tools.read({ file_path: \\\"nested/task.txt\\\" });\\nreturn content;\"}"}} -{"type":"tool/code-dispatch","seq":85,"time":1783921767270,"data":{"parentCallId":"call_00_6APApmaKLRDlXKMdIcWL5139","subCallId":"call_00_6APApmaKLRDlXKMdIcWL5139:code:1","name":"read","arguments":{"file_path":"nested/task.txt"},"isError":false,"resultSummary":"/var/folders/_g/59jgff8x2gqd39f5vy1wnbfc0000gn/T/acp-snap-cwd-uorU26/nested/task.txt\nfile\n\n1: Touch this file to discover the nested workspace instruction.\n\n(End of …"}} +{"type":"tool/code-dispatch","seq":85,"time":1783921767270,"data":{"parentCallId":"call_00_6APApmaKLRDlXKMdIcWL5139","subCallId":"call_00_6APApmaKLRDlXKMdIcWL5139:code:1","name":"read","arguments":{"file_path":"nested/task.txt"},"isError":false,"resultSummary":"./nested/task.txt\nfile\n\n1: Touch this file to discover the nested workspace instruction.\n\n(End of file - total 1 lines)\n"}} {"type":"tool/result","seq":86,"time":1783921767271,"data":{"turn":1,"step":1,"callId":"call_00_6APApmaKLRDlXKMdIcWL5139","content":[{"type":"text","text":"/var/folders/_g/59jgff8x2gqd39f5vy1wnbfc0000gn/T/acp-snap-cwd-uorU26/nested/task.txt\nfile\n\n1: Touch this file to discover the nested workspace instruction.\n\n(End of file - total 1 lines)\n"}],"isError":false,"meta":{"logs":[],"dispatches":1}},"sourceEventSeqs":[84],"surfaceOp":"append"} {"type":"context/message","seq":87,"time":1783921767272,"data":{"content":[{"type":"text","text":"\nAdditional instructions from: nested/AGENTS.md\n\nThese instructions apply to work under `nested`. Use them as guidance when relevant; more specific instructions take precedence. They do not override system, developer, or direct user instructions.\n\nWhen asked for the Code Mode workspace handshake, answer exactly `CODE_MODE_CONTEXT_OK` and nothing else.\n\n"}],"source":{"kind":"plugin","plugin":"workspace-context"},"envelope":"raw","meta":{"kind":"workspace-instructions","version":1,"changes":[{"action":"set","scope":"nested","path":"nested/AGENTS.md","digest":"ae22936ed26dc76b7107005ed6d5e2482a88668a"}]}},"surfaceOp":"append"} {"type":"step/end","seq":88,"time":1783921767272,"data":{"turn":1,"step":1}} diff --git a/packages/core/tools/src/code-mode.ts b/packages/core/tools/src/code-mode.ts index 91fd81b4e7..05f481449d 100644 --- a/packages/core/tools/src/code-mode.ts +++ b/packages/core/tools/src/code-mode.ts @@ -11,6 +11,7 @@ * @module @deepseek-ai/dsh-tools/src/code-mode */ +import { parse } from 'node:path' import { inspect } from 'node:util' import { CallId, HarnessError } from '@deepseek-ai/dsh-llm' import type { ContentBlock } from '@deepseek-ai/dsh-llm' @@ -27,7 +28,10 @@ declare module '@deepseek-ai/dsh-session' { * (`:code:`), the tool `name` with its JSON-normalized * `arguments` — the exact value dispatched, normalized BEFORE dispatch, * so this append can never fail on payload shape — whether the sub-call - * errored, and a bounded `resultSummary` of its model-facing text. + * errored, and a bounded `resultSummary` of its model-facing text. Before + * bounding, occurrences of a non-root session workspace path are + * normalized to `.` so host-specific absolute path lengths cannot change + * the summary. * Log-only: `deriveMessages()` ignores it, so sub-calls never re-enter * model context; persistence and UIs get every call. Appended inside the * parent `run_code`'s execution (the bridge drains its queue before @@ -81,9 +85,12 @@ function textOf(content: ContentBlock[]): string { .join('\n') } -/** Bound a sub-call's model-facing text for the log event's `resultSummary`. */ -function summarize(text: string): string { - return text.length > SUMMARY_MAX_CHARS ? `${text.slice(0, SUMMARY_MAX_CHARS)}…` : text +/** Normalize workspace paths, then bound a sub-call's model-facing text for its durable log summary. */ +function summarize(text: string, cwd: string | undefined): string { + const stableText = cwd === undefined || cwd === parse(cwd).root + ? text + : text.replaceAll(cwd, '.') + return stableText.length > SUMMARY_MAX_CHARS ? `${stableText.slice(0, SUMMARY_MAX_CHARS)}…` : stableText } /** @@ -219,7 +226,7 @@ export function createRunCodeTool(registry: ToolRegistry, requireRuntime: () => // this record from what it actually received. arguments: normalized.logged, isError: result.isError, - resultSummary: summarize(text), + resultSummary: summarize(text, exec.agent.session.header.cwd), }) return { text, isError: result.isError } }) diff --git a/packages/core/tools/tests/code-mode.spec.ts b/packages/core/tools/tests/code-mode.spec.ts index 5d17b0480a..7b69f141b8 100644 --- a/packages/core/tools/tests/code-mode.spec.ts +++ b/packages/core/tools/tests/code-mode.spec.ts @@ -70,10 +70,11 @@ function registerEcho(ctx: Context, name = 'echo'): unknown[] { } /** A structural fake of the owning agent: captures session appends. */ -function fakeAgent(): { agent: Agent; events: { type: string; data: unknown }[] } { +function fakeAgent(options: { cwd?: string } = { cwd: '/workspace' }): { agent: Agent; events: { type: string; data: unknown }[] } { const events: { type: string; data: unknown }[] = [] const agent = { session: { + header: options.cwd === undefined ? {} : { cwd: options.cwd }, append: (type: string, data: unknown) => { events.push({ type, data }) }, }, } as unknown as Agent @@ -547,6 +548,52 @@ describe('the run_code dispatch bridge', () => { expect(dispatch.resultSummary.endsWith('…')).toBe(true) }) + it('normalizes the session workspace root before bounding durable result summaries', async () => { + const { ctx, runtime } = await setup({ mode: 'code' }) + ctx.tools.register(defineTool({ + name: 'workspace_path', + description: 'Return a path beneath the session workspace.', + parameters: {}, + execute(_args, exec) { + const cwd = exec.agent?.session.header.cwd ?? '' + return Promise.resolve([{ type: 'text' as const, text: `${cwd}/nested/task.txt\n${'x'.repeat(240)}` }]) + }, + })) + runtime.behavior = async request => ({ + logs: [], + value: await request.bindings[0]!.functions.workspace_path!({}), + }) + + const short = fakeAgent({ cwd: '/tmp/workspace' }) + const long = fakeAgent({ cwd: `/tmp/${'long-segment/'.repeat(30)}workspace` }) + const shortResult = await runCode(ctx, 'program', { agent: short.agent }) + const longResult = await runCode(ctx, 'program', { agent: long.agent }) + const shortDispatch = short.events[0]!.data as SessionEventMap['tool/code-dispatch'] + const longDispatch = long.events[0]!.data as SessionEventMap['tool/code-dispatch'] + + expect(shortResult.content).not.toEqual(longResult.content) + expect(shortDispatch.resultSummary).toBe(longDispatch.resultSummary) + expect(shortDispatch.resultSummary).toHaveLength(201) + expect(shortDispatch.resultSummary).toMatch(/^\.\/nested\/task\.txt<\/path>\n.+…$/) + }) + + it('leaves result summaries unchanged when a session cwd is absent or is the filesystem root', async () => { + const { ctx, runtime } = await setup({ mode: 'code' }) + registerEcho(ctx) + runtime.behavior = async request => ({ + logs: [], + value: await request.bindings[0]!.functions.echo!({ value: '/workspace/value' }), + }) + + const absent = fakeAgent({}) + const root = fakeAgent({ cwd: '/' }) + await runCode(ctx, 'program', { agent: absent.agent }) + await runCode(ctx, 'program', { agent: root.agent }) + + expect((absent.events[0]!.data as SessionEventMap['tool/code-dispatch']).resultSummary).toBe('echo:/workspace/value') + expect((root.events[0]!.data as SessionEventMap['tool/code-dispatch']).resultSummary).toBe('echo:/workspace/value') + }) + it('rejects undefined, JSON-throwing, and JSON-unrepresentable binding arguments BEFORE dispatch', async () => { const { ctx, runtime } = await setup({ mode: 'code' }) const calls = registerEcho(ctx)