Merge remote-tracking branch 'origin/master' into worktree/pr628-merge-20260727
# Conflicts: # .agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.i18n.yaml # .agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.md # .agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.zh.md # docs/architecture.i18n.yaml # docs/architecture.md # docs/architecture.zh.md # docs/cordis-catalog/events.md # docs/core-data-structures/llm-streaming.i18n.yaml # docs/core-data-structures/llm-streaming.md # docs/core-data-structures/llm-streaming.zh.md # docs/event-producer-consumer.md # examples/acp-agent/tests/snapshots/empty-response-retry/session.jsonl # packages/compact/compact-basic/src/index.ts # packages/compact/compact-basic/tests/compact-basic.spec.ts # packages/cordis/tool-cordis/src/api-catalog.ts # packages/core/agent-loop/README.i18n.yaml # packages/core/agent-loop/README.md # packages/core/agent-loop/README.zh.md # packages/core/agent-loop/src/loop.ts # packages/core/agent-loop/tests/request-recovery.spec.ts # packages/core/agent/src/types.ts # packages/core/scope/tests/invariant.spec.ts # packages/llm/llm-retry/README.i18n.yaml # packages/llm/llm-retry/README.md # packages/llm/llm-retry/README.zh.md # packages/llm/llm-retry/src/index.ts # packages/llm/llm-retry/src/invariant.ts # packages/llm/llm-retry/tests/invariant.spec.ts # packages/llm/llm-retry/tests/retry.spec.ts # packages/plan/plan-mode/src/index.ts # packages/plan/plan-mode/tests/integration.spec.ts # packages/plan/plan-mode/tests/plan-mode.spec.ts
This commit is contained in:
@@ -32,6 +32,7 @@ export {
|
||||
type LaunchedAcpTestAgent,
|
||||
} from './launcher.ts'
|
||||
export {
|
||||
extractSnapshotSpillPaths,
|
||||
normalizeSessionLog,
|
||||
normalizeStdout,
|
||||
scrubRequestHeaders,
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* Pure ACP transcript and session-log normalizers. They scrub session ids, run cwd, RPC ids,
|
||||
* timestamps, and hook duration while preserving deterministic event sequence numbers.
|
||||
* Request-header scrubbers stay composable so one scenario per header class can pin prompt and
|
||||
* tool-schema sidecars while retaining any model-visible prefix in the session log.
|
||||
* tool-schema sidecars.
|
||||
* @module @deepseek-ai/dsh-acp-snapshot/normalize
|
||||
*/
|
||||
|
||||
@@ -10,7 +10,6 @@ const SESSION_ID = '{{sessionId}}'
|
||||
const CWD = '{{cwd}}'
|
||||
const SYSTEM = '{{system}}'
|
||||
const TOOLS = '{{tools}}'
|
||||
const MESSAGE_PREFIX = '{{messagePrefix}}'
|
||||
const EVENT_TIME = '{{eventTime}}'
|
||||
const EVENT_OMITTED_BYTES = '{{eventOmittedBytes}}'
|
||||
|
||||
@@ -36,6 +35,23 @@ const SNAPSHOT_SPILL_PATH_RE = new RegExp(
|
||||
'g',
|
||||
)
|
||||
|
||||
/**
|
||||
* Extract every snapshot-mode spill path from a session log, keyed by spill
|
||||
* filename. Used by refresh write-back to keep spill paths stable across runs.
|
||||
* @param content - the raw session log text to scan.
|
||||
* @returns spill filename → the full matched spill path, last match wins per name.
|
||||
*/
|
||||
export function extractSnapshotSpillPaths(content: string): Map<string, string> {
|
||||
const result = new Map<string, string>()
|
||||
for (const match of content.matchAll(SNAPSHOT_SPILL_PATH_RE)) {
|
||||
const name = match[1]
|
||||
/* v8 ignore next -- the filename capture is required and non-empty whenever the spill regex matches */
|
||||
if (name === undefined) continue
|
||||
result.set(name, match[0])
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/** Convert separators only inside generated path-bearing text markers. */
|
||||
function canonicalizeEmbeddedPaths(value: string): string {
|
||||
return value
|
||||
@@ -69,9 +85,14 @@ function scrubString(value: string, ctx: NormalizeContext, cwdPathMode: CwdPathM
|
||||
let out = value
|
||||
// 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.
|
||||
// longer one before it is tokenized. macOS additionally symlinks
|
||||
// /tmp → /private/tmp and /var → /private/var: the session header cwd may
|
||||
// omit the /private prefix while fs tools resolve symlinks, so cover the
|
||||
// prefixed form of every spelling too, then collapse a residual prefixed
|
||||
// token.
|
||||
const cwdSpellings = [...new Set([ctx.cwd, ...ctx.cwdAliases ?? []])]
|
||||
.filter(spelling => spelling.length > 0)
|
||||
.flatMap(spelling => [`/private${spelling}`, spelling])
|
||||
.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)
|
||||
@@ -240,14 +261,13 @@ export function scrubToolSchemas(rawLog: string): string {
|
||||
* @returns The JSONL with all header bulk tokenized, other lines byte-identical.
|
||||
*/
|
||||
export function scrubRequestHeaders(rawLog: string): string {
|
||||
return scrubHeaderContent(rawLog, { system: true, tools: true, prefix: true })
|
||||
return scrubHeaderContent(rawLog, { system: true, tools: true })
|
||||
}
|
||||
|
||||
/** Which independent request-header payloads a scrubber replaces. */
|
||||
interface HeaderScrubOptions {
|
||||
system?: boolean
|
||||
tools?: boolean
|
||||
prefix?: boolean
|
||||
}
|
||||
|
||||
/** Transform the selected request-header payloads. */
|
||||
@@ -264,10 +284,6 @@ function scrubHeaderContent(rawLog: string, options: HeaderScrubOptions): string
|
||||
let touched = false
|
||||
if (options.system === true && 'system' in header) { header.system = SYSTEM; touched = true }
|
||||
if (options.tools === true && 'tools' in header) { header.tools = TOOLS; touched = true }
|
||||
if (options.prefix === true && Array.isArray(header.messagePrefix)) {
|
||||
header.messagePrefix = header.messagePrefix.map(() => MESSAGE_PREFIX)
|
||||
touched = true
|
||||
}
|
||||
return touched ? JSON.stringify(record) : line
|
||||
}
|
||||
return line
|
||||
|
||||
@@ -23,6 +23,7 @@ import { type AgentUnderTest, type HarvestedLog, type InputScript, runScenario }
|
||||
import {
|
||||
type CwdPathMode,
|
||||
type NormalizeContext,
|
||||
extractSnapshotSpillPaths,
|
||||
normalizeSessionLog,
|
||||
normalizeStdout,
|
||||
scrubRequestHeaders,
|
||||
@@ -55,9 +56,7 @@ export interface Scenario {
|
||||
* Whether the run persists a comparable session log to diff against the
|
||||
* `session.jsonl` fixture. Defaults to {@link hasModelTurn} (a model turn
|
||||
* always produces a log worth comparing). Set it independently for a scenario
|
||||
* that produces a non-trivial log WITHOUT a model turn — e.g. a prompt blocked
|
||||
* by a `UserPromptSubmit` hook, which opens a `rejected` turn carrying `hook/*`
|
||||
* events but never calls the model.
|
||||
* that produces a non-trivial durable log without calling the model.
|
||||
*/
|
||||
comparesLog?: boolean
|
||||
/**
|
||||
@@ -448,7 +447,7 @@ export function unknownToolCallIds(rawLog: string): string[] {
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the cross-log id/cwd replacements used by refresh write-back.
|
||||
* Build the cross-log id/cwd/spill-path replacements used by refresh write-back.
|
||||
*
|
||||
* @param logs The freshly harvested logs, in fixture order.
|
||||
* @param fixtures The existing fixture contents, in matching order.
|
||||
@@ -466,6 +465,16 @@ export function refreshFixtureReplacements(logs: HarvestedLog[], fixtures: strin
|
||||
replacements.push({ from, to })
|
||||
}
|
||||
}
|
||||
// Stabilize snapshot spill paths: match by filename suffix so the raw
|
||||
// fixture does not churn on every refresh from a different session run.
|
||||
const freshSpills = extractSnapshotSpillPaths((logs[i] as HarvestedLog).content)
|
||||
const existingSpills = extractSnapshotSpillPaths(fixtures[i] ?? '')
|
||||
for (const [name, existingPath] of existingSpills) {
|
||||
const freshPath = freshSpills.get(name)
|
||||
if (freshPath !== undefined && freshPath !== existingPath) {
|
||||
replacements.push({ from: freshPath, to: existingPath })
|
||||
}
|
||||
}
|
||||
}
|
||||
return replacements
|
||||
}
|
||||
@@ -706,8 +715,8 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void {
|
||||
await expect(stdout, `${expected.file} mismatch`).toMatchFileSnapshot(join(dir, expected.file))
|
||||
}
|
||||
|
||||
// A model turn always produces a log worth comparing; a hook scenario can
|
||||
// produce one without a model turn (a `rejected` turn carrying `hook/*`).
|
||||
// A model turn always produces a log worth comparing; an explicitly
|
||||
// authored non-model scenario may opt in independently.
|
||||
if (comparesLog) {
|
||||
// The harvested logs (primary-first) must match their committed fixtures 1:1.
|
||||
expect(result.sessionLogs.length, 'this scenario must persist one log per session fixture').toBe(fixtureFiles.length)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
type NormalizeContext,
|
||||
extractSnapshotSpillPaths,
|
||||
normalizeSessionLog,
|
||||
normalizeStdout,
|
||||
scrubRequestHeaders,
|
||||
@@ -242,6 +243,21 @@ describe('normalizeSessionLog', () => {
|
||||
expect(out).not.toContain('/private{{spillLocator')
|
||||
})
|
||||
|
||||
it('scrubs macOS /private prefix on cwd-rooted fs tool result paths', () => {
|
||||
const ev = JSON.stringify({
|
||||
type: 'tool/result', seq: 2, time: 5,
|
||||
data: {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: `The file /private${ctx.cwd}/config.txt has been updated successfully.`,
|
||||
}],
|
||||
},
|
||||
})
|
||||
const out = normalizeSessionLog(`${header({ cwd: ctx.cwd })}\n${ev}\n`, ctx)
|
||||
expect(out).toContain('{{cwd}}/config.txt')
|
||||
expect(out).not.toContain('/private{{cwd}}')
|
||||
})
|
||||
|
||||
it('scrubs fixed snapshot spill paths', () => {
|
||||
const ev = JSON.stringify({
|
||||
type: 'tool/result', seq: 2, time: 5,
|
||||
@@ -354,6 +370,24 @@ describe('normalizeSessionLog', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('extractSnapshotSpillPaths', () => {
|
||||
it('maps each spill filename to its full matched path, last match wins per name', () => {
|
||||
const log = [
|
||||
'Full formatted result stored at: /tmp/dsh-acp-snapshot-spill/session-c22bc3f1d2af/8a7b6c5d4e3f-bash.txt. Use read with offset/limit, or grep this path to search within it.',
|
||||
'stale copy at /tmp/dsh-acp-snap-012345678/session-aaaaaaaaaaaa/bbbbbbbbbbbb-grep.txt then',
|
||||
'fresh copy at /tmp/dsh-acp-snap-012345678/session-cccccccccccc/dddddddddddd-grep.txt then',
|
||||
].join('\n')
|
||||
expect(extractSnapshotSpillPaths(log)).toEqual(new Map([
|
||||
['bash.txt', '/tmp/dsh-acp-snapshot-spill/session-c22bc3f1d2af/8a7b6c5d4e3f-bash.txt'],
|
||||
['grep.txt', '/tmp/dsh-acp-snap-012345678/session-cccccccccccc/dddddddddddd-grep.txt'],
|
||||
]))
|
||||
})
|
||||
|
||||
it('returns an empty map when the log carries no snapshot spill paths', () => {
|
||||
expect(extractSnapshotSpillPaths('no spill paths here, only /tmp/other.txt\n')).toEqual(new Map())
|
||||
})
|
||||
})
|
||||
|
||||
describe('scrubRequestHeaders', () => {
|
||||
const headerLine = JSON.stringify({ type: 'session', version: 0, id: 's', createdAt: 1, cwd: '/w' })
|
||||
const headerEvent = (header: object) =>
|
||||
@@ -389,25 +423,6 @@ describe('scrubRequestHeaders', () => {
|
||||
expect(toolsOnly).not.toContain('{{system}}')
|
||||
})
|
||||
|
||||
it('scrubs the header session prefix to one token per message, keeping the count', () => {
|
||||
const ev = headerEvent({
|
||||
config: { model: 'm' },
|
||||
messagePrefix: [
|
||||
{ role: 'user', content: [{ type: 'text', text: 'workspace AGENTS digest' }] },
|
||||
{ role: 'user', content: [{ type: 'text', text: 'skills catalog' }] },
|
||||
],
|
||||
})
|
||||
const out = scrubRequestHeaders(`${headerLine}\n${ev}\n`)
|
||||
expect(out).toContain('"messagePrefix":["{{messagePrefix}}","{{messagePrefix}}"]')
|
||||
expect(out).not.toContain('AGENTS digest')
|
||||
expect(out).not.toContain('skills catalog')
|
||||
// Absence stays absent — a prefix-less header gains no token…
|
||||
expect(scrubRequestHeaders(`${headerLine}\n${headerEvent({ system: 's' })}\n`)).not.toContain('{{messagePrefix}}')
|
||||
// …and a non-array shape passes through untouched.
|
||||
const odd = JSON.stringify({ type: 'request/header', seq: 4, time: 9, data: { header: { config: { model: 'm' }, messagePrefix: 'weird' }, reason: 'initial' } })
|
||||
expect(scrubRequestHeaders(`${headerLine}\n${odd}\n`)).toContain('"messagePrefix":"weird"')
|
||||
})
|
||||
|
||||
it('leaves malformed headers with no scrubbable payload byte-identical', () => {
|
||||
const headerless = JSON.stringify({ type: 'request/header', seq: 10, time: 9, data: { reason: 'initial' } })
|
||||
const nullData = JSON.stringify({ type: 'request/header', seq: 11, time: 9, data: null })
|
||||
@@ -426,14 +441,13 @@ describe('scrubRequestHeaders', () => {
|
||||
})
|
||||
|
||||
describe('scrubSystemPrompts', () => {
|
||||
it('scrubs only system prompt payloads while keeping tools and prefixes verbatim', () => {
|
||||
it('scrubs only system prompt payloads while keeping tools verbatim', () => {
|
||||
const header = JSON.stringify({
|
||||
type: 'request/header', seq: 1, time: 2,
|
||||
data: {
|
||||
header: {
|
||||
system: 'full prompt',
|
||||
tools: [{ name: 'read', description: 'full schema' }],
|
||||
messagePrefix: [{ role: 'user', content: [{ type: 'text', text: 'full prefix' }] }],
|
||||
},
|
||||
reason: 'initial',
|
||||
},
|
||||
@@ -444,7 +458,6 @@ describe('scrubSystemPrompts', () => {
|
||||
header: {
|
||||
system: 'new prompt',
|
||||
tools: [{ name: 'read', description: 'changed schema' }],
|
||||
messagePrefix: [{ role: 'user', content: [{ type: 'text', text: 'changed prefix' }] }],
|
||||
},
|
||||
reason: 'change',
|
||||
},
|
||||
@@ -459,23 +472,20 @@ describe('scrubSystemPrompts', () => {
|
||||
expect(out).not.toContain('full prompt')
|
||||
expect(out).not.toContain('new prompt')
|
||||
expect(out).toContain('full schema')
|
||||
expect(out).toContain('full prefix')
|
||||
expect(out).toContain('changed schema')
|
||||
expect(out).toContain('changed prefix')
|
||||
expect(out.split('\n')[2]).toBe(toolsOnly)
|
||||
expect(scrubSystemPrompts(out)).toBe(out)
|
||||
})
|
||||
})
|
||||
|
||||
describe('scrubToolSchemas', () => {
|
||||
it('scrubs only tool-schema payloads while keeping prompts and prefixes verbatim', () => {
|
||||
it('scrubs only tool-schema payloads while keeping prompts verbatim', () => {
|
||||
const header = JSON.stringify({
|
||||
type: 'request/header', seq: 1, time: 2,
|
||||
data: {
|
||||
header: {
|
||||
system: 'full prompt',
|
||||
tools: [{ name: 'read', description: 'full schema', parameters: { type: 'object' } }],
|
||||
messagePrefix: [{ role: 'user', content: [{ type: 'text', text: 'full prefix' }] }],
|
||||
},
|
||||
reason: 'initial',
|
||||
},
|
||||
@@ -486,7 +496,6 @@ describe('scrubToolSchemas', () => {
|
||||
header: {
|
||||
system: 'new prompt',
|
||||
tools: [{ name: 'grep', description: 'new schema' }],
|
||||
messagePrefix: [{ role: 'user', content: [{ type: 'text', text: 'changed prefix' }] }],
|
||||
},
|
||||
reason: 'change',
|
||||
},
|
||||
@@ -502,8 +511,6 @@ describe('scrubToolSchemas', () => {
|
||||
expect(out).not.toContain('new schema')
|
||||
expect(out).toContain('full prompt')
|
||||
expect(out).toContain('new prompt')
|
||||
expect(out).toContain('full prefix')
|
||||
expect(out).toContain('changed prefix')
|
||||
expect(out.split('\n')[2]).toBe(systemOnly)
|
||||
expect(scrubToolSchemas(out)).toBe(out)
|
||||
})
|
||||
|
||||
@@ -458,6 +458,29 @@ describe('refreshFixtureReplacements', () => {
|
||||
{ from: '/new', to: '/old' },
|
||||
])
|
||||
})
|
||||
|
||||
it('stabilizes moved snapshot spill paths by filename while skipping unchanged or unmatched names', () => {
|
||||
const spill = (session: string, hash: string, name: string): string =>
|
||||
`/tmp/dsh-acp-snapshot-spill/session-${session}/${hash}-${name}`
|
||||
const record = (text: string): string =>
|
||||
`${JSON.stringify({ type: 'session', id: 'same', cwd: '/same' })}\n`
|
||||
+ `${JSON.stringify({ type: 'tool/result', data: { content: [{ type: 'text', text: `stored at: ${text} ` }] } })}\n`
|
||||
const freshBash = spill('aaaaaaaaaaaa', 'bbbbbbbbbbbb', 'bash.txt')
|
||||
const oldBash = spill('cccccccccccc', 'dddddddddddd', 'bash.txt')
|
||||
const shared = spill('eeeeeeeeeeee', 'ffffffffffff', 'grep.txt')
|
||||
const orphan = spill('111111111111', '222222222222', 'orphan.txt')
|
||||
const logs: HarvestedLog[] = [{
|
||||
id: 'diagnostic',
|
||||
createdAt: 1,
|
||||
content: record(`${freshBash} and ${shared}`),
|
||||
}]
|
||||
const fixtures = [record(`${oldBash} and ${shared} and ${orphan}`)]
|
||||
// bash.txt moved → replaced; grep.txt is identical and orphan.txt has no
|
||||
// fresh counterpart → both skipped.
|
||||
expect(refreshFixtureReplacements(logs, fixtures)).toEqual([
|
||||
{ from: freshBash, to: oldBash },
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('stabilizeRefreshLog', () => {
|
||||
|
||||
Reference in New Issue
Block a user