From bcb049bef308c3ee4a48bccb9023999b73c5471a Mon Sep 17 00:00:00 2001 From: Turtle Date: Thu, 30 Jul 2026 14:57:27 +0800 Subject: [PATCH 1/2] fix(prompt): distinguish checkout from workdir --- ...rce-checkout-workdir-distinction.i18n.yaml | 6 ++ ...-30-source-checkout-workdir-distinction.md | 33 +++++++++ ...-source-checkout-workdir-distinction.zh.md | 33 +++++++++ .../source-checkout-workdir/session.jsonl | 30 +++++++++ .../terminal.expected.txt | 67 +++++++++++++++++++ .../tui-agent/tests/tui-keyless-smoke.e2e.ts | 9 +-- examples/tui-agent/tests/tui.snapshot.ts | 36 +++++++++- packages/ui/app-boot/README.i18n.yaml | 4 +- packages/ui/app-boot/README.md | 2 +- packages/ui/app-boot/README.zh.md | 2 +- packages/ui/app-boot/src/index.ts | 12 ++-- packages/ui/app-boot/tests/app-boot.spec.ts | 4 +- 12 files changed, 220 insertions(+), 18 deletions(-) create mode 100644 .agents/notes/implemented/bug-fix/2026-07-30-source-checkout-workdir-distinction.i18n.yaml create mode 100644 .agents/notes/implemented/bug-fix/2026-07-30-source-checkout-workdir-distinction.md create mode 100644 .agents/notes/implemented/bug-fix/2026-07-30-source-checkout-workdir-distinction.zh.md create mode 100644 examples/tui-agent/tests/snapshots/source-checkout-workdir/session.jsonl create mode 100644 examples/tui-agent/tests/snapshots/source-checkout-workdir/terminal.expected.txt diff --git a/.agents/notes/implemented/bug-fix/2026-07-30-source-checkout-workdir-distinction.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-30-source-checkout-workdir-distinction.i18n.yaml new file mode 100644 index 0000000000..311c4971d3 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-30-source-checkout-workdir-distinction.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-30-source-checkout-workdir-distinction.md +2026-07-30-source-checkout-workdir-distinction.md: ba6d9dd12b55a54d4ae8d2e91ad83ac3c1dc47fd +2026-07-30-source-checkout-workdir-distinction.zh.md: ffc2ac7baa2b1bb8ce54607638c35869fc338825 diff --git a/.agents/notes/implemented/bug-fix/2026-07-30-source-checkout-workdir-distinction.md b/.agents/notes/implemented/bug-fix/2026-07-30-source-checkout-workdir-distinction.md new file mode 100644 index 0000000000..ba6d9dd12b --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-30-source-checkout-workdir-distinction.md @@ -0,0 +1,33 @@ +# Agent Note: Source checkout paths do not define working directories + +Status: implemented + +English | [中文](2026-07-30-source-checkout-workdir-distinction.zh.md) + +## Problem + +The `harness:source` prompt section follows the [source-location decision](../../archived/feature/2026-07-21-dsh-system-prompt-source-path.md), but its original wording called the checkout “your own source code” without distinguishing that path from the session workspace. In a normal TUI configuration that does not state `{{cwd}}` in its persona, this may be the only fixed absolute path near the start of the system prompt. DeepSeek V4 could therefore answer “what's the workdir?” with the harness checkout instead of determining the session's current working directory. + +A blanket statement that the checkout is not the working directory would also be false. `dsh meta` intentionally makes the source checkout both values. + +## Decision + +The section identifies the path as the “DeepSeek Harness implementation checkout.” It says that the checkout location and current working directory are separate values that may differ, forbids inferring the working directory from the checkout path, directs the model to use `pwd`, and limits the checkout's purpose to inspecting or extending DSH itself. + +The path derivation, global `harness:source` ownership, and `-99` ordering remain unchanged. Describing the values as conceptually separate rather than always unequal keeps the instruction accurate in both ordinary project sessions and `dsh meta`. + +## Verification + +The `dsh-app-boot` unit test pins the exact text and its ordering. The CLI keyless PTY smoke inspects the assembled request header. The TUI `source-checkout-workdir` snapshot mounts the section with `/opt/dsh-source`, asks “what's the workdir?” through a recorded DeepSeek V4 turn, and requires the replayed transcript to run `pwd` and report the generated workspace rather than the checkout. + +## Alternatives considered + +**Say that the checkout is never the working directory.** Rejected because `dsh meta` deliberately makes them the same path. + +**Put the current working directory in the global source section.** Rejected because the source section is launcher-global while the working directory belongs to each session; combining them would duplicate the loop's `cwd` ownership and make a stable source fact vary per agent. + +**Remove the source path from the prompt.** Rejected because self-referential DSH tools still need a reliable checkout location when the launcher starts from an unrelated project. + +## Consequences + +The prompt is longer and a direct working-directory question may spend one inexpensive `pwd` tool call. In exchange, the model no longer treats the harness implementation path as an implicit task workspace, while meta mode remains truthful when both values coincide. diff --git a/.agents/notes/implemented/bug-fix/2026-07-30-source-checkout-workdir-distinction.zh.md b/.agents/notes/implemented/bug-fix/2026-07-30-source-checkout-workdir-distinction.zh.md new file mode 100644 index 0000000000..ffc2ac7baa --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-30-source-checkout-workdir-distinction.zh.md @@ -0,0 +1,33 @@ +# Agent Note: 源码 checkout 路径不定义工作目录 + +Status: implemented + +[English](2026-07-30-source-checkout-workdir-distinction.md) | 中文 + +## Problem + +`harness:source` 提示词段遵循[源码位置决策](../../archived/feature/2026-07-21-dsh-system-prompt-source-path.md),但原有措辞把 checkout 称为“你自己的源代码”,却没有区分该路径与会话 workspace。在 persona 不声明 `{{cwd}}` 的普通 TUI 配置中,这可能是系统提示词开头附近唯一固定的绝对路径。因此,DeepSeek V4 可能会直接用 harness checkout 回答“what's the workdir?”,而不是确定会话的当前工作目录。 + +直接断言 checkout 不是工作目录同样不准确。`dsh meta` 会有意让源码 checkout 同时充当这两个值。 + +## Decision + +该提示词段将路径标识为“DeepSeek Harness implementation checkout”。它说明 checkout 位置与当前工作目录是两个可能不同的值,禁止从 checkout 路径推断工作目录,指示模型使用 `pwd`,并限定该 checkout 只用于检查或扩展 DSH 自身。 + +路径推导方式、全局 `harness:source` 所有权和 `-99` 顺序均保持不变。将两者描述为概念上独立、而不是始终不相等,使这条指令在普通项目会话和 `dsh meta` 中都准确。 + +## Verification + +`dsh-app-boot` 单元测试固定了完整文本及其顺序。CLI 无密钥 PTY 冒烟测试检查组装后的请求 header。TUI 的 `source-checkout-workdir` 快照把该提示词段挂载为 `/opt/dsh-source`,通过录制的 DeepSeek V4 turn 提问“what's the workdir?”,并要求回放 transcript 运行 `pwd`,报告生成的 workspace 而不是 checkout。 + +## Alternatives considered + +**声明 checkout 永远不是工作目录。**拒绝:`dsh meta` 会有意让它们指向同一路径。 + +**把当前工作目录写入全局源码提示词段。**拒绝:源码提示词段由 launcher 全局持有,而工作目录属于各个会话;将两者合并会重复 loop 对 `cwd` 的所有权,还会让稳定的源码事实随 agent 变化。 + +**从提示词中删除源码路径。**拒绝:launcher 从无关项目启动时,自引用 DSH 工具仍需要可靠的 checkout 位置。 + +## Consequences + +提示词会变长,直接询问工作目录时可能多花一次廉价的 `pwd` 工具调用。作为交换,模型不再把 harness 实现路径当作隐含的任务 workspace;当 meta 模式使两个值重合时,提示词仍然准确。 diff --git a/examples/tui-agent/tests/snapshots/source-checkout-workdir/session.jsonl b/examples/tui-agent/tests/snapshots/source-checkout-workdir/session.jsonl new file mode 100644 index 0000000000..f7458c3b46 --- /dev/null +++ b/examples/tui-agent/tests/snapshots/source-checkout-workdir/session.jsonl @@ -0,0 +1,30 @@ +{"type":"session","version":0,"id":"main-session","createdAt":1784606400000,"cwd":"{{cwd}}"} +{"type":"turn/start","seq":0,"time":1784606400000,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":1,"time":1784606400000,"data":{"content":[{"type":"text","text":"what's the workdir?"}],"source":{"kind":"user"},"role":"user","id":"3fdc2885-1bea-4c6c-b4af-dbd5af7594f8"},"surfaceOp":"append"} +{"type":"session/title","seq":2,"time":1784606400000,"data":{"title":"what's the workdir?","messageSeqs":[1],"source":{"kind":"fallback"}}} +{"type":"step/start","seq":3,"time":1784606400000,"data":{"turn":1,"step":1}} +{"type":"request/header","seq":4,"time":1784606400000,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash","reasoningEffort":"high"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"assistant/chunk","seq":5,"time":1784606400000,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"reasoning-chunks","seq0":6,"time0":1784606400000,"data":{"turn":1,"step":1,"index":0,"dt":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"texts":["The"," user"," is"," asking"," about"," the"," current"," working"," directory","."," Let"," me"," check"," using"," p","wd","."]}} +{"type":"assistant/chunk","seq":23,"time":1784606400000,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"tool-call-chunks","seq0":24,"time0":1784606400000,"data":{"turn":1,"step":1,"index":1,"dt":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"id":"call_00_AqoWTncquNel5ZHsJHOo7491","name":"bash","args":["","{","\"","command","\"",": ","\"","p","wd","\"",", ","\"","description","\"",": ","\"","Print"," current"," working"," directory","\"","}"]}} +{"type":"assistant/chunk","seq":46,"time":1784606400000,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user is asking about the current working directory. Let me check using pwd."}}}} +{"type":"assistant/chunk","seq":47,"time":1784606400000,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_AqoWTncquNel5ZHsJHOo7491","name":"bash","arguments":"{\"command\": \"pwd\", \"description\": \"Print current working directory\"}"}}}} +{"type":"assistant/chunk","seq":48,"time":1784606400000,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3332,"outputTokens":80,"cacheReadTokens":0,"reasoningTokens":17}}}} +{"type":"assistant/chunk","seq":49,"time":1784606400000,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":50,"time":1784606400000,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user is asking about the current working directory. Let me check using pwd."},{"type":"tool-call","id":"call_00_AqoWTncquNel5ZHsJHOo7491","name":"bash","arguments":"{\"command\": \"pwd\", \"description\": \"Print current working directory\"}"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"063a9245-32c3-4551-9ace-d43f10ed5582"},"usage":{"inputTokens":3332,"outputTokens":80,"cacheReadTokens":0,"reasoningTokens":17}},"sourceEventSeqs":[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],"surfaceOp":"append"} +{"type":"tool/call","seq":51,"time":1784606400000,"data":{"turn":1,"step":1,"callId":"call_00_AqoWTncquNel5ZHsJHOo7491","name":"bash","arguments":"{\"command\": \"pwd\", \"description\": \"Print current working directory\"}"}} +{"type":"tool/result","seq":52,"time":1784606400000,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_AqoWTncquNel5ZHsJHOo7491"},"content":[{"type":"tool-result","toolCallId":"call_00_AqoWTncquNel5ZHsJHOo7491","content":[{"type":"text","text":"{{cwd}}\n"}],"isError":false}],"role":"user","id":"16086d3b-6dfa-4970-a06e-78561475af8c"}},"sourceEventSeqs":[51],"surfaceOp":"append"} +{"type":"step/end","seq":53,"time":1784606400000,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":54,"time":1784606400000,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":55,"time":1784606400000,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"reasoning-chunks","seq0":56,"time0":1784606400000,"data":{"turn":1,"step":2,"index":0,"dt":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"texts":["The"," current"," working"," directory"," is"," `/","private","/t","mp","/d","sh","-t","ui","-s","n","apshot","-source","-check","out","-work","dir","-","61","I","wn","K","`."]}} +{"type":"assistant/chunk","seq":83,"time":1784606400000,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"text-chunks","seq0":84,"time0":1784606400000,"data":{"turn":1,"step":2,"index":1,"dt":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"texts":["The"," current"," working"," directory"," is",":\n\n","```\n","/","private","/t","mp","/d","sh","-t","ui","-s","n","apshot","-source","-check","out","-work","dir","-","61","I","wn","K","\n","```\n\n","Note"," that"," the"," Deep","Se","ek"," Har","ness"," SDK"," checkout"," is"," at"," `/","opt","/d","sh","-source","`"," —"," that","'s"," a"," separate"," location"," used"," for"," inspecting"," or"," extending"," D","SH"," itself",","," while"," the"," working"," directory"," above"," is"," the"," current"," working"," directory"," for"," this"," session","."]}} +{"type":"assistant/chunk","seq":161,"time":1784606400000,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The current working directory is `{{cwd}}`."}}}} +{"type":"assistant/chunk","seq":162,"time":1784606400000,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"The current working directory is:\n\n```\n{{cwd}}\n```\n\nNote that the DeepSeek Harness SDK checkout is at `/opt/dsh-source` — that's a separate location used for inspecting or extending DSH itself, while the working directory above is the current working directory for this session."}}}} +{"type":"assistant/chunk","seq":163,"time":1784606400000,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":117,"outputTokens":105,"cacheReadTokens":3328,"reasoningTokens":27}}}} +{"type":"assistant/chunk","seq":164,"time":1784606400000,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":165,"time":1784606400000,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The current working directory is `{{cwd}}`."},{"type":"text","text":"The current working directory is:\n\n```\n{{cwd}}\n```\n\nNote that the DeepSeek Harness SDK checkout is at `/opt/dsh-source` — that's a separate location used for inspecting or extending DSH itself, while the working directory above is the current working directory for this session."}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"76c18b85-00aa-45f6-a32f-a2bd75412470"},"usage":{"inputTokens":117,"outputTokens":105,"cacheReadTokens":3328,"reasoningTokens":27}},"sourceEventSeqs":[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,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158,159,160,161,162,163,164],"surfaceOp":"append"} +{"type":"step/end","seq":166,"time":1784606400000,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":167,"time":1784606400000,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/tui-agent/tests/snapshots/source-checkout-workdir/terminal.expected.txt b/examples/tui-agent/tests/snapshots/source-checkout-workdir/terminal.expected.txt new file mode 100644 index 0000000000..81103c12bd --- /dev/null +++ b/examples/tui-agent/tests/snapshots/source-checkout-workdir/terminal.expected.txt @@ -0,0 +1,67 @@ +terminal 100x36 buffer=normal length=36 base=0 viewport=0 +lifecycle started=1 stopped=0 progress=inactive +title "what's the workdir? — DSH TUI snapshot" +cursor hidden column=7 viewportRow=32 bufferRow=32 +buffer +0| " DEEPSEEK HARNESS" + style 1-8 fg=bright-magenta bold + style 10-16 bold +1| " what's the workdir?" + style 1-19 dim +2| " main-session" + style 1-12 dim +3| +4| "You " + style 0-2 fg=bright-magenta bold underline +5| "what's the workdir? " +6| +7| "Assistant " + style 0-8 fg=bright-magenta bold underline +8| "Reasoning " + style 0-8 dim italic +9| "The user is asking about the current working directory. Let me check using pwd. " + style 0-78 dim italic +10| +11| "● Tool / bash / Print current working directory" + style 0-46 fg=green +12| "$ pwd " + style 0-4 dim +13| "/workspace/project " + style 0-59 dim +14| "[exit 0] " + style 0-7 dim +15| "Model wait 0.0s · Completed 2026-07-21 12:00:00 " + style 0-46 dim +16| +17| "Assistant " + style 0-8 fg=bright-magenta bold underline +18| "Reasoning " + style 0-8 dim italic +19| "The current working directory is /workspace/project. " + style 0-32 dim italic + style 33-84 fg=cyan + style 85-85 dim italic +20| "The current working directory is: " +21| " " +22| " " +23| " /workspace/project " + style 2-53 fg=cyan +24| " " +25| " " +26| "Note that the DeepSeek Harness SDK checkout is at /opt/dsh-source — that's a separate location used " + style 50-64 fg=cyan +27| "for inspecting or extending DSH itself, while the working directory above is the current working " +28| "directory for this session. " +29| "Model wait 0.0s · Completed 2026-07-21 12:00:00 " + style 0-46 dim +30| +31| "/workspace/project deepseek-v4-flash ↑3.4k ↓185 cache 49% 3% c" + style 0-51 fg=bright-magenta bold + style 54-70 dim + style 73-93 dim + style 96-99 dim +32| " dsh ◍ " + style 1-3 fg=bright-magenta bold + style 5-6 dim + style 7-7 inverse +33-35| diff --git a/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts b/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts index cd5f83e4e3..f31e3c6853 100644 --- a/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts +++ b/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts @@ -412,11 +412,12 @@ describe('dsh CLI keyless smoke (apps/cli through the same PTY)', () => { expect(output).toContain('ui-tui: session "missing-session" failed to start:') }, LOADER_SMOKE_TEST_TIMEOUT_MS) - it('tells the model its source path and offers the bundled maintenance skills', async () => { + it('distinguishes its source path from the current workdir and offers the bundled maintenance skills', async () => { // The launcher resolves the checkout root three hops up from apps/cli/{src,lib}; // this test file sits an equal depth under the same root, so the same hop applies. - // The source-path line is a system-prompt section; the bundled skills reach the - // model through a durable user message, so each assertion targets its own field. + // The source-path line explicitly distinguishes that checkout from the current workdir; + // bundled skills reach the model through a durable user message, so each assertion + // targets its own field. const sourceRoot = fileURLToPath(new URL('../../..', import.meta.url)) let context: LoggedRequestContext = { system: '', skillCatalog: '' } await smoke({ @@ -432,7 +433,7 @@ describe('dsh CLI keyless smoke (apps/cli through the same PTY)', () => { ], inspect: async (cwd) => { context = await readLoggedRequestContext(cwd) }, }) - expect(context.system).toContain(`Your own source code is the checkout at ${sourceRoot}; you can read it there to learn how dsh works and how to extend it.`) + expect(context.system).toContain(`The DeepSeek Harness implementation checkout is at ${sourceRoot}. The checkout location and current working directory are separate values and may differ; never infer the working directory from this path. Use pwd to determine the current working directory. Use this checkout only to inspect or extend DSH itself.`) expect(context.skillCatalog).toContain("- `dsh-customize`: Customize or maintain any dsh source checkout — the one powering the current DSH process, the installed `dsh` command, or a sibling dsh/deepseek-harness clone. Use before any requested action that alters such a checkout's files or git state. Read-only questions that only inspect the checkout do not trigger this. Do not edit the personal staging checkout directly.") expect(context.skillCatalog).toContain('- `dsh-upgrade`: Upgrades a source-installed, personally customized DSH checkout to upstream master while preserving local changes and an unchanged rollback worktree. Use when the user asks to update or upgrade DSH.') expect(context.skillCatalog).toContain('- `dsh-upstream-customization`: Classifies personal DSH customizations for upstream contribution and, after explicit per-feature approval, rebuilds one on upstream master and opens a draft pull request. Use when the user asks to contribute, publish, or upstream a local DSH change, or asks whether one is worth proposing.') diff --git a/examples/tui-agent/tests/tui.snapshot.ts b/examples/tui-agent/tests/tui.snapshot.ts index 95b960e3b6..b96aaaf691 100644 --- a/examples/tui-agent/tests/tui.snapshot.ts +++ b/examples/tui-agent/tests/tui.snapshot.ts @@ -7,6 +7,7 @@ import { Context } from 'cordis' import { scrubRequestHeaders, tokenizeSessionFixtureCwd } from '@deepseek-ai/dsh-acp-snapshot' import type { Agent } from '@deepseek-ai/dsh-agent' import * as AgentCore from '@deepseek-ai/dsh-agent-spine-demo' +import { addHarnessSourceSection } from '@deepseek-ai/dsh-app-boot' import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' import WorkerCodeRuntime from '@deepseek-ai/dsh-code-runtime-worker' @@ -53,6 +54,8 @@ interface Scenario { leavePlanModeAfterFirstTurn?: boolean recorded: boolean seedWorkspace?: boolean + /** Add the launcher's model-visible DSH source checkout at this fixed path. */ + harnessSourceRoot?: string /** * Load the opt-in `todo_write` tool for this scenario. The shipped tui-agent * config omits it, so only the todo-plan scenario (the enabled-path proof) @@ -94,6 +97,13 @@ const SCENARIOS: Scenario[] = [ expectedTools: ['bash'], recorded: true, }, + { + name: 'source-checkout-workdir', + composition: 'native', + expectedTools: ['bash'], + recorded: true, + harnessSourceRoot: '/opt/dsh-source', + }, { name: 'parallel-file-reads', composition: 'native', @@ -187,6 +197,12 @@ function rawSessionLog(session: Session): string { ].join('\n') } +async function materializeFixtureCwd(fixtureFile: string, cwd: string, replayRoot: string): Promise { + const realized = join(replayRoot, basename(fixtureFile)) + await writeFile(realized, (await readFile(fixtureFile, 'utf8')).split('{{cwd}}').join(cwd)) + return realized +} + function normalizeTerminalSnapshot(snapshot: string, cwd: string, displayCwd: string): string { return snapshot .split(`/private${cwd}`).join('/workspace/project') @@ -212,6 +228,7 @@ async function mountScenarioContext( displayCwd: string, fixtureFile: string, childFiles: string[], + replayRoot: string | undefined, ): Promise { class SnapshotLocalFileSystem extends LocalFileSystem { override async resolve( @@ -231,6 +248,7 @@ async function mountScenarioContext( tools: { mode: scenario.composition === 'code' ? 'code' : scenario.composition === 'advanced' ? 'both' : 'native' }, skills: { local: { agentsHome: join(cwd, '.agents') } }, }) + if (scenario.harnessSourceRoot !== undefined) addHarnessSourceSection(ctx, scenario.harnessSourceRoot) await ctx.plugin(TokenMeterService) await ctx.plugin(LocalSubprocessService) await ctx.plugin(LocalBashExecutor, { cwd, timeoutMs: 30_000 }) @@ -263,7 +281,12 @@ async function mountScenarioContext( if (MODE === 'record' && scenario.recorded) { await ctx.plugin(LlmDeepSeek) } else { - installLlmReplay(ctx, { file: fixtureFile, childFiles, providers: PROVIDERS }) + if (replayRoot === undefined) throw new Error('replay mode requires an isolated fixture directory') + // Recorded model text may name the generated cwd. Realize the portable token + // outside that cwd so tools see only the scenario workspace during replay. + const replayFile = await materializeFixtureCwd(fixtureFile, cwd, replayRoot) + const replayChildFiles = await Promise.all(childFiles.map(file => materializeFixtureCwd(file, cwd, replayRoot))) + installLlmReplay(ctx, { file: replayFile, childFiles: replayChildFiles, providers: PROVIDERS }) } return ctx } @@ -287,15 +310,19 @@ async function runScenario(scenario: Scenario): Promise { const cwd = await mkdtemp(join(SNAPSHOT_TMP_ROOT, `dsh-tui-snapshot-${scenario.name}-`)) const displayCwd = `/tmp/${basename(cwd)}` + let replayRoot: string | undefined let ctx: Context | undefined let controller: ReturnType | undefined const terminal = new HeadlessTerminal(100, 36) try { + if (!(MODE === 'record' && scenario.recorded)) { + replayRoot = await mkdtemp(join(SNAPSHOT_TMP_ROOT, `dsh-tui-replay-${scenario.name}-`)) + } if (scenario.seedWorkspace === true) { const source = join(scenarioDir(scenario), 'workspace') await cp(source, cwd, { recursive: true }) } - ctx = await mountScenarioContext(scenario, cwd, displayCwd, fixtureFile, childFiles) + ctx = await mountScenarioContext(scenario, cwd, displayCwd, fixtureFile, childFiles, replayRoot) const disposedSessions: Session[] = [] ctx.on('session/disposed', (session) => { disposedSessions.push(session) }) const workflowEvents: string[] = [] @@ -406,6 +433,10 @@ async function runScenario(scenario: Scenario): Promise { const firstHeader = events.find(event => event.type === 'request/header') expect(firstHeader?.type === 'request/header' && firstHeader.data.header.system) .toContain(FILE_REFERENCE_PROMPT) + if (scenario.harnessSourceRoot !== undefined) { + expect(firstHeader?.type === 'request/header' && firstHeader.data.header.system) + .toContain(`The DeepSeek Harness implementation checkout is at ${scenario.harnessSourceRoot}. The checkout location and current working directory are separate values and may differ; never infer the working directory from this path. Use pwd to determine the current working directory. Use this checkout only to inspect or extend DSH itself.`) + } expect(events.filter(event => event.type === 'tool/call').map(event => event.data.name)).toEqual(scenario.expectedTools) for (const [type, count] of Object.entries(scenario.expectedEventCounts ?? {})) { expect(events.filter(event => event.type === type), `${scenario.name} must emit ${type}`).toHaveLength(count) @@ -477,6 +508,7 @@ async function runScenario(scenario: Scenario): Promise { await ctx?.fiber.dispose() await terminal.dispose() await rm(cwd, { recursive: true, force: true }) + if (replayRoot !== undefined) await rm(replayRoot, { recursive: true, force: true }) clock.mockRestore() } } diff --git a/packages/ui/app-boot/README.i18n.yaml b/packages/ui/app-boot/README.i18n.yaml index 08a274fd25..4804a0ad66 100644 --- a/packages/ui/app-boot/README.i18n.yaml +++ b/packages/ui/app-boot/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/ui/app-boot/README.md -README.md: 0282d3e9559d55c3fe5b07df133747750c06ebad -README.zh.md: b7121bbd288cd6e3f9ef2301de6018ceb380eb06 +README.md: b187f691df4c6ca9c4fe1fe3bf7e631f14997b01 +README.zh.md: 6f86573fbb1e60bf290d90f67e4a574c96219e0a diff --git a/packages/ui/app-boot/README.md b/packages/ui/app-boot/README.md index 0282d3e955..b187f691df 100644 --- a/packages/ui/app-boot/README.md +++ b/packages/ui/app-boot/README.md @@ -13,7 +13,7 @@ Shared boot glue for the app bins ([`dsh`](../../../apps/cli/README.md), [`dsh-c | `loadPersonalPatches(binName, dir?)` | Parse the optional `config.yaml` in the Harness home (default [`resolveDshHome()`](../../util/paths/README.md): `$DSH_HOME`, else `~/.dsh`) — a top-level YAML array of include `PatchOptions` (id-targeted config overrides, `insert` lists, `!!js` allowed); absent file → `undefined`, an unreadable/unparsable/non-array file throws | | `boot(binName, absoluteConfigPath, patches?, prepare?)` | Create the root context, run optional host preparation before plugins mount (e.g. `ctx.provide(RESUME_SESSION_ID_KEY, id)`), then mount the Loader/include tree, await it, assert entries loaded, and return the root context | | `RESUME_SESSION_ID_KEY` | Context key a bin sets through `boot`'s `prepare` hook to hand a resume session id to the booted config; the config reads it as the bare identifier `resumeSessionId` in a `!!js` expression, so resuming needs no environment variable | -| `addHarnessSourceSection(ctx, sourceRoot)` | Add a global `harness:source` prompt section (ordered just after the harness identity, before the persona) telling the agent the on-disk path to its own source checkout; a no-op returning `undefined` when the booted tree has no `systemPrompt` service. The section is registered against that service's fiber, so a dev HMR reload of the system prompt drops it until the next boot | +| `addHarnessSourceSection(ctx, sourceRoot)` | Add a global `harness:source` prompt section (ordered just after the harness identity, before the persona) telling the agent the on-disk path to the DSH implementation checkout while warning it not to infer the current working directory from that path and to use `pwd` instead; a no-op returning `undefined` when the booted tree has no `systemPrompt` service. The section is registered against that service's fiber, so a dev HMR reload of the system prompt drops it until the next boot | | `HARNESS_SOURCE_SECTION` | The `'harness:source'` section name `addHarnessSourceSection` registers under | Two failure classes the guards handle: `loader.await()` swallows init rejections (`Promise.allSettled`) — Node still exits non-zero on the resulting unhandled rejection, and `installFailLoud` replaces the noisy dump with one labelled line and a guaranteed `exit(1)`; a failed plugin import is only logged by the Loader (the process would otherwise exit 0 on a usable config typo), leaving a fiber-less entry that `assertEntriesLoaded` turns into a `boot()` rejection naming every failed plugin. diff --git a/packages/ui/app-boot/README.zh.md b/packages/ui/app-boot/README.zh.md index b7121bbd28..6f86573fbb 100644 --- a/packages/ui/app-boot/README.zh.md +++ b/packages/ui/app-boot/README.zh.md @@ -13,7 +13,7 @@ | `loadPersonalPatches(binName, dir?)` | 解析 Harness home 中可选的 `config.yaml`(默认使用 [`resolveDshHome()`](../../util/paths/README.md):先取 `$DSH_HOME`,否则取 `~/.dsh`):其顶层是一个 YAML 数组,内容为 include 的 `PatchOptions`(按 id 定位的配置覆盖、`insert` 列表,允许 `!!js`);文件不存在时返回 `undefined`,文件不可读、不可解析或内容不是数组时抛出异常 | | `boot(binName, absoluteConfigPath, patches?, prepare?)` | 创建根上下文,在插件挂载前执行可选的宿主准备操作(例如 `ctx.provide(RESUME_SESSION_ID_KEY, id)`),再挂载 Loader/include 树并等待其结算,断言所有条目均已加载,最后返回根上下文 | | `RESUME_SESSION_ID_KEY` | bin 通过 `boot` 的 `prepare` 钩子设置的上下文键,用于把要恢复的会话 id 交给已启动配置;配置以裸标识符 `resumeSessionId` 在 `!!js` 表达式中读取它,因此恢复操作无需环境变量 | -| `addHarnessSourceSection(ctx, sourceRoot)` | 添加全局 `harness:source` 提示词段落(顺序紧随 harness 身份、位于 persona 之前),告知 agent(智能体)自身源代码 checkout 的磁盘路径;如果已启动树没有此项服务,则不执行操作并返回 `undefined`。这里的服务是 `systemPrompt`;该段落注册到它的 fiber,因此开发环境 HMR(热模块替换)重新加载系统提示词后,它会消失直至下次启动 | +| `addHarnessSourceSection(ctx, sourceRoot)` | 添加全局 `harness:source` 提示词段落(顺序紧随 harness 身份、位于 persona 之前),告知 agent(智能体)DSH 实现代码 checkout 的磁盘路径,同时提醒它不得据此推断当前工作目录,而应使用 `pwd`;如果已启动树没有此项服务,则不执行操作并返回 `undefined`。这里的服务是 `systemPrompt`;该段落注册到它的 fiber,因此开发环境 HMR(热模块替换)重新加载系统提示词后,它会消失直至下次启动 | | `HARNESS_SOURCE_SECTION` | `'harness:source'` 段落名称,供 `addHarnessSourceSection` 注册使用 | 这些保护处理两类故障。`loader.await()` 会吞掉初始化 rejection(`Promise.allSettled`);Node 仍会因随后产生的未处理 rejection 以非零状态退出,而 `installFailLoud` 会把冗长转储替换为一行带标签的消息,并确保执行 `exit(1)`。插件导入失败则只会由 Loader 记录日志(否则,即使配置存在拼写错误,进程也会以代码 0 退出),并留下没有 fiber 的条目;`assertEntriesLoaded` 会将其转换为 `boot()` rejection,并在其中列出每个导入失败插件的名称。 diff --git a/packages/ui/app-boot/src/index.ts b/packages/ui/app-boot/src/index.ts index 4644912304..47e8fef5d2 100644 --- a/packages/ui/app-boot/src/index.ts +++ b/packages/ui/app-boot/src/index.ts @@ -213,11 +213,11 @@ export async function boot( export const HARNESS_SOURCE_SECTION = 'harness:source' /** - * Add a global prompt section naming the on-disk path to the harness source - * checkout the running bin was launched from, so the agent knows where its own - * source lives (the self-referential `dsh-tool-cordis` toolset reads and edits - * it). Call once on the settled boot context ({@link boot}); the section orders - * just after the harness identity opener (`-100`) and before the deployment + * Add a global prompt section naming the on-disk harness source checkout while + * explicitly distinguishing it from the task workspace and current working + * directory. The self-referential `dsh-tool-cordis` toolset reads and edits this + * checkout. Call once on the settled boot context ({@link boot}); the section + * orders just after the harness identity opener (`-100`) and before the deployment * persona (`0`). A booted tree with no `systemPrompt` service has no prompt to * augment, so this is then a no-op that returns `undefined`. The section is * registered against the `systemPrompt` service's fiber, so a dev HMR reload of @@ -232,6 +232,6 @@ export function addHarnessSourceSection(ctx: Context, sourceRoot: string): (() = return systemPrompt.section({ name: HARNESS_SOURCE_SECTION, order: -99, - text: `Your own source code is the checkout at ${sourceRoot}; you can read it there to learn how dsh works and how to extend it.`, + text: `The DeepSeek Harness implementation checkout is at ${sourceRoot}. The checkout location and current working directory are separate values and may differ; never infer the working directory from this path. Use pwd to determine the current working directory. Use this checkout only to inspect or extend DSH itself.`, }) } diff --git a/packages/ui/app-boot/tests/app-boot.spec.ts b/packages/ui/app-boot/tests/app-boot.spec.ts index db425c301a..ede5ba01de 100644 --- a/packages/ui/app-boot/tests/app-boot.spec.ts +++ b/packages/ui/app-boot/tests/app-boot.spec.ts @@ -193,9 +193,9 @@ describe('boot', () => { describe('addHarnessSourceSection', () => { const SOURCE_ROOT = `${sep}opt${sep}harness-src` - const EXPECTED = `Your own source code is the checkout at ${SOURCE_ROOT}; you can read it there to learn how dsh works and how to extend it.` + const EXPECTED = `The DeepSeek Harness implementation checkout is at ${SOURCE_ROOT}. The checkout location and current working directory are separate values and may differ; never infer the working directory from this path. Use pwd to determine the current working directory. Use this checkout only to inspect or extend DSH itself.` - it('adds the source path between the harness identity and the deployment persona', async () => { + it('distinguishes the source path from the current workdir between identity and persona', async () => { const ctx = new Context() try { await ctx.plugin(SystemPrompt, { persona: 'You are a coding agent.' }) From 9af9222871ead15b64871b8d57e7dba3f351c9bf Mon Sep 17 00:00:00 2001 From: Turtle Date: Thu, 30 Jul 2026 15:35:21 +0800 Subject: [PATCH 2/2] test(tui): stabilize workdir snapshot output --- .../source-checkout-workdir/terminal.expected.txt | 4 ++-- examples/tui-agent/tests/tui.snapshot.ts | 11 +++++++++++ 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/examples/tui-agent/tests/snapshots/source-checkout-workdir/terminal.expected.txt b/examples/tui-agent/tests/snapshots/source-checkout-workdir/terminal.expected.txt index 81103c12bd..b9ecb5a553 100644 --- a/examples/tui-agent/tests/snapshots/source-checkout-workdir/terminal.expected.txt +++ b/examples/tui-agent/tests/snapshots/source-checkout-workdir/terminal.expected.txt @@ -26,8 +26,8 @@ buffer style 0-46 fg=green 12| "$ pwd " style 0-4 dim -13| "/workspace/project " - style 0-59 dim +13| "/workspace/project " + style 0-17 dim 14| "[exit 0] " style 0-7 dim 15| "Model wait 0.0s · Completed 2026-07-21 12:00:00 " diff --git a/examples/tui-agent/tests/tui.snapshot.ts b/examples/tui-agent/tests/tui.snapshot.ts index b96aaaf691..81cdfd6f22 100644 --- a/examples/tui-agent/tests/tui.snapshot.ts +++ b/examples/tui-agent/tests/tui.snapshot.ts @@ -56,6 +56,8 @@ interface Scenario { seedWorkspace?: boolean /** Add the launcher's model-visible DSH source checkout at this fixed path. */ harnessSourceRoot?: string + /** Replace the real `pwd` result with a portable fixed-length workspace path. */ + normalizePwdResult?: boolean /** * Load the opt-in `todo_write` tool for this scenario. The shipped tui-agent * config omits it, so only the todo-plan scenario (the enabled-path proof) @@ -103,6 +105,7 @@ const SCENARIOS: Scenario[] = [ expectedTools: ['bash'], recorded: true, harnessSourceRoot: '/opt/dsh-source', + normalizePwdResult: true, }, { name: 'parallel-file-reads', @@ -323,6 +326,14 @@ async function runScenario(scenario: Scenario): Promise { await cp(source, cwd, { recursive: true }) } ctx = await mountScenarioContext(scenario, cwd, displayCwd, fixtureFile, childFiles, replayRoot) + if (scenario.normalizePwdResult === true) { + ctx.on('tools/post-execute', async (exec, result, next) => { + const args = exec.arguments as { command?: unknown } + return exec.name === 'bash' && args.command === 'pwd' && !result.isError + ? { kind: 'accept', content: [{ type: 'text', text: '/workspace/project\n' }] } + : next() + }) + } const disposedSessions: Session[] = [] ctx.on('session/disposed', (session) => { disposedSessions.push(session) }) const workflowEvents: string[] = []