Stabilize master CI across platforms

This commit is contained in:
Tianyi Cui
2026-07-25 00:10:37 +08:00
parent b97b1b4a3f
commit f9a638b8a6
24 changed files with 173 additions and 86 deletions

View File

@@ -1,4 +1,5 @@
import { describe, expect, it } from 'vitest'
import { join } from 'node:path'
import type { Context } from 'cordis'
import Loader from '@cordisjs/plugin-loader'
import { TOOL_ORDER_REST } from '@deepseek-ai/dsh-system-prompt'
@@ -60,7 +61,7 @@ describe('dsh-tui-demo app', () => {
])
expect(calls[0]?.config).toBeUndefined()
expect(calls[2]?.config).toEqual({ root: '/tmp/tui-sessions', compression: 'none' })
expect(calls[4]?.config).toEqual({ path: '/tmp/tui-sessions/session-query.db' })
expect(calls[4]?.config).toEqual({ path: join('/tmp/tui-sessions', 'session-query.db') })
expect(calls[5]?.config).toEqual({
maxReferences: 2,
candidateLimit: 7,

View File

@@ -7,7 +7,7 @@ import { describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import { mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join, sep } from 'node:path'
import { join, resolve, sep } from 'node:path'
import { CallId } from '@deepseek-ai/dsh-llm'
import SystemPrompt, { renderPrompt } from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry from '@deepseek-ai/dsh-tools'
@@ -729,13 +729,13 @@ describe('sandbox escalation surface (write/edit)', () => {
it('a plain write stamps the default mode with the calling session root', async () => {
const { ctx, fs } = await setupConfining()
await call(ctx, 'write', { file_path: 'a.txt', content: 'x' }, escalationAgent())
expect(fs.stamped).toEqual([{ mode: 'workspace-write', workspaceRoot: '/session-project' }])
expect(fs.stamped).toEqual([{ mode: 'workspace-write', workspaceRoot: resolve('/session-project') }])
})
it('a standing session override folds onto the stamp', async () => {
const { ctx, fs } = await setupConfining()
await call(ctx, 'write', { file_path: 'a.txt', content: 'x' }, escalationAgent([{ type: 'sandbox/mode', data: { mode: 'read-only' } }]))
expect(fs.stamped).toEqual([{ mode: 'read-only', workspaceRoot: '/session-project' }])
expect(fs.stamped).toEqual([{ mode: 'read-only', workspaceRoot: resolve('/session-project') }])
})
it('a denied write maps to the shared marker plus the escalation hint (isError)', async () => {
@@ -768,7 +768,7 @@ describe('sandbox escalation surface (write/edit)', () => {
agent: escalationAgent() as never,
signal: new AbortController().signal,
})
expect(fs.stamped).toEqual([{ mode: 'danger-full-access', workspaceRoot: '/session-project' }])
expect(fs.stamped).toEqual([{ mode: 'danger-full-access', workspaceRoot: resolve('/session-project') }])
})
it('a rejected escalation fails closed with its own text and never mutates', async () => {

View File

@@ -8,6 +8,8 @@ Client-disconnect detection hangs off the **response** `close` event, not the re
A request whose handling throws (a malformed %-escape hitting `decodeURIComponent`, a client dropping mid-body) is answered 400 — or the socket destroyed when headers are already out — and reported to `onError`; it never becomes a process-killing unhandled rejection.
In development, the client-plugin registry synchronously captures each built bundle's stat baseline before it returns, then polls those baselines and re-hashes changed content. An immediate rebuild therefore cannot disappear into an asynchronously established watch baseline; a rename window retains the last successful baseline and retries when the bundle reappears.
## Model Experience
None, as the package is a pure HTTP carrier between the browser and the injected API handler; nothing here reaches a model request.

View File

@@ -22,8 +22,7 @@
*/
import { createHash } from 'node:crypto'
import { readFileSync, unwatchFile, watchFile } from 'node:fs'
import type { Stats } from 'node:fs'
import { readFileSync, statSync, type Stats } from 'node:fs'
import { dirname, join } from 'node:path'
import type { Context } from 'cordis'
@@ -107,9 +106,9 @@ export interface WebPluginRegistryDeps {
onError: (err: Error) => void
/**
* Dev-mode bundle watching: stat-poll every scanned row's client bundle
* (fs.watchFile — polling by design: network mounts deliver no inotify
* events) and re-hash + notify onRebuilt subscribers on change. Absent =
* no watching (prod composition).
* with an explicit stat baseline (polling by design: network mounts deliver
* no inotify events) and re-hash + notify onRebuilt subscribers on change.
* Absent = no watching (prod composition).
*/
watch?: {
/** Stat-poll interval in milliseconds; default 500 (the build-side watcher's polling default). */
@@ -217,52 +216,66 @@ export function createHostWebPluginRegistry(deps: WebPluginRegistryDeps): HostWe
return rev
}
// Dev bundle watch: one fs.watchFile stat poll per table row. A torn read
// of a half-written bundle self-heals — the ongoing write keeps changing
// the stats, so the next poll tick re-hashes the completed file.
const watched = new Map<string, { path: string; listener: (curr: Stats, prev: Stats) => void }>()
// Dev bundle watch: capture every row's baseline synchronously before the
// registry is returned, then poll those baselines. fs.watchFile establishes
// its first baseline asynchronously, so an immediate rebuild can otherwise
// become the baseline and disappear without an observed delta.
const watched = new Map<string, { path: string; mtimeMs: number; size: number }>()
const syncWatches = (): void => {
if (watchInterval === undefined) return
for (const [id, watch] of watched) {
if (table.get(id)?.clientPath === watch.path) continue
unwatchFile(watch.path, watch.listener)
watched.delete(id)
}
for (const [id, record] of table) {
if (watched.has(id)) continue
const listener = (curr: Stats, prev: Stats): void => {
// fs.watchFile fires on any stat delta (atime included); only content
// signals count. An all-zero curr means the file vanished mid-rebuild
// — the completing write fires the next tick, so skipping is safe.
if (curr.mtimeMs === prev.mtimeMs && curr.size === prev.size) return
if (curr.mtimeMs === 0) return
const before = table.get(id)?.entry.rev
let rev: string | undefined
try {
rev = rebuilt(id)
} catch (error) {
const code = (error as NodeJS.ErrnoException).code
if (code === 'ENOENT') return // mid-rename window; the completed write fires the next poll tick
deps.onError(error instanceof Error ? error : new Error(String(error)))
return
}
if (rev === undefined || rev === before) return
for (const notify of rebuildListeners) {
// A throwing subscriber must not escape the fs.watchFile callback
// (that would skip later subscribers and can kill the process).
try {
notify(id, rev)
} catch (error) {
deps.onError(error instanceof Error ? error : new Error(String(error)))
}
}
}
watchFile(record.clientPath, { interval: watchInterval, persistent: false }, listener)
watched.set(id, { path: record.clientPath, listener })
const baseline = statSync(record.clientPath)
watched.set(id, { path: record.clientPath, mtimeMs: baseline.mtimeMs, size: baseline.size })
}
}
syncWatches()
const pollWatches = (): void => {
for (const [id, watch] of watched) {
let current: Stats
try {
current = statSync(watch.path)
} catch (error) {
const code = (error as NodeJS.ErrnoException).code
if (code === 'ENOENT') continue // mid-rename window; retry against the retained baseline
deps.onError(error instanceof Error ? error : new Error(String(error)))
continue
}
if (current.mtimeMs === watch.mtimeMs && current.size === watch.size) continue
const before = table.get(id)?.entry.rev
let rev: string | undefined
try {
rev = rebuilt(id)
} catch (error) {
const code = (error as NodeJS.ErrnoException).code
if (code === 'ENOENT') continue // mid-rename window; retry against the retained baseline
watch.mtimeMs = current.mtimeMs
watch.size = current.size
deps.onError(error instanceof Error ? error : new Error(String(error)))
continue
}
watch.mtimeMs = current.mtimeMs
watch.size = current.size
if (rev === undefined || rev === before) continue
for (const notify of rebuildListeners) {
// A throwing subscriber must not skip later subscribers or escape the
// polling callback into the process event loop.
try {
notify(id, rev)
} catch (error) {
deps.onError(error instanceof Error ? error : new Error(String(error)))
}
}
}
}
const watchTimer = watchInterval === undefined ? undefined : setInterval(pollWatches, watchInterval)
watchTimer?.unref()
let pending = false
const unsubscribe = deps.ctx.on('internal/plugin', () => {
if (pending) return
@@ -291,7 +304,7 @@ export function createHostWebPluginRegistry(deps: WebPluginRegistryDeps): HostWe
},
dispose: () => {
unsubscribe()
for (const { path, listener } of watched.values()) unwatchFile(path, listener)
if (watchTimer !== undefined) clearInterval(watchTimer)
watched.clear()
rebuildListeners.clear()
},

View File

@@ -1,12 +1,12 @@
# @deepseek-ai/dsh-pty-local
Local `node-pty` backend for `ctx.pty`. It starts an interactive shell under the shared `ctx.sandboxPolicy`, strips credential-shaped ambient environment variables, retains bounded line-oriented output, detects readiness, and tears down the captured process tree rooted at the `node-pty` child.
Local Linux/macOS `node-pty` backend for `ctx.pty`; loading it on another platform fails as unsupported. It starts an interactive shell under the shared `ctx.sandboxPolicy`, strips credential-shaped ambient environment variables, retains bounded line-oriented output, detects readiness, and tears down the captured process tree rooted at the `node-pty` child.
## Plugin (`pty-local`)
The plugin injects `pty`, `sandbox`, and `sandboxPolicy`, then registers the configured backend type (`shell`). `danger-full-access` starts the shell directly; confined modes wrap the exact shell argv through `ctx.sandbox`. The effective session mode is resolved at spawn. A change to a different effective mode is rejected before its `sandbox/mode` event commits while that owner has an open PTY or a spawn in progress; the fence is attached to the exact owner and therefore outlives a local-provider reload that retains existing sessions. Wait for creation to settle and close the sessions before changing modes, so a terminal opened with wider access cannot survive a downgrade.
Linux readiness combines a foreground-verified private bash prompt marker, foreground-process-group syscall inspection, silence fallback, and absolute timeout. macOS uses the verified prompt marker plus silence/timeout because it has no `/proc` syscall surface. A marker is not ready until printable prompt text arrives, including when the OSC marker and `PS1` are split across data callbacks. Unrecognized or unreadable process state is never a positive exact-idle signal. During unpublished startup, a fallback requires observed output; zero-output silence cannot publish an empty session, and timeout rejects the spawn. Cancellation closes the unpublished shell and rejects with the caller's exact abort reason even when its foreground process group is not observable yet; if that close fails, `PtyBackendCleanupError` separately preserves the cleanup failure for registry disposal. Incomplete terminal-control sequences are bounded by `maxReadBytes` and discarded through their terminator after crossing that limit; a trailing carriage return is carried across callbacks so split CRLF becomes one newline.
Linux readiness combines a foreground-verified private bash prompt marker, foreground-process-group syscall inspection, silence fallback, and absolute timeout. macOS uses the verified prompt marker plus silence/timeout because it has no `/proc` syscall surface. A marker is not ready until printable prompt text arrives, including when the OSC marker and `PS1` are split across data callbacks; when bash prints the marker before the kernel publishes its return to the foreground process group, polling retains the candidate until bash ownership is observable. Unrecognized or unreadable process state is never a positive exact-idle signal. During unpublished startup, a fallback requires observed output; zero-output silence cannot publish an empty session, and timeout rejects the spawn. Cancellation closes the unpublished shell and rejects with the caller's exact abort reason even when its foreground process group is not observable yet; if that close fails, `PtyBackendCleanupError` separately preserves the cleanup failure for registry disposal. Incomplete terminal-control sequences are bounded by `maxReadBytes` and discarded through their terminator after crossing that limit; a trailing carriage return is carried across callbacks so split CRLF becomes one newline.
Send cancellation resolves the current foreground process group and delivers a real `SIGINT`; it never emulates interruption by writing `\x03`, so raw-mode programs remain cancellable. Close sends `SIGTERM` to descendants, waits, then sends `SIGKILL` to the union of captured survivors and newly scanned descendants so reparenting cannot hide a process from teardown. It verifies that every retained identity is gone or, on Linux, a non-executing zombie before stopping the shell; zombie entries are quiescent and are reaped as the shell exits. A survivor failure does not cache a permanently rejected close; a later close retries the teardown.

View File

@@ -288,11 +288,12 @@ export class LocalPtySession implements PtyBackendSession {
if (sanitized.prompt) {
const foregroundPgid = this.inspector.foregroundPgid(this.pid)
if (this.shellPgid === undefined) this.shellPgid = foregroundPgid
if (foregroundPgid !== undefined && foregroundPgid === this.shellPgid) {
this.promptSeen = true
this.promptTextSeen = sanitized.promptText === true
this.lastOutputAt = Date.now()
}
// Bash can print PROMPT_COMMAND before the kernel publishes its return
// to the foreground process group. Retain the marker; polling below is
// the authority that accepts it only after bash owns the foreground.
this.promptSeen = true
this.promptTextSeen = sanitized.promptText === true
this.lastOutputAt = Date.now()
} else if (this.promptSeen && sanitized.promptText === true) {
this.promptTextSeen = true
}
@@ -312,8 +313,11 @@ export class LocalPtySession implements PtyBackendSession {
return
}
if (this.promptSeen && this.promptTextSeen && Date.now() - this.lastOutputAt >= this.config.pollIntervalMs) {
this.settleActive('stdin_read')
return
const pgid = this.inspector.foregroundPgid(this.pid)
if (this.shellPgid !== undefined && pgid === this.shellPgid) {
this.settleActive('stdin_read')
return
}
}
const elapsed = Date.now() - operation.startedAt
const startupHasOutput = !this.initializing || this.scrollback.snapshot().text.length > 0
@@ -324,7 +328,13 @@ export class LocalPtySession implements PtyBackendSession {
return
}
}
if (startupHasOutput && Date.now() - this.lastOutputAt >= this.config.idleSilenceMs) {
// A complete owned marker is stronger evidence than silence, but can race
// the kernel's foreground-PGID handoff. Once it is pending, wait for bash
// ownership (or the absolute timeout) instead of misclassifying that race
// as inferred idle.
if (!(this.promptSeen && this.promptTextSeen)
&& startupHasOutput
&& Date.now() - this.lastOutputAt >= this.config.idleSilenceMs) {
this.settleActive('inferred_idle')
return
}

View File

@@ -187,7 +187,12 @@ describe('LocalPtyBackend startup rollback', () => {
kill() { exitListener?.({ exitCode: 0, signal: 15 }) },
resize() {}, clear() {}, pause() {}, resume() {},
} as IPty
const backend = new LocalPtyBackend(ctx, config(), inspector, () => terminal)
const backend = new LocalPtyBackend(
ctx,
config(),
{ ...inspector, foregroundPgid: () => terminal.pid },
() => terminal,
)
const session = await backend.spawn(spec(agent(ctx)))
expect(session.motd).toBe('dsh> ')
await session.close('test complete')

View File

@@ -286,7 +286,7 @@ describe('LocalPtySession readiness and output', () => {
expect(session.motd).toBe('dsh> ')
})
it('trusts prompt markers only while the startup shell owns the foreground group', async () => {
it('retains a prompt marker until the startup shell regains the foreground group', async () => {
vi.useFakeTimers()
const terminal = new FakeTerminal()
const inspector = new FakeInspector()
@@ -297,13 +297,13 @@ describe('LocalPtySession readiness and output', () => {
let settled = false
void operation.done.then(() => { settled = true })
inspector.pgid = 789
terminal.emitData('\x1b]133;D;0\x07spoofed')
await vi.advanceTimersByTimeAsync(10)
terminal.emitData('\x1b]133;D;0\x07dsh> ')
await vi.advanceTimersByTimeAsync(60)
expect(settled).toBe(false)
inspector.pgid = 456
terminal.emitData('\x1b]133;D;0\x07dsh> ')
await vi.advanceTimersByTimeAsync(10)
expect(settled).toBe(true)
expect((await operation.done).waitReason).toBe('stdin_read')
})
})

View File

@@ -69,7 +69,7 @@ describe('SandboxPolicyService', () => {
})
})
it('resolves a symlink-sensitive session cwd with filesystem semantics', async () => {
it.skipIf(process.platform === 'win32')('resolves a symlink-sensitive session cwd with POSIX component semantics', async () => {
const root = mkdtempSync(join(tmpdir(), 'dsh-policy-cwd-'))
try {
const lexical = join(root, 'lexical')
@@ -78,7 +78,7 @@ describe('SandboxPolicyService', () => {
mkdirSync(lexical)
mkdirSync(child, { recursive: true })
const link = join(lexical, 'link')
symlinkSync(child, link, process.platform === 'win32' ? 'junction' : 'dir')
symlinkSync(child, link, 'dir')
const cwd = `${link}${sep}..`
const ctx = await mounted({ mode: 'workspace-write', workspaceRoot: '/fallback' })

View File

@@ -14,7 +14,7 @@ import { canonicalPath, writableRoots } from '@deepseek-ai/dsh-sandbox'
describe('canonicalPath', () => {
it('resolves symlinks (an existing path realpaths)', () => {
const dir = mkdtempSync(join(tmpdir(), 'dsh-roots-'))
expect(canonicalPath(dir)).toBe(realpathSync(dir))
expect(canonicalPath(dir)).toBe(realpathSync.native(dir))
})
it('returns the spelling as-is when the path cannot be resolved (conservative — matches nothing until it exists)', () => {
@@ -30,9 +30,9 @@ describe('writableRoots', () => {
it('workspace-write grants the workspace root plus the platform temp areas, canonical and deduplicated', () => {
const ws = mkdtempSync(join(tmpdir(), 'dsh-ws-'))
const roots = writableRoots({ mode: 'workspace-write', workspaceRoot: ws })
expect(roots).toContain(realpathSync(ws))
expect(roots).toContain(realpathSync.native(ws))
expect(roots).toContain(canonicalPath('/tmp'))
expect(roots).toContain(realpathSync(tmpdir()))
expect(roots).toContain(realpathSync.native(tmpdir()))
// Deduplicated after canonicalization (/tmp and os.tmpdir() may coincide).
expect(new Set(roots).size).toBe(roots.length)
})

View File

@@ -1,7 +1,7 @@
import { execFile } from 'node:child_process'
import { existsSync } from 'node:fs'
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { homedir, tmpdir } from 'node:os'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { promisify } from 'node:util'
@@ -20,6 +20,15 @@ const repoRoot = fileURLToPath(new URL('../../../../', import.meta.url))
const builtScripts = join(repoRoot, 'packages/sdk/scripts/lib/bin.js')
const temporary: string[] = []
function resolveCorepackHome(): string {
return process.env.COREPACK_HOME ?? join(
process.env.XDG_CACHE_HOME
?? process.env.LOCALAPPDATA
?? join(homedir(), process.platform === 'win32' ? 'AppData/Local' : '.cache'),
'node/corepack',
)
}
afterEach(async () => {
await Promise.all(temporary.splice(0).map(path => rm(path, { recursive: true, force: true })))
})
@@ -71,13 +80,16 @@ describe.skipIf(!existsSync(builtScripts))('live-linked generated projects', ()
}
`)
const cacheRoot = join(tmpdir(), 'dsh-sdk-link-cache', name)
const pnpmStore = name === 'pnpm'
? (await execFileAsync(name, ['store', 'path', '--silent'], { encoding: 'utf8' })).stdout.trim()
: undefined
const commandEnvironment = {
...scrubEnvironment(),
COREPACK_HOME: join(cacheRoot, 'corepack'),
XDG_CACHE_HOME: join(cacheRoot, 'cache'),
COREPACK_HOME: resolveCorepackHome(),
...name === 'pnpm' ? {} : { XDG_CACHE_HOME: join(cacheRoot, 'cache') },
XDG_DATA_HOME: join(cacheRoot, 'data'),
npm_config_cache: join(cacheRoot, 'npm'),
pnpm_config_store_dir: join(cacheRoot, 'pnpm-store'),
...pnpmStore === undefined ? {} : { pnpm_config_store_dir: pnpmStore },
}
await execFileAsync(name, manager.installCommand(), {
cwd: root,

View File

@@ -251,17 +251,15 @@ describe('SessionPersistenceJsonl: durability and crash semantics', () => {
})
it('surfaces non-ENOENT snapshot stat failures after discovery', async () => {
const blocker = join(root, 'snapshot-not-a-directory')
await writeFile(blocker, 'x')
const persistence = ctx.sessionPersistence as unknown as {
listArtifacts(): Promise<Array<{ header: SessionHeader; path: string }>>
}
const discovery = vi.spyOn(persistence, 'listArtifacts').mockResolvedValue([{
header: meta('snapshot-stat-failure'),
path: join(blocker, 'session.jsonl'),
path: `${root}\0snapshot-stat-failure`,
}])
await expect(ctx.sessionPersistence.listSnapshots()).rejects.toThrow(/ENOTDIR/)
await expect(ctx.sessionPersistence.listSnapshots()).rejects.toThrow(/null bytes/)
discovery.mockRestore()
})

View File

@@ -6,7 +6,7 @@ Four layers, importable separately:
- **`launchAcpTestAgent` (launcher)** — boots a source agent under tsx or a built `lib` agent under plain Node from a supplied cwd, connects the SDK client over a raw-byte stdout tee, collects session updates and stderr, surfaces asynchronous spawn failures through startup, fails closed on unhandled permission requests, and owns graceful or signalled shutdown. Shutdown waits for process exit, inherited stdio closure, and ACP parser exhaustion before resolving or propagating a child error, so captures are complete and callers can remove owned paths after either outcome. When Windows accepts forced termination but publishes its exit marker asynchronously, shutdown gives that marker a bounded grace before treating fallback refusal as a second failure. Snapshot and ordinary e2e suites share this process boundary; a test supplies only agent paths, cwd, environment overrides, and any permission policy.
- **`runScenario` (harness)** — drives ACP JSON-RPC stdio from a deterministic `input.json` script through the launcher, tees raw stdout for the expected-output and purity checks, and harvests every persisted raw JSONL session log (parent and subagent children, primary-first) after graceful stdin EOF. `AgentUnderTest` supplies absolute `binScript`, optional `libBinScript`, `configPath`, and `tsconfigPath` paths because the subprocess cwd is outside the repo; `workspaceParent` may move the generated child cwd from the platform temp directory when that grant is itself under test. Startup failures preserve captured agent stderr in the rejected diagnostic.
- **Normalizers** — pure functions turning the two captured surfaces into stable text: `normalizeStdout` (JSON-RPC ids → first-seen sequence; UUIDs/cwd → tokens; cwd-rooted separators selected as canonical `/` or host-native; `session_info_update.updatedAt``{{updatedAt}}`; doubles as the stdout-purity check), `normalizeSessionLog` (times zeroed, `seq` kept, the same cwd-path policy), `scrubSystemPrompts` (prompt text → `{{system}}`), `scrubToolSchemas` (schema bulk → `{{tools}}`), and `scrubRequestHeaders` (all header bulk → `{{system}}`/`{{tools}}`/`{{messagePrefix}}` outside each pin, structure kept — [pinned-header Agent Note](../../../.agents/notes/implemented/testing/2026-07-06-pin-request-header-content-in-one-scenario.md)).
- **Normalizers** — pure functions turning the two captured surfaces into stable text: `normalizeStdout` (JSON-RPC ids → first-seen sequence; UUIDs and every native/JavaScript filesystem spelling of the generated cwd → tokens, longest-first; cwd-rooted separators selected as canonical `/` or host-native; `session_info_update.updatedAt``{{updatedAt}}`; doubles as the stdout-purity check), `normalizeSessionLog` (times zeroed, `seq` kept, the same cwd-path policy), `scrubSystemPrompts` (prompt text → `{{system}}`), `scrubToolSchemas` (schema bulk → `{{tools}}`), and `scrubRequestHeaders` (all header bulk → `{{system}}`/`{{tools}}`/`{{messagePrefix}}` outside each pin, structure kept — [pinned-header Agent Note](../../../.agents/notes/implemented/testing/2026-07-06-pin-request-header-content-in-one-scenario.md)).
- **`defineAcpSnapshotSuite` (factory)** — registers the whole describe/it tree for a scenario table: per-scenario expected-output and re-persisted-log comparisons, record/refresh fixture write-back, rejection of structured `UNKNOWN_TOOL` results, the per-header-class pin (`system-prompt.expected.md` plus `tool-schemas.expected.json`) with its live uniformity guard, and the fixture guard block (no orphan scenario dirs, required files present, exactly one pin per class, every JSONL prompt/schema-scrubbed, non-pinning fixtures fully header-scrubbed). Refresh expands packed timing envelopes before aligning existing volatile event times, so switching between packed and unpacked layouts cannot shift later records; fresh chunk-fragment arrays remain authoritative. A newly inserted `session/title` receives its preceding event's time so feature-driven insertions do not churn the remainder of a fixture. Each scenario directory's `session.jsonl` plus contiguous `session.<n>.jsonl` siblings are the ordered primary/child inventory; the scenario table does not duplicate their count. Must be called at vitest collection time.
A consuming `*.snapshot.ts` is the scenario table plus one factory call:

View File

@@ -17,7 +17,7 @@
*/
import { cp, mkdtemp, readFile, readdir, rm } from 'node:fs/promises'
import { existsSync } from 'node:fs'
import { existsSync, realpathSync } from 'node:fs'
import { createHash } from 'node:crypto'
import { tmpdir } from 'node:os'
import { basename, dirname, join, delimiter } from 'node:path'
@@ -141,6 +141,8 @@ export interface RunResult {
sessionId?: string
/** The generated cwd the session ran in (the bash workspace). */
cwd: string
/** Filesystem-resolved spellings of {@link cwd} that child processes may report. */
cwdAliases: string[]
/**
* Every persisted session log harvested after the run, ordered primary-first:
* the top-level (parent) session — the one with no `parentSession` — then each
@@ -222,6 +224,7 @@ export function snapshotSpillRoot(
*/
export async function runScenario(input: InputScript, opts: RunOptions): Promise<RunResult> {
const cwd = await mkdtemp(join(opts.workspaceParent ?? tmpdir(), 'acp-snap-cwd-'))
const cwdAliases = [...new Set([realpathSync(cwd), realpathSync.native(cwd)])]
const sessionsRoot = await mkdtemp(join(tmpdir(), 'acp-snap-sessions-'))
// Fixed path length: spill-policy budgets the preview against the REAL path
// before stdout normalization, so tmpdir() length differences churn expected outputs.
@@ -329,6 +332,7 @@ export async function runScenario(input: InputScript, opts: RunOptions): Promise
rawStdout: launched.rawStdout(),
stderr: launched.stderr(),
cwd,
cwdAliases,
...sessionId !== undefined ? { sessionId } : {},
sessionLogs,
}

View File

@@ -46,6 +46,8 @@ export interface NormalizeContext {
sessionIds: string[]
/** The generated cwd the run used — replaced with `{{cwd}}`. */
cwd: string
/** Other filesystem spellings of the same cwd (for example Windows short and long paths). */
cwdAliases?: readonly string[]
}
/** How cwd-rooted path separators are represented after the cwd is tokenized. */
@@ -60,9 +62,13 @@ export interface NormalizeOptions {
/** Replace cwd, session ids, and any stray UUID with stable tokens in a string. */
function scrubString(value: string, ctx: NormalizeContext, cwdPathMode: CwdPathMode): string {
let out = value
// cwd first (longest, most specific), then explicit session ids, then any
// residual UUID (covers ids that appear in places we didn't enumerate).
out = out.split(ctx.cwd).join(CWD)
// Filesystem APIs can report one directory with several spellings. Replace
// every known spelling longest-first so a shorter alias cannot corrupt a
// longer one before it is tokenized.
const cwdSpellings = [...new Set([ctx.cwd, ...ctx.cwdAliases ?? []])]
.filter(spelling => spelling.length > 0)
.sort((left, right) => right.length - left.length)
for (const spelling of cwdSpellings) out = out.split(spelling).join(CWD)
out = out.split(`/private${CWD}`).join(CWD)
if (cwdPathMode === 'canonical') {
// Restrict separator conversion to paths rooted at the cwd token. A global

View File

@@ -629,6 +629,7 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void {
...result.sessionLogs.map(l => l.id),
],
cwd: result.cwd,
cwdAliases: result.cwdAliases,
}
// Record writes live model fixtures; keyless refresh writes every comparable replayed

View File

@@ -44,6 +44,24 @@ describe('normalizeStdout', () => {
expect(out).not.toContain(ctx.sessionIds[0] as string)
})
it('scrubs every filesystem spelling of the cwd longest-first', () => {
const longCwd = String.raw`C:\Users\runneradmin\AppData\Local\Temp\acp-snapshot`
const aliasedCtx: NormalizeContext = {
sessionIds: [],
cwd: String.raw`C:\Users\RUNNER~1\AppData\Local\Temp\acp-snapshot`,
cwdAliases: [
longCwd,
String.raw`C:\Users\runneradmin\AppData\Local\Temp\acp`,
],
}
const raw = JSON.stringify({
cwd: longCwd,
path: `${longCwd}\\nested\\proof.txt`,
})
const frame = JSON.parse(normalizeStdout(raw, aliasedCtx)) as { cwd: string; path: string }
expect(frame).toEqual({ cwd: '{{cwd}}', path: '{{cwd}}/nested/proof.txt' })
})
it('canonicalizes only cwd-rooted path separators', () => {
const windowsCtx: NormalizeContext = {
sessionIds: [],

View File

@@ -1877,7 +1877,7 @@ describe('pi-tui chat lifecycle and transcript', () => {
await mkdir(join(cwd, 'docs'), { recursive: true })
await writeFile(join(cwd, 'src', 'source-file.ts'), 'export const source = true\n')
await writeFile(join(cwd, 'docs', 'design notes.md'), '# Design\n')
await writeFile(join(cwd, 'unsafe\nfile.ts'), 'unsafe name\n')
await writeFile(join(cwd, 'unsafe\u007ffile.ts'), 'unsafe name\n')
const result = await setup({
cwd,
tools: {