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:
Tianyi Cui
2026-07-27 23:29:26 +08:00
461 changed files with 8294 additions and 10405 deletions

View File

@@ -32,6 +32,7 @@ export {
type LaunchedAcpTestAgent,
} from './launcher.ts'
export {
extractSnapshotSpillPaths,
normalizeSessionLog,
normalizeStdout,
scrubRequestHeaders,

View File

@@ -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

View File

@@ -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)

View File

@@ -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)
})

View File

@@ -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', () => {