Преглед на файлове

test(e2e): drive the TUI keyless smoke through the cross-platform PTY harness

The smoke's inline Python pty driver only ran on POSIX (no termios on
Windows). Rebuild every scenario — banner sweep, scripted conversation with
model switch, /skill:, Code Mode overlay, resume failure, and the dsh CLI
suite (default boot, personal overlay, invalid overlay, --resume flag,
source-path prompt) — as marker-gated action lists on pty-harness.ts, which
drives ConPTY via node-pty on Windows and the Python driver elsewhere. The
harness gains configArgs (bins with built-in default configs), prepare
(workspace seeding), and inspect (post-run log assertions); examples/
declares the session-title provider the shipped cordis.yml now mounts.
Turtle преди 1 месец
родител
ревизия
06827eb868

+ 1 - 0
examples/package.json

@@ -37,6 +37,7 @@
     "@deepseek-ai/dsh-spill-local": "workspace:*",
     "@deepseek-ai/dsh-spill-policy": "workspace:*",
     "@deepseek-ai/dsh-tui-demo": "workspace:*",
+    "@deepseek-ai/dsh-session-title-first-message-llm": "workspace:*",
     "@deepseek-ai/dsh-subagent": "workspace:*",
     "@deepseek-ai/dsh-subagent-acp": "workspace:*",
     "@deepseek-ai/dsh-subagent-fork": "workspace:*",

+ 151 - 257
examples/tui-agent/tests/tui-keyless-smoke.e2e.ts

@@ -1,10 +1,9 @@
-import { spawn } from 'node:child_process'
-import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from 'node:fs/promises'
-import { tmpdir } from 'node:os'
+import { mkdir, readdir, readFile, writeFile } from 'node:fs/promises'
 import { dirname, join } from 'node:path'
 import { fileURLToPath } from 'node:url'
 import { describe, expect, it } from 'vitest'
-import { LOADER_SMOKE_TEST_TIMEOUT_MS, resolveExampleLaunch } from '@deepseek-ai/dsh-loader-smoke'
+import { LOADER_SMOKE_TEST_TIMEOUT_MS } from '@deepseek-ai/dsh-loader-smoke'
+import { runTuiPtySmoke, type TuiPtySmokeOptions } from './pty-harness.ts'
 
 const binScript = fileURLToPath(new URL('../../../packages/examples/tui-demo/src/bin.ts', import.meta.url))
 const dshBinScript = fileURLToPath(new URL('../../../apps/cli/src/bin.ts', import.meta.url))
@@ -13,204 +12,25 @@ const codeModeConfigPath = fileURLToPath(new URL('../code-mode.cordis.yml', impo
 const scriptedConfigPath = fileURLToPath(new URL('./fixtures/tui-scripted.cordis.yml', import.meta.url))
 const tsconfigPath = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url))
 
-const PTY_DRIVER = String.raw`
-import errno, json, os, pty, select, signal, sys, time
-node, launch_args_json, launch_env_json, cwd, resume_session_id, scenario, boot_marker = sys.argv[1:]
-env = os.environ.copy()
-env.update(json.loads(launch_env_json))
-env.update({
-    "COLUMNS": "100",
-    "LINES": "30",
-})
-# Deterministic banner: a developer shell's COLORTERM=truecolor would switch the
-# banner to the per-letter gradient (one SGR per letter), breaking the literal
-# DEEPSEEK assertions. The gradient path has its own unit and snapshot coverage.
-env.pop("COLORTERM", None)
-if resume_session_id:
-    env["RESUME_SESSION_ID"] = resume_session_id
-pid, fd = pty.fork()
-if pid == 0:
-    os.chdir(cwd)
-    os.execvpe(node, [node, *json.loads(launch_args_json)], env)
-
-output = bytearray()
-answered_question = False
-opened_selector = False
-selected_model = False
-sent_prompt = False
-sent_exit = False
-deadline = time.monotonic() + 25
-status = None
-while time.monotonic() < deadline:
-    ready, _, _ = select.select([fd], [], [], 0.05)
-    if ready:
-        try:
-            chunk = os.read(fd, 65536)
-        except OSError as error:
-            if error.errno != errno.EIO:
-                raise
-            chunk = b""
-        if chunk:
-            output.extend(chunk)
-    if scenario == "conversation" and not opened_selector and b"scripted TUI ready." in output:
-        os.write(fd, b"/model\r")
-        opened_selector = True
-    if scenario == "conversation" and opened_selector and not selected_model and b"Select model" in output:
-        os.write(fd, b"\x1b[B\r")
-        selected_model = True
-    if scenario == "conversation" and selected_model and not sent_prompt and b"Model selected: tui-scripted/tui-scripted-model-pro." in output:
-        os.write(fd, b"exercise the TUI\r")
-        sent_prompt = True
-    if scenario == "conversation" and sent_prompt and not answered_question and b"How should the scripted run proceed?" in output:
-        os.write(fd, b"\r")
-        answered_question = True
-    if scenario == "conversation" and answered_question and not sent_exit and b"Decision received. Scripted TUI run complete." in output:
-        os.write(fd, b"/exit\r")
-        sent_exit = True
-    if scenario == "skill" and not selected_model and b"scripted TUI ready." in output:
-        os.write(fd, b"/model tui-scripted/tui-scripted-model-pro\r")
-        selected_model = True
-    if scenario == "skill" and selected_model and not sent_prompt and b"Model selected: tui-scripted/tui-scripted-model-pro." in output:
-        os.write(fd, b"/skill:scripted-skill\r")
-        sent_prompt = True
-    if scenario == "skill" and sent_prompt and not sent_exit and b"Scripted skill body received." in output:
-        os.write(fd, b"/exit\r")
-        sent_exit = True
-    if scenario == "boot" and not sent_exit and boot_marker.encode() in output:
-        os.write(fd, b"/exit\r")
-        sent_exit = True
-    waited, candidate = os.waitpid(pid, os.WNOHANG)
-    if waited == pid:
-        status = candidate
-        break
-
-if status is None:
-    os.kill(pid, signal.SIGKILL)
-    _, status = os.waitpid(pid, 0)
-sys.stdout.buffer.write(output)
-if scenario == "resume-failure":
-    if b'ui-tui: session "missing-session" failed to start:' not in output:
-        sys.stderr.write("TUI did not render the startup failure before timeout\n")
-        sys.exit(126)
-    if not os.WIFEXITED(status) or os.WEXITSTATUS(status) != 1:
-        sys.stderr.write("TUI startup failure did not exit with status 1\n")
-        sys.exit(127)
-elif scenario == "conversation":
-    if not sent_prompt:
-        sys.stderr.write("TUI did not render the scripted welcome marker before timeout\n")
-        sys.exit(128)
-    if not answered_question:
-        sys.stderr.write("TUI did not render the user-question dialog before timeout\n")
-        sys.exit(129)
-    if not sent_exit:
-        sys.stderr.write("TUI did not finish the scripted tool round-trip before timeout\n")
-        sys.exit(130)
-    if not os.WIFEXITED(status) or os.WEXITSTATUS(status) != 0:
-        sys.stderr.write("TUI scripted conversation did not exit cleanly\n")
-        sys.exit(131)
-elif scenario == "skill":
-    if not sent_prompt:
-        sys.stderr.write("TUI did not render the scripted welcome marker before typing /skill:\n")
-        sys.exit(132)
-    if b"Scripted skill body received." not in output:
-        sys.stderr.write("TUI did not deliver the loaded skill body to the model before timeout\n")
-        sys.exit(133)
-    if not sent_exit:
-        sys.stderr.write("TUI did not reach idle to accept /exit after the skill turn\n")
-        sys.exit(134)
-    if not os.WIFEXITED(status) or os.WEXITSTATUS(status) != 0:
-        sys.stderr.write("TUI skill scenario did not exit cleanly\n")
-        sys.exit(135)
-else:
-    if not sent_exit:
-        sys.stderr.write("TUI did not render its welcome marker before timeout\n")
-        sys.exit(124)
-    if not os.WIFEXITED(status) or os.WEXITSTATUS(status) != 0:
-        sys.stderr.write("TUI child did not exit cleanly\n")
-        sys.exit(125)
-`
-
-interface TuiLoaderSmokeOptions {
-  config?: string
-  resumeSessionId?: string
-  scenario?: 'boot' | 'conversation' | 'resume-failure' | 'skill'
-  /** Welcome text the boot scenario waits for before sending `/exit`. */
-  bootMarker?: string
-  /** Bin to boot; defaults to the tui-demo bin (the dsh CLI tests override). */
-  srcBin?: string
-  /** Argument vector for the bin; defaults to `[config]`. */
-  configArgs?: string[]
-  /** Files written into the isolated Harness home (`$DSH_HOME`) before launch. */
-  personalFiles?: Record<string, string>
-  /** Skill bundles written under the isolated agents home (`.agents/skills/`) before launch, keyed by path below that root. */
-  skillFiles?: Record<string, string>
-  /** Runs against the workspace `cwd` after a clean exit, before it is removed. */
-  inspect?: (cwd: string) => Promise<void>
-}
-
-async function runTuiLoaderSmoke(options: TuiLoaderSmokeOptions = {}): Promise<string> {
-  const cwd = await mkdtemp(join(tmpdir(), 'tui-agent-smoke-'))
-  try {
-    // Personal config is always isolated from the developer's real ~/.dsh;
-    // a test opts into an overlay by supplying files under the Harness home.
-    const dshHome = join(cwd, '.dsh')
-    for (const [name, content] of Object.entries(options.personalFiles ?? {})) {
-      await mkdir(dshHome, { recursive: true })
-      await writeFile(join(dshHome, name), content)
+/**
+ * Seed the harness workspace: personal files land in the isolated Harness home
+ * (`.dsh`), skill bundles under the agents home's `skills/` root — the same
+ * trees `$DSH_HOME` / `$DSH_AGENTS_HOME` point the child at.
+ */
+function seedWorkspace(
+  files: { personal?: Record<string, string>; skills?: Record<string, string> },
+): (cwd: string) => Promise<void> {
+  return async (cwd) => {
+    for (const [name, content] of Object.entries(files.personal ?? {})) {
+      const file = join(cwd, '.dsh', name)
+      await mkdir(dirname(file), { recursive: true })
+      await writeFile(file, content)
     }
-    // The child chdirs to this cwd and the scripted config roots fs-local here,
-    // so a skill dropped under DSH_AGENTS_HOME's `skills/` root is discoverable
-    // and its body readable through the same tree the model-facing stack uses.
-    const skillsRoot = join(cwd, '.agents', 'skills')
-    for (const [name, content] of Object.entries(options.skillFiles ?? {})) {
-      const file = join(skillsRoot, name)
+    for (const [name, content] of Object.entries(files.skills ?? {})) {
+      const file = join(cwd, '.agents', 'skills', name)
       await mkdir(dirname(file), { recursive: true })
       await writeFile(file, content)
     }
-    const launch = resolveExampleLaunch({
-      srcBin: options.srcBin ?? binScript,
-      configArgs: options.configArgs ?? [options.config ?? configPath],
-      tsconfigPath,
-      exposeInternals: true,
-      env: {
-        DEEPSEEK_API_KEY: 'keyless-tui-no-call',
-        DSH_HOME: dshHome,
-        DSH_AGENTS_HOME: join(cwd, '.agents'),
-      },
-    })
-    return await new Promise((resolve, reject) => {
-      const child = spawn('python3', [
-        '-c',
-        PTY_DRIVER,
-        launch.command,
-        JSON.stringify(launch.args),
-        JSON.stringify(launch.env),
-        cwd,
-        options.resumeSessionId ?? '',
-        options.scenario ?? 'boot',
-        // With no configured welcome the borderless banner sweeps in; its
-        // detail line's session id (`main-session-<uuid>`) renders only once
-        // the sweep reaches it, so it marks a settled banner.
-        options.bootMarker ?? 'main-session-',
-      ], { stdio: ['ignore', 'pipe', 'pipe'] })
-      let stdout = ''
-      let stderr = ''
-      child.stdout.setEncoding('utf8')
-      child.stdout.on('data', (chunk: string) => { stdout += chunk })
-      child.stderr.setEncoding('utf8')
-      child.stderr.on('data', (chunk: string) => { stderr += chunk })
-      child.once('error', reject)
-      child.once('exit', (code) => {
-        if (code !== 0) {
-          reject(new Error(`TUI PTY smoke exited ${String(code)}. stdout:\n${stdout}\nstderr:\n${stderr}`))
-          return
-        }
-        // Inspect the workspace before `finally` removes it (e.g. the session log).
-        void (options.inspect?.(cwd) ?? Promise.resolve()).then(() => { resolve(stdout) }, reject)
-      })
-    })
-  } finally {
-    await rm(cwd, { recursive: true, force: true })
   }
 }
 
@@ -229,12 +49,35 @@ async function readLoggedSystemPrompt(cwd: string): Promise<string> {
   throw new Error(`session log ${logRelPath} has no request/header event`)
 }
 
+/** Shared defaults: the keyless key, the tui-demo bin, and the live cordis.yml. */
+function smoke(overrides: Partial<TuiPtySmokeOptions> & { label: string }): Promise<string> {
+  return runTuiPtySmoke({
+    tempDirPrefix: 'tui-agent-smoke-',
+    binScript,
+    configPath,
+    tsconfigPath,
+    env: { DEEPSEEK_API_KEY: 'keyless-tui-no-call' },
+    ...overrides,
+  })
+}
+
+// The scripted conversation switches to the pro model first: the scripted
+// adapter proves routing + prompt variables by rejecting tool-ful calls on any
+// other route (see fixtures/tui-scripted-llm.ts).
+const SELECT_PRO_MODEL = [
+  { waitFor: 'scripted TUI ready.', send: '/model\r' },
+  { waitFor: 'Select model', send: '\x1b[B\r' },
+] as const
+
 describe('tui-agent keyless smoke (real Loader tree in a PTY)', () => {
   it('boots pi-tui, sweeps the borderless banner in, accepts /exit, and restores the terminal', async () => {
-    const output = await runTuiLoaderSmoke()
     // With no configured welcome the borderless banner sweeps in left-to-right;
-    // the boot scenario waits for the detail line's session id, which renders
-    // only once the sweep reaches it.
+    // the detail line's session id (`main-session-<uuid>`) renders only once
+    // the sweep reaches it, so it marks a settled banner.
+    const output = await smoke({
+      label: 'tui-agent boot',
+      actions: [{ waitFor: 'main-session-', send: '/exit\r' }],
+    })
     expect(output).toContain('DEEPSEEK')
     expect(output).toContain('HARNESS')
     expect(output).toContain('main-session-')
@@ -244,8 +87,24 @@ describe('tui-agent keyless smoke (real Loader tree in a PTY)', () => {
     expect(output).toContain('\u001B[?2004l')
   }, LOADER_SMOKE_TEST_TIMEOUT_MS)
 
-  it('streams a response, answers a user-question dialog, completes the tool round-trip, and exits cleanly', async () => {
-    const output = await runTuiLoaderSmoke({ config: scriptedConfigPath, scenario: 'conversation' })
+  it('switches models, streams a response, answers a user-question dialog, and exits cleanly', async () => {
+    const output = await smoke({
+      label: 'tui-agent conversation',
+      tempDirPrefix: 'tui-agent-conversation-',
+      configPath: scriptedConfigPath,
+      actions: [
+        ...SELECT_PRO_MODEL,
+        { waitFor: 'Model selected: tui-scripted/tui-scripted-model-pro.', send: 'exercise the TUI\r' },
+        { waitFor: 'How should the scripted run proceed?', send: '\r' },
+        { waitFor: 'Decision received. Scripted TUI run complete.', send: '' },
+        // Session title: the first user message drives the first-message-llm
+        // provider's tool-less title call; the scripted adapter answers it, the
+        // accepted title lands in the log, and the TUI renders the terminal
+        // window title as `<session title> — <configured title>` via OSC 0.
+        // Gating /exit on it keeps the assertion race-free.
+        { waitFor: 'scripted session title — DeepSeek Harness', send: '/exit\r' },
+      ],
+    })
     expect(output).toContain('I need one decision before I continue.')
     expect(output).toContain(String.raw`\x1b]2;MODEL_CONTROLLED\x07`)
     expect(output).toContain(String.raw`\x1b[999CMODEL_CURSOR`)
@@ -253,13 +112,7 @@ describe('tui-agent keyless smoke (real Loader tree in a PTY)', () => {
     expect(output).not.toContain('\u001B]2;MODEL_CONTROLLED\u0007')
     expect(output).not.toContain('\u001B[999CMODEL_CURSOR')
     expect(output).not.toContain('\u009B31mMODEL_C1')
-    expect(output).toContain('How should the scripted run proceed?')
     expect(output).toContain('Safe')
-    expect(output).toContain('Decision received. Scripted TUI run complete.')
-    // Session title: the first user message drives the first-message-llm
-    // provider's tool-less title call; the scripted adapter answers it, the
-    // accepted title lands in the log, and the TUI renders the terminal window
-    // title as `<session title> — <configured title>` via OSC 0.
     expect(output).toContain('\u001B]0;scripted session title — DeepSeek Harness\u0007')
     expect(output).toContain('\u001B[?2004l')
   }, LOADER_SMOKE_TEST_TIMEOUT_MS)
@@ -270,20 +123,28 @@ describe('tui-agent keyless smoke (real Loader tree in a PTY)', () => {
     // the local provider loads `scripted-skill` from the agents home, and the
     // rendered `<skill name="…">` block reaches the model — proven by the
     // scripted adapter echoing the fixture's body marker only when it arrives.
-    const output = await runTuiLoaderSmoke({
-      config: scriptedConfigPath,
-      scenario: 'skill',
-      skillFiles: {
-        'scripted-skill/SKILL.md': [
-          '---',
-          'name: scripted-skill',
-          'description: Keyless PTY proof that the skill command loads a local skill into the conversation.',
-          '---',
-          '',
-          'SCRIPTED SKILL BODY MARKER',
-          '',
-        ].join('\n'),
-      },
+    const output = await smoke({
+      label: 'tui-agent skill',
+      tempDirPrefix: 'tui-agent-skill-',
+      configPath: scriptedConfigPath,
+      prepare: seedWorkspace({
+        skills: {
+          'scripted-skill/SKILL.md': [
+            '---',
+            'name: scripted-skill',
+            'description: Keyless PTY proof that the skill command loads a local skill into the conversation.',
+            '---',
+            '',
+            'SCRIPTED SKILL BODY MARKER',
+            '',
+          ].join('\n'),
+        },
+      }),
+      actions: [
+        ...SELECT_PRO_MODEL,
+        { waitFor: 'Model selected: tui-scripted/tui-scripted-model-pro.', send: '/skill:scripted-skill\r' },
+        { waitFor: 'Scripted skill body received.', send: '/exit\r' },
+      ],
     })
     expect(output).toContain('Scripted skill body received.')
     expect(output).toContain('\u001B[?2004l')
@@ -292,23 +153,39 @@ describe('tui-agent keyless smoke (real Loader tree in a PTY)', () => {
   it('boots the Code Mode overlay tree, renders its banner, and exits cleanly', async () => {
     // The overlay's only keyless composition proof: the include+patch tree,
     // worker code runtime, and one-tool registry all mount before the banner.
-    const output = await runTuiLoaderSmoke({
-      config: codeModeConfigPath,
-      bootMarker: 'TUI Code Mode ready.',
+    const output = await smoke({
+      label: 'tui-agent code mode',
+      tempDirPrefix: 'tui-agent-code-mode-',
+      configPath: codeModeConfigPath,
+      actions: [{ waitFor: 'TUI Code Mode ready.', send: '/exit\r' }],
     })
     expect(output).toContain('TUI Code Mode ready.')
     expect(output).toContain('\u001B[?2004l')
   }, LOADER_SMOKE_TEST_TIMEOUT_MS)
 
   it('prints a config-resume failure and exits instead of leaving a blank terminal', async () => {
-    const output = await runTuiLoaderSmoke({ resumeSessionId: 'missing-session', scenario: 'resume-failure' })
+    const output = await smoke({
+      label: 'tui-agent resume failure',
+      tempDirPrefix: 'tui-agent-resume-',
+      env: {
+        DEEPSEEK_API_KEY: 'keyless-tui-no-call',
+        RESUME_SESSION_ID: 'missing-session',
+      },
+      expectedExitCode: 1,
+    })
     expect(output).toContain('ui-tui: session "missing-session" failed to start:')
   }, LOADER_SMOKE_TEST_TIMEOUT_MS)
 })
 
 describe('dsh CLI keyless smoke (apps/cli through the same PTY)', () => {
   it('boots the shipped default config with no arguments and no personal overlay', async () => {
-    const output = await runTuiLoaderSmoke({ srcBin: dshBinScript, configArgs: [] })
+    const output = await smoke({
+      label: 'dsh default boot',
+      tempDirPrefix: 'dsh-default-boot-',
+      binScript: dshBinScript,
+      configArgs: [],
+      actions: [{ waitFor: 'main-session-', send: '/exit\r' }],
+    })
     expect(output).toContain('DEEPSEEK')
     expect(output).toContain('main-session-')
     expect(output).not.toContain('╭')
@@ -320,45 +197,55 @@ describe('dsh CLI keyless smoke (apps/cli through the same PTY)', () => {
     // The whole personal-config chain in one boot: the personal .env supplies
     // the variable, config.yaml patches the tui-agent entry with a `!!js`
     // reference to it, and the banner renders the patched welcome verbatim.
-    const output = await runTuiLoaderSmoke({
-      srcBin: dshBinScript,
+    const output = await smoke({
+      label: 'dsh personal overlay',
+      tempDirPrefix: 'dsh-personal-overlay-',
+      binScript: dshBinScript,
       configArgs: [],
-      bootMarker: 'PERSONAL OVERLAY READY.',
-      personalFiles: {
-        '.env': 'DSH_PERSONAL_WELCOME=PERSONAL OVERLAY READY.\n',
-        'config.yaml': [
-          '- id: tui-agent',
-          "  name: '@deepseek-ai/dsh-tui-demo'",
-          '  config:',
-          '    provider: deepseek',
-          '    model: deepseek-v4-flash',
-          '    workspaceContext: false',
-          '    welcome: !!js process.env.DSH_PERSONAL_WELCOME',
-          '',
-        ].join('\n'),
-      },
+      prepare: seedWorkspace({
+        personal: {
+          '.env': 'DSH_PERSONAL_WELCOME=PERSONAL OVERLAY READY.\n',
+          'config.yaml': [
+            '- id: tui-agent',
+            "  name: '@deepseek-ai/dsh-tui-demo'",
+            '  config:',
+            '    provider: deepseek',
+            '    model: deepseek-v4-flash',
+            '    workspaceContext: false',
+            '    welcome: !!js process.env.DSH_PERSONAL_WELCOME',
+            '',
+          ].join('\n'),
+        },
+      }),
+      actions: [{ waitFor: 'PERSONAL OVERLAY READY.', send: '/exit\r' }],
     })
     expect(output).toContain('PERSONAL OVERLAY READY.')
     expect(output).toContain('\u001B[?2004l')
   }, LOADER_SMOKE_TEST_TIMEOUT_MS)
 
   it('fails loud instead of booting when the personal config.yaml is invalid', async () => {
-    await expect(runTuiLoaderSmoke({
-      srcBin: dshBinScript,
+    const output = await smoke({
+      label: 'dsh invalid personal config',
+      tempDirPrefix: 'dsh-invalid-personal-',
+      binScript: dshBinScript,
       configArgs: [],
-      personalFiles: { 'config.yaml': 'id: not-a-list\n' },
-    })).rejects.toThrow('must be a top-level YAML array of loader patch entries')
+      prepare: seedWorkspace({ personal: { 'config.yaml': 'id: not-a-list\n' } }),
+      expectedExitCode: 1,
+    })
+    expect(output).toContain('must be a top-level YAML array of loader patch entries')
   }, LOADER_SMOKE_TEST_TIMEOUT_MS)
 
   it('routes the --resume flag into the config resume intake, failing loud on a missing id', async () => {
     // The flag path end to end: apps/cli parses `--resume missing-session` and
-    // sets RESUME_SESSION_ID (the PTY driver does NOT here), the shipped
-    // config's `!!js` reads it, and the resume fails loud — proving the printed
-    // `dsh --resume <id>` hint reaches the same intake as the env var.
-    const output = await runTuiLoaderSmoke({
-      srcBin: dshBinScript,
+    // sets RESUME_SESSION_ID, the shipped config's `!!js` reads it, and the
+    // resume fails loud — proving the printed `dsh --resume <id>` hint reaches
+    // the same intake as the env var.
+    const output = await smoke({
+      label: 'dsh resume flag failure',
+      tempDirPrefix: 'dsh-resume-flag-',
+      binScript: dshBinScript,
       configArgs: ['--resume', 'missing-session'],
-      scenario: 'resume-failure',
+      expectedExitCode: 1,
     })
     expect(output).toContain('ui-tui: session "missing-session" failed to start:')
   }, LOADER_SMOKE_TEST_TIMEOUT_MS)
@@ -368,10 +255,17 @@ describe('dsh CLI keyless smoke (apps/cli through the same PTY)', () => {
     // this test file sits an equal depth under the same root, so the same hop applies.
     const sourceRoot = fileURLToPath(new URL('../../..', import.meta.url))
     let loggedSystem = ''
-    await runTuiLoaderSmoke({
-      srcBin: dshBinScript,
+    await smoke({
+      label: 'dsh source-path prompt',
+      tempDirPrefix: 'dsh-source-path-',
+      binScript: dshBinScript,
       configArgs: [scriptedConfigPath],
-      scenario: 'conversation',
+      actions: [
+        ...SELECT_PRO_MODEL,
+        { waitFor: 'Model selected: tui-scripted/tui-scripted-model-pro.', send: 'exercise the TUI\r' },
+        { waitFor: 'How should the scripted run proceed?', send: '\r' },
+        { waitFor: 'Decision received. Scripted TUI run complete.', send: '/exit\r' },
+      ],
       inspect: async (cwd) => { loggedSystem = await readLoggedSystemPrompt(cwd) },
     })
     expect(loggedSystem).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.`)

+ 101 - 101
packages/context/workspace-context/tests/workspace-context.spec.ts

@@ -278,8 +278,8 @@ describe('workspace context instruction discovery', () => {
         '$DSH_HOME/AGENTS.md',
         'AGENTS.md',
         'CLAUDE.md',
-        'packages/CLAUDE.md',
-        'packages/app/AGENTS.md',
+        join('packages', 'CLAUDE.md'),
+        join('packages', 'app', 'AGENTS.md'),
       ])
       expect(files.map(file => file.absolutePath)).toContain(join(root, 'CLAUDE.md'))
     } finally {
@@ -304,8 +304,8 @@ describe('workspace context instruction discovery', () => {
       expect(files.map(file => file.displayPath)).toEqual([
         'AGENTS.md',
         'AGENTS.local.md',
-        'pkg/CLAUDE.md',
-        'pkg/CLAUDE.local.md',
+        join('pkg', 'CLAUDE.md'),
+        join('pkg', 'CLAUDE.local.md'),
       ])
     } finally {
       await rm(root, { recursive: true, force: true })
@@ -855,7 +855,7 @@ describe('workspace context request injection', () => {
         signal: testToolSignal,
         callId: CallId('no-fs-post-execute'),
         name: 'read',
-        arguments: { file_path: 'pkg/file.txt' },
+        arguments: { file_path: join('pkg', 'file.txt') },
         agent: stubAgent('/virtual/repo'),
       }), {
         isError: false,
@@ -891,7 +891,7 @@ describe('workspace context request injection', () => {
         signal: testToolSignal,
         callId: CallId('read-blocked-post-execute'),
         name: 'read',
-        arguments: { file_path: 'pkg/file.txt' },
+        arguments: { file_path: join('pkg', 'file.txt') },
         agent,
       })
       const result = {
@@ -989,7 +989,7 @@ describe('workspace context request injection', () => {
       await composeBaselinePrefix(ctx, agent)
 
       expect(derivedText(agent)).toContain('omitted AGENTS.md')
-      expect(derivedText(agent)).toContain('Instructions from: pkg/AGENTS.md\n\npackage rule')
+      expect(derivedText(agent)).toContain(`Instructions from: ${join('pkg', 'AGENTS.md')}\n\npackage rule`)
     } finally {
       await rm(root, { recursive: true, force: true })
       await rm(home, { recursive: true, force: true })
@@ -1479,7 +1479,7 @@ describe('workspace context request injection', () => {
       await composeBaselinePrefix(ctx, agent)
 
       expect(derivedText(agent)).toContain('Instructions from: AGENTS.md\n\nroot schema default rule')
-      expect(derivedText(agent)).toContain('Instructions from: child/AGENTS.md\n\nchild schema default rule')
+      expect(derivedText(agent)).toContain(`Instructions from: ${join('child', 'AGENTS.md')}\n\nchild schema default rule`)
       await ctx.fiber.dispose()
     } finally {
       await rm(root, { recursive: true, force: true })
@@ -1680,7 +1680,7 @@ describe('dynamic nested workspace context injection', () => {
           { type: 'block-end', index: 1, block: { type: 'tool-call', id: CallId('abort-after-read'), name: 'abort_step', arguments: '{}' } },
           { type: 'finish', reason: { kind: 'tool-calls' } },
         ] satisfies StreamChunk[],
-        toolCallResponse('read-after-abort', 'read', { file_path: 'pkg/deep/file.txt' }),
+        toolCallResponse('read-after-abort', 'read', { file_path: join('pkg', 'deep', 'file.txt') }),
         textResponse('done'),
       ])
       await ctx.plugin(LlmService)
@@ -1757,7 +1757,7 @@ describe('dynamic nested workspace context injection', () => {
       const exec = stubToolExecution({
         callId: CallId('cancelled-dynamic-read'),
         name: 'read',
-        arguments: { file_path: 'pkg/file.txt' },
+        arguments: { file_path: join('pkg', 'file.txt') },
         agent: stubAgent(root),
         signal: controller.signal,
       })
@@ -1792,7 +1792,7 @@ describe('dynamic nested workspace context injection', () => {
         signal: testToolSignal,
         callId: CallId('read-nested'),
         name: 'read',
-        arguments: { file_path: 'pkg/deep/file.txt' },
+        arguments: { file_path: join('pkg', 'deep', 'file.txt') },
         agent,
       })
 
@@ -1804,7 +1804,7 @@ describe('dynamic nested workspace context injection', () => {
         changes: [{
           action: 'set',
           scope: sk('pkg', 'AGENTS.md'),
-          path: 'pkg/AGENTS.md',
+          path: join('pkg', 'AGENTS.md'),
         }],
       })
       const meta = workspaceContextOf(result)?.meta
@@ -1852,16 +1852,16 @@ describe('dynamic nested workspace context injection', () => {
         signal: testToolSignal,
         callId: CallId('read-configured-nested-candidate'),
         name: 'read',
-        arguments: { file_path: 'pkg/deep/file.txt' },
+        arguments: { file_path: join('pkg', 'deep', 'file.txt') },
         agent: stubAgent(root),
       })
 
       const text = blocksText(workspaceContextOf(result)?.content)
-      expect(text).toContain('Additional instructions from: pkg/CLAUDE.local.md')
+      expect(text).toContain(`Additional instructions from: ${join('pkg', 'CLAUDE.local.md')}`)
       expect(text).toContain('local package rule')
-      expect(text).toContain('Additional instructions from: pkg/AGENTS.md')
+      expect(text).toContain(`Additional instructions from: ${join('pkg', 'AGENTS.md')}`)
       expect(text).toContain('native package rule')
-      expect(text.indexOf('pkg/CLAUDE.local.md')).toBeLessThan(text.indexOf('pkg/AGENTS.md'))
+      expect(text.indexOf(join('pkg', 'CLAUDE.local.md'))).toBeLessThan(text.indexOf(join('pkg', 'AGENTS.md')))
     } finally {
       await rm(root, { recursive: true, force: true })
       await rm(home, { recursive: true, force: true })
@@ -1884,7 +1884,7 @@ describe('dynamic nested workspace context injection', () => {
         signal: testToolSignal,
         callId: CallId('read-nested-overlay'),
         name: 'read',
-        arguments: { file_path: 'pkg/deep/file.txt' },
+        arguments: { file_path: join('pkg', 'deep', 'file.txt') },
         agent: stubAgent(root),
       })
 
@@ -1893,13 +1893,13 @@ describe('dynamic nested workspace context injection', () => {
         ? meta.changes
         : []
       expect(changes).toEqual(expect.arrayContaining([
-        expect.objectContaining({ action: 'set', path: 'pkg/AGENTS.md' }),
-        expect.objectContaining({ action: 'set', path: 'pkg/AGENTS.local.md' }),
+        expect.objectContaining({ action: 'set', path: join('pkg', 'AGENTS.md') }),
+        expect.objectContaining({ action: 'set', path: join('pkg', 'AGENTS.local.md') }),
       ]))
       const text = blocksText(workspaceContextOf(result)?.content)
-      expect(text).toContain('Additional instructions from: pkg/AGENTS.md')
+      expect(text).toContain(`Additional instructions from: ${join('pkg', 'AGENTS.md')}`)
       expect(text).toContain('nested base rule')
-      expect(text).toContain('Additional instructions from: pkg/AGENTS.local.md')
+      expect(text).toContain(`Additional instructions from: ${join('pkg', 'AGENTS.local.md')}`)
       expect(text).toContain('nested local rule')
     } finally {
       await rm(root, { recursive: true, force: true })
@@ -1926,13 +1926,13 @@ describe('dynamic nested workspace context injection', () => {
         signal: testToolSignal,
         callId: CallId('read-nested-overlay-disabled'),
         name: 'read',
-        arguments: { file_path: 'pkg/deep/file.txt' },
+        arguments: { file_path: join('pkg', 'deep', 'file.txt') },
         agent: stubAgent(root),
       })
 
       const text = blocksText(workspaceContextOf(result)?.content)
-      expect(text).toContain('Additional instructions from: pkg/AGENTS.md')
-      expect(text).not.toContain('pkg/AGENTS.local.md')
+      expect(text).toContain(`Additional instructions from: ${join('pkg', 'AGENTS.md')}`)
+      expect(text).not.toContain(join('pkg', 'AGENTS.local.md'))
     } finally {
       await rm(root, { recursive: true, force: true })
       await rm(home, { recursive: true, force: true })
@@ -1954,14 +1954,14 @@ describe('dynamic nested workspace context injection', () => {
         signal: testToolSignal,
         callId: CallId('read-nested-1'),
         name: 'read',
-        arguments: { file_path: 'pkg/deep/file.txt' },
+        arguments: { file_path: join('pkg', 'deep', 'file.txt') },
         agent,
       })
       const second = await ctx.tools.execute({
         signal: testToolSignal,
         callId: CallId('read-nested-2'),
         name: 'read',
-        arguments: { file_path: 'pkg/deep/file.txt' },
+        arguments: { file_path: join('pkg', 'deep', 'file.txt') },
         agent,
       })
 
@@ -1992,12 +1992,12 @@ describe('dynamic nested workspace context injection', () => {
 
       const first = await ctx.tools.execute({
         signal: testToolSignal,
-        callId: CallId('read-before-version-fast-path'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent,
+        callId: CallId('read-before-version-fast-path'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent,
       })
       appendAdditionalContexts(agent, first)
       const second = await ctx.tools.execute({
         signal: testToolSignal,
-        callId: CallId('read-with-version-fast-path'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent,
+        callId: CallId('read-with-version-fast-path'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent,
       })
 
       expect(first.additionalContexts).toBeDefined()
@@ -2029,17 +2029,17 @@ describe('dynamic nested workspace context injection', () => {
 
       const first = await ctx.tools.execute({
         signal: testToolSignal,
-        callId: CallId('read-before-same-digest-version-change'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent,
+        callId: CallId('read-before-same-digest-version-change'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent,
       })
       appendAdditionalContexts(agent, first)
       fs.entries.set(instructionPath, { type: 'file', content: 'same package rule', version: FsVersion('revision-2') })
       const afterVersionChange = await ctx.tools.execute({
         signal: testToolSignal,
-        callId: CallId('read-after-same-digest-version-change'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent,
+        callId: CallId('read-after-same-digest-version-change'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent,
       })
       const afterRefresh = await ctx.tools.execute({
         signal: testToolSignal,
-        callId: CallId('read-after-version-cache-refresh'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent,
+        callId: CallId('read-after-version-cache-refresh'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent,
       })
 
       expect(afterVersionChange.additionalContexts).toBeUndefined()
@@ -2070,11 +2070,11 @@ describe('dynamic nested workspace context injection', () => {
 
       const first = await ctx.tools.execute({
         signal: testToolSignal,
-        callId: CallId('read-from-first-session'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent: stubAgent(root),
+        callId: CallId('read-from-first-session'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent: stubAgent(root),
       })
       const second = await ctx.tools.execute({
         signal: testToolSignal,
-        callId: CallId('read-from-second-session'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent: stubAgent(root),
+        callId: CallId('read-from-second-session'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent: stubAgent(root),
       })
 
       expect(first.additionalContexts).toBeDefined()
@@ -2100,18 +2100,18 @@ describe('dynamic nested workspace context injection', () => {
 
       const first = await ctx.tools.execute({
         signal: testToolSignal,
-        callId: CallId('read-before-change'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent,
+        callId: CallId('read-before-change'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent,
       })
       appendAdditionalContexts(agent, first)
       await write(join(root, 'pkg/AGENTS.md'), 'new package rule with more detail')
       const changed = await ctx.tools.execute({
         signal: testToolSignal,
-        callId: CallId('read-after-change'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent,
+        callId: CallId('read-after-change'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent,
       })
 
       expect(workspaceContextOf(changed)?.meta).toMatchObject({
         kind: 'workspace-instructions',
-        changes: [{ action: 'replace', scope: sk('pkg', 'AGENTS.md'), path: 'pkg/AGENTS.md' }],
+        changes: [{ action: 'replace', scope: sk('pkg', 'AGENTS.md'), path: join('pkg', 'AGENTS.md') }],
       })
       expect(blocksText(workspaceContextOf(changed)?.content)).toBe([
         '<system-reminder>',
@@ -2142,7 +2142,7 @@ describe('dynamic nested workspace context injection', () => {
 
       const first = await ctx.tools.execute({
         signal: testToolSignal,
-        callId: CallId('read-both-siblings'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent,
+        callId: CallId('read-both-siblings'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent,
       })
       const firstText = blocksText(workspaceContextOf(first)?.content)
       expect(firstText).toContain('native package rule')
@@ -2151,14 +2151,14 @@ describe('dynamic nested workspace context injection', () => {
       await rm(join(root, 'pkg/AGENTS.md'))
       const removed = await ctx.tools.execute({
         signal: testToolSignal,
-        callId: CallId('read-after-one-sibling-removed'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent,
+        callId: CallId('read-after-one-sibling-removed'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent,
       })
 
       // Removing one candidate only removes its own scope; the sibling scope is untouched.
       expect(workspaceContextOf(removed)?.meta).toMatchObject({
-        changes: [{ action: 'remove', scope: sk('pkg', 'AGENTS.md'), path: 'pkg/AGENTS.md' }],
+        changes: [{ action: 'remove', scope: sk('pkg', 'AGENTS.md'), path: join('pkg', 'AGENTS.md') }],
       })
-      expect(blocksText(workspaceContextOf(removed)?.content)).toContain('Instructions removed: pkg/AGENTS.md')
+      expect(blocksText(workspaceContextOf(removed)?.content)).toContain(`Instructions removed: ${join('pkg', 'AGENTS.md')}`)
       expect(blocksText(workspaceContextOf(removed)?.content)).not.toContain('sibling package rule')
     } finally {
       await rm(root, { recursive: true, force: true })
@@ -2180,16 +2180,16 @@ describe('dynamic nested workspace context injection', () => {
 
       const result = await ctx.tools.execute({
         signal: testToolSignal,
-        callId: CallId('read-nested-dup-siblings'), name: 'read', arguments: { file_path: 'pkg/deep/file.txt' }, agent,
+        callId: CallId('read-nested-dup-siblings'), name: 'read', arguments: { file_path: join('pkg', 'deep', 'file.txt') }, agent,
       })
 
       expect(workspaceContextOf(result)?.meta).toMatchObject({
-        changes: [{ action: 'set', scope: sk('pkg', 'AGENTS.md'), path: 'pkg/AGENTS.md' }],
+        changes: [{ action: 'set', scope: sk('pkg', 'AGENTS.md'), path: join('pkg', 'AGENTS.md') }],
       })
       const text = blocksText(workspaceContextOf(result)?.content)
       expect(text.match(/nested rule/g)).toHaveLength(1)
-      expect(text).toContain('Additional instructions from: pkg/AGENTS.md')
-      expect(text).not.toContain('pkg/CLAUDE.md')
+      expect(text).toContain(`Additional instructions from: ${join('pkg', 'AGENTS.md')}`)
+      expect(text).not.toContain(join('pkg', 'CLAUDE.md'))
     } finally {
       await rm(root, { recursive: true, force: true })
       await rm(home, { recursive: true, force: true })
@@ -2210,7 +2210,7 @@ describe('dynamic nested workspace context injection', () => {
 
       const first = await ctx.tools.execute({
         signal: testToolSignal,
-        callId: CallId('read-before-dup-convergence'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent,
+        callId: CallId('read-before-dup-convergence'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent,
       })
       const firstText = blocksText(workspaceContextOf(first)?.content)
       expect(firstText).toContain('canonical nested rule')
@@ -2219,13 +2219,13 @@ describe('dynamic nested workspace context injection', () => {
       await write(join(root, 'pkg/CLAUDE.md'), 'canonical nested rule')
       const converged = await ctx.tools.execute({
         signal: testToolSignal,
-        callId: CallId('read-after-dup-convergence'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent,
+        callId: CallId('read-after-dup-convergence'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent,
       })
 
       expect(workspaceContextOf(converged)?.meta).toMatchObject({
-        changes: [{ action: 'remove', scope: sk('pkg', 'CLAUDE.md'), path: 'pkg/CLAUDE.md' }],
+        changes: [{ action: 'remove', scope: sk('pkg', 'CLAUDE.md'), path: join('pkg', 'CLAUDE.md') }],
       })
-      expect(blocksText(workspaceContextOf(converged)?.content)).toContain('Instructions removed: pkg/CLAUDE.md')
+      expect(blocksText(workspaceContextOf(converged)?.content)).toContain(`Instructions removed: ${join('pkg', 'CLAUDE.md')}`)
     } finally {
       await rm(root, { recursive: true, force: true })
       await rm(home, { recursive: true, force: true })
@@ -2246,25 +2246,25 @@ describe('dynamic nested workspace context injection', () => {
 
       const first = await ctx.tools.execute({
         signal: testToolSignal,
-        callId: CallId('read-before-earlier-converges'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent,
+        callId: CallId('read-before-earlier-converges'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent,
       })
       appendAdditionalContexts(agent, first)
       // Only the earlier candidate changes; the sibling stays byte-identical but now duplicates it.
       await write(join(root, 'pkg/AGENTS.md'), 'secondary nested rule')
       const converged = await ctx.tools.execute({
         signal: testToolSignal,
-        callId: CallId('read-after-earlier-converges'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent,
+        callId: CallId('read-after-earlier-converges'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent,
       })
 
       expect(workspaceContextOf(converged)?.meta).toMatchObject({
         changes: [
-          { action: 'replace', scope: sk('pkg', 'AGENTS.md'), path: 'pkg/AGENTS.md' },
-          { action: 'remove', scope: sk('pkg', 'CLAUDE.md'), path: 'pkg/CLAUDE.md' },
+          { action: 'replace', scope: sk('pkg', 'AGENTS.md'), path: join('pkg', 'AGENTS.md') },
+          { action: 'remove', scope: sk('pkg', 'CLAUDE.md'), path: join('pkg', 'CLAUDE.md') },
         ],
       })
       const text = blocksText(workspaceContextOf(converged)?.content)
-      expect(text).toContain('Instructions removed: pkg/CLAUDE.md')
-      expect(text).toContain('Updated instructions from: pkg/AGENTS.md')
+      expect(text).toContain(`Instructions removed: ${join('pkg', 'CLAUDE.md')}`)
+      expect(text).toContain(`Updated instructions from: ${join('pkg', 'AGENTS.md')}`)
     } finally {
       await rm(root, { recursive: true, force: true })
       await rm(home, { recursive: true, force: true })
@@ -2284,19 +2284,19 @@ describe('dynamic nested workspace context injection', () => {
 
       const first = await ctx.tools.execute({
         signal: testToolSignal,
-        callId: CallId('read-before-remove'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent,
+        callId: CallId('read-before-remove'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent,
       })
       appendAdditionalContexts(agent, first)
       await rm(join(root, 'pkg/AGENTS.md'))
       const removed = await ctx.tools.execute({
         signal: testToolSignal,
-        callId: CallId('read-after-remove'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent,
+        callId: CallId('read-after-remove'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent,
       })
 
       expect(workspaceContextOf(removed)?.meta).toEqual({
         kind: 'workspace-instructions',
         version: 1,
-        changes: [{ action: 'remove', scope: sk('pkg', 'AGENTS.md'), path: 'pkg/AGENTS.md' }],
+        changes: [{ action: 'remove', scope: sk('pkg', 'AGENTS.md'), path: join('pkg', 'AGENTS.md') }],
       })
       expect(blocksText(workspaceContextOf(removed)?.content)).toBe([
         '<system-reminder>',
@@ -2324,7 +2324,7 @@ describe('dynamic nested workspace context injection', () => {
 
       const first = await ctx.tools.execute({
         signal: testToolSignal,
-        callId: CallId('read-before-symlink-dir'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent,
+        callId: CallId('read-before-symlink-dir'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent,
       })
       appendAdditionalContexts(agent, first)
       expect(blocksText(workspaceContextOf(first)?.content)).toContain('package rule')
@@ -2337,13 +2337,13 @@ describe('dynamic nested workspace context injection', () => {
       await symlink(join(root, 'pkg/elsewhere'), join(root, 'pkg/AGENTS.md'))
       const removed = await ctx.tools.execute({
         signal: testToolSignal,
-        callId: CallId('read-after-symlink-dir'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent,
+        callId: CallId('read-after-symlink-dir'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent,
       })
 
       expect(workspaceContextOf(removed)?.meta).toMatchObject({
-        changes: [{ action: 'remove', scope: sk('pkg', 'AGENTS.md'), path: 'pkg/AGENTS.md' }],
+        changes: [{ action: 'remove', scope: sk('pkg', 'AGENTS.md'), path: join('pkg', 'AGENTS.md') }],
       })
-      expect(blocksText(workspaceContextOf(removed)?.content)).toContain('Instructions removed: pkg/AGENTS.md')
+      expect(blocksText(workspaceContextOf(removed)?.content)).toContain(`Instructions removed: ${join('pkg', 'AGENTS.md')}`)
     } finally {
       await rm(root, { recursive: true, force: true })
       await rm(home, { recursive: true, force: true })
@@ -2363,26 +2363,26 @@ describe('dynamic nested workspace context injection', () => {
 
       const first = await ctx.tools.execute({
         signal: testToolSignal,
-        callId: CallId('read-before-tombstone'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent,
+        callId: CallId('read-before-tombstone'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent,
       })
       appendAdditionalContexts(agent, first)
       await rm(join(root, 'pkg/AGENTS.md'))
       const removed = await ctx.tools.execute({
         signal: testToolSignal,
-        callId: CallId('read-to-create-tombstone'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent,
+        callId: CallId('read-to-create-tombstone'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent,
       })
       appendAdditionalContexts(agent, removed)
       await write(join(root, 'pkg/AGENTS.md'), 'restored package rule')
 
       const restored = await ctx.tools.execute({
         signal: testToolSignal,
-        callId: CallId('read-after-tombstone'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent,
+        callId: CallId('read-after-tombstone'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent,
       })
 
       expect(workspaceContextOf(restored)?.meta).toMatchObject({
-        changes: [{ action: 'set', scope: sk('pkg', 'AGENTS.md'), path: 'pkg/AGENTS.md' }],
+        changes: [{ action: 'set', scope: sk('pkg', 'AGENTS.md'), path: join('pkg', 'AGENTS.md') }],
       })
-      expect(blocksText(workspaceContextOf(restored)?.content)).toContain('Additional instructions from: pkg/AGENTS.md')
+      expect(blocksText(workspaceContextOf(restored)?.content)).toContain(`Additional instructions from: ${join('pkg', 'AGENTS.md')}`)
       expect(blocksText(workspaceContextOf(restored)?.content)).toContain('restored package rule')
     } finally {
       await rm(root, { recursive: true, force: true })
@@ -2408,13 +2408,13 @@ describe('dynamic nested workspace context injection', () => {
 
       const first = await ctx.tools.execute({
         signal: testToolSignal,
-        callId: CallId('read-before-provider-failure'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent,
+        callId: CallId('read-before-provider-failure'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent,
       })
       appendAdditionalContexts(agent, first)
       fs.throwOnStat.add(join(root, 'pkg/AGENTS.md'))
       const duringFailure = await ctx.tools.execute({
         signal: testToolSignal,
-        callId: CallId('read-during-provider-failure'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent,
+        callId: CallId('read-during-provider-failure'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent,
       })
 
       expect(first.additionalContexts).toBeDefined()
@@ -2440,7 +2440,7 @@ describe('dynamic nested workspace context injection', () => {
         signal: testToolSignal,
         callId: CallId('read-before-resume'),
         name: 'read',
-        arguments: { file_path: 'pkg/deep/file.txt' },
+        arguments: { file_path: join('pkg', 'deep', 'file.txt') },
         agent,
       })
       appendAdditionalContexts(agent, first)
@@ -2453,7 +2453,7 @@ describe('dynamic nested workspace context injection', () => {
         signal: testToolSignal,
         callId: CallId('read-after-resume'),
         name: 'read',
-        arguments: { file_path: 'pkg/deep/file.txt' },
+        arguments: { file_path: join('pkg', 'deep', 'file.txt') },
         agent: resumed,
       })
 
@@ -2477,7 +2477,7 @@ describe('dynamic nested workspace context injection', () => {
       const original = stubAgent(root)
       const first = await ctx.tools.execute({
         signal: testToolSignal,
-        callId: CallId('read-before-offline-change'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent: original,
+        callId: CallId('read-before-offline-change'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent: original,
       })
       appendAdditionalContexts(original, first)
       await write(join(root, 'pkg/AGENTS.md'), 'new nested rule after resume')
@@ -2487,7 +2487,7 @@ describe('dynamic nested workspace context injection', () => {
 
       const update = resumed.session.events.findLast(event => event.type === 'context/message')
       expect(update?.type === 'context/message' && update.data.meta).toMatchObject({
-        changes: [{ action: 'replace', scope: sk('pkg', 'AGENTS.md'), path: 'pkg/AGENTS.md' }],
+        changes: [{ action: 'replace', scope: sk('pkg', 'AGENTS.md'), path: join('pkg', 'AGENTS.md') }],
       })
       expect(update?.type === 'context/message' && blocksText(update.data.content)).toContain('new nested rule after resume')
     } finally {
@@ -2510,7 +2510,7 @@ describe('dynamic nested workspace context injection', () => {
         signal: testToolSignal,
         callId: CallId('read-before-compact'),
         name: 'read',
-        arguments: { file_path: 'pkg/deep/file.txt' },
+        arguments: { file_path: join('pkg', 'deep', 'file.txt') },
         agent,
       })
       const contextSeq = appendAdditionalContexts(agent, first)!
@@ -2518,7 +2518,7 @@ describe('dynamic nested workspace context injection', () => {
         signal: testToolSignal,
         callId: CallId('read-while-visible'),
         name: 'read',
-        arguments: { file_path: 'pkg/deep/file.txt' },
+        arguments: { file_path: join('pkg', 'deep', 'file.txt') },
         agent,
       })
 
@@ -2534,7 +2534,7 @@ describe('dynamic nested workspace context injection', () => {
         signal: testToolSignal,
         callId: CallId('read-after-compact'),
         name: 'read',
-        arguments: { file_path: 'pkg/deep/file.txt' },
+        arguments: { file_path: join('pkg', 'deep', 'file.txt') },
         agent,
       })
 
@@ -2564,7 +2564,7 @@ describe('dynamic nested workspace context injection', () => {
         signal: testToolSignal,
         callId: CallId('read-package'),
         name: 'read',
-        arguments: { file_path: 'pkg/file.txt' },
+        arguments: { file_path: join('pkg', 'file.txt') },
         agent,
       })
       appendAdditionalContexts(agent, first)
@@ -2573,7 +2573,7 @@ describe('dynamic nested workspace context injection', () => {
         signal: testToolSignal,
         callId: CallId('read-subtree'),
         name: 'read',
-        arguments: { file_path: 'pkg/sub/file.txt' },
+        arguments: { file_path: join('pkg', 'sub', 'file.txt') },
         agent,
       })
 
@@ -2601,7 +2601,7 @@ describe('dynamic nested workspace context injection', () => {
         signal: testToolSignal,
         callId: CallId('read-subtree-omitting-parent'),
         name: 'read',
-        arguments: { file_path: 'pkg/sub/file.txt' },
+        arguments: { file_path: join('pkg', 'sub', 'file.txt') },
         agent,
       })
       appendAdditionalContexts(agent, first)
@@ -2610,13 +2610,13 @@ describe('dynamic nested workspace context injection', () => {
         signal: testToolSignal,
         callId: CallId('read-parent-after-omit'),
         name: 'read',
-        arguments: { file_path: 'pkg/other.txt' },
+        arguments: { file_path: join('pkg', 'other.txt') },
         agent,
       })
 
       const firstText = blocksText(workspaceContextOf(first)?.content)
-      expect(firstText).toContain('omitted pkg/AGENTS.md')
-      expect(firstText).not.toContain('## pkg/AGENTS.md')
+      expect(firstText).toContain(`omitted ${join('pkg', 'AGENTS.md')}`)
+      expect(firstText).not.toContain(`## ${join('pkg', 'AGENTS.md')}`)
       expect(firstText).toContain('subtree rule')
       expect(blocksText(workspaceContextOf(second)?.content)).toContain('parent rule')
     } finally {
@@ -2646,9 +2646,9 @@ describe('dynamic nested workspace context injection', () => {
           version: 1,
           changes: [
             null,
-            { action: 'unknown', scope: 'pkg', path: 'pkg/AGENTS.md' },
+            { action: 'unknown', scope: 'pkg', path: join('pkg', 'AGENTS.md') },
             { action: 'set', scope: 'pkg', path: 42 },
-            { action: 'set', scope: 'pkg', path: 'pkg/AGENTS.md', digest: 42 },
+            { action: 'set', scope: 'pkg', path: join('pkg', 'AGENTS.md'), digest: 42 },
           ],
         },
       }, { surfaceOp: 'append' })
@@ -2663,7 +2663,7 @@ describe('dynamic nested workspace context injection', () => {
         meta: {
           kind: 'workspace-instructions',
           version: 1,
-          changes: [{ action: 'set', scope: 'pkg', path: 'pkg/AGENTS.md', digest: 'spoof' }],
+          changes: [{ action: 'set', scope: 'pkg', path: join('pkg', 'AGENTS.md'), digest: 'spoof' }],
         },
       }, { surfaceOp: 'append' })
 
@@ -2671,7 +2671,7 @@ describe('dynamic nested workspace context injection', () => {
         signal: testToolSignal,
         callId: CallId('read-after-spoofed-state'),
         name: 'read',
-        arguments: { file_path: 'pkg/deep/file.txt' },
+        arguments: { file_path: join('pkg', 'deep', 'file.txt') },
         agent,
       })
 
@@ -2736,13 +2736,13 @@ describe('dynamic nested workspace context injection', () => {
 
       const failedStat = await ctx.waterfall('tools/post-execute', stubToolExecution({
         signal: testToolSignal,
-        callId: CallId('provider-stat-failure'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent,
+        callId: CallId('provider-stat-failure'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent,
       }), result, async () => ({ kind: 'accept' as const }))
       fs.throwOnStat.clear()
       fs.entries.set(join(root, 'pkg/AGENTS.md'), { type: 'directory' })
       const mismatchedStat = await ctx.waterfall('tools/post-execute', stubToolExecution({
         signal: testToolSignal,
-        callId: CallId('provider-stat-mismatch'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent,
+        callId: CallId('provider-stat-mismatch'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent,
       }), result, async () => ({ kind: 'accept' as const }))
 
       expect(failedStat).toEqual({ kind: 'accept' })
@@ -2770,7 +2770,7 @@ describe('dynamic nested workspace context injection', () => {
         signal: testToolSignal,
         callId: CallId('read-with-unreadable-nested-instruction'),
         name: 'read',
-        arguments: { file_path: 'pkg/deep/file.txt' },
+        arguments: { file_path: join('pkg', 'deep', 'file.txt') },
         agent: stubAgent(root),
       })
 
@@ -2805,7 +2805,7 @@ describe('dynamic nested workspace context injection', () => {
         signal: testToolSignal,
         callId: CallId('read-with-downstream'),
         name: 'read',
-        arguments: { file_path: 'pkg/deep/file.txt' },
+        arguments: { file_path: join('pkg', 'deep', 'file.txt') },
         agent: stubAgent(root),
       })
 
@@ -2814,7 +2814,7 @@ describe('dynamic nested workspace context injection', () => {
       expect(workspaceContextOf(result)?.source).toEqual({ kind: 'plugin', plugin: 'workspace-context' })
       expect(workspaceContextOf(result)?.meta).toMatchObject({
         kind: 'workspace-instructions',
-        changes: [{ action: 'set', scope: sk('pkg', 'AGENTS.md'), path: 'pkg/AGENTS.md' }],
+        changes: [{ action: 'set', scope: sk('pkg', 'AGENTS.md'), path: join('pkg', 'AGENTS.md') }],
       })
       expect(blocksText(workspaceContextOf(result)?.content)).toContain('nested package rule')
       expect(blocksText(workspaceContextOf(result)?.content)).not.toContain('downstream context')
@@ -2850,7 +2850,7 @@ describe('dynamic nested workspace context injection', () => {
         signal: testToolSignal,
         callId: CallId('read-blocked-downstream'),
         name: 'read',
-        arguments: { file_path: 'pkg/deep/file.txt' },
+        arguments: { file_path: join('pkg', 'deep', 'file.txt') },
         agent: stubAgent(root),
       })
 
@@ -2891,7 +2891,7 @@ describe('dynamic nested workspace context injection', () => {
         signal: testToolSignal,
         callId: CallId('outer-block-first'),
         name: 'read',
-        arguments: { file_path: 'pkg/deep/file.txt' },
+        arguments: { file_path: join('pkg', 'deep', 'file.txt') },
         agent,
       })
       shouldBlock = false
@@ -2899,7 +2899,7 @@ describe('dynamic nested workspace context injection', () => {
         signal: testToolSignal,
         callId: CallId('outer-block-retry'),
         name: 'read',
-        arguments: { file_path: 'pkg/deep/file.txt' },
+        arguments: { file_path: join('pkg', 'deep', 'file.txt') },
         agent,
       })
 
@@ -2935,7 +2935,7 @@ describe('dynamic nested workspace context injection', () => {
             signal: testToolSignal,
             callId: CallId(`${exec.callId}:nested`),
             name: 'read',
-            arguments: { file_path: 'pkg/deep/file.txt' },
+            arguments: { file_path: join('pkg', 'deep', 'file.txt') },
             ...exec.agent === undefined ? {} : { agent: exec.agent },
             parent: exec.token,
             ...exec.signal === undefined ? {} : { signal: exec.signal },
@@ -3026,8 +3026,8 @@ describe('dynamic nested workspace context injection', () => {
         isError: false,
       }
       const cases = [
-        { name: 'read', arguments: { file_path: 'pkg/deep/file.txt' }, agent: undefined },
-        { name: 'bash', arguments: { file_path: 'pkg/deep/file.txt' }, agent },
+        { name: 'read', arguments: { file_path: join('pkg', 'deep', 'file.txt') }, agent: undefined },
+        { name: 'bash', arguments: { file_path: join('pkg', 'deep', 'file.txt') }, agent },
         { name: 'read', arguments: null, agent },
         { name: 'read', arguments: {}, agent },
         { name: 'read', arguments: { file_path: 1 }, agent },
@@ -3064,7 +3064,7 @@ describe('dynamic nested workspace context injection', () => {
         signal: testToolSignal,
         callId: CallId('read-with-disabled-budget'),
         name: 'read',
-        arguments: { file_path: 'pkg/deep/file.txt' },
+        arguments: { file_path: join('pkg', 'deep', 'file.txt') },
         agent: stubAgent(root),
       })
 
@@ -3089,7 +3089,7 @@ describe('dynamic nested workspace context injection', () => {
         signal: testToolSignal,
         callId: CallId('read-missing'),
         name: 'read',
-        arguments: { file_path: 'pkg/missing.txt' },
+        arguments: { file_path: join('pkg', 'missing.txt') },
         agent: stubAgent(root),
       })
 
@@ -3116,7 +3116,7 @@ describe('dynamic nested workspace context injection', () => {
         signal: testToolSignal,
         callId: CallId('read-after-dispose'),
         name: 'read',
-        arguments: { file_path: 'pkg/deep/file.txt' },
+        arguments: { file_path: join('pkg', 'deep', 'file.txt') },
         agent: stubAgent(root),
       })
 
@@ -3159,7 +3159,7 @@ describe('workspace context pending state', () => {
     const [change] = commitPendingInstructionContexts(agent, [workspaceChangeContext('pkg', 'one')], pending)
     expect(change).toBeDefined()
     versions.set(agent.session, new Map([['pkg', {
-      path: 'pkg/AGENTS.md', version: FsVersion('v1'), digest: 'one', trimmedDigest: 'one',
+      path: join('pkg', 'AGENTS.md'), version: FsVersion('v1'), digest: 'one', trimmedDigest: 'one',
     }]]))
 
     const unrelated = agent.session.append('context/message', {
@@ -3196,7 +3196,7 @@ describe('workspace context pending state', () => {
     agent.session.append('step/start', { turn: 1, step: 1 })
     commitPendingInstructionContexts(agent, [workspaceChangeContext('pkg', 'one')], pending)
     versions.set(agent.session, new Map([['pkg', {
-      path: 'pkg/AGENTS.md', version: FsVersion('v1'), digest: 'one', trimmedDigest: 'one',
+      path: join('pkg', 'AGENTS.md'), version: FsVersion('v1'), digest: 'one', trimmedDigest: 'one',
     }]]))
 
     const ended = agent.session.append('step/end', { turn: 1, step: 1 })

+ 3 - 3
packages/ui/tui/tests/harness.ts

@@ -31,7 +31,7 @@ export interface TuiHarnessOptions {
   beforeMount?: (session: Session) => void
   cwd?: string | null
   formatCwd?: TuiRuntime['formatCwd']
-  /** Fake-agent creation options; auto-title resolves its target from `provider`/`model`. */
+  /** Fake-agent creation options (`provider`/`model` seed the model selector's initial target). */
   agentOptions?: AgentOptions
   contextWindow?: number
   contextTokens?: number
@@ -94,8 +94,8 @@ export async function createTuiTestHarness<TerminalType extends Terminal, Exit e
   } else {
     await options.configureContext(ctx)
   }
-  // A configureContext may mount the real LlmService (e.g. the auto-title
-  // suites); only fill the advisory-catalog stub when none was provided.
+  // A configureContext may mount the real LlmService; only fill the
+  // advisory-catalog stub when none was provided.
   if (ctx.get('llm') === undefined) {
     ctx.provide('llm', {
       listProviders() {

+ 3 - 0
pnpm-lock.yaml

@@ -188,6 +188,9 @@ importers:
       '@deepseek-ai/dsh-session-persistence-jsonl':
         specifier: workspace:*
         version: link:../packages/session-persistence/session-persistence-jsonl
+      '@deepseek-ai/dsh-session-title-first-message-llm':
+        specifier: workspace:*
+        version: link:../packages/session-title/session-title-first-message-llm
       '@deepseek-ai/dsh-spill-local':
         specifier: workspace:*
         version: link:../packages/spill/spill-local