Selaa lähdekoodia

Fix path-dependent Code Mode snapshots

Yichen Jiang 2 kuukautta sitten
vanhempi
sitoutus
4e47a7c1bf

+ 2 - 2
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 (`<parent>:code:<n>`), 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 (`<parent>:code:<n>`), 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
 

+ 1 - 1
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
 

+ 1 - 1
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":"<path>/var/folders/_g/59jgff8x2gqd39f5vy1wnbfc0000gn/T/acp-snap-cwd-uorU26/nested/task.txt</path>\n<type>file</type>\n<content>\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":"<path>./nested/task.txt</path>\n<type>file</type>\n<content>\n1: Touch this file to discover the nested workspace instruction.\n\n(End of file - total 1 lines)\n</content>"}}
 {"type":"tool/result","seq":86,"time":1783921767271,"data":{"turn":1,"step":1,"callId":"call_00_6APApmaKLRDlXKMdIcWL5139","content":[{"type":"text","text":"<path>/var/folders/_g/59jgff8x2gqd39f5vy1wnbfc0000gn/T/acp-snap-cwd-uorU26/nested/task.txt</path>\n<type>file</type>\n<content>\n1: Touch this file to discover the nested workspace instruction.\n\n(End of file - total 1 lines)\n</content>"}],"isError":false,"meta":{"logs":[],"dispatches":1}},"sourceEventSeqs":[84],"surfaceOp":"append"}
 {"type":"context/message","seq":87,"time":1783921767272,"data":{"content":[{"type":"text","text":"<system-reminder>\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</system-reminder>"}],"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}}

+ 12 - 5
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' {
      * (`<parent>:code:<n>`), 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 }
         })

+ 48 - 1
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: `<path>${cwd}/nested/task.txt</path>\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(/^<path>\.\/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)